From fc2f6d5346ec924d75f5bc007d55f277fb426d19 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 3 Nov 2026 03:50:12 +0000 Subject: [PATCH] Install shell completion as an optional command --- app.go | 1 + command.go | 1 + completion.go | 49 +++++++++++++- completion_install_test.go | 108 ++++++++++++++++++++++++++++++ docs/roadmap/implementation.md | 14 +++- internal/cobra/completion.go | 10 ++- internal/cobra/completion_test.go | 25 +++++++ parser.go | 3 + registry.go | 11 +++ 9 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 completion_install_test.go diff --git a/app.go b/app.go index 041ae14..7062423 100644 --- a/app.go +++ b/app.go @@ -27,6 +27,7 @@ type routingSnapshot struct { commandAliases []commandmeta.CommandAlias flagAliases map[string]resolvedFlagAliases discoveryFlags map[string]resolvedFlagAliases + completion bool } type commandNode struct { diff --git a/command.go b/command.go index a3273fc..5cdd0bc 100644 --- a/command.go +++ b/command.go @@ -74,6 +74,7 @@ type commandRecord struct { payload commandPayload issues []error rootOnly bool + completion bool absent bool invalid bool } diff --git a/completion.go b/completion.go index dda13fe..24fd775 100644 --- a/completion.go +++ b/completion.go @@ -1,6 +1,53 @@ package commandkit -import commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +import ( + "fmt" + "io" + "strings" + "unicode" + + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +) + +// Completion declares opt-in shell script generation for the executable +// basename used to invoke the application. Install it at the application root. +// Generated shell identifiers support letters, digits, underscores, dots, +// hyphens, and colons; a leading hyphen is not supported. +func Completion(executable string) Command { + children := make([]Command, 0, 3) + for _, shell := range []string{"bash", "zsh", "fish"} { + shell := shell + children = append(children, NewCommand(CommandSpec[struct{}]{ + Name: shell, Summary: "Generate " + shell + " completion script.", + Run: func(invocation Invocation, _ struct{}) Outcome { + _ = invocation.Reporter().RenderOutput(func(writer io.Writer) error { + return internalcobra.CompletionScript(writer, executable, shell) + }) + return Success() + }, + })) + } + command := RootGroup("completion", "Generate shell completion scripts.", children...) + command.record.completion = true + if !validCompletionExecutable(executable) { + return WithError(command, fmt.Errorf("unsupported completion executable basename %q", executable)) + } + return command +} + +// Cobra emits the basename into shell identifiers and unquoted command words. +func validCompletionExecutable(name string) bool { + if name == "" || name == "." || name == ".." || strings.HasPrefix(name, "-") { + return false + } + for _, ch := range name { + if !(unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '-' || ch == ':' || ch == '.') { + return false + } + } + return true +} // CompletionChoice is a literal suggested value and its optional description. type CompletionChoice struct { diff --git a/completion_install_test.go b/completion_install_test.go new file mode 100644 index 0000000..fa9bea9 --- /dev/null +++ b/completion_install_test.go @@ -0,0 +1,108 @@ +package commandkit + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +func TestCompletionDeclarationGeneratesScripts(t *testing.T) { + for _, name := range []string{"app", "my-app", "tool.exe", "café"} { + if !validCompletionExecutable(name) { + t.Fatalf("supported executable rejected: %q", name) + } + } + app, err := New(Identity{Name: "Human Readable Name"}, Completion("my-app")) + if err != nil { + t.Fatal(err) + } + if !app.routeSnapshot().completion { + t.Fatal("completion not present in snapshot") + } + for _, shell := range []string{"bash", "zsh", "fish"} { + var output, diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), []string{"completion", shell}, Streams{Out: &output, Err: &diagnostics}) + if outcome.Status != 0 || !strings.Contains(output.String(), "my-app") || strings.Contains(output.String(), "Human Readable Name") { + t.Fatalf("%s outcome=%#v output=%q diagnostics=%q", shell, outcome, output.String(), diagnostics.String()) + } + output.Reset() + outcome = app.Execute(context.Background(), []string{"completion", shell, "--quiet"}, Streams{Out: &output, Err: &diagnostics}) + if outcome.Status != 0 || output.Len() != 0 { + t.Fatalf("quiet %s outcome=%#v output=%q", shell, outcome, output.String()) + } + } + var help bytes.Buffer + outcome := app.Execute(context.Background(), []string{"--help"}, Streams{Out: &help}) + if outcome.Status != 0 || !strings.Contains(help.String(), "completion") || strings.Contains(help.String(), "__complete") { + t.Fatalf("help outcome=%#v output=%q", outcome, help.String()) + } + writeErr := errors.New("destination failed") + outcome = app.Execute(context.Background(), []string{"completion", "bash"}, Streams{Out: completionErrorWriter{writeErr}, Err: io.Discard}) + if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) { + t.Fatalf("script delivery failure: %#v", outcome) + } +} + +type completionErrorWriter struct{ err error } + +func (w completionErrorWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestCompletionInstallationCollisionsAndRollback(t *testing.T) { + run := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(Invocation, struct{}) Outcome { return Success() }}) + query := NewCommand(CommandSpec[struct{}]{Name: "__complete", Run: func(Invocation, struct{}) Outcome { return Success() }}) + for _, declarations := range [][]Declaration{ + {Completion("app"), query}, + {query, Completion("app")}, + {Completion("app"), run, Alias("__complete nested", "run")}, + {run, Alias("__complete nested", "run"), Completion("app")}, + {Completion("app"), Group("completion", "collision")}, + {Group("outer", "", Completion("app"))}, + {Completion("dir/app")}, + {Completion("two words")}, + } { + if _, err := New(Identity{Name: "example"}, declarations...); err == nil { + t.Fatalf("accepted conflicting declarations: %#v", declarations) + } + } + app, err := New(Identity{Name: "example"}, run) + if err != nil { + t.Fatal(err) + } + before := app.routeSnapshot() + if err := app.Register(Completion("app"), query); err == nil || app.routeSnapshot() != before { + t.Fatalf("failed batch changed snapshot: %v", err) + } + if err := app.Register(Completion("app")); err != nil { + t.Fatal(err) + } + if app.routeSnapshot() == before || !app.routeSnapshot().completion { + t.Fatal("late completion installation did not refresh snapshot") + } + if err := app.Register(query); err == nil { + t.Fatal("query command accepted after completion installation") + } + if err := app.Register(Alias("__complete nested", "run")); err == nil { + t.Fatal("query alias accepted after completion installation") + } + + withoutCompletion, err := New(Identity{Name: "example"}, query) + if err != nil || withoutCompletion.routeSnapshot().completion { + t.Fatalf("ordinary query-named command: %v", err) + } + if outcome := withoutCompletion.Execute(context.Background(), []string{"__complete"}, Streams{}); outcome.Status != 0 { + t.Fatalf("ordinary command outcome: %#v", outcome) + } + if err := withoutCompletion.Register(Completion("app")); err == nil || withoutCompletion.routeSnapshot().completion { + t.Fatal("completion accepted an existing query command") + } + aliased, err := New(Identity{Name: "example"}, run, Alias("__complete nested", "run")) + if err != nil { + t.Fatal(err) + } + if err := aliased.Register(Completion("app")); err == nil || aliased.routeSnapshot().completion { + t.Fatal("completion accepted an existing query alias") + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c113291..9d3e4d6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shell completion implementation plan -Status: Stages 1–2 complete. Stage 3 is next. +Status: Stages 1–3 complete. Stage 4 is next. Implement the [shell completion roadmap](completion.md) from baseline `2301872`. Follow the stages in order in one sprint. Intermediate stages may @@ -109,6 +109,8 @@ internal adapter, and neutral metadata package tests pass with `GOWORK=off` and ## Stage 3: Install completion as an ordinary declaration +Status: complete. + 1. Add `func Completion(executable string) Command`. The parameter is the executable basename the operator types, independent of the human-readable `Identity.Name`. Use a root-only `completion` group with three runnable @@ -134,6 +136,16 @@ internal adapter, and neutral metadata package tests pass with `GOWORK=off` and rollback, late installation, and absence of new reserved names for an app that does not install completion. +Completion: added the root-only completion declaration and three script +commands, with generator output delivered through the invocation reporter. +Registration validates hidden-query collisions in either order, and the routing +snapshot reflects committed installation. Tests cover help, quiet output, +delivery failure, invalid basenames, opt-in behavior, collisions, rollback, +nesting, and late installation. Fish variable names are adjusted to support +dotted executable basenames; interactive checks of those names pass in Bash, +Zsh, and Fish. Root and adapter package tests pass with `GOWORK=off` and +`GOTOOLCHAIN=local`. + ## Stage 4: Resolve the entered command context 1. Add a private core completion request that separates completed words from diff --git a/internal/cobra/completion.go b/internal/cobra/completion.go index 5120cd1..af11f62 100644 --- a/internal/cobra/completion.go +++ b/internal/cobra/completion.go @@ -9,6 +9,9 @@ import ( spfcobra "github.com/spf13/cobra" ) +// CompletionQueryToken is the hidden request understood by generated scripts. +const CompletionQueryToken = spfcobra.ShellCompRequestCmd + // CompletionScript generates a shell script for the installed executable. func CompletionScript(w io.Writer, executable, shell string) error { root := &spfcobra.Command{Use: executable} @@ -56,6 +59,11 @@ func replaceCompletionText(script, old, replacement string) (string, error) { func adjustFishCompletion(script, name string) (string, error) { var err error + cobraName := strings.NewReplacer("-", "_", ":", "_").Replace(name) + variableName := strings.ReplaceAll(cobraName, ".", "_") + if cobraName != variableName { + script = strings.ReplaceAll(script, "__"+cobraName+"_", "__"+variableName+"_") + } script, err = replaceCompletionText(script, `set -l lastArg (string escape -- (commandline -ct))`, "set -l lastArg (string unescape -- (commandline -ct))\n set -q lastArg[1]; or set lastArg \"\"") @@ -72,7 +80,7 @@ func adjustFishCompletion(script, name string) (string, error) { if err != nil { return "", err } - variable := "__" + strings.NewReplacer("-", "_", ":", "_").Replace(name) + "_comp_results" + variable := "__" + variableName + "_comp_results" directories := fmt.Sprintf(`if test $dirfilter -eq 1 set -l flagPrefix (string match -r -- '^-[^=]*=' (commandline -ct)) set --global %[1]s diff --git a/internal/cobra/completion_test.go b/internal/cobra/completion_test.go index c029636..c9899f4 100644 --- a/internal/cobra/completion_test.go +++ b/internal/cobra/completion_test.go @@ -52,6 +52,9 @@ sys.stdout.write(os.environ['PROBE_REPLY']) if err := os.WriteFile(filepath.Join(dir, "probe"), []byte(fixture), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, "probe.app"), []byte(fixture), 0o755); err != nil { + t.Fatal(err) + } if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil { t.Fatal(err) } @@ -114,6 +117,28 @@ sys.stdout.write(os.environ['PROBE_REPLY']) } }) } + t.Run(shell+"/dotted executable", func(t *testing.T) { + var generated bytes.Buffer + if err := CompletionScript(&generated, "probe.app", shell); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(script, generated.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + log := filepath.Join(dir, "log") + if err := os.WriteFile(log, nil, 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("python3", "testdata/completion_terminal.py", shell, script, "probe.app --config=./par") + cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_REPLY="+reply("./partial file\tdescription\n", spfcobra.ShellCompDirectiveNoFileComp), "PROBE_DIR="+dir) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("shell completion: %v\n%s", err, out) + } + contents, err := os.ReadFile(log) + if err != nil || !strings.Contains(string(contents), `["__complete", "--config=./par"]`) || !strings.Contains(string(contents), `["--config=./partial file"]`) { + t.Fatalf("query and edited line: %q; read error: %v", contents, err) + } + }) } } diff --git a/parser.go b/parser.go index 161cb0a..e6b6430 100644 --- a/parser.go +++ b/parser.go @@ -77,6 +77,9 @@ func (a *App) buildRoutingSnapshot() *routingSnapshot { flagAliases: a.flagAliases, discoveryFlags: a.discoveryFlags, } + if node := a.commands["completion"]; node != nil { + snapshot.completion = node.command.record.completion + } for _, rule := range a.routingRules { if rule.record.kind != commandAliasRule { continue diff --git a/registry.go b/registry.go index 556c24e..67225a4 100644 --- a/registry.go +++ b/registry.go @@ -6,6 +6,7 @@ import ( "sort" internalalias "gitea.maximumdirect.net/eric/CommandKit/internal/alias" + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" ) @@ -122,6 +123,16 @@ func (a *App) buildInstallationCandidate(commands []Command, addedRules []Routin if !sameRoutingRules(rules, a.routingRules) { candidate.changed = true } + if node := candidate.commands["completion"]; node != nil && node.command.record.completion { + if _, exists := candidate.commands[internalcobra.CompletionQueryToken]; exists { + return nil, fmt.Errorf("command path %q is reserved for completion", internalcobra.CompletionQueryToken) + } + for _, rule := range rules { + if rule.record.kind == commandAliasRule && len(rule.record.sourcePath) > 0 && rule.record.sourcePath[0] == internalcobra.CompletionQueryToken { + return nil, fmt.Errorf("command alias %q conflicts with completion", rule.record.sourcePath.String()) + } + } + } var mappings []internalalias.Mapping for _, rule := range rules {