Interpret command flag aliases in the parser

This commit is contained in:
2026-10-19 19:31:23 +00:00
parent 58b71c390f
commit 75fc45f072
8 changed files with 389 additions and 11 deletions

View File

@@ -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**

View File

@@ -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
}

View File

@@ -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

122
internal/cobra/flags.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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"))),