Interpret flags and operands for completion
This commit is contained in:
62
binding.go
62
binding.go
@@ -4,6 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
// 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 newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||||
|
|
||||||
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
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 {
|
for _, argument := range definitions {
|
||||||
binding := argument.binding
|
if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
|
||||||
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", argument.Name, argument.Min, argument.Max)
|
||||||
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired {
|
if enforceRequired {
|
||||||
minimum := 0
|
minimum := 0
|
||||||
for _, argument := range definitions {
|
for _, argument := range definitions {
|
||||||
if argument.binding.min > len(operands)-minimum {
|
if argument.Min > total-minimum {
|
||||||
return nil, fmt.Errorf("not enough positional arguments")
|
return nil, fmt.Errorf("not enough positional arguments")
|
||||||
}
|
}
|
||||||
minimum += argument.binding.min
|
minimum += argument.Min
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suffixMinimum := make([]int, len(definitions)+1)
|
suffixMinimum := make([]int, len(definitions)+1)
|
||||||
for i := len(definitions) - 1; i >= 0; i-- {
|
for i := len(definitions) - 1; i >= 0; i-- {
|
||||||
minimum := definitions[i].binding.min
|
minimum := definitions[i].Min
|
||||||
remaining := len(operands) - suffixMinimum[i+1]
|
remaining := total - suffixMinimum[i+1]
|
||||||
if minimum > remaining {
|
if minimum > remaining {
|
||||||
suffixMinimum[i] = len(operands)
|
suffixMinimum[i] = total
|
||||||
} else {
|
} else {
|
||||||
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allocated := make([][]string, len(definitions))
|
counts := make([]int, len(definitions))
|
||||||
position := 0
|
position := 0
|
||||||
for i, argument := range definitions {
|
for i, argument := range definitions {
|
||||||
binding := argument.binding
|
remaining := total - position
|
||||||
remaining := len(operands) - position
|
|
||||||
available := remaining - suffixMinimum[i+1]
|
available := remaining - suffixMinimum[i+1]
|
||||||
if available < 0 {
|
if available < 0 {
|
||||||
available = 0
|
available = 0
|
||||||
}
|
}
|
||||||
count := available
|
count := available
|
||||||
if binding.max >= 0 && count > binding.max {
|
if argument.Max >= 0 && count > argument.Max {
|
||||||
count = binding.max
|
count = argument.Max
|
||||||
}
|
}
|
||||||
if !enforceRequired && count < binding.min && remaining > 0 {
|
if !enforceRequired && count < argument.Min && remaining > 0 {
|
||||||
count = remaining
|
count = remaining
|
||||||
if binding.max >= 0 && count > binding.max {
|
if argument.Max >= 0 && count > argument.Max {
|
||||||
count = binding.max
|
count = argument.Max
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired && count < binding.min {
|
if enforceRequired && count < argument.Min {
|
||||||
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
|
return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
|
||||||
}
|
}
|
||||||
allocated[i] = operands[position : position+count]
|
counts[i] = count
|
||||||
position += count
|
position += count
|
||||||
}
|
}
|
||||||
if position < len(operands) {
|
if position < total {
|
||||||
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
|
return nil, fmt.Errorf("received %d extra positional arguments", total-position)
|
||||||
}
|
}
|
||||||
return allocated, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
217
completion_flags.go
Normal file
217
completion_flags.go
Normal 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
146
completion_flags_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Shell completion implementation plan
|
# 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
|
Implement the [shell completion roadmap](completion.md) from baseline
|
||||||
`2301872`. Follow the stages in order in one sprint. Intermediate stages may
|
`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
|
## Stage 5: Interpret flags and locate value positions
|
||||||
|
|
||||||
|
Status: complete.
|
||||||
|
|
||||||
1. Walk the completed tail using the same canonical and alias spellings and
|
1. Walk the completed tail using the same canonical and alias spellings and
|
||||||
flag token rules as normal parsing. Factor shared token classification
|
flag token rules as normal parsing. Factor shared token classification
|
||||||
from `internal/cobra/flags.go` into neutral helpers where needed; keep
|
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
|
operands, terminators, and optional/repeated operands before a required
|
||||||
suffix. Keep existing normal execution tests green at the sprint boundary.
|
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
|
## Stage 6: Assemble candidates and deliver query replies
|
||||||
|
|
||||||
1. Produce a neutral result of candidate replacements, optional descriptions,
|
1. Produce a neutral result of candidate replacements, optional descriptions,
|
||||||
|
|||||||
@@ -8,52 +8,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []commandmeta.FlagAlias) ([]string, map[string][]string, []commandmeta.FlagOccurrence, *commandmeta.Failure) {
|
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)
|
lookup := commandmeta.NewFlagLookup(flags, aliases)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spellings := make(map[string][]string)
|
spellings := make(map[string][]string)
|
||||||
var occurrences []commandmeta.FlagOccurrence
|
var occurrences []commandmeta.FlagOccurrence
|
||||||
var interpreted []string
|
var interpreted []string
|
||||||
for index := 0; index < len(argv); {
|
for index := 0; index < len(argv); {
|
||||||
token := argv[index]
|
token := argv[index]
|
||||||
if token == "--" {
|
kind := commandmeta.ClassifyFlagToken(token)
|
||||||
|
if kind == commandmeta.TerminatorToken {
|
||||||
interpreted = append(interpreted, argv[index:]...)
|
interpreted = append(interpreted, argv[index:]...)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(token, "--") && len(token) > 2 {
|
if kind == commandmeta.LongFlagToken {
|
||||||
body := token[2:]
|
original, value, attached := commandmeta.SplitLongFlag(token)
|
||||||
equal := strings.IndexByte(body, '=')
|
flag, ok := lookup.Find(original)
|
||||||
inputName := body
|
|
||||||
if equal >= 0 {
|
|
||||||
inputName = body[:equal]
|
|
||||||
}
|
|
||||||
original := "--" + inputName
|
|
||||||
flag, ok := canonical[original]
|
|
||||||
if !ok {
|
|
||||||
flag, ok = byAlias[original]
|
|
||||||
}
|
|
||||||
if !ok {
|
if !ok {
|
||||||
interpreted = append(interpreted, token)
|
interpreted = append(interpreted, token)
|
||||||
return interpreted, spellings, occurrences, nil
|
return interpreted, spellings, occurrences, nil
|
||||||
}
|
}
|
||||||
spellings[flag.Name] = append(spellings[flag.Name], original)
|
spellings[flag.Name] = append(spellings[flag.Name], original)
|
||||||
if equal >= 0 {
|
if attached {
|
||||||
value := body[equal+1:]
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
||||||
index++
|
index++
|
||||||
@@ -74,19 +49,11 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm
|
|||||||
index++
|
index++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(token) > 1 && token[0] == '-' {
|
if kind == commandmeta.ShortFlagToken {
|
||||||
if token == "-" {
|
|
||||||
interpreted = append(interpreted, token)
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
consumedNext := false
|
consumedNext := false
|
||||||
for offset := 1; offset < len(token); {
|
for offset := 1; offset < len(token); {
|
||||||
original := "-" + token[offset:offset+1]
|
original := "-" + token[offset:offset+1]
|
||||||
flag, ok := canonical[original]
|
flag, ok := lookup.Find(original)
|
||||||
if !ok {
|
|
||||||
flag, ok = byAlias[original]
|
|
||||||
}
|
|
||||||
if !ok {
|
if !ok {
|
||||||
interpreted = append(interpreted, "-"+token[offset:])
|
interpreted = append(interpreted, "-"+token[offset:])
|
||||||
return interpreted, spellings, occurrences, nil
|
return interpreted, spellings, occurrences, nil
|
||||||
|
|||||||
67
internal/commandmeta/flags.go
Normal file
67
internal/commandmeta/flags.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user