Share flag consumption between parsing and completion

This commit is contained in:
2026-11-07 15:53:32 +00:00
parent 51a147a6e5
commit 02cd49d6b6
6 changed files with 153 additions and 114 deletions

View File

@@ -53,44 +53,26 @@ func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) complet
state.ended = true state.ended = true
case commandmeta.OperandToken: case commandmeta.OperandToken:
state.operands = append(state.operands, token) state.operands = append(state.operands, token)
case commandmeta.LongFlagToken: case commandmeta.LongFlagToken, commandmeta.ShortFlagToken:
spelling, _, attached := commandmeta.SplitLongFlag(token) for offset := 1; ; {
flag, ok := lookup.Find(spelling) fact := commandmeta.ConsumeFlag(lookup, token, offset)
if !ok { if !fact.Found {
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 state.invalid = true
return state return state
} }
state.supplied[flag.Name] = true state.supplied[fact.Flag.Name] = true
suffix := token[offset+1:] if fact.NeedsNext {
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
break
}
continue
}
if suffix == "" {
if index+1 == len(tokens) { if index+1 == len(tokens) {
flag := fact.Flag
state.pending = &flag state.pending = &flag
return state return state
} }
index++ index++
} }
break if fact.NextOffset == 0 {
break
}
offset = fact.NextOffset
} }
} }
} }
@@ -132,10 +114,10 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)} return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
} }
if strings.HasPrefix(current, "--") { if strings.HasPrefix(current, "--") {
spelling, value, attached := commandmeta.SplitLongFlag(current) fact := commandmeta.ConsumeFlag(lookup, current, 1)
if attached { if fact.Attached {
if flag, ok := lookup.Find(spelling); ok { if fact.Found {
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value} return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: fact.Spelling + "=", valuePrefix: fact.Value}
} }
return completionTarget{} return completionTarget{}
} }
@@ -145,25 +127,26 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
for name, value := range supplied { for name, value := range supplied {
used[name] = value used[name] = value
} }
for offset := 1; offset < len(current); offset++ { for offset := 1; offset < len(current); {
flag, ok := lookup.Find("-" + current[offset:offset+1]) fact := commandmeta.ConsumeFlag(lookup, current, offset)
if !ok { if !fact.Found {
return completionTarget{} return completionTarget{}
} }
used[flag.Name] = true used[fact.Flag.Name] = true
suffix := current[offset+1:] if fact.Flag.Boolean && fact.Attached {
if flag.Boolean { return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: current[:offset+2], valuePrefix: fact.Value}
if strings.HasPrefix(suffix, "=") { }
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]} if !fact.Flag.Boolean {
prefix := current[:offset+1]
if strings.HasPrefix(current[offset+1:], "=") {
prefix += "="
} }
continue return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: prefix, valuePrefix: fact.Value}
} }
prefix := current[:offset+1] if fact.NextOffset == 0 {
if strings.HasPrefix(suffix, "=") { break
prefix += "="
suffix = suffix[1:]
} }
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix} offset = fact.NextOffset
} }
var choices []commandmeta.CompletionChoice var choices []commandmeta.CompletionChoice
for _, flag := range flags { for _, flag := range flags {

View File

@@ -79,3 +79,40 @@ func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
t.Fatalf("outcome=%#v input=%#v", outcome, received) t.Fatalf("outcome=%#v input=%#v", outcome, received)
} }
} }
func TestCompletionConsumesFlagGrammarAndKeepsPendingValues(t *testing.T) {
flags := []commandmeta.Flag{{Name: "verbose", Short: "v", Boolean: true}, {Name: "choice", Short: "c", Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices}}}
lookup := commandmeta.NewFlagLookup(flags, []commandmeta.FlagAlias{{Source: "-x", Target: "--choice"}})
for _, tc := range []struct {
name string
tokens []string
pending, operand string
invalid bool
}{
{name: "cluster attached empty", tokens: []string{"-vc="}, operand: ""},
{name: "option-looking value", tokens: []string{"-vx", "--other"}, operand: ""},
{name: "pending", tokens: []string{"-vx"}, pending: "choice"},
{name: "unknown cluster", tokens: []string{"-vz"}, invalid: true},
{name: "terminator", tokens: []string{"--", "--choice"}, operand: "--choice"},
} {
t.Run(tc.name, func(t *testing.T) {
state := walkCompletionFlags(tc.tokens, lookup)
if state.invalid != tc.invalid {
t.Fatalf("state=%#v", state)
}
if tc.pending != "" && (state.pending == nil || state.pending.Name != tc.pending) {
t.Fatalf("pending=%#v", state.pending)
}
if tc.pending == "" && state.pending != nil {
t.Fatalf("unexpected pending=%#v", state.pending)
}
if tc.operand != "" && (len(state.operands) != 1 || state.operands[0] != tc.operand) {
t.Fatalf("operands=%v", state.operands)
}
})
}
target := completionOptionTarget("-vc=", flags, lookup, nil)
if target.kind != completionTargetValue || target.prefix != "-vc=" || target.valuePrefix != "" {
t.Fatalf("partial target = %#v", target)
}
}

View File

@@ -1,6 +1,6 @@
# Consolidated audit catalog # Consolidated audit catalog
Status: **13 distinct open findings: 13 P3**, consolidated from 30 Status: **12 distinct open findings: 12 P3**, consolidated from 30
numbered entries in seven audit documents. Reviewed audit state: numbered entries in seven audit documents. Reviewed audit state:
`db732e3a1de55c844f77870e1c6db683c2685d5d`. `db732e3a1de55c844f77870e1c6db683c2685d5d`.
@@ -335,6 +335,11 @@ F14 under the decisions recorded there and in F13.
## F16 — P3: Parsing and completion duplicate flag-consumption grammar ## F16 — P3: Parsing and completion duplicate flag-consumption grammar
**Complete.** A neutral consumption operation is shared by execution parsing
and completion. Focused grammar, provenance and completion tests and available
Bash interactive checks pass; missing-value and partial-input policies remain
with their callers.
Execution parsing and completion separately implement short clusters, Execution parsing and completion separately implement short clusters,
attached/next values, boolean assignments, terminators, and unknown spellings. attached/next values, boolean assignments, terminators, and unknown spellings.
Shared classification helpers cover only part of the grammar, leaving changes to Shared classification helpers cover only part of the grammar, leaving changes to
@@ -350,7 +355,7 @@ CLI behavior; no public parser or replacement backend is selected.
Retain caller-specific branches where combining them would require extensive Retain caller-specific branches where combining them would require extensive
modes or special cases. Repair F06's completion fixture before validating this modes or special cases. Repair F06's completion fixture before validating this
refactor. The [F16 analysis](remediation-analysis.md#f16--shared-flag-consumption) refactor. The [F16 analysis](remediation-analysis.md#f16--shared-flag-consumption)
records the accepted boundary; implementation remains pending. records the accepted boundary.
**Original:** [03-complexity M3](03-complexity.md#m3--p3-parsing-and-completion-maintain-parallel-flag-consumption-logic). **Original:** [03-complexity M3](03-complexity.md#m3--p3-parsing-and-completion-maintain-parallel-flag-consumption-logic).

View File

@@ -1,6 +1,6 @@
# Audit remediation implementation plan # Audit remediation implementation plan
Status: in progress; Stages 1–13 complete. Status: in progress; Stages 1–14 complete.
Baseline: `d492c7747175c01a53261206c2b4356e53453621`. Before this sprint, Baseline: `d492c7747175c01a53261206c2b4356e53453621`. Before this sprint,
intervening changes recorded decisions and retired the completed older intervening changes recorded decisions and retired the completed older
@@ -470,6 +470,12 @@ Keep F24 open until the binding wrappers are handled in stage 20.
## Stage 14 — Share internal flag-consumption facts ## Stage 14 — Share internal flag-consumption facts
**Complete.** Parsing and completion share neutral consumption facts while
retaining their separate failure and partial-input policies. Focused parser,
alias, occurrence and completion tests and both interactive Bash checks passed
in the offline aligned Go 1.27.0 workspace. Zsh and Fish remain unavailable as
recorded in Stage 6.
Findings: F16. Sources: `internal/commandmeta/flags.go`, Findings: F16. Sources: `internal/commandmeta/flags.go`,
`internal/cobra/flags.go`, and `completion_flags.go`; stage 6 is prerequisite. `internal/cobra/flags.go`, and `completion_flags.go`; stage 6 is prerequisite.

View File

@@ -2,7 +2,6 @@ package cobra
import ( import (
"fmt" "fmt"
"strings"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
) )
@@ -20,78 +19,43 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm
interpreted = append(interpreted, argv[index:]...) interpreted = append(interpreted, argv[index:]...)
break break
} }
if kind == commandmeta.LongFlagToken { if kind == commandmeta.LongFlagToken || kind == commandmeta.ShortFlagToken {
original, value, attached := commandmeta.SplitLongFlag(token) offset := 1
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 attached {
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index++
continue
}
if !flag.Boolean && index+1 < len(argv) {
value := argv[index+1]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index += 2
continue
}
if !flag.Boolean {
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
}
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"})
interpreted = append(interpreted, "--"+flag.Name)
index++
continue
}
if kind == commandmeta.ShortFlagToken {
consumedNext := false consumedNext := false
for offset := 1; offset < len(token); { for {
original := "-" + token[offset:offset+1] fact := commandmeta.ConsumeFlag(lookup, token, offset)
flag, ok := lookup.Find(original) if !fact.Found {
if !ok { if kind == commandmeta.ShortFlagToken {
interpreted = append(interpreted, "-"+token[offset:]) interpreted = append(interpreted, "-"+token[offset:])
} else {
interpreted = append(interpreted, token)
}
return interpreted, spellings, occurrences, nil return interpreted, spellings, occurrences, nil
} }
flag, original := fact.Flag, fact.Spelling
spellings[flag.Name] = append(spellings[flag.Name], original) spellings[flag.Name] = append(spellings[flag.Name], original)
suffix := token[offset+1:] value := fact.Value
if flag.Boolean { if fact.NeedsNext {
if strings.HasPrefix(suffix, "=") { if index+1 >= len(argv) {
value := suffix[1:] return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
break
} }
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"}) value = argv[index+1]
interpreted = append(interpreted, "--"+flag.Name)
offset++
continue
}
if suffix != "" {
value := suffix
if strings.HasPrefix(value, "=") {
value = value[1:]
}
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
break
}
if index+1 < len(argv) {
value := argv[index+1]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index += 2
consumedNext = true consumedNext = true
}
if !fact.Attached && !fact.NeedsNext {
value = "true"
interpreted = append(interpreted, "--"+flag.Name)
} else {
interpreted = append(interpreted, "--"+flag.Name+"="+value)
}
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
if fact.NextOffset == 0 {
break break
} }
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)} offset = fact.NextOffset
} }
if !consumedNext { index++
if consumedNext {
index++ index++
} }
continue continue

View File

@@ -65,3 +65,47 @@ func (lookup FlagLookup) Find(spelling string) (Flag, bool) {
} }
return flag, ok return flag, ok
} }
// FlagConsumption describes one flag within an option token. NextOffset is zero
// when the token is exhausted; otherwise it identifies the next short spelling.
type FlagConsumption struct {
Flag Flag
Spelling string
Found bool
Value string
Attached bool
NeedsNext bool
NextOffset int
}
// ConsumeFlag reads a long option or a short option at offset in a cluster.
func ConsumeFlag(lookup FlagLookup, token string, offset int) FlagConsumption {
var result FlagConsumption
if ClassifyFlagToken(token) == LongFlagToken {
result.Spelling, result.Value, result.Attached = SplitLongFlag(token)
result.Flag, result.Found = lookup.Find(result.Spelling)
result.NeedsNext = result.Found && !result.Flag.Boolean && !result.Attached
return result
}
result.Spelling = "-" + token[offset:offset+1]
result.Flag, result.Found = lookup.Find(result.Spelling)
if !result.Found {
return result
}
suffix := token[offset+1:]
if result.Flag.Boolean {
if strings.HasPrefix(suffix, "=") {
result.Attached, result.Value = true, suffix[1:]
} else if suffix != "" {
result.NextOffset = offset + 1
}
return result
}
if suffix == "" {
result.NeedsNext = true
return result
}
result.Attached = true
result.Value = strings.TrimPrefix(suffix, "=")
return result
}