Files
commandkit/execute.go

368 lines
12 KiB
Go

package commandkit
import (
"context"
"errors"
"fmt"
"io"
"strconv"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
// Execute parses and dispatches one invocation. args excludes the executable
// name. Supplied values are converted before command input construction; help
// does not construct caller inputs or run acquisition callbacks. It applies the
// parsed reporting selection, finalizes invocation presentation, and returns
// the resulting Outcome without exiting the process. Supplied streams remain
// caller-owned; nil destinations are replaced with io.Discard. A nil context
// returns an operational failure.
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
current := newExecution(a, ctx, streams)
if a == nil {
err := errors.New("cannot execute commands on a nil app")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
if ctx == nil {
err := errors.New("invocation context must not be nil")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
routedSnapshot := current.snapshot
current.route = resolveEnteredRoute(args, routedSnapshot)
current.recordRouteEvents()
parsed := current.parse(current.route.args)
current.reporter.applySelection(parsed.Reporting)
current.recordSelectionEvents(parsed)
current.submitDebugEvents()
var outcome Outcome
path := parsed.Path
handlerRan := false
if parsed.Failure != nil {
outcome = Usage(parsed.Failure.Message)
} else if current.route.kind == routeDiscovery {
if parsed.HelpRequested {
outcome = current.renderHelp(parsed.Path)
path = clonePath(current.route.enteredPath)
} else {
entered := current.route.enteredPath.String()
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
path = clonePath(current.route.enteredPath)
}
} else {
payload, ok := routedSnapshot.runners[parsed.Key]
if !ok || payload == nil {
outcome = current.renderHelp(parsed.Path)
} else if parsed.HelpRequested || !payload.hasRun() {
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil {
outcome = classifyInputError(err)
} else {
outcome = current.renderHelp(parsed.Path)
}
} else {
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
if err != nil {
outcome = classifyInputError(err)
} else {
handlerRan = true
outcome = run(current)
}
}
}
return current.finalize(outcome, path, handlerRan)
}
type execution struct {
app *App
ctx context.Context
streams Streams
reporter *Reporter
debugEvents []string
options commandmeta.ParseOptions
snapshot *routingSnapshot
session *internalcobra.Session
route routeResult
}
type commandExecution struct {
invocation Invocation
renderHelpTargetsFunc func([]string) Outcome
}
func (current *execution) commandContext() commandExecution {
return commandExecution{
invocation: current.invocation(),
renderHelpTargetsFunc: current.renderHelpTargets,
}
}
func (current *execution) invocation() Invocation {
return Invocation{
Context: current.ctx,
reporter: current.reporter,
enteredPath: current.route.enteredPath.String(),
canonicalPath: current.route.canonicalPath.String(),
}
}
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
return current.renderHelpTargetsFunc(targets)
}
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
streams = normalizeStreams(streams)
current := &execution{
app: app,
ctx: ctx,
streams: streams,
reporter: newReporter(streams),
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
}
if app != nil {
current.snapshot = app.routeSnapshot()
}
return current
}
func (current *execution) parse(args []string) commandmeta.Result {
if current.app == nil {
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
}
if current.session == nil {
current.prepareSession()
}
return reduceReportingOccurrences(current.session.Parse(args))
}
func (current *execution) prepareSession() {
if current.app == nil {
return
}
if current.snapshot == nil {
current.snapshot = current.app.routeSnapshot()
}
options := current.options
if current.snapshot != nil {
switch current.route.kind {
case routeCanonical:
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
}
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
}
}
func (current *execution) presentationSession() *internalcobra.Session {
if current.app == nil {
return nil
}
snapshot := current.app.routeSnapshot()
if current.session == nil || current.snapshot != snapshot {
current.snapshot = snapshot
current.session = nil
current.prepareSession()
}
return current.session
}
func (current *execution) renderHelp(path []string) Outcome {
if current.app == nil {
err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
}
session := current.presentationSession()
view := current.app.helpView(current.route, current.snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
func (current *execution) renderHelpTargets(targets []string) Outcome {
if current.app == nil {
err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
}
snapshot := current.app.routeSnapshot()
if snapshot == nil {
err := errors.New("cannot render help without command metadata")
return Failure(err.Error()).WithCause(err)
}
route := resolveEnteredRoute(targets, snapshot)
// Explicit help targets are paths, not command invocations with operands.
// Discovery routes have no canonical path and retain only trailing options.
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
session := internalcobra.Prepare(snapshot.metadata, current.options)
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
options := current.options
switch route.kind {
case routeCanonical:
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
}
session := internalcobra.Prepare(snapshot.metadata, options)
view := current.app.helpView(route, snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
func (current *execution) usageLine(path []string) string {
if current.app == nil {
return "Usage: command [command]"
}
session := current.presentationSession()
if session != nil {
if usage, failure := session.UsageLine(path); failure == nil {
return "Usage: " + usage
}
}
return current.app.usageFallback()
}
func (current *execution) routeUsageLine() string {
line := current.usageLine(clonePath(current.route.canonicalPath))
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
return line + "\nAlias for: " + canonical
}
func (current *execution) recordRouteEvents() {
entered := current.route.enteredPath.String()
canonical := current.route.canonicalPath.String()
switch current.route.kind {
case routeCanonical:
if entered == canonical {
if canonical == "" {
current.debugEvents = append(current.debugEvents, "selected the application root command")
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
}
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
}
case routeDiscovery:
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
default:
if entered != "" {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
}
}
}
func (current *execution) recordSelectionEvents(result commandmeta.Result) {
mode := "ordinary"
flagName := ""
switch result.Reporting.Mode {
case commandmeta.ReportingModeQuiet:
mode, flagName = "quiet", "quiet"
case commandmeta.ReportingModeVerbose:
mode, flagName = "verbose", "verbose"
case commandmeta.ReportingModeDebug:
mode, flagName = "debug", "debug"
}
event := "selected ordinary reporting mode"
if flagName != "" {
event = fmt.Sprintf("selected %s reporting mode", mode)
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name != flagName {
continue
}
enabled, err := strconv.ParseBool(occurrence.RawValue)
if err != nil || !enabled {
continue
}
canonical := "--" + flagName
if occurrence.Spelling != canonical {
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
} else {
event += fmt.Sprintf(" using %s", canonical)
}
break
}
}
current.debugEvents = append(current.debugEvents, event)
if result.Reporting.ProgressSet {
state := "disabled"
if result.Reporting.Progress {
state = "enabled"
}
event := "explicitly " + state + " progress output"
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name == "progress" {
if occurrence.Spelling != "--progress" {
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
}
break
}
}
current.debugEvents = append(current.debugEvents, event)
}
}
func (current *execution) submitDebugEvents() {
events := current.debugEvents
current.debugEvents = nil
for _, event := range events {
_ = current.reporter.Debug(event)
}
}
func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
if outcome.Kind == OutcomeUsage {
details := append([]Detail(nil), outcome.Explanation.Details...)
details = append(details, func(w io.Writer) error {
line := current.usageText(path)
if !strings.HasSuffix(line, "\n") {
line += "\n"
}
_, err := io.WriteString(w, line)
return err
})
outcome.Explanation.Details = details
}
var completion func(*Reporter)
if handlerRan {
completion = func(reporter *Reporter) {
kind := outcome.Kind
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
kind = OutcomeSuccess
}
path := current.route.canonicalPath.String()
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
}
}
return current.reporter.finalize(outcome, completion)
}
func (current *execution) usageText(path []string) string {
switch {
case current.route.kind == routeDiscovery && current.app != nil:
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
return current.routeUsageLine()
default:
return current.usageLine(path)
}
}
func (a *App) usageLine(path []string) string {
if a != nil {
if usage, failure := internalcobra.UsageLine(a.routeSnapshot().metadata, path); failure == nil {
return "Usage: " + usage
}
}
return a.usageFallback()
}
func (a *App) usageFallback() string {
name := "command"
if a != nil && a.identity.Name != "" {
name = a.identity.Name
}
return fmt.Sprintf("Usage: %s [command]", name)
}