Capture completion hints in command metadata

This commit is contained in:
2026-11-03 03:47:23 +00:00
parent 1d193c87fe
commit 0bed76a6f7
6 changed files with 209 additions and 25 deletions

View File

@@ -98,6 +98,8 @@ type FlagMeta struct {
Usage string
// Required reports whether execution requires the flag to be supplied.
Required bool
// Completion describes optional value suggestions.
Completion CompletionHint
}
// ArgumentMeta describes a positional command argument. Its name and usage are
@@ -107,6 +109,8 @@ type ArgumentMeta struct {
Name string
// Usage briefly describes the argument.
Usage string
// Completion describes optional value suggestions.
Completion CompletionHint
}
// Flag represents a typed binding for a command flag. Copies retain binding
@@ -202,13 +206,14 @@ func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
for index, flag := range c.spec.Flags {
binding := flag.binding
flags[index] = commandmeta.Flag{
Name: binding.meta.Name,
Short: binding.meta.Short,
Usage: binding.meta.Usage,
Default: binding.defaultText,
Boolean: binding.boolean,
Repeated: binding.repeated,
Required: binding.meta.Required,
Name: binding.meta.Name,
Short: binding.meta.Short,
Usage: binding.meta.Usage,
Default: binding.defaultText,
Boolean: binding.boolean,
Repeated: binding.repeated,
Required: binding.meta.Required,
Completion: bridgeCompletion(binding.meta.Completion),
}
}
return flags
@@ -219,10 +224,11 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
for index, argument := range c.spec.Arguments {
binding := argument.binding
arguments[index] = commandmeta.Argument{
Name: binding.meta.Name,
Usage: binding.meta.Usage,
Min: binding.min,
Max: binding.max,
Name: binding.meta.Name,
Usage: binding.meta.Usage,
Min: binding.min,
Max: binding.max,
Completion: bridgeCompletion(binding.meta.Completion),
}
}
return arguments

55
completion.go Normal file
View File

@@ -0,0 +1,55 @@
package commandkit
import commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
// CompletionChoice is a literal suggested value and its optional description.
type CompletionChoice struct {
Value string
Description string
}
// CompletionHint selects suggestions for a flag or positional value. Its zero
// value requests no value suggestions; hints do not constrain accepted input.
type CompletionHint struct {
kind completionHintKind
choices []CompletionChoice
}
type completionHintKind uint8
const (
completionNone completionHintKind = iota
completionChoices
completionFiles
completionDirectories
)
// CompleteChoices suggests literal values. The supplied slice is copied.
func CompleteChoices(choices ...CompletionChoice) CompletionHint {
return CompletionHint{kind: completionChoices, choices: append([]CompletionChoice(nil), choices...)}
}
// CompleteFiles requests shell filename completion.
func CompleteFiles() CompletionHint { return CompletionHint{kind: completionFiles} }
// CompleteDirectories requests shell directory completion.
func CompleteDirectories() CompletionHint { return CompletionHint{kind: completionDirectories} }
// NoCompletion requests no value suggestions.
func NoCompletion() CompletionHint { return CompletionHint{} }
func bridgeCompletion(hint CompletionHint) commandmeta.CompletionHint {
result := commandmeta.CompletionHint{}
switch hint.kind {
case completionChoices:
result.Kind = commandmeta.CompletionChoices
for _, choice := range hint.choices {
result.Choices = append(result.Choices, commandmeta.CompletionChoice{Value: choice.Value, Description: choice.Description})
}
case completionFiles:
result.Kind = commandmeta.CompletionFiles
case completionDirectories:
result.Kind = commandmeta.CompletionDirectories
}
return result
}

73
completion_test.go Normal file
View File

@@ -0,0 +1,73 @@
package commandkit
import (
"context"
"reflect"
"testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
type inner struct{ values Supplied[[]string] }
type outer struct{ flags FlagValues[inner] }
choices := []CompletionChoice{{Value: "alpha", Description: "first"}}
hint := CompleteChoices(choices...)
choices[0] = CompletionChoice{Value: "changed"}
parse := func(value string) (string, error) { return value, nil }
repeated := RepeatFlag(FlagMeta{Name: "tag", Completion: hint}, nil, parse,
func(input *inner, value Supplied[[]string]) { input.values = value })
captured := CaptureFlags([]Flag[inner]{repeated}, func(input *outer) *FlagValues[inner] { return &input.flags })
command := NewCommand(CommandSpec[outer]{
Name: "run",
Flags: append(captured,
ValueFlag(FlagMeta{Name: "file", Completion: CompleteFiles()}, (*string)(nil), parse, func(*outer, Supplied[string]) {}),
BoolFlag(FlagMeta{Name: "enabled"}, false, func(*outer, Supplied[bool]) {}),
ValueFlag(FlagMeta{Name: "empty", Completion: CompleteChoices()}, (*string)(nil), parse, func(*outer, Supplied[string]) {})),
Arguments: []Argument[outer]{
ValueArgument(ArgumentMeta{Name: "directory", Completion: CompleteDirectories()}, false, parse, func(*outer, Supplied[string]) {}),
RepeatArgument(ArgumentMeta{Name: "values", Completion: hint}, 0, -1, parse, func(*outer, Supplied[[]string]) {}),
},
})
flags := command.record.payload.bridgeFlags()
arguments := command.record.payload.bridgeArguments()
want := commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices, Choices: []commandmeta.CompletionChoice{{Value: "alpha", Description: "first"}}}
if !flags[0].Repeated || !reflect.DeepEqual(flags[0].Completion, want) || !reflect.DeepEqual(arguments[1].Completion, want) {
t.Fatalf("captured hints: flags=%#v arguments=%#v", flags, arguments)
}
if flags[1].Completion.Kind != commandmeta.CompletionFiles || flags[2].Completion.Kind != commandmeta.CompletionNone || flags[3].Completion.Kind != commandmeta.CompletionChoices || len(flags[3].Completion.Choices) != 0 || arguments[0].Completion.Kind != commandmeta.CompletionDirectories {
t.Fatalf("hint strategies: flags=%#v arguments=%#v", flags, arguments)
}
flags[0].Completion.Choices[0].Value = "mutated"
if got := command.record.payload.bridgeFlags()[0].Completion; !reflect.DeepEqual(got, want) {
t.Fatalf("bridged choices changed captured hint: %#v", got)
}
if got := bridgeCompletion(NoCompletion()); got.Kind != commandmeta.CompletionNone || !reflect.DeepEqual(got, bridgeCompletion(CompletionHint{})) {
t.Fatalf("no completion: %#v", got)
}
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
if !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
t.Fatalf("reporting flag choices: %#v", flag)
}
}
}
func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
type input struct{ mode Supplied[string] }
var received input
command := NewCommand(CommandSpec[input]{
Name: "run",
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "mode", Completion: CompleteChoices(CompletionChoice{Value: "suggested"})}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(i *input, value Supplied[string]) { i.mode = value })},
Run: func(_ Invocation, value input) Outcome { received = value; return Success() },
})
app, err := New(Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
outcome := app.Execute(context.Background(), []string{"run", "--mode=other"}, Streams{})
if outcome.Status != 0 || !received.mode.Set || received.mode.Value != "other" {
t.Fatalf("outcome=%#v input=%#v", outcome, received)
}
}

View File

@@ -1,6 +1,6 @@
# Shell completion implementation plan
Status: Stage 1 complete. Stage 2 is next.
Status: Stages 1–2 complete. Stage 3 is next.
Implement the [shell completion roadmap](completion.md) from baseline
`2301872`. Follow the stages in order in one sprint. Intermediate stages may
@@ -62,6 +62,8 @@ this run; the focused adapter suite and the full interactive matrix pass.
## Stage 2: Capture completion hints
Status: complete.
1. Add the following public API in the root package. Add
`Completion CompletionHint` to both `FlagMeta` and `ArgumentMeta`;
existing binding constructors and their signatures stay in place.
@@ -97,6 +99,14 @@ this run; the focused adapter suite and the full interactive matrix pass.
suggestions, repeated/captured bindings keep hints, and values absent from
a suggestion list still reach the existing converter.
Completion: added the public choice and hint constructors, carried hints through
flag and operand bindings into neutral metadata, and supplied explicit boolean
choices for built-in help and reporting flags. Focused tests cover copied
choices, repeated and captured bindings, the empty-choice strategy, independent
bridge copies, and execution of values outside the suggested choices. Root,
internal adapter, and neutral metadata package tests pass with `GOWORK=off` and
`GOTOOLCHAIN=local`.
## Stage 3: Install completion as an ordinary declaration
1. Add `func Completion(executable string) Command`. The parameter is the

View File

@@ -9,7 +9,7 @@ import (
func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []commandmeta.FlagAlias) ([]string, map[string][]string, []commandmeta.FlagOccurrence, *commandmeta.Failure) {
canonical := make(map[string]commandmeta.Flag, len(flags)*2+2)
help := commandmeta.Flag{Name: "help", Short: "h", Boolean: true}
help := commandmeta.HelpFlag()
canonical["--help"] = help
canonical["-h"] = help
for _, flag := range flags {

View File

@@ -26,21 +26,45 @@ type Command struct {
// Argument describes one positional input for usage rendering.
type Argument struct {
Name string
Usage string
Min int
Max int
Name string
Usage string
Min int
Max int
Completion CompletionHint
}
// Flag describes one local option without exposing a backend value.
type Flag struct {
Name string
Short string
Usage string
Default string
Boolean bool
Repeated bool
Required bool
Name string
Short string
Usage string
Default string
Boolean bool
Repeated bool
Required bool
Completion CompletionHint
}
// CompletionKind selects the neutral value suggestion strategy.
type CompletionKind uint8
const (
CompletionNone CompletionKind = iota
CompletionChoices
CompletionFiles
CompletionDirectories
)
// CompletionChoice carries a literal value and optional description.
type CompletionChoice struct {
Value string
Description string
}
// CompletionHint describes value suggestions without backend types.
type CompletionHint struct {
Kind CompletionKind
Choices []CompletionChoice
}
var reportingFlags = [...]Flag{
@@ -50,8 +74,24 @@ var reportingFlags = [...]Flag{
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
}
// BooleanCompletion returns the ordinary explicit boolean suggestions.
func BooleanCompletion() CompletionHint {
return CompletionHint{Kind: CompletionChoices, Choices: []CompletionChoice{{Value: "true"}, {Value: "false"}}}
}
// HelpFlag describes the built-in help option for parser contexts.
func HelpFlag() Flag {
return Flag{Name: "help", Short: "h", Boolean: true, Completion: BooleanCompletion()}
}
// ReportingFlags returns the flags shared by CommandKit's invocation reporter.
func ReportingFlags() []Flag { return append([]Flag(nil), reportingFlags[:]...) }
func ReportingFlags() []Flag {
flags := append([]Flag(nil), reportingFlags[:]...)
for index := range flags {
flags[index].Completion = BooleanCompletion()
}
return flags
}
// IsReportingFlag reports whether name is reserved for a shared reporting flag.
func IsReportingFlag(name string) bool {