Interpret flags and operands for completion

This commit is contained in:
2026-11-03 03:54:22 +00:00
parent 8bb5383169
commit 4c47a0ca0b
6 changed files with 491 additions and 65 deletions

View File

@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"strconv"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
// ValueFlag declares a singleton flag with a typed parser, optional default,
@@ -255,60 +257,76 @@ func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpell
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
arguments := make([]commandmeta.Argument, len(definitions))
for i, definition := range definitions {
arguments[i] = commandmeta.Argument{Name: definition.binding.meta.Name, Min: definition.binding.min, Max: definition.binding.max}
}
counts, err := allocateOperandCounts(len(operands), arguments, enforceRequired)
if err != nil {
return nil, err
}
allocated := make([][]string, len(definitions))
position := 0
for i, count := range counts {
allocated[i] = operands[position : position+count]
position += count
}
return allocated, nil
}
func allocateOperandCounts(total int, definitions []commandmeta.Argument, enforceRequired bool) ([]int, error) {
for _, argument := range definitions {
binding := argument.binding
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", argument.Name, argument.Min, argument.Max)
}
}
if enforceRequired {
minimum := 0
for _, argument := range definitions {
if argument.binding.min > len(operands)-minimum {
if argument.Min > total-minimum {
return nil, fmt.Errorf("not enough positional arguments")
}
minimum += argument.binding.min
minimum += argument.Min
}
}
suffixMinimum := make([]int, len(definitions)+1)
for i := len(definitions) - 1; i >= 0; i-- {
minimum := definitions[i].binding.min
remaining := len(operands) - suffixMinimum[i+1]
minimum := definitions[i].Min
remaining := total - suffixMinimum[i+1]
if minimum > remaining {
suffixMinimum[i] = len(operands)
suffixMinimum[i] = total
} else {
suffixMinimum[i] = suffixMinimum[i+1] + minimum
}
}
allocated := make([][]string, len(definitions))
counts := make([]int, len(definitions))
position := 0
for i, argument := range definitions {
binding := argument.binding
remaining := len(operands) - position
remaining := total - position
available := remaining - suffixMinimum[i+1]
if available < 0 {
available = 0
}
count := available
if binding.max >= 0 && count > binding.max {
count = binding.max
if argument.Max >= 0 && count > argument.Max {
count = argument.Max
}
if !enforceRequired && count < binding.min && remaining > 0 {
if !enforceRequired && count < argument.Min && remaining > 0 {
count = remaining
if binding.max >= 0 && count > binding.max {
count = binding.max
if argument.Max >= 0 && count > argument.Max {
count = argument.Max
}
}
if enforceRequired && count < binding.min {
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
if enforceRequired && count < argument.Min {
return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
}
allocated[i] = operands[position : position+count]
counts[i] = count
position += count
}
if position < len(operands) {
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
if position < total {
return nil, fmt.Errorf("received %d extra positional arguments", total-position)
}
return allocated, nil
return counts, nil
}

217
completion_flags.go Normal file
View File

@@ -0,0 +1,217 @@
package commandkit
import (
"strings"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
type completionTargetKind uint8
const (
completionTargetNone completionTargetKind = iota
completionTargetCommands
completionTargetFlags
completionTargetValue
completionTargetOperand
)
type completionTarget struct {
kind completionTargetKind
choices []commandmeta.CompletionChoice
hint commandmeta.CompletionHint
prefix string
valuePrefix string
}
type completionFlagState struct {
supplied map[string]bool
operands []string
pending *commandmeta.Flag
ended bool
invalid bool
}
func completionFlags(context completionContext) []commandmeta.Flag {
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
flags = append(flags, commandmeta.HelpFlag())
flags = append(flags, context.command.CoreFlags...)
flags = append(flags, context.command.Flags...)
return flags
}
func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) completionFlagState {
state := completionFlagState{supplied: make(map[string]bool)}
for index := 0; index < len(tokens); index++ {
token := tokens[index]
if state.ended {
state.operands = append(state.operands, token)
continue
}
switch commandmeta.ClassifyFlagToken(token) {
case commandmeta.TerminatorToken:
state.ended = true
case commandmeta.OperandToken:
state.operands = append(state.operands, token)
case commandmeta.LongFlagToken:
spelling, _, attached := commandmeta.SplitLongFlag(token)
flag, ok := lookup.Find(spelling)
if !ok {
state.invalid = true
return state
}
state.supplied[flag.Name] = true
if !flag.Boolean && !attached {
if index+1 == len(tokens) {
state.pending = &flag
return state
}
index++
}
case commandmeta.ShortFlagToken:
for offset := 1; offset < len(token); offset++ {
flag, ok := lookup.Find("-" + token[offset:offset+1])
if !ok {
state.invalid = true
return state
}
state.supplied[flag.Name] = true
suffix := token[offset+1:]
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
break
}
continue
}
if suffix == "" {
if index+1 == len(tokens) {
state.pending = &flag
return state
}
index++
}
break
}
}
}
return state
}
func completionTargetFor(snapshot *routingSnapshot, context completionContext) completionTarget {
if context.command == nil {
return completionTarget{}
}
flags := completionFlags(context)
lookup := commandmeta.NewFlagLookup(flags, context.aliases)
state := walkCompletionFlags(context.tail, lookup)
if state.invalid {
return completionTarget{}
}
if state.pending != nil {
return completionTarget{kind: completionTargetValue, hint: state.pending.Completion, valuePrefix: context.current}
}
if !state.ended && context.commandPosition {
if children := completionChildren(snapshot, context); len(children) > 0 {
return completionTarget{kind: completionTargetCommands, choices: children}
}
}
if !state.ended && strings.HasPrefix(context.current, "-") {
return completionOptionTarget(context.current, flags, lookup, state.supplied)
}
if hint, ok := completionOperandHint(context.command.Arguments, len(state.operands)); ok {
return completionTarget{kind: completionTargetOperand, hint: hint, valuePrefix: context.current}
}
if !state.ended && context.current == "" {
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, state.supplied, "")}
}
return completionTarget{}
}
func completionOptionTarget(current string, flags []commandmeta.Flag, lookup commandmeta.FlagLookup, supplied map[string]bool) completionTarget {
if current == "-" {
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
}
if strings.HasPrefix(current, "--") {
spelling, value, attached := commandmeta.SplitLongFlag(current)
if attached {
if flag, ok := lookup.Find(spelling); ok {
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
}
return completionTarget{}
}
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
}
used := make(map[string]bool, len(supplied))
for name, value := range supplied {
used[name] = value
}
for offset := 1; offset < len(current); offset++ {
flag, ok := lookup.Find("-" + current[offset:offset+1])
if !ok {
return completionTarget{}
}
used[flag.Name] = true
suffix := current[offset+1:]
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
}
continue
}
prefix := current[:offset+1]
if strings.HasPrefix(suffix, "=") {
prefix += "="
suffix = suffix[1:]
}
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
}
var choices []commandmeta.CompletionChoice
for _, flag := range flags {
if flag.Short != "" && completionFlagEligible(flag, used) {
choices = append(choices, commandmeta.CompletionChoice{Value: current + flag.Short, Description: flag.Usage})
}
}
return completionTarget{kind: completionTargetFlags, choices: choices}
}
func completionFlagNames(flags []commandmeta.Flag, supplied map[string]bool, prefix string) []commandmeta.CompletionChoice {
var choices []commandmeta.CompletionChoice
for _, flag := range flags {
if !completionFlagEligible(flag, supplied) {
continue
}
for _, spelling := range []string{"--" + flag.Name, shortCompletionSpelling(flag.Short)} {
if spelling != "" && strings.HasPrefix(spelling, prefix) {
choices = append(choices, commandmeta.CompletionChoice{Value: spelling, Description: flag.Usage})
}
}
}
return choices
}
func completionFlagEligible(flag commandmeta.Flag, supplied map[string]bool) bool {
return !supplied[flag.Name] || flag.Repeated || commandmeta.IsReportingFlag(flag.Name)
}
func shortCompletionSpelling(short string) string {
if short == "" {
return ""
}
return "-" + short
}
func completionOperandHint(arguments []commandmeta.Argument, completed int) (commandmeta.CompletionHint, bool) {
total := completed + 1
counts, err := allocateOperandCounts(total, arguments, false)
if err != nil {
return commandmeta.CompletionHint{}, false
}
position := 0
for index, count := range counts {
position += count
if position >= total {
return arguments[index].Completion, true
}
}
return commandmeta.CompletionHint{}, false
}

146
completion_flags_test.go Normal file
View File

@@ -0,0 +1,146 @@
package commandkit
import (
"reflect"
"testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
func TestCompletionFlagAndValuePositions(t *testing.T) {
parse := func(value string) (string, error) { return value, nil }
choice := CompleteChoices(CompletionChoice{Value: "blue"}, CompletionChoice{Value: "green"})
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Flags: []Flag[struct{}]{
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: choice}, (*string)(nil), parse, func(*struct{}, Supplied[string]) {}),
RepeatFlag(FlagMeta{Name: "tag", Short: "t", Completion: choice}, nil, parse, func(*struct{}, Supplied[[]string]) {}),
BoolFlag(FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, Supplied[bool]) {}),
},
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "example"}, command,
FlagAliasFor("run", "--colour", "--color"), FlagAliasFor("run", "-C", "--color"), FlagAlias("-v", "--verbose"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
for _, tc := range []struct {
name, current, prefix, value string
completed []string
kind completionTargetKind
hint commandmeta.CompletionKind
}{
{"pending takes option", "--quiet", "", "--quiet", []string{"run", "--color"}, completionTargetValue, commandmeta.CompletionChoices},
{"terminator consumed as value", "-x", "", "-x", []string{"run", "--color", "--", "--tag"}, completionTargetValue, commandmeta.CompletionChoices},
{"repeated singleton value", "--color=gr", "--color=", "gr", []string{"run", "--color=blue"}, completionTargetValue, commandmeta.CompletionChoices},
{"long alias value", "--colour=bl", "--colour=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"long empty value", "--color=", "--color=", "", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"short alias value", "-sCbl", "-sC", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"short equals", "-sC=bl", "-sC=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"reporting boolean", "--verbose=f", "--verbose=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"short boolean alias", "-v=f", "-v=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"application boolean", "--switch=f", "--switch=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionNone},
{"scoped alias unavailable at root", "--colour=bl", "", "", nil, completionTargetNone, commandmeta.CompletionNone},
{"unknown completed", "--c", "", "", []string{"run", "--unknown"}, completionTargetNone, commandmeta.CompletionNone},
{"unknown attached", "--unknown=x", "", "", []string{"run"}, completionTargetNone, commandmeta.CompletionNone},
{"terminator", "--color", "", "", []string{"run", "--"}, completionTargetNone, commandmeta.CompletionNone},
} {
t.Run(tc.name, func(t *testing.T) {
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, tc.completed, tc.current))
if target.kind != tc.kind || target.hint.Kind != tc.hint || target.prefix != tc.prefix || target.valuePrefix != tc.value {
t.Fatalf("target=%#v", target)
}
})
}
context := resolveCompletionContext(snapshot, []string{"run", "--colour=blue", "--tag=one", "--verbose"}, "--")
target := completionTargetFor(snapshot, context)
names := completionChoiceNames(target.choices)
if containsCompletionName(names, "--color") || !containsCompletionName(names, "--tag") || !containsCompletionName(names, "--verbose") {
t.Fatalf("flag suggestions=%q", names)
}
cluster := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-sv"))
if cluster.kind != completionTargetFlags || !containsCompletionName(completionChoiceNames(cluster.choices), "-svc") || containsCompletionName(completionChoiceNames(cluster.choices), "-svs") {
t.Fatalf("short cluster=%#v", cluster)
}
dash := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-"))
if !containsCompletionName(completionChoiceNames(dash.choices), "--color") || !containsCompletionName(completionChoiceNames(dash.choices), "-c") {
t.Fatalf("dash suggestions=%#v", dash)
}
}
func TestCompletionOperandAllocation(t *testing.T) {
arguments := []commandmeta.Argument{
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
{Name: "repeated", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}},
{Name: "required", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()},
}
for _, tc := range []struct {
completed int
want commandmeta.CompletionKind
}{
{0, commandmeta.CompletionChoices},
{1, commandmeta.CompletionChoices},
{2, commandmeta.CompletionChoices},
} {
hint, ok := completionOperandHint(arguments, tc.completed)
if !ok || hint.Kind != tc.want {
t.Fatalf("%d operands: %#v, %v", tc.completed, hint, ok)
}
}
bounded := []commandmeta.Argument{{Name: "only", Max: 1, Completion: commandmeta.BooleanCompletion()}}
if _, ok := completionOperandHint(bounded, 1); ok {
t.Fatal("excess operand received a hint")
}
trailing := []commandmeta.Argument{{Name: "first", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()}, {Name: "rest", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}}}
if hint, ok := completionOperandHint(trailing, 1); !ok || hint.Kind != commandmeta.CompletionDirectories {
t.Fatalf("repeated trailing operand: %#v, %v", hint, ok)
}
if got, want := completionChoiceNames(commandmeta.BooleanCompletion().Choices), []string{"true", "false"}; !reflect.DeepEqual(got, want) {
t.Fatalf("boolean choices=%q", got)
}
}
func TestCompletionOperandAndFlagContexts(t *testing.T) {
command := &commandmeta.Command{
Arguments: []commandmeta.Argument{{Name: "item", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}}},
Flags: []commandmeta.Flag{{Name: "name", Completion: commandmeta.BooleanCompletion()}},
}
for _, tc := range []struct {
name, current string
tail []string
kind completionTargetKind
hint commandmeta.CompletionKind
}{
{"empty chooses operand", "", nil, completionTargetOperand, commandmeta.CompletionFiles},
{"dash chooses flag", "--n", nil, completionTargetFlags, commandmeta.CompletionNone},
{"after terminator", "--n", []string{"--"}, completionTargetOperand, commandmeta.CompletionFiles},
{"interspersed flag value", "value", []string{"--name", "--quiet"}, completionTargetOperand, commandmeta.CompletionFiles},
{"excess operand", "value", []string{"already"}, completionTargetNone, commandmeta.CompletionNone},
} {
t.Run(tc.name, func(t *testing.T) {
target := completionTargetFor(nil, completionContext{command: command, tail: tc.tail, current: tc.current})
if target.kind != tc.kind || target.hint.Kind != tc.hint {
t.Fatalf("target=%#v", target)
}
})
}
}
func completionChoiceNames(choices []commandmeta.CompletionChoice) []string {
var names []string
for _, choice := range choices {
names = append(names, choice.Value)
}
return names
}
func containsCompletionName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}

View File

@@ -1,6 +1,6 @@
# Shell completion implementation plan
Status: Stages 1–4 complete. Stage 5 is next.
Status: Stages 1–5 complete. Stage 6 is next.
Implement the [shell completion roadmap](completion.md) from baseline
`2301872`. Follow the stages in order in one sprint. Intermediate stages may
@@ -184,6 +184,8 @@ The root package suite passes with `GOWORK=off` and `GOTOOLCHAIN=local`.
## Stage 5: Interpret flags and locate value positions
Status: complete.
1. Walk the completed tail using the same canonical and alias spellings and
flag token rules as normal parsing. Factor shared token classification
from `internal/cobra/flags.go` into neutral helpers where needed; keep
@@ -216,6 +218,15 @@ The root package suite passes with `GOWORK=off` and `GOTOOLCHAIN=local`.
operands, terminators, and optional/repeated operands before a required
suffix. Keep existing normal execution tests green at the sprint boundary.
Completion: shared neutral token classification and flag lookup with normal
parsing, and extracted operand count allocation for execution and completion.
The completion walk records consumed flags and operands, locates pending or
attached values, preserves entered prefixes, and filters canonical flag names
by supplied identity. Tests cover aliases, short clusters, repeated flags,
boolean assignments, option-looking values, terminators, interspersed operands,
and operand cardinality. Root, adapter, and neutral metadata package tests pass
with `GOWORK=off` and `GOTOOLCHAIN=local`.
## Stage 6: Assemble candidates and deliver query replies
1. Produce a neutral result of candidate replacements, optional descriptions,

View File

@@ -8,52 +8,27 @@ 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.HelpFlag()
canonical["--help"] = help
canonical["-h"] = help
for _, flag := range flags {
canonical["--"+flag.Name] = flag
if flag.Short != "" {
canonical["-"+flag.Short] = flag
}
}
byAlias := make(map[string]commandmeta.Flag, len(aliases))
for _, alias := range aliases {
if target, ok := canonical[alias.Target]; ok {
byAlias[alias.Source] = target
}
}
lookup := commandmeta.NewFlagLookup(flags, aliases)
spellings := make(map[string][]string)
var occurrences []commandmeta.FlagOccurrence
var interpreted []string
for index := 0; index < len(argv); {
token := argv[index]
if token == "--" {
kind := commandmeta.ClassifyFlagToken(token)
if kind == commandmeta.TerminatorToken {
interpreted = append(interpreted, argv[index:]...)
break
}
if strings.HasPrefix(token, "--") && len(token) > 2 {
body := token[2:]
equal := strings.IndexByte(body, '=')
inputName := body
if equal >= 0 {
inputName = body[:equal]
}
original := "--" + inputName
flag, ok := canonical[original]
if !ok {
flag, ok = byAlias[original]
}
if kind == commandmeta.LongFlagToken {
original, value, attached := commandmeta.SplitLongFlag(token)
flag, ok := lookup.Find(original)
if !ok {
interpreted = append(interpreted, token)
return interpreted, spellings, occurrences, nil
}
spellings[flag.Name] = append(spellings[flag.Name], original)
if equal >= 0 {
value := body[equal+1:]
if attached {
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index++
@@ -74,19 +49,11 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm
index++
continue
}
if len(token) > 1 && token[0] == '-' {
if token == "-" {
interpreted = append(interpreted, token)
index++
continue
}
if kind == commandmeta.ShortFlagToken {
consumedNext := false
for offset := 1; offset < len(token); {
original := "-" + token[offset:offset+1]
flag, ok := canonical[original]
if !ok {
flag, ok = byAlias[original]
}
flag, ok := lookup.Find(original)
if !ok {
interpreted = append(interpreted, "-"+token[offset:])
return interpreted, spellings, occurrences, nil

View File

@@ -0,0 +1,67 @@
package commandmeta
import "strings"
// FlagTokenKind identifies the portion of the shared option grammar a token uses.
type FlagTokenKind uint8
const (
OperandToken FlagTokenKind = iota
TerminatorToken
LongFlagToken
ShortFlagToken
)
// ClassifyFlagToken distinguishes option tokens without consuming their values.
func ClassifyFlagToken(token string) FlagTokenKind {
if token == "--" {
return TerminatorToken
}
if strings.HasPrefix(token, "--") && len(token) > 2 {
return LongFlagToken
}
if len(token) > 1 && token[0] == '-' {
return ShortFlagToken
}
return OperandToken
}
// SplitLongFlag separates the entered spelling from an optional attached value.
func SplitLongFlag(token string) (spelling, value string, attached bool) {
return strings.Cut(token, "=")
}
// FlagLookup maps canonical and applicable alias spellings to neutral flags.
type FlagLookup struct {
canonical map[string]Flag
aliases map[string]Flag
}
// NewFlagLookup includes the built-in help flag alongside the supplied flags.
func NewFlagLookup(flags []Flag, aliases []FlagAlias) FlagLookup {
canonical := make(map[string]Flag, len(flags)*2+2)
help := HelpFlag()
canonical["--help"], canonical["-h"] = help, help
for _, flag := range flags {
canonical["--"+flag.Name] = flag
if flag.Short != "" {
canonical["-"+flag.Short] = flag
}
}
lookup := FlagLookup{canonical: canonical, aliases: make(map[string]Flag, len(aliases))}
for _, alias := range aliases {
if target, ok := canonical[alias.Target]; ok {
lookup.aliases[alias.Source] = target
}
}
return lookup
}
// Find returns the canonical flag for an entered spelling.
func (lookup FlagLookup) Find(spelling string) (Flag, bool) {
flag, ok := lookup.canonical[spelling]
if !ok {
flag, ok = lookup.aliases[spelling]
}
return flag, ok
}