From 75fc45f072d095ea9e4a0e81712b93ba85272143 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 19 Oct 2026 19:31:23 +0000 Subject: [PATCH] Interpret command flag aliases in the parser --- docs/roadmap/implementation.md | 9 +- execute.go | 26 +++++- flag_aliases.go | 15 ++++ internal/cobra/flags.go | 122 +++++++++++++++++++++++++++ internal/cobra/flags_test.go | 137 +++++++++++++++++++++++++++++++ internal/cobra/parse.go | 34 +++++++- internal/commandmeta/metadata.go | 16 +++- route_test.go | 41 +++++++++ 8 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 internal/cobra/flags.go create mode 100644 internal/cobra/flags_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3fbcf09..c03352a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Application-owned aliases implementation plan -Status: in progress; Stages 1–7 completed. +Status: in progress; Stages 1–8 completed. ## Authority, baseline, and scope @@ -465,6 +465,13 @@ canonical/alias clusters; `-cvq` consuming `vq`; `-qv=false`; explicit empty values for long, short, and aliased spellings; missing values; unknown cluster members; `--message --hlep` as a value; `--message=--hlep`; post-`--` operands. +**Completed:** The routed flag context now reaches the adapter through neutral +flag-alias records. One adapter pass interprets canonical and alternate long +and short spellings, preserves option values and terminator operands, and feeds +only canonical flags to pflag. Raw values retain per-occurrence source +spellings. Canonical short empty values now follow the same long-form boundary. +Verified with root and both integration module suites using the local workspace. + ## Stage 9: Wire execution and invocation identity **Work** diff --git a/execute.go b/execute.go index 8ed0663..a5d3e51 100644 --- a/execute.go +++ b/execute.go @@ -80,7 +80,6 @@ func newExecution(app *App, ctx context.Context, streams Streams) *execution { } if app != nil { current.snapshot = app.routeSnapshot() - current.session = internalcobra.Prepare(current.snapshot.metadata, current.options) } return current } @@ -89,9 +88,31 @@ func (current *execution) parse(args []string) commandmeta.Result { if current.app == nil { return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}} } + if current.session == nil { + current.prepareSession() + } return current.session.Parse(args) } +func (current *execution) prepareSession() { + if current.app == nil { + return + } + if current.snapshot == nil { + current.snapshot = current.app.routeSnapshot() + } + options := current.options + if current.snapshot != nil { + switch current.route.kind { + case routeCanonical: + options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords() + case routeDiscovery: + options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords() + } + current.session = internalcobra.Prepare(current.snapshot.metadata, options) + } +} + func (current *execution) presentationSession() *internalcobra.Session { if current.app == nil { return nil @@ -99,7 +120,8 @@ func (current *execution) presentationSession() *internalcobra.Session { snapshot := current.app.routeSnapshot() if current.session == nil || current.snapshot != snapshot { current.snapshot = snapshot - current.session = internalcobra.Prepare(snapshot.metadata, current.options) + current.session = nil + current.prepareSession() } return current.session } diff --git a/flag_aliases.go b/flag_aliases.go index fc06c68..d87841a 100644 --- a/flag_aliases.go +++ b/flag_aliases.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" ) @@ -28,6 +29,20 @@ type flagAliasContext struct { discovery bool } +func (aliases resolvedFlagAliases) neutralRecords() []commandmeta.FlagAlias { + keys := make([]string, 0, len(aliases.bySpelling)) + for key := range aliases.bySpelling { + keys = append(keys, key) + } + sort.Strings(keys) + records := make([]commandmeta.FlagAlias, 0, len(keys)) + for _, key := range keys { + alias := aliases.bySpelling[key] + records = append(records, commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()}) + } + return records +} + func (spelling capturedFlagSpelling) token() string { if spelling.short { return "-" + spelling.name diff --git a/internal/cobra/flags.go b/internal/cobra/flags.go new file mode 100644 index 0000000..3938cea --- /dev/null +++ b/internal/cobra/flags.go @@ -0,0 +1,122 @@ +package cobra + +import ( + "strings" + + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +) + +func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []commandmeta.FlagAlias) ([]string, map[string][]string) { + canonical := make(map[string]commandmeta.Flag, len(flags)*2+2) + help := commandmeta.Flag{Name: "help", Short: "h", Boolean: true} + 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) + interpreted := make([]string, 0, len(argv)) + for index := 0; index < len(argv); { + token := argv[index] + if token == "--" { + 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 !ok { + interpreted = append(interpreted, token) + index++ + continue + } + spellings[flag.Name] = append(spellings[flag.Name], original) + if equal >= 0 { + interpreted = append(interpreted, "--"+flag.Name+"="+body[equal+1:]) + index++ + continue + } + if !flag.Boolean && index+1 < len(argv) && argv[index+1] != "--" { + interpreted = append(interpreted, "--"+flag.Name+"="+argv[index+1]) + index += 2 + continue + } + interpreted = append(interpreted, "--"+flag.Name) + index++ + continue + } + if len(token) > 1 && token[0] == '-' { + if token == "-" { + interpreted = append(interpreted, token) + index++ + continue + } + consumedNext := false + for offset := 1; offset < len(token); { + original := "-" + token[offset:offset+1] + flag, ok := canonical[original] + if !ok { + flag, ok = byAlias[original] + } + if !ok { + interpreted = append(interpreted, "-"+token[offset:]) + break + } + spellings[flag.Name] = append(spellings[flag.Name], original) + suffix := token[offset+1:] + if flag.Boolean { + if strings.HasPrefix(suffix, "=") { + interpreted = append(interpreted, "--"+flag.Name+"="+suffix[1:]) + break + } + interpreted = append(interpreted, "--"+flag.Name) + offset++ + continue + } + if suffix != "" { + value := suffix + if strings.HasPrefix(value, "=") { + value = value[1:] + } + interpreted = append(interpreted, "--"+flag.Name+"="+value) + break + } + if index+1 < len(argv) && argv[index+1] != "--" { + interpreted = append(interpreted, "--"+flag.Name+"="+argv[index+1]) + index += 2 + consumedNext = true + break + } + interpreted = append(interpreted, "--"+flag.Name) + break + } + if !consumedNext { + index++ + } + continue + } + interpreted = append(interpreted, token) + index++ + } + return interpreted, spellings +} diff --git a/internal/cobra/flags_test.go b/internal/cobra/flags_test.go new file mode 100644 index 0000000..f77dc6c --- /dev/null +++ b/internal/cobra/flags_test.go @@ -0,0 +1,137 @@ +package cobra + +import ( + "reflect" + "testing" + + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +) + +func flagGrammarTree() *commandmeta.Command { + return &commandmeta.Command{Name: "app", Children: []*commandmeta.Command{{ + Name: "run", Runnable: true, + Flags: []commandmeta.Flag{ + {Name: "color", Short: "c"}, + {Name: "verbose", Short: "v", Boolean: true}, + {Name: "quiet", Short: "q", Boolean: true}, + {Name: "message", Short: "m"}, + {Name: "tag", Short: "t", Repeated: true}, + }, + }}} +} + +func flagGrammarOptions() ParseOptions { + return ParseOptions{FlagAliases: []commandmeta.FlagAlias{ + {Source: "--colour", Target: "--color"}, + {Source: "-C", Target: "--color"}, + {Source: "-w", Target: "--verbose"}, + {Source: "-z", Target: "--quiet"}, + {Source: "--taggy", Target: "-t"}, + {Source: "--hlep", Target: "--help"}, + }} +} + +func TestParseCanonicalAndAliasShortGrammar(t *testing.T) { + for _, test := range []struct { + name string + args []string + values [][]string + spellings [][]string + }{ + {"boolean cluster", []string{"run", "-vq"}, [][]string{nil, {"true"}, {"true"}, nil, nil}, [][]string{nil, {"-v"}, {"-q"}, nil, nil}}, + {"aliased boolean cluster", []string{"run", "-wz"}, [][]string{nil, {"true"}, {"true"}, nil, nil}, [][]string{nil, {"-w"}, {"-z"}, nil, nil}}, + {"value short separated", []string{"run", "-c", "blue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}}, + {"value short equals", []string{"run", "-c=blue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}}, + {"value short attached", []string{"run", "-cblue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}}, + {"long alias", []string{"run", "--colour=blue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"--colour"}, nil, nil, nil, nil}}, + {"short alias", []string{"run", "-Cblue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-C"}, nil, nil, nil, nil}}, + {"cluster value consumes suffix", []string{"run", "-vqcblue"}, [][]string{{"blue"}, {"true"}, {"true"}, nil, nil}, [][]string{{"-c"}, {"-v"}, {"-q"}, nil, nil}}, + {"cluster value consumes next", []string{"run", "-vqc", "blue"}, [][]string{{"blue"}, {"true"}, {"true"}, nil, nil}, [][]string{{"-c"}, {"-v"}, {"-q"}, nil, nil}}, + {"mixed canonical and alias cluster", []string{"run", "-wqcblue"}, [][]string{{"blue"}, {"true"}, {"true"}, nil, nil}, [][]string{{"-c"}, {"-w"}, {"-q"}, nil, nil}}, + {"value takes remaining suffix", []string{"run", "-cvq"}, [][]string{{"vq"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}}, + {"boolean explicit false", []string{"run", "-v=false"}, [][]string{nil, {"false"}, nil, nil, nil}, [][]string{nil, {"-v"}, nil, nil, nil}}, + {"boolean explicit false in cluster", []string{"run", "-qv=false"}, [][]string{nil, {"false"}, {"true"}, nil, nil}, [][]string{nil, {"-v"}, {"-q"}, nil, nil}}, + {"empty canonical short", []string{"run", "-c="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}}, + {"empty canonical long", []string{"run", "--color="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"--color"}, nil, nil, nil, nil}}, + {"empty separated short", []string{"run", "-c", ""}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}}, + {"empty aliased long", []string{"run", "--colour="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"--colour"}, nil, nil, nil, nil}}, + {"empty aliased short", []string{"run", "-C="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"-C"}, nil, nil, nil, nil}}, + {"empty boolean short", []string{"run", "-v="}, [][]string{nil, {""}, nil, nil, nil}, [][]string{nil, {"-v"}, nil, nil, nil}}, + } { + t.Run(test.name, func(t *testing.T) { + result := Parse(flagGrammarTree(), test.args, flagGrammarOptions()) + if result.Failure != nil { + t.Fatalf("parse failed: %s", result.Failure.Message) + } + if !reflect.DeepEqual(result.FlagValues, test.values) { + t.Errorf("values = %#v, want %#v", result.FlagValues, test.values) + } + if !reflect.DeepEqual(result.FlagSpellings, test.spellings) { + t.Errorf("spellings = %#v, want %#v", result.FlagSpellings, test.spellings) + } + }) + } +} + +func TestParseFlagAliasesPreserveValuesAndTerminators(t *testing.T) { + result := Parse(flagGrammarTree(), []string{"run", "--message", "--hlep", "--message=--hlep", "--taggy", "one", "--", "--taggy"}, flagGrammarOptions()) + if result.Failure != nil { + t.Fatalf("parse failed: %s", result.Failure.Message) + } + if got, want := result.FlagValues[3], []string{"--hlep", "--hlep"}; !reflect.DeepEqual(got, want) { + t.Fatalf("message values = %#v, want %#v", got, want) + } + if got, want := result.FlagSpellings[3], []string{"--message", "--message"}; !reflect.DeepEqual(got, want) { + t.Fatalf("message spellings = %#v, want %#v", got, want) + } + if got, want := result.FlagValues[4], []string{"one"}; !reflect.DeepEqual(got, want) { + t.Fatalf("tag values = %#v, want %#v", got, want) + } + if got, want := result.Operands, []string{"--taggy"}; !reflect.DeepEqual(got, want) { + t.Fatalf("operands = %#v, want %#v", got, want) + } +} + +func TestParseHelpAliasAndUnknownShortMembers(t *testing.T) { + result := Parse(flagGrammarTree(), []string{"run", "--hlep"}, flagGrammarOptions()) + if result.Failure != nil || !result.HelpRequested { + t.Fatalf("help alias result = %#v", result) + } + result = Parse(flagGrammarTree(), []string{"run", "-vx"}, flagGrammarOptions()) + if result.Failure == nil || result.Failure.Message == "" { + t.Fatalf("unknown short cluster member result = %#v", result) + } + result = Parse(flagGrammarTree(), []string{"run", "--color"}, flagGrammarOptions()) + if result.Failure == nil || result.Failure.Message == "" { + t.Fatalf("missing value result = %#v", result) + } + result = Parse(flagGrammarTree(), []string{"run", "--colour"}, flagGrammarOptions()) + if result.Failure == nil || result.Failure.Message == "" { + t.Fatalf("missing aliased value result = %#v", result) + } + result = Parse(flagGrammarTree(), []string{"run", "-c"}, flagGrammarOptions()) + if result.Failure == nil || result.Failure.Message == "" { + t.Fatalf("missing short value result = %#v", result) + } + result = Parse(flagGrammarTree(), []string{"run", "-C"}, flagGrammarOptions()) + if result.Failure == nil || result.Failure.Message == "" { + t.Fatalf("missing aliased short value result = %#v", result) + } + result = Parse(flagGrammarTree(), []string{"run", "-vfalse"}, flagGrammarOptions()) + if result.Failure == nil || result.Failure.Message == "" { + t.Fatalf("boolean suffix without equals result = %#v", result) + } +} + +func TestParseFlagAliasOccurrenceOrderAndSpellings(t *testing.T) { + result := Parse(flagGrammarTree(), []string{"run", "--taggy=first", "-t", "second", "--taggy", "third"}, flagGrammarOptions()) + if result.Failure != nil { + t.Fatalf("parse failed: %s", result.Failure.Message) + } + if got, want := result.FlagValues[4], []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) { + t.Fatalf("tag values = %#v, want %#v", got, want) + } + if got, want := result.FlagSpellings[4], []string{"--taggy", "-t", "--taggy"}; !reflect.DeepEqual(got, want) { + t.Fatalf("tag spellings = %#v, want %#v", got, want) + } +} diff --git a/internal/cobra/parse.go b/internal/cobra/parse.go index 474ccb5..13c374a 100644 --- a/internal/cobra/parse.go +++ b/internal/cobra/parse.go @@ -22,6 +22,7 @@ type Session struct { metadata *commandmeta.Command root *spfcobra.Command declarations map[*spfcobra.Command]*commandmeta.Command + flagAliases []commandmeta.FlagAlias } // Prepare constructs a fresh backend tree for one invocation. @@ -31,6 +32,7 @@ func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Sessi return session } session.metadata = root + session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...) options = normalizeOptions(options) session.root, session.declarations = buildTree(root, options) return session @@ -75,16 +77,28 @@ func (session *Session) Parse(argv []string) commandmeta.Result { } remaining := append(commandpath.CloneTokens(unconsumed), tail...) - if err := selected.ParseFlags(remaining); err != nil { + interpreted, spellings := interpretFlagTokens(remaining, selectedNode.Flags, session.flagAliases) + for _, flag := range selectedNode.Flags { + if value := selected.Flags().Lookup(flag.Name); value != nil { + value.Value.(*rawValue).queueSpellings(spellings[flag.Name]) + } + } + if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil { + helpFlag.Value.(*rawValue).queueSpellings(spellings["help"]) + } + if err := selected.ParseFlags(interpreted); err != nil { return failure(result, err.Error()) } operands := selected.Flags().Args() result.Operands = append([]string(nil), operands...) result.FlagValues = make([][]string, len(selectedNode.Flags)) + result.FlagSpellings = make([][]string, len(selectedNode.Flags)) for index, flag := range selectedNode.Flags { if value := selected.Flags().Lookup(flag.Name); value != nil { - result.FlagValues[index] = value.Value.(*rawValue).valuesCopy() + raw := value.Value.(*rawValue) + result.FlagValues[index] = raw.valuesCopy() + result.FlagSpellings[index] = raw.spellingsCopy() } } if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil { @@ -337,15 +351,25 @@ func failure(result commandmeta.Result, message string) commandmeta.Result { } type rawValue struct { - values []string - boolean bool + values []string + spellings []string + pendingSpellings []string + boolean bool } func (v *rawValue) Set(value string) error { v.values = append(v.values, value) + if len(v.pendingSpellings) > 0 { + v.spellings = append(v.spellings, v.pendingSpellings[0]) + v.pendingSpellings = v.pendingSpellings[1:] + } return nil } +func (v *rawValue) queueSpellings(spellings []string) { + v.pendingSpellings = append(v.pendingSpellings, spellings...) +} + func (v *rawValue) String() string { if len(v.values) > 0 { return v.values[len(v.values)-1] @@ -364,6 +388,8 @@ func (v *rawValue) IsBoolFlag() bool { return v.boolean } func (v *rawValue) valuesCopy() []string { return append([]string(nil), v.values...) } +func (v *rawValue) spellingsCopy() []string { return append([]string(nil), v.spellings...) } + func (v *rawValue) lastBool() (bool, error) { var value bool for _, raw := range v.values { diff --git a/internal/commandmeta/metadata.go b/internal/commandmeta/metadata.go index 864e801..697fae9 100644 --- a/internal/commandmeta/metadata.go +++ b/internal/commandmeta/metadata.go @@ -41,6 +41,12 @@ type Flag struct { Required bool } +// FlagAlias maps one option spelling to a canonical flag spelling. +type FlagAlias struct { + Source string + Target string +} + // Failure is a neutral parsing or presentation failure. type Failure struct { Message string @@ -53,14 +59,16 @@ type Result struct { Path []string HelpRequested bool FlagValues [][]string + FlagSpellings [][]string Operands []string Failure *Failure } // ParseOptions makes parser context and streams explicit. type ParseOptions struct { - Context context.Context - In io.Reader - Out io.Writer - Err io.Writer + Context context.Context + In io.Reader + Out io.Writer + Err io.Writer + FlagAliases []FlagAlias } diff --git a/route_test.go b/route_test.go index f5d4cc6..aba4af7 100644 --- a/route_test.go +++ b/route_test.go @@ -5,6 +5,7 @@ import ( "context" "io" "reflect" + "strings" "testing" ) @@ -189,6 +190,46 @@ func TestExecuteDispatchesEnteredAliasToCanonicalRunner(t *testing.T) { } } +func TestExecuteAppliesCanonicalCommandFlagAliasesAfterRouting(t *testing.T) { + type input struct{ color string } + var got string + command := NewCommand(CommandSpec[input]{ + Name: "inspect", + Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "color", Short: "c"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(target *input, supplied Supplied[string]) { target.color = supplied.Value }, + )}, + Run: func(_ Invocation, value input) Outcome { + got = value.color + return Success() + }, + }) + app, err := New(Identity{Name: "Example"}, command, + Alias("shortcut", "inspect"), + FlagAliasFor("inspect", "--colour", "-c"), + ) + if err != nil { + t.Fatal(err) + } + + outcome := app.Execute(context.Background(), []string{"shortcut", "--colour=blue"}, Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != OutcomeSuccess || got != "blue" { + t.Fatalf("alias flag execution = (%v, color %q), want success and blue", outcome.Kind, got) + } +} + +func TestExecuteAppliesGlobalHelpAliasAtApplicationRoot(t *testing.T) { + app, err := New(Identity{Name: "Example"}, FlagAlias("--hlep", "--help")) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"--hlep"}, Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), "Usage:") { + t.Fatalf("root help alias outcome = %v, output %q", outcome.Kind, output.String()) + } +} + func TestAliasOnlyPrefixProducesDiscoveryRoute(t *testing.T) { app, err := New(Identity{Name: "Example"}, Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),