diff --git a/command.go b/command.go index 21965a2..a3273fc 100644 --- a/command.go +++ b/command.go @@ -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 diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..dda13fe --- /dev/null +++ b/completion.go @@ -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 +} diff --git a/completion_test.go b/completion_test.go new file mode 100644 index 0000000..9433780 --- /dev/null +++ b/completion_test.go @@ -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) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 19c4e3e..c113291 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/cobra/flags.go b/internal/cobra/flags.go index 2dc706a..af11470 100644 --- a/internal/cobra/flags.go +++ b/internal/cobra/flags.go @@ -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 { diff --git a/internal/commandmeta/metadata.go b/internal/commandmeta/metadata.go index 399f5cf..827c4d2 100644 --- a/internal/commandmeta/metadata.go +++ b/internal/commandmeta/metadata.go @@ -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 {