diff --git a/app.go b/app.go index 1b4e63f..d806652 100644 --- a/app.go +++ b/app.go @@ -20,8 +20,12 @@ type App struct { } type routingSnapshot struct { - metadata *commandmeta.Command - runners map[commandmeta.Key]commandPayload + metadata *commandmeta.Command + runners map[commandmeta.Key]commandPayload + aliases *alias.Resolver + canonical map[string]*canonicalNode + flagAliases map[string]resolvedFlagAliases + discoveryFlags map[string]resolvedFlagAliases } type commandNode struct { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2fcf659..3fbcf09 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Application-owned aliases implementation plan -Status: in progress; Stages 1–6 completed. +Status: in progress; Stages 1–7 completed. ## Authority, baseline, and scope @@ -439,6 +439,13 @@ local workspace. an explicit longer route while canonical `run full` remains unchanged; flags/values/space-containing argv tokens never becoming command components. +**Completed:** Routing snapshots now retain canonical path metadata and the +separate command-alias resolver. Execution resolves command prefixes before +parsing, tracks the original input-token origins through alias chains, and +preserves the remaining argv. Canonical operands, flags, terminators, unknown +paths, and alias-only discovery prefixes receive distinct route results. +Verified with root and both integration module suites using the local workspace. + ## Stage 8: Integrate parser-aware flag aliasing and short grammar **Work** diff --git a/execute.go b/execute.go index 99a75f2..8ed0663 100644 --- a/execute.go +++ b/execute.go @@ -23,7 +23,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco streams = normalizeStreams(streams) current := newExecution(a, ctx, streams) routedSnapshot := current.snapshot - parsed := current.parse(args) + current.route = resolveEnteredRoute(args, routedSnapshot) + parsed := current.parse(current.route.args) if parsed.Failure != nil { return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path) } @@ -51,6 +52,7 @@ type execution struct { options commandmeta.ParseOptions snapshot *routingSnapshot session *internalcobra.Session + route routeResult } type commandExecution struct { diff --git a/internal/alias/provenance_test.go b/internal/alias/provenance_test.go new file mode 100644 index 0000000..b0b2460 --- /dev/null +++ b/internal/alias/provenance_test.go @@ -0,0 +1,28 @@ +package alias + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +func TestResolveWithProvenanceCarriesInputOriginsThroughChains(t *testing.T) { + resolver, err := NewResolver([]Mapping{ + {Source: commandpath.Path{"collections"}, Target: commandpath.Path{"list", "collections"}}, + {Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}}, + }) + if err != nil { + t.Fatal(err) + } + + path := commandpath.Path{"collections", "--format", "yaml"} + gotPath, gotOrigins := resolver.ResolveWithProvenance(path) + if want := (commandpath.Path{"promptkit", "list", "collections", "--format", "yaml"}); !reflect.DeepEqual(gotPath, want) { + t.Fatalf("resolved path = %v, want %v", gotPath, want) + } + wantOrigins := [][]int{{0}, {0}, {0}, {1}, {2}} + if !reflect.DeepEqual(gotOrigins, wantOrigins) { + t.Fatalf("origins = %v, want %v", gotOrigins, wantOrigins) + } +} diff --git a/internal/alias/resolver.go b/internal/alias/resolver.go index 62f22a4..92f5110 100644 --- a/internal/alias/resolver.go +++ b/internal/alias/resolver.go @@ -132,3 +132,39 @@ func (resolver *Resolver) Resolve(path commandpath.Path) commandpath.Path { current = next } } + +// ResolveWithProvenance resolves a command path and carries the input token +// indexes that contributed to each output component. Rewritten target +// components inherit the origins of the matched source prefix. +func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandpath.Path, [][]int) { + current := path.Clone() + origins := make([][]int, len(path)) + for index := range path { + origins[index] = []int{index} + } + for { + next, mapping, matched := resolver.ResolveOnce(current) + if !matched { + return current, origins + } + + matchedOrigins := make([]int, 0) + seen := make(map[int]struct{}) + for _, componentOrigins := range origins[:len(mapping.Source)] { + for _, origin := range componentOrigins { + if _, exists := seen[origin]; exists { + continue + } + seen[origin] = struct{}{} + matchedOrigins = append(matchedOrigins, origin) + } + } + nextOrigins := make([][]int, 0, len(mapping.Target)+len(origins)-len(mapping.Source)) + for range mapping.Target { + nextOrigins = append(nextOrigins, append([]int(nil), matchedOrigins...)) + } + nextOrigins = append(nextOrigins, origins[len(mapping.Source):]...) + current = next + origins = nextOrigins + } +} diff --git a/parser.go b/parser.go index 1b76b88..9c5c530 100644 --- a/parser.go +++ b/parser.go @@ -26,7 +26,14 @@ func (a *App) routeSnapshot() *routingSnapshot { func (a *App) buildRoutingSnapshot() *routingSnapshot { root := &commandmeta.Command{Name: a.identity.Name} - snapshot := &routingSnapshot{metadata: root, runners: make(map[commandmeta.Key]commandPayload)} + snapshot := &routingSnapshot{ + metadata: root, + runners: make(map[commandmeta.Key]commandPayload), + aliases: a.aliases, + canonical: buildCanonicalTree(a.commands), + flagAliases: a.flagAliases, + discoveryFlags: a.discoveryFlags, + } names := make([]string, 0, len(a.commands)) for name := range a.commands { names = append(names, name) diff --git a/route.go b/route.go new file mode 100644 index 0000000..df4b823 --- /dev/null +++ b/route.go @@ -0,0 +1,96 @@ +package commandkit + +import ( + "strings" + + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +type routeKind uint8 + +const ( + routeUnknown routeKind = iota + routeCanonical + routeDiscovery +) + +type routeResult struct { + kind routeKind + args []string + enteredPath commandpath.Path + canonicalPath commandpath.Path + discoveryPath commandpath.Path +} + +func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult { + path, trailing := commandPathTokens(args) + result := routeResult{kind: routeUnknown} + if snapshot == nil { + result.args = append(append([]string(nil), path...), trailing...) + result.enteredPath = path.Clone() + return result + } + + resolved, origins := snapshot.aliases.ResolveWithProvenance(path) + trace := traceCanonicalPath(snapshot.canonical, resolved) + switch { + case trace.exact != nil: + result.kind = routeCanonical + result.canonicalPath = trace.exact.path.Clone() + case trace.operandOwner != nil: + result.kind = routeCanonical + result.canonicalPath = trace.operandOwner.path.Clone() + } + + if result.kind == routeCanonical { + 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 { + result.kind = routeDiscovery + result.discoveryPath = discovery + result.enteredPath = discovery.Clone() + } else { + result.enteredPath = path.Clone() + } + + result.args = make([]string, 0, len(resolved)+len(trailing)) + result.args = append(result.args, resolved...) + result.args = append(result.args, trailing...) + return result +} + +func commandPathTokens(args []string) (commandpath.Path, []string) { + end := len(args) + for index, token := range args { + if token == "--" || (token != "-" && strings.HasPrefix(token, "-")) { + end = index + break + } + } + path := make(commandpath.Path, end) + copy(path, args[:end]) + return path, append([]string(nil), args[end:]...) +} + +func enteredEndpoint(path commandpath.Path, origins [][]int, canonicalLength int) commandpath.Path { + inputLength := 0 + for _, componentOrigins := range origins[:canonicalLength] { + for _, origin := range componentOrigins { + if origin+1 > inputLength { + inputLength = origin + 1 + } + } + } + return path[:inputLength].Clone() +} + +func findDiscoveryPath(contexts map[string]resolvedFlagAliases, path commandpath.Path) (commandpath.Path, bool) { + for length := len(path); length > 0; length-- { + prefix := path[:length] + if _, exists := contexts[prefix.Key()]; exists { + return prefix.Clone(), true + } + } + return nil, false +} diff --git a/route_test.go b/route_test.go new file mode 100644 index 0000000..f5d4cc6 --- /dev/null +++ b/route_test.go @@ -0,0 +1,214 @@ +package commandkit + +import ( + "bytes" + "context" + "io" + "reflect" + "testing" +) + +func routeTestApp(t *testing.T, extra ...Declaration) *App { + t.Helper() + collections := simpleRunnable("collections") + list := Group("list", "List", collections) + testCommand := runnableWithOperands("test", 0, 1) + promptkit := Group("promptkit", "PromptKit", list, testCommand) + run := runnableWithOperands("run", 0, 1) + inspect := runnableWithOperands("inspect", 1, 1) + declarations := []Declaration{ + promptkit, + run, + inspect, + Alias("collections", "list collections"), + Alias("list collections", "promptkit list collections"), + Alias("pk", "promptkit"), + Alias("promptkit list bundles", "promptkit list collections"), + Alias("smoke", "promptkit test"), + Alias("r", "run"), + Alias("r full", "inspect"), + } + declarations = append(declarations, extra...) + app, err := New(Identity{Name: "Example"}, declarations...) + if err != nil { + t.Fatal(err) + } + return app +} + +func TestEnteredRoutesResolveChainsAndPreserveTokenOrigins(t *testing.T) { + app := routeTestApp(t) + snapshot := app.routeSnapshot() + for _, test := range []struct { + name string + args []string + entered string + canonical string + wantArgs []string + wantKind routeKind + }{ + { + name: "command alias chain", + args: []string{"collections", "--tag", "first"}, + entered: "collections", + canonical: "promptkit list collections", + wantArgs: []string{"promptkit", "list", "collections", "--tag", "first"}, + wantKind: routeCanonical, + }, + { + name: "group alias composition", + args: []string{"pk", "list", "bundles"}, + entered: "pk list bundles", + canonical: "promptkit list collections", + wantArgs: []string{"promptkit", "list", "collections"}, + wantKind: routeCanonical, + }, + { + name: "smoke operand", + args: []string{"smoke", "all"}, + entered: "smoke", + canonical: "promptkit test", + wantArgs: []string{"promptkit", "test", "all"}, + wantKind: routeCanonical, + }, + { + name: "explicit longer alternate spelling", + args: []string{"r", "full"}, + entered: "r full", + canonical: "inspect", + wantArgs: []string{"inspect"}, + wantKind: routeCanonical, + }, + { + name: "canonical runnable operand", + args: []string{"run", "full"}, + entered: "run", + canonical: "run", + wantArgs: []string{"run", "full"}, + wantKind: routeCanonical, + }, + { + name: "single dash remains an operand", + args: []string{"run", "-"}, + entered: "run", + canonical: "run", + wantArgs: []string{"run", "-"}, + wantKind: routeCanonical, + }, + { + name: "unknown path", + args: []string{"missing", "route"}, + entered: "missing route", + wantArgs: []string{"missing", "route"}, + wantKind: routeUnknown, + }, + { + name: "space-containing token is not split", + args: []string{"collections list"}, + entered: "collections list", + wantArgs: []string{"collections list"}, + wantKind: routeUnknown, + }, + { + name: "flag values and spaced tokens", + args: []string{"collections", "--message", "--hlep", "--text=two words"}, + entered: "collections", + canonical: "promptkit list collections", + wantArgs: []string{"promptkit", "list", "collections", "--message", "--hlep", "--text=two words"}, + wantKind: routeCanonical, + }, + { + name: "terminator stops command matching", + args: []string{"collections", "--", "r", "full"}, + entered: "collections", + canonical: "promptkit list collections", + wantArgs: []string{"promptkit", "list", "collections", "--", "r", "full"}, + wantKind: routeCanonical, + }, + } { + t.Run(test.name, func(t *testing.T) { + got := resolveEnteredRoute(test.args, snapshot) + if got.kind != test.wantKind { + t.Fatalf("route kind = %d, want %d", got.kind, test.wantKind) + } + if got.enteredPath.String() != test.entered { + t.Errorf("entered path = %q, want %q", got.enteredPath.String(), test.entered) + } + if got.canonicalPath.String() != test.canonical { + t.Errorf("canonical path = %q, want %q", got.canonicalPath.String(), test.canonical) + } + if !reflect.DeepEqual(got.args, test.wantArgs) { + t.Errorf("routed args = %q, want %q", got.args, test.wantArgs) + } + }) + } +} + +func TestLongerRouteSelectionDoesNotFallBackAfterTargetParsingFails(t *testing.T) { + app := routeTestApp(t) + result := resolveEnteredRoute([]string{"r", "full"}, app.routeSnapshot()) + if got, want := result.canonicalPath.String(), "inspect"; got != want { + t.Fatalf("canonical path = %q, want %q", got, want) + } + + var diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), []string{"r", "full"}, Streams{Out: io.Discard, Err: &diagnostics}) + if outcome.Kind != OutcomeUsage { + t.Fatalf("outcome kind = %v, want usage; diagnostics: %s", outcome.Kind, diagnostics.String()) + } +} + +func TestEnteredAliasCanSelectExplicitLongerMapping(t *testing.T) { + app := routeTestApp(t, Alias("pk list bundles", "inspect")) + result := resolveEnteredRoute([]string{"pk", "list", "bundles"}, app.routeSnapshot()) + if got, want := result.canonicalPath.String(), "inspect"; got != want { + t.Fatalf("canonical path = %q, want %q", got, want) + } + if got, want := result.enteredPath.String(), "pk list bundles"; got != want { + t.Fatalf("entered path = %q, want %q", got, want) + } +} + +func TestExecuteDispatchesEnteredAliasToCanonicalRunner(t *testing.T) { + runs := 0 + command := NewCommand(CommandSpec[struct{}]{ + Name: "canonical", Summary: "Canonical", + Run: func(Invocation, struct{}) Outcome { + runs++ + return Success() + }, + }) + app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "canonical")) + if err != nil { + t.Fatal(err) + } + + outcome := app.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != OutcomeSuccess || runs != 1 { + t.Fatalf("alias execution = (%v, runs %d), want success and one canonical run", outcome.Kind, runs) + } +} + +func TestAliasOnlyPrefixProducesDiscoveryRoute(t *testing.T) { + app, err := New(Identity{Name: "Example"}, + Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))), + Alias("list collections", "promptkit list collections"), + ) + if err != nil { + t.Fatal(err) + } + + result := resolveEnteredRoute([]string{"list", "--help"}, app.routeSnapshot()) + if result.kind != routeDiscovery { + t.Fatalf("route kind = %d, want discovery", result.kind) + } + if got, want := result.discoveryPath.String(), "list"; got != want { + t.Fatalf("discovery path = %q, want %q", got, want) + } + if got, want := result.enteredPath.String(), "list"; got != want { + t.Fatalf("entered path = %q, want %q", got, want) + } + if !reflect.DeepEqual(result.args, []string{"list", "--help"}) { + t.Fatalf("discovery args = %q, want [list --help]", result.args) + } +}