Install shell completion as an optional command
This commit is contained in:
1
app.go
1
app.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
108
completion_install_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
registry.go
11
registry.go
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user