Resolve command context for completion

This commit is contained in:
2026-11-03 03:51:26 +00:00
parent fc2f6d5346
commit 8bb5383169
3 changed files with 166 additions and 1 deletions

80
completion_context.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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