Keep command routing independent of operand counts
This commit is contained in:
@@ -66,7 +66,7 @@ longer-shortcut cases and the complete offline root suite passed on Go 1.26.8.
|
|||||||
|
|
||||||
## R-2: Select canonical routing context before validating operand counts
|
## R-2: Select canonical routing context before validating operand counts
|
||||||
|
|
||||||
Status: open. Priority: P2. Category: unintended parsing restriction and identity.
|
Status: complete. Priority: P2. Category: unintended parsing restriction and identity.
|
||||||
|
|
||||||
**Evidence:** [resolveEnteredRoute](../../route.go#L25) also uses the complete
|
**Evidence:** [resolveEnteredRoute](../../route.go#L25) also uses the complete
|
||||||
operand-count predicate from R-1, but it examines only tokens before the first
|
operand-count predicate from R-1, but it examines only tokens before the first
|
||||||
@@ -100,6 +100,15 @@ entered path `r` and canonical path `run`; partial-operand help describes that
|
|||||||
command and recognizes its applicable aliases. Test canonical and shortcut
|
command and recognizes its applicable aliases. Test canonical and shortcut
|
||||||
forms, and ensure genuinely invalid execution arity still produces usage errors.
|
forms, and ensure genuinely invalid execution arity still produces usage errors.
|
||||||
|
|
||||||
|
**Completed:** Canonical tracing now selects runnable commands structurally;
|
||||||
|
installation alone applies the operand-prefix capacity check. Runtime routing
|
||||||
|
keeps command identity and applicable flag aliases for partial or excess
|
||||||
|
operands, while input preparation retains arity validation. Regressions cover
|
||||||
|
interspersed canonical/alias flags, incomplete help, invalid arity, path metadata,
|
||||||
|
and discovery beneath a zero-operand runnable command. The excess-operand
|
||||||
|
regression failed before this change; the full offline root suite and the
|
||||||
|
additional discovery check passed on Go 1.26.8.
|
||||||
|
|
||||||
## R-3: Preserve exact target lookup for the explicit help command
|
## R-3: Preserve exact target lookup for the explicit help command
|
||||||
|
|
||||||
Status: open. Priority: P2. Category: help grammar regression.
|
Status: open. Priority: P2. Category: help grammar regression.
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
|
|||||||
}
|
}
|
||||||
resolved := aliases.Resolve(prefix)
|
resolved := aliases.Resolve(prefix)
|
||||||
trace := traceCanonicalPath(roots, resolved)
|
trace := traceCanonicalPath(roots, resolved)
|
||||||
if trace.exact != nil || trace.operandOwner != nil {
|
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
prefixes[key] = prefix
|
prefixes[key] = prefix
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolve
|
|||||||
if sourceTrace.exact != nil {
|
if sourceTrace.exact != nil {
|
||||||
return fmt.Errorf("command alias source %q conflicts with canonical command %q", record.sourcePath.String(), sourceTrace.exact.path.String())
|
return fmt.Errorf("command alias source %q conflicts with canonical command %q", record.sourcePath.String(), sourceTrace.exact.path.String())
|
||||||
}
|
}
|
||||||
if sourceTrace.operandOwner != nil {
|
if sourceTrace.operandOwner != nil && acceptsOperandPrefix(sourceTrace.operandOwner.command, len(record.sourcePath)-len(sourceTrace.operandOwner.path)) {
|
||||||
return fmt.Errorf("command alias source %q intercepts operands of canonical command %q", record.sourcePath.String(), sourceTrace.operandOwner.path.String())
|
return fmt.Errorf("command alias source %q intercepts operands of canonical command %q", record.sourcePath.String(), sourceTrace.operandOwner.path.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,15 +318,14 @@ func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolve
|
|||||||
func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path) commandAliasTrace {
|
func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path) commandAliasTrace {
|
||||||
var current *canonicalNode
|
var current *canonicalNode
|
||||||
children := roots
|
children := roots
|
||||||
for index, component := range path {
|
for _, component := range path {
|
||||||
next := children[component]
|
next := children[component]
|
||||||
if next != nil {
|
if next != nil {
|
||||||
current = next
|
current = next
|
||||||
children = current.children
|
children = current.children
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
operandCount := len(path) - index
|
if current != nil && current.command.record.payload != nil && current.command.record.payload.hasRun() {
|
||||||
if current != nil && acceptsOperandPrefix(current.command, operandCount) {
|
|
||||||
return commandAliasTrace{operandOwner: current}
|
return commandAliasTrace{operandOwner: current}
|
||||||
}
|
}
|
||||||
return commandAliasTrace{}
|
return commandAliasTrace{}
|
||||||
|
|||||||
25
route.go
25
route.go
@@ -37,22 +37,23 @@ func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult {
|
|||||||
case trace.exact != nil:
|
case trace.exact != nil:
|
||||||
result.kind = routeCanonical
|
result.kind = routeCanonical
|
||||||
result.canonicalPath = trace.exact.path.Clone()
|
result.canonicalPath = trace.exact.path.Clone()
|
||||||
case trace.operandOwner != nil:
|
case len(path) == 0:
|
||||||
result.kind = routeCanonical
|
result.kind = routeCanonical
|
||||||
result.canonicalPath = trace.operandOwner.path.Clone()
|
default:
|
||||||
|
if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, path); ok && len(discovery) == len(path) {
|
||||||
|
result.kind = routeDiscovery
|
||||||
|
result.discoveryPath = discovery
|
||||||
|
result.enteredPath = discovery.Clone()
|
||||||
|
result.args = commandpath.CloneTokens(trailing)
|
||||||
|
} else if trace.operandOwner != nil {
|
||||||
|
result.kind = routeCanonical
|
||||||
|
result.canonicalPath = trace.operandOwner.path.Clone()
|
||||||
|
} else {
|
||||||
|
result.enteredPath = path.Clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.kind == routeCanonical {
|
if result.kind == routeCanonical {
|
||||||
result.enteredPath = enteredEndpoint(path, origins, len(result.canonicalPath))
|
result.enteredPath = enteredEndpoint(path, origins, len(result.canonicalPath))
|
||||||
} else if len(path) == 0 {
|
|
||||||
result.kind = routeCanonical
|
|
||||||
} else if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, path); ok && len(discovery) == len(path) {
|
|
||||||
result.kind = routeDiscovery
|
|
||||||
result.discoveryPath = discovery
|
|
||||||
result.enteredPath = discovery.Clone()
|
|
||||||
result.args = commandpath.CloneTokens(trailing)
|
|
||||||
} else {
|
|
||||||
result.enteredPath = path.Clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.kind != routeDiscovery {
|
if result.kind != routeDiscovery {
|
||||||
|
|||||||
@@ -11,6 +11,89 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
|
||||||
|
for _, spelling := range []string{"run", "r"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
help bool
|
||||||
|
usage bool
|
||||||
|
}{
|
||||||
|
{name: "interspersed alias", args: []string{"first", "--fmt=yaml", "second"}},
|
||||||
|
{name: "interspersed canonical flag", args: []string{"first", "--format=yaml", "second"}},
|
||||||
|
{name: "partial help", args: []string{"first", "--help"}, help: true},
|
||||||
|
{name: "partial help with flag alias", args: []string{"first", "--fmt=yaml", "--help"}, help: true},
|
||||||
|
{name: "excess operands", args: []string{"first", "second", "third", "--fmt=yaml"}, usage: true},
|
||||||
|
{name: "missing operands", args: []string{"first", "--fmt=yaml"}, usage: true},
|
||||||
|
} {
|
||||||
|
t.Run(spelling+"/"+test.name, func(t *testing.T) {
|
||||||
|
type input struct {
|
||||||
|
operands []string
|
||||||
|
format Supplied[string]
|
||||||
|
}
|
||||||
|
built, ran := 0, false
|
||||||
|
command := NewCommand(CommandSpec[input]{
|
||||||
|
Name: "run", NewInput: func() input { built++; return input{} },
|
||||||
|
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { return value, nil },
|
||||||
|
func(target *input, value Supplied[string]) { target.format = value })},
|
||||||
|
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 2, 2,
|
||||||
|
func(value string) (string, error) { return value, nil },
|
||||||
|
func(target *input, value Supplied[[]string]) { target.operands = value.Value })},
|
||||||
|
Run: func(inv Invocation, value input) Outcome {
|
||||||
|
ran = true
|
||||||
|
if inv.EnteredPath() != spelling || inv.CanonicalPath() != "run" {
|
||||||
|
t.Errorf("invocation paths = %q / %q", inv.EnteredPath(), inv.CanonicalPath())
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(value.operands, []string{"first", "second"}) || !value.format.Set || value.format.Value != "yaml" {
|
||||||
|
t.Errorf("input = %#v", value)
|
||||||
|
}
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Demo"}, command, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--format"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), append([]string{spelling}, test.args...), Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if test.usage {
|
||||||
|
if outcome.Kind != OutcomeUsage || strings.Contains(diagnostics.String(), "unknown flag") || !strings.Contains(diagnostics.String(), "Demo "+spelling+" ARG") {
|
||||||
|
t.Fatalf("invalid arity outcome = %v, diagnostics %q", outcome.Kind, diagnostics.String())
|
||||||
|
}
|
||||||
|
} else if outcome.Kind != OutcomeSuccess {
|
||||||
|
t.Fatalf("outcome = %#v, diagnostics %q", outcome, diagnostics.String())
|
||||||
|
}
|
||||||
|
if test.help && (!strings.Contains(output.String(), "Demo "+spelling+" ARG") || !strings.Contains(output.String(), "--fmt → --format")) {
|
||||||
|
t.Errorf("command help = %q", output.String())
|
||||||
|
}
|
||||||
|
if test.help || test.usage {
|
||||||
|
if built != 0 || ran {
|
||||||
|
t.Fatal("help or invalid arity initialized input or ran a handler")
|
||||||
|
}
|
||||||
|
} else if built != 1 || !ran {
|
||||||
|
t.Fatal("valid invocation did not run once")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnableCommandCanHaveAliasOnlyDiscoveryPrefixes(t *testing.T) {
|
||||||
|
ran := false
|
||||||
|
app, err := New(Identity{Name: "Demo"},
|
||||||
|
NewCommand(CommandSpec[struct{}]{Name: "box", Run: func(Invocation, struct{}) Outcome { ran = true; return Success() }}),
|
||||||
|
simpleRunnable("inspect"), Alias("box admin pick", "inspect"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"box", "admin", "--help"}, Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), "pick → inspect") || ran {
|
||||||
|
t.Fatalf("discovery outcome = %v, help %q, ran %t", outcome.Kind, output.String(), ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func routeTestApp(t *testing.T, extra ...Declaration) *App {
|
func routeTestApp(t *testing.T, extra ...Declaration) *App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
collections := simpleRunnable("collections")
|
collections := simpleRunnable("collections")
|
||||||
|
|||||||
Reference in New Issue
Block a user