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 commandAliases []commandmeta.CommandAlias
flagAliases map[string]resolvedFlagAliases flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases discoveryFlags map[string]resolvedFlagAliases
completion bool
} }
type commandNode struct { type commandNode struct {

View File

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

View File

@@ -1,6 +1,53 @@
package commandkit 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. // CompletionChoice is a literal suggested value and its optional description.
type CompletionChoice struct { 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 # 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 Implement the [shell completion roadmap](completion.md) from baseline
`2301872`. Follow the stages in order in one sprint. Intermediate stages may `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 ## Stage 3: Install completion as an ordinary declaration
Status: complete.
1. Add `func Completion(executable string) Command`. The parameter is the 1. Add `func Completion(executable string) Command`. The parameter is the
executable basename the operator types, independent of the human-readable executable basename the operator types, independent of the human-readable
`Identity.Name`. Use a root-only `completion` group with three runnable `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 rollback, late installation, and absence of new reserved names for an app
that does not install completion. 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 ## Stage 4: Resolve the entered command context
1. Add a private core completion request that separates completed words from 1. Add a private core completion request that separates completed words from

View File

@@ -9,6 +9,9 @@ import (
spfcobra "github.com/spf13/cobra" 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. // CompletionScript generates a shell script for the installed executable.
func CompletionScript(w io.Writer, executable, shell string) error { func CompletionScript(w io.Writer, executable, shell string) error {
root := &spfcobra.Command{Use: executable} root := &spfcobra.Command{Use: executable}
@@ -56,6 +59,11 @@ func replaceCompletionText(script, old, replacement string) (string, error) {
func adjustFishCompletion(script, name string) (string, error) { func adjustFishCompletion(script, name string) (string, error) {
var err 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, script, err = replaceCompletionText(script,
`set -l lastArg (string escape -- (commandline -ct))`, `set -l lastArg (string escape -- (commandline -ct))`,
"set -l lastArg (string unescape -- (commandline -ct))\n set -q lastArg[1]; or set lastArg \"\"") "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 { if err != nil {
return "", err return "", err
} }
variable := "__" + strings.NewReplacer("-", "_", ":", "_").Replace(name) + "_comp_results" variable := "__" + variableName + "_comp_results"
directories := fmt.Sprintf(`if test $dirfilter -eq 1 directories := fmt.Sprintf(`if test $dirfilter -eq 1
set -l flagPrefix (string match -r -- '^-[^=]*=' (commandline -ct)) set -l flagPrefix (string match -r -- '^-[^=]*=' (commandline -ct))
set --global %[1]s 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 { if err := os.WriteFile(filepath.Join(dir, "probe"), []byte(fixture), 0o755); err != nil {
t.Fatal(err) 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 { if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
t.Fatal(err) 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, flagAliases: a.flagAliases,
discoveryFlags: a.discoveryFlags, discoveryFlags: a.discoveryFlags,
} }
if node := a.commands["completion"]; node != nil {
snapshot.completion = node.command.record.completion
}
for _, rule := range a.routingRules { for _, rule := range a.routingRules {
if rule.record.kind != commandAliasRule { if rule.record.kind != commandAliasRule {
continue continue

View File

@@ -6,6 +6,7 @@ import (
"sort" "sort"
internalalias "gitea.maximumdirect.net/eric/CommandKit/internal/alias" internalalias "gitea.maximumdirect.net/eric/CommandKit/internal/alias"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
) )
@@ -122,6 +123,16 @@ func (a *App) buildInstallationCandidate(commands []Command, addedRules []Routin
if !sameRoutingRules(rules, a.routingRules) { if !sameRoutingRules(rules, a.routingRules) {
candidate.changed = true 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 var mappings []internalalias.Mapping
for _, rule := range rules { for _, rule := range rules {