Resolve command context for completion
This commit is contained in:
80
completion_context.go
Normal file
80
completion_context.go
Normal 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
|
||||
}
|
||||
75
completion_context_test.go
Normal file
75
completion_context_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user