Install shell completion as an optional command

This commit is contained in:
2026-11-03 03:50:12 +00:00
parent 0bed76a6f7
commit fc2f6d5346
9 changed files with 219 additions and 3 deletions

1
app.go
View File

@@ -27,6 +27,7 @@ type routingSnapshot struct {
commandAliases []commandmeta.CommandAlias
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
completion bool
}
type commandNode struct {

View File

@@ -74,6 +74,7 @@ type commandRecord struct {
payload commandPayload
issues []error
rootOnly bool
completion bool
absent bool
invalid bool
}

View File

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

108
completion_install_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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