From d914295ced33e2984526b2b18d02ac024254f1a5 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 8 Nov 2026 15:08:50 +0000 Subject: [PATCH] Align shared debug help and completion --- completion_flags.go | 3 +-- completion_flags_test.go | 28 ++++++++++++++++++++ completion_query_test.go | 48 ++++++++++++++++++++++++++++++++++ docs/roadmap/implementation.md | 5 ++++ internal/cobra/parse.go | 18 ++++++++++++- 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/completion_flags.go b/completion_flags.go index d7a0bee..15dfe32 100644 --- a/completion_flags.go +++ b/completion_flags.go @@ -35,8 +35,7 @@ type completionFlagState struct { 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...) + flags = append(flags, commandmeta.EffectiveFlags(context.command.Flags, context.command.CoreFlags)...) return flags } diff --git a/completion_flags_test.go b/completion_flags_test.go index da5045f..ee0d121 100644 --- a/completion_flags_test.go +++ b/completion_flags_test.go @@ -70,6 +70,34 @@ func TestCompletionFlagAndValuePositions(t *testing.T) { } } +func TestSharedDebugCompletionHints(t *testing.T) { + for _, tc := range []struct { + name string + hint CompletionHint + want commandmeta.CompletionKind + }{ + {"none", NoCompletion(), commandmeta.CompletionNone}, + {"choices", CompleteChoices(CompletionChoice{Value: "true"}), commandmeta.CompletionChoices}, + {"files", CompleteFiles(), commandmeta.CompletionFiles}, + {"directories", CompleteDirectories(), commandmeta.CompletionDirectories}, + } { + t.Run(tc.name, func(t *testing.T) { + command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Completion: tc.hint}, false, func(*struct{}, Supplied[bool]) {})}, Run: func(Invocation, struct{}) Outcome { return Success() }}) + app, err := New(Identity{Name: "example"}, command) + if err != nil { + t.Fatal(err) + } + snapshot := app.routeSnapshot() + for _, current := range []string{"--debug=", "-d="} { + target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, current)) + if target.kind != completionTargetValue || target.hint.Kind != tc.want { + t.Fatalf("%s: %#v", current, target) + } + } + }) + } +} + func TestCompletionOperandAllocation(t *testing.T) { arguments := []commandmeta.Argument{ {Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}}, diff --git a/completion_query_test.go b/completion_query_test.go index 04f6e60..4e50567 100644 --- a/completion_query_test.go +++ b/completion_query_test.go @@ -120,3 +120,51 @@ func TestCompletionQueryDiscovery(t *testing.T) { t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String()) } } + +func TestSharedDebugHelpAndCompletion(t *testing.T) { + var factories, setters, checks, handlers int + command := NewCommand(CommandSpec[struct{}]{ + Name: "run", + NewInput: func() struct{} { factories++; return struct{}{} }, + Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Usage: "application tracing", Required: true, Completion: CompleteChoices(CompletionChoice{Value: "true"}, CompletionChoice{Value: "trace"})}, true, func(*struct{}, Supplied[bool]) { setters++ })}, + CheckInput: func(struct{}) error { checks++; return nil }, + Run: func(Invocation, struct{}) Outcome { handlers++; return Success() }, + }) + app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run")) + if err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"run", "--help"}, {"help", "run"}, {"shortcut", "--help"}, {"help", "shortcut"}, {"run", "--unknown"}, {"shortcut", "--unknown"}} { + var output, diagnostic bytes.Buffer + result := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostic}) + text := output.String() + diagnostic.String() + if strings.Count(text, "--debug") != 1 || !strings.Contains(text, "-d") || !strings.Contains(text, "application tracing") || !strings.Contains(text, "application input defaults to true") || !strings.Contains(text, "required") || !strings.Contains(text, "default: false") { + t.Fatalf("%v: outcome=%#v text=%q", args, result, text) + } + } + for _, tc := range []struct { + args []string + want string + count int + }{ + {[]string{"__complete", "run", "--deb"}, "--debug", 1}, + {[]string{"__complete", "run", "-"}, "\n-d\t", 1}, + {[]string{"__complete", "run", "--debug=tr"}, "trace", 1}, + {[]string{"__complete", "run", "-d=tr"}, "trace", 1}, + } { + var output bytes.Buffer + result := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: io.Discard}) + if result.Kind != OutcomeSuccess || strings.Count(output.String(), tc.want) != tc.count { + t.Fatalf("%v: %#v %q", tc.args, result, output.String()) + } + } + if factories != 0 || setters != 0 || checks != 0 || handlers != 0 { + t.Fatalf("callbacks: %d %d %d %d", factories, setters, checks, handlers) + } + if result := app.Execute(context.Background(), []string{"run", "--debug=invalid", "--help"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage { + t.Fatalf("help skipped boolean validation: %#v", result) + } + if result := app.Execute(context.Background(), []string{"run", "--debug=trace"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage { + t.Fatalf("suggestion bypassed boolean parsing: %#v", result) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 53b037e..7021cfc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -223,6 +223,11 @@ capture, selection, application and rename, with visibility still independent. ## Stage 5 — Present one debug option in help and completion +**Complete.** Completion uses the same effective declarations as parsing and +help. Canonical and alias help and usage failures expose the combined option; +focused tests cover local shorts, requiredness, application defaults, choices, +file and directory hints, boolean validation, and idle application callbacks. + Primary files: [completion_flags.go](../../completion_flags.go), [internal/cobra/parse.go](../../internal/cobra/parse.go), [internal/commandmeta](../../internal/commandmeta/metadata.go). diff --git a/internal/cobra/parse.go b/internal/cobra/parse.go index dc5e631..bc5a01e 100644 --- a/internal/cobra/parse.go +++ b/internal/cobra/parse.go @@ -252,7 +252,7 @@ func displayCommandPath(rootName string, path []string) string { return strings.Join(parts, " ") } -// UsageLine returns Cobra's usage line for an exact path on this session. +// UsageLine returns usage for an exact path on this session. func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) { if session == nil || session.root == nil { return "", &commandmeta.Failure{Message: "command tree is nil"} @@ -267,6 +267,22 @@ func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) if selected.HasSubCommands() { line += " [command]" } + for _, flag := range declaration.Flags { + if flag.Name != "debug" || !flag.Boolean { + continue + } + for _, effective := range commandmeta.EffectiveFlags(declaration.Flags, declaration.CoreFlags) { + if effective.Name == "debug" { + spelling := "--debug" + if effective.Short != "" { + spelling = "-" + effective.Short + ", " + spelling + } + line += "\n " + spelling + " " + flagUsage(effective) + break + } + } + break + } return line, nil }