diff --git a/completion_context.go b/completion_context.go new file mode 100644 index 0000000..299c9a8 --- /dev/null +++ b/completion_context.go @@ -0,0 +1,80 @@ +package commandkit + +import ( + "strings" + + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +type completionContext struct { + route routeResult + command *commandmeta.Command + aliases []commandmeta.FlagAlias + completed []string + tail []string + current string + commandPosition bool +} + +func resolveCompletionContext(snapshot *routingSnapshot, completed []string, current string) completionContext { + context := completionContext{completed: append([]string(nil), completed...), current: current} + if snapshot == nil { + return context + } + context.route = resolveEnteredRoute(completed, snapshot) + switch context.route.kind { + case routeCanonical: + context.command = completionCommand(snapshot.metadata, context.route.canonicalPath) + if context.command == nil || len(context.route.args) < len(context.route.canonicalPath) { + context.command = nil + return context + } + context.tail = append([]string(nil), context.route.args[len(context.route.canonicalPath):]...) + context.aliases = snapshot.flagAliases[context.route.canonicalPath.Key()].neutralRecords() + context.commandPosition = len(context.tail) == 0 + case routeDiscovery: + context.command = &commandmeta.Command{CoreFlags: commandmeta.ReportingFlags()} + context.tail = append([]string(nil), context.route.args...) + context.aliases = snapshot.discoveryFlags[context.route.discoveryPath.Key()].neutralRecords() + } + return context +} + +func completionCommand(root *commandmeta.Command, path commandpath.Path) *commandmeta.Command { + current := root + for _, name := range path { + if current == nil { + return nil + } + var next *commandmeta.Command + for _, child := range current.Children { + if child.Name == name { + next = child + break + } + } + current = next + } + return current +} + +func completionChildren(snapshot *routingSnapshot, context completionContext) []commandmeta.CompletionChoice { + if snapshot == nil || context.command == nil || !context.commandPosition { + return nil + } + var choices []commandmeta.CompletionChoice + for _, child := range context.command.Children { + if !strings.HasPrefix(child.Name, context.current) || (len(context.route.canonicalPath) == 0 && child.Name == internalcobra.CompletionQueryToken) { + continue + } + entered := append(append([]string(nil), context.completed...), child.Name) + route := resolveEnteredRoute(entered, snapshot) + intended := append(context.route.canonicalPath.Clone(), child.Name) + if route.kind == routeCanonical && route.canonicalPath.Equal(intended) { + choices = append(choices, commandmeta.CompletionChoice{Value: child.Name, Description: child.Summary}) + } + } + return choices +} diff --git a/completion_context_test.go b/completion_context_test.go new file mode 100644 index 0000000..ca8157c --- /dev/null +++ b/completion_context_test.go @@ -0,0 +1,75 @@ +package commandkit + +import ( + "reflect" + "testing" + + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +) + +func TestCompletionCommandContext(t *testing.T) { + parse := func(value string) (string, error) { return value, nil } + mixed := NewCommand(CommandSpec[struct{}]{ + Name: "mixed", Children: []Command{simpleRunnable("child")}, + Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "item"}, false, parse, func(*struct{}, Supplied[string]) {})}, + Run: func(Invocation, struct{}) Outcome { return Success() }, + }) + app, err := New(Identity{Name: "example"}, + Group("workspace", "", Group("list", "", simpleRunnable("shadow"), simpleRunnable("show"))), + simpleRunnable("other"), mixed, Completion("example"), + Alias("ws", "workspace"), Alias("w", "ws"), Alias("ws list shadow", "other"), + Alias("legacy inspect", "workspace list"), FlagAlias("--silent", "--quiet"), + FlagAliasFor("workspace list", "--detail", "--verbose"), + ) + if err != nil { + t.Fatal(err) + } + snapshot := app.routeSnapshot() + for _, tc := range []struct { + name string + completed []string + current string + path string + position bool + choices []string + kind routeKind + }{ + {"root", nil, "work", "", true, []string{"workspace"}, routeCanonical}, + {"group", []string{"workspace"}, "li", "workspace", true, []string{"list"}, routeCanonical}, + {"entered alias", []string{"ws", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical}, + {"alias chain", []string{"w", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical}, + {"discovery", []string{"legacy"}, "in", "", false, nil, routeDiscovery}, + {"complete alias", []string{"legacy", "inspect"}, "sh", "workspace list", true, []string{"shadow", "show"}, routeCanonical}, + {"runnable child", []string{"mixed"}, "ch", "mixed", true, []string{"child"}, routeCanonical}, + {"unmatched child", []string{"mixed"}, "other", "mixed", true, nil, routeCanonical}, + {"runnable operand", []string{"mixed", "value"}, "ch", "mixed", false, nil, routeCanonical}, + {"flag ends path", []string{"mixed", "--quiet"}, "ch", "mixed", false, nil, routeCanonical}, + {"flag before child", []string{"workspace", "--quiet"}, "li", "workspace", false, nil, routeCanonical}, + {"unknown", []string{"missing"}, "x", "", false, nil, routeUnknown}, + {"hidden endpoint", nil, "__comp", "", true, nil, routeCanonical}, + } { + t.Run(tc.name, func(t *testing.T) { + original := append([]string(nil), tc.completed...) + context := resolveCompletionContext(snapshot, tc.completed, tc.current) + if context.route.kind != tc.kind || context.route.canonicalPath.String() != tc.path || context.commandPosition != tc.position || !reflect.DeepEqual(tc.completed, original) { + t.Fatalf("context=%#v completed=%q", context, tc.completed) + } + var names []string + for _, choice := range completionChildren(snapshot, context) { + names = append(names, choice.Value) + } + if !reflect.DeepEqual(names, tc.choices) { + t.Fatalf("children=%q, want %q", names, tc.choices) + } + if tc.kind == routeDiscovery && !reflect.DeepEqual(context.aliases, []commandmeta.FlagAlias{{Source: "--silent", Target: "--quiet"}}) { + t.Fatalf("discovery aliases=%#v", context.aliases) + } + if (tc.name == "runnable child" || tc.name == "unmatched child") && (context.command == nil || len(context.command.Arguments) != 1) { + t.Fatalf("runnable operand metadata missing: %#v", context.command) + } + if tc.name == "entered alias" && !reflect.DeepEqual(context.aliases, []commandmeta.FlagAlias{{Source: "--detail", Target: "--verbose"}, {Source: "--silent", Target: "--quiet"}}) { + t.Fatalf("canonical aliases=%#v", context.aliases) + } + }) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9d3e4d6..271a63b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shell completion implementation plan -Status: Stages 1–3 complete. Stage 4 is next. +Status: Stages 1–4 complete. Stage 5 is next. Implement the [shell completion roadmap](completion.md) from baseline `2301872`. Follow the stages in order in one sprint. Intermediate stages may @@ -148,6 +148,8 @@ Zsh, and Fish. Root and adapter package tests pass with `GOWORK=off` and ## Stage 4: Resolve the entered command context +Status: complete. + 1. Add a private core completion request that separates completed words from the current word. Call `resolveEnteredRoute` on completed words only; use the snapshot's canonical metadata, resolved flag-alias context, and @@ -172,6 +174,14 @@ Zsh, and Fish. Root and adapter package tests pass with `GOWORK=off` and child shadowed in the entered route, and flags beginning before the complete command path. Use existing routing rules as the oracle. +Completion: added private request context resolution using completed words and +the committed routing snapshot. Canonical child candidates are checked by +resolving the entered route with the candidate inserted; discovery-only and +unknown contexts do not invent command continuations. Tests cover canonical +and aliased routes, chains, scoped and discovery flag aliases, shadowed +children, runnable operand metadata, and paths ended by flags or operands. +The root package suite passes with `GOWORK=off` and `GOTOOLCHAIN=local`. + ## Stage 5: Interpret flags and locate value positions 1. Walk the completed tail using the same canonical and alias spellings and