diff --git a/binding.go b/binding.go index 55b3f99..60c69eb 100644 --- a/binding.go +++ b/binding.go @@ -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 } diff --git a/completion_flags.go b/completion_flags.go new file mode 100644 index 0000000..1988e17 --- /dev/null +++ b/completion_flags.go @@ -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 +} diff --git a/completion_flags_test.go b/completion_flags_test.go new file mode 100644 index 0000000..42f941f --- /dev/null +++ b/completion_flags_test.go @@ -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 +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 271a63b..bbf3b9c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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, diff --git a/internal/cobra/flags.go b/internal/cobra/flags.go index af11470..75c9e12 100644 --- a/internal/cobra/flags.go +++ b/internal/cobra/flags.go @@ -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 diff --git a/internal/commandmeta/flags.go b/internal/commandmeta/flags.go new file mode 100644 index 0000000..c20860b --- /dev/null +++ b/internal/commandmeta/flags.go @@ -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 +}