diff --git a/completion_shell_test.go b/completion_shell_test.go new file mode 100644 index 0000000..99c5579 --- /dev/null +++ b/completion_shell_test.go @@ -0,0 +1,93 @@ +package commandkit + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestCompletionConsumerInShells(t *testing.T) { + if os.Getenv("COMMANDKIT_COMPLETION_SHELLS") != "1" { + t.Skip("set COMMANDKIT_COMPLETION_SHELLS=1 to run shell completion checks") + } + dir := t.TempDir() + app := filepath.Join(dir, "probe") + build := exec.Command("go", "build", "-o", app, "./testdata/completion") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build completion fixture: %v\n%s", err, output) + } + if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil { + t.Fatal(err) + } + tests := []struct { + name, line string + want []string + directory bool + }{ + {"canonical command", "probe ru", []string{"run"}, false}, + {"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false}, + {"choice", "probe run --format=te", []string{"run", "--format=text"}, false}, + {"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false}, + {"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false}, + {"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false}, + {"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false}, + {"short cluster equals file", `probe run -sc=./partial\ f`, []string{"run", "-sc=./partial file"}, false}, + {"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true}, + {"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true}, + {"empty operand", "probe run ", []string{"run", "alpha"}, false}, + {"leading dash", "probe run --for", []string{"run", "--format"}, false}, + } + for _, shell := range []string{"bash", "zsh", "fish"} { + generator := exec.Command(app, "completion", shell) + script, err := generator.CombinedOutput() + if err != nil { + t.Fatalf("generate %s script: %v\n%s", shell, err, script) + } + scriptPath := filepath.Join(dir, shell+".completion") + if err := os.WriteFile(scriptPath, script, 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range tests { + t.Run(shell+"/"+tc.name, func(t *testing.T) { + log := filepath.Join(dir, "log") + if err := os.WriteFile(log, nil, 0o600); err != nil { + t.Fatal(err) + } + terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, tc.line) + terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_DIR="+dir) + if output, err := terminal.CombinedOutput(); err != nil { + t.Fatalf("shell completion: %v\n%s", err, output) + } + content, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + var got []string + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("fixture output %q: %v", content, err) + } + if tc.directory && len(got) > 0 { + got[len(got)-1] = strings.TrimSuffix(got[len(got)-1], "/") + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("edited arguments=%q, want %q", got, tc.want) + } + }) + } + t.Run(shell+"/descriptions", func(t *testing.T) { + terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=") + terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1") + output, err := terminal.CombinedOutput() + if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") { + t.Fatalf("completion descriptions: %v\n%s", err, output) + } + }) + } +} diff --git a/docs/roadmap/completion.md b/docs/roadmap/completion.md index 4b076bd..b54708c 100644 --- a/docs/roadmap/completion.md +++ b/docs/roadmap/completion.md @@ -1,7 +1,7 @@ # Shell completion -Status: the selected initial scope is implemented; final sprint validation -remains. Canonical suggestions and alias-aware interpretation are available for +Status: the selected initial scope is implemented and validated. Canonical +suggestions and alias-aware interpretation are available for Bash, Zsh, and Fish through explicitly installed completion support. ## Purpose diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f11e295..1f557df 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shell completion implementation plan -Status: Stages 1–7 complete. Stage 8 is next. +Status: Stages 1–8 complete. Implement the [shell completion roadmap](completion.md) from baseline `2301872`. Follow the stages in order in one sprint. Intermediate stages may @@ -305,6 +305,8 @@ checks pass. ## Stage 8: Validate the assembled sprint +Status: complete. + 1. Exercise the generated scripts in Bash, Zsh, and Fish using a built fixture executable, a disposable directory with filenames and directories containing spaces, and isolated shell configuration. Verify actual completion output @@ -323,3 +325,21 @@ checks pass. dependency, and no changes to ordinary routing, flag acceptance, or report behavior outside the selected completion scope. Review documentation links, formatting, and the final diff. Record the actual validation results. + +Completion: the opt-in `TestCompletionConsumerInShells` builds a CommandKit +fixture, generates its scripts, and verifies edited invocations and displayed +descriptions in interactive Bash 5.3.9, Zsh 5.9, and Fish 4.2.1. The fixture +uses isolated shell configuration and paths containing spaces. Run it with +Python pexpect, Bash completion, and the three shells available using +`COMMANDKIT_COMPLETION_SHELLS=1 GOTOOLCHAIN=local go test -count=1 -run +'^TestCompletionConsumerInShells$' .`. Zsh and Fish were extracted to a +disposable directory for this run; their executable, library, Zsh module, and +function paths were supplied through `PATH`, `LD_LIBRARY_PATH`, +`COMPLETION_ZSH_MODULE_PATH`, and `COMPLETION_ZSH_FUNCTION_ROOT`, respectively. +The adapter's interactive +transport matrix and additional short-equals and Unicode-basename checks also +pass in all three shells. The root and both optional module suites pass in the +local workspace, as does the root suite with `GOWORK=off`. Public GoDoc and +dependency checks show no exposed backend types or reverse optional-module +dependency. Existing execution suites, changed documentation links, formatting, +and diff whitespace checks pass. The local workspace file is ignored. diff --git a/internal/cobra/completion_test.go b/internal/cobra/completion_test.go index 3d90dad..a348ccb 100644 --- a/internal/cobra/completion_test.go +++ b/internal/cobra/completion_test.go @@ -67,8 +67,10 @@ 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) + for _, executable := range []string{"probe.app", "café"} { + if err := os.WriteFile(filepath.Join(dir, executable), []byte(fixture), 0o755); err != nil { + t.Fatal(err) + } } if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil { t.Fatal(err) @@ -110,6 +112,7 @@ sys.stdout.write(os.environ['PROBE_REPLY']) {"short cluster directory", "probe -sc./partial", pathReply(commandmeta.CompletionDirectories, "-sc"), []string{"__complete", "-sc./partial"}, []string{"-sc./partial dir"}}, {"short path with equals", "probe -cfoo=ba", pathReply(commandmeta.CompletionFiles, "-c"), []string{"__complete", "-cfoo=ba"}, []string{"-cfoo=bar"}}, {"short attached choice", "probe -cbl", choiceReply("-c", "blue"), []string{"__complete", "-cbl"}, []string{"-cblue"}}, + {"short equals choice", "probe -c=bl", choiceReply("-c=", "blue"), []string{"__complete", "-c=bl"}, []string{"-c=blue"}}, {"short choice with equals", "probe -cfoo=ba", choiceReply("-c", "foo=bar"), []string{"__complete", "-cfoo=ba"}, []string{"-cfoo=bar"}}, {"short no suggestions", "probe -cnomatch", pathReply(commandmeta.CompletionChoices, "-c"), []string{"__complete", "-cnomatch"}, []string{"-cnomatch"}}, {"literal prefix marker", "probe __commandkit_path_pre", choiceReply("", completionPathPrefixMarker+"-c"), []string{"__complete", "__commandkit_path_pre"}, []string{completionPathPrefixMarker + "-c"}}, @@ -158,28 +161,30 @@ 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) - } - }) + for _, executable := range []string{"probe.app", "café"} { + t.Run(shell+"/executable "+executable, func(t *testing.T) { + var generated bytes.Buffer + if err := CompletionScript(&generated, executable, 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, executable+" --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/internal/cobra/testdata/completion_terminal.py b/internal/cobra/testdata/completion_terminal.py index 5deadec..0b13f43 100644 --- a/internal/cobra/testdata/completion_terminal.py +++ b/internal/cobra/testdata/completion_terminal.py @@ -39,7 +39,17 @@ try: child.expect_exact("PROMPT> ") child.send(line) child.send("\t") - child.sendline("") - child.expect_exact("PROMPT> ") + if os.environ.get("PROBE_LIST") == "1": + child.send("\t") + remaining = ["Readable text", "YAML output"] + captured = [] + while remaining: + index = child.expect_exact(remaining) + captured.append(child.before + child.after) + remaining.pop(index) + print("".join(captured)) + else: + child.sendline("") + child.expect_exact("PROMPT> ") finally: child.close(force=True) diff --git a/testdata/completion/main.go b/testdata/completion/main.go new file mode 100644 index 0000000..91e8947 --- /dev/null +++ b/testdata/completion/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" +) + +func main() { + args := os.Args[1:] + if logPath := os.Getenv("PROBE_LOG"); logPath != "" && (len(args) == 0 || args[0] != internalcobra.CompletionQueryToken) { + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + _ = json.NewEncoder(file).Encode(args) + _ = file.Close() + } + parse := func(value string) (string, error) { return value, nil } + flag := func(meta commandkit.FlagMeta) commandkit.Flag[struct{}] { + return commandkit.ValueFlag(meta, (*string)(nil), parse, func(*struct{}, commandkit.Supplied[string]) {}) + } + run := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ + Name: "run", Summary: "Run a fixture command.", + Flags: []commandkit.Flag[struct{}]{ + commandkit.BoolFlag(commandkit.FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, commandkit.Supplied[bool]) {}), + flag(commandkit.FlagMeta{Name: "format", Usage: "Report format.", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"})}), + flag(commandkit.FlagMeta{Name: "config", Short: "c", Usage: "Configuration path.", Completion: commandkit.CompleteFiles()}), + flag(commandkit.FlagMeta{Name: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}), + }, + Arguments: []commandkit.Argument[struct{}]{commandkit.ValueArgument( + commandkit.ArgumentMeta{Name: "item", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "alpha", Description: "Example item"})}, + false, parse, func(*struct{}, commandkit.Supplied[string]) {})}, + Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }, + }) + app, err := commandkit.New(commandkit.Identity{Name: "Completion fixture"}, commandkit.Completion("probe"), run, commandkit.Alias("shortcut", "run")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: os.Stdout, Err: os.Stderr}) + if outcome.Status != 0 { + os.Exit(outcome.Status) + } +}