diff --git a/completion_query.go b/completion_query.go new file mode 100644 index 0000000..9dc8843 --- /dev/null +++ b/completion_query.go @@ -0,0 +1,53 @@ +package commandkit + +import ( + "errors" + "io" + "strings" + + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +) + +func completeRequest(snapshot *routingSnapshot, words []string) (commandmeta.CompletionResult, error) { + if len(words) == 0 { + return commandmeta.CompletionResult{}, errors.New("completion request has no current word") + } + context := resolveCompletionContext(snapshot, words[:len(words)-1], words[len(words)-1]) + target := completionTargetFor(snapshot, context) + result := commandmeta.CompletionResult{Prefix: target.prefix} + switch target.kind { + case completionTargetCommands, completionTargetFlags: + result.Kind = commandmeta.CompletionChoices + result.Choices = target.choices + case completionTargetValue, completionTargetOperand: + result.Kind = target.hint.Kind + if target.hint.Kind == commandmeta.CompletionChoices { + for _, choice := range target.hint.Choices { + if strings.HasPrefix(choice.Value, target.valuePrefix) { + result.Choices = append(result.Choices, choice) + } + } + } + } + return result, nil +} + +func (current *execution) complete(words []string) Outcome { + result, err := completeRequest(current.snapshot, words) + var reply string + if err == nil { + reply, err = internalcobra.CompletionReply(result) + } + if err != nil { + reply = internalcobra.CompletionErrorReply() + } + _ = current.reporter.RenderOutput(func(writer io.Writer) error { + _, writeErr := io.WriteString(writer, reply) + return writeErr + }) + if err != nil { + return current.reporter.finalize(Failure("Could not complete the shell request.").WithCause(err), nil) + } + return current.reporter.finalize(Success(), nil) +} diff --git a/completion_query_test.go b/completion_query_test.go new file mode 100644 index 0000000..8a4c42f --- /dev/null +++ b/completion_query_test.go @@ -0,0 +1,117 @@ +package commandkit + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" +) + +func TestCompletionQueryDispatch(t *testing.T) { + var factories, converters, handlers int + command := NewCommand(CommandSpec[struct{}]{ + Name: "run", Summary: "Run a command.", + NewInput: func() struct{} { factories++; return struct{}{} }, + Flags: []Flag[struct{}]{ + ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: CompleteChoices(CompletionChoice{Value: "blue", Description: "cool\ncolor"}, CompletionChoice{Value: "green"}, CompletionChoice{Value: "foo=bar"})}, (*string)(nil), + func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}), + ValueFlag(FlagMeta{Name: "file", Short: "f", Completion: CompleteFiles()}, (*string)(nil), + func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}), + }, + Run: func(Invocation, struct{}) Outcome { handlers++; return Success() }, + }) + app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"), FlagAliasFor("run", "--colour", "--color")) + if err != nil { + t.Fatal(err) + } + emptyReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + args []string + want string + empty bool + }{ + {"empty word", []string{"__complete", ""}, "run\tRun a command.", false}, + {"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false}, + {"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false}, + {"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false}, + {"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false}, + {"unknown route", []string{"__complete", "missing", ""}, emptyReply, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: &diagnostic}) + if outcome.Status != 0 || diagnostic.Len() != 0 || (tc.empty && output.String() != tc.want) || (!tc.empty && !strings.Contains(output.String(), tc.want)) { + t.Fatalf("outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String()) + } + }) + } + fileReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{Kind: commandmeta.CompletionFiles, Prefix: "-f"}) + if err != nil { + t.Fatal(err) + } + var fileOutput bytes.Buffer + fileOutcome := app.Execute(context.Background(), []string{"__complete", "run", "-f./part"}, Streams{Out: &fileOutput}) + if fileOutcome.Status != 0 || fileOutput.String() != fileReply { + t.Fatalf("file query outcome=%#v reply=%q", fileOutcome, fileOutput.String()) + } + if factories != 0 || converters != 0 || handlers != 0 { + t.Fatalf("query invoked application callbacks: %d %d %d", factories, converters, handlers) + } + if err := app.Register(simpleRunnable("later")); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if outcome := app.Execute(context.Background(), []string{"__complete", "lat"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "later") { + t.Fatalf("later command outcome=%#v reply=%q", outcome, output.String()) + } +} + +func TestCompletionQueryErrorsAndDelivery(t *testing.T) { + app, err := New(Identity{Name: "example"}, Completion("example")) + if err != nil { + t.Fatal(err) + } + var output, diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), []string{"__complete"}, Streams{Out: &output, Err: &diagnostics}) + if outcome.Status != 1 || outcome.Cause == nil || output.String() != internalcobra.CompletionErrorReply() || diagnostics.Len() == 0 { + t.Fatalf("malformed query outcome=%#v reply=%q diagnostics=%q", outcome, output.String(), diagnostics.String()) + } + writeErr := errors.New("reply destination failed") + writer := &completionCountingWriter{err: writeErr} + outcome = app.Execute(context.Background(), []string{"__complete", ""}, Streams{Out: writer, Err: io.Discard}) + if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || writer.calls != 1 { + t.Fatalf("reply write failure: %#v", outcome) + } +} + +type completionCountingWriter struct { + err error + calls int +} + +func (writer *completionCountingWriter) Write([]byte) (int, error) { + writer.calls++ + return 0, writer.err +} + +func TestCompletionQueryDiscovery(t *testing.T) { + app, err := New(Identity{Name: "example"}, Completion("example"), + Group("workspace", "", simpleRunnable("inspect")), Alias("legacy inspect", "workspace inspect"), FlagAlias("--silent", "--quiet")) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"__complete", "legacy", "--"}, Streams{Out: &output}) + if outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") || strings.Contains(output.String(), "inspect") || strings.Contains(output.String(), "--silent") { + t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String()) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index bbf3b9c..5b545a9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shell completion implementation plan -Status: Stages 1–5 complete. Stage 6 is next. +Status: Stages 1–6 complete. Stage 7 is next. Implement the [shell completion roadmap](completion.md) from baseline `2301872`. Follow the stages in order in one sprint. Intermediate stages may @@ -229,6 +229,8 @@ with `GOWORK=off` and `GOTOOLCHAIN=local`. ## Stage 6: Assemble candidates and deliver query replies +Status: complete. + 1. Produce a neutral result of candidate replacements, optional descriptions, or a filesystem strategy with value context. Keep prefix preservation distinct from matching; the adapter translates the neutral replacement @@ -259,6 +261,17 @@ with `GOWORK=off` and `GOTOOLCHAIN=local`. Use counter fixtures to prove target handlers, converters, factories, and providers are untouched. Verify no special dispatch without installation. +Completion: connected the installed query before normal routing and reporting +selection, assembled neutral candidates and filesystem strategies, and routed +protocol output through managed reporting. Invalid transport requests return an +error directive and a separate diagnostic; write failures remain presentation +evidence. The adapter preserves short attached prefixes through a private cue +consumed by the generated scripts, while retaining literal choice values and +normalizing descriptions. Query tests cover empty and malformed requests, +aliases and discovery, later registration, callback isolation, reporting flags +as data, and writer failure. Root and adapter package suites and the full +interactive Bash, Zsh, and Fish transport matrix pass. + ## Stage 7: Annotate maintained commands and examples 1. Annotate static format/scope flags where their choices already come from diff --git a/execute.go b/execute.go index 97bc313..457d16a 100644 --- a/execute.go +++ b/execute.go @@ -30,6 +30,9 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco err := errors.New("invocation context must not be nil") return current.finalize(Failure(err.Error()).WithCause(err), nil, false) } + if current.snapshot != nil && current.snapshot.completion && len(args) > 0 && args[0] == internalcobra.CompletionQueryToken { + return current.complete(args[1:]) + } routedSnapshot := current.snapshot current.route = resolveEnteredRoute(args, routedSnapshot) current.recordRouteEvents() diff --git a/internal/cobra/completion.go b/internal/cobra/completion.go index af11f62..a5a68be 100644 --- a/internal/cobra/completion.go +++ b/internal/cobra/completion.go @@ -6,12 +6,57 @@ import ( "io" "strings" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" spfcobra "github.com/spf13/cobra" ) // CompletionQueryToken is the hidden request understood by generated scripts. const CompletionQueryToken = spfcobra.ShellCompRequestCmd +const completionPathPrefixMarker = "__commandkit_path_prefix__" + +// CompletionReply translates neutral suggestions to the shell line protocol. +func CompletionReply(result commandmeta.CompletionResult) (string, error) { + var directive spfcobra.ShellCompDirective + switch result.Kind { + case commandmeta.CompletionNone, commandmeta.CompletionChoices: + directive = spfcobra.ShellCompDirectiveNoFileComp + case commandmeta.CompletionFiles: + directive = spfcobra.ShellCompDirectiveDefault + case commandmeta.CompletionDirectories: + directive = spfcobra.ShellCompDirectiveFilterDirs + default: + return "", fmt.Errorf("unsupported completion suggestion strategy") + } + var output strings.Builder + if result.Kind != commandmeta.CompletionNone && result.Prefix != "" && !strings.Contains(result.Prefix, "=") { + output.WriteString(completionPathPrefixMarker) + output.WriteString(result.Prefix) + output.WriteByte('\n') + } + if result.Kind == commandmeta.CompletionChoices { + for _, choice := range result.Choices { + if choice.Value == "" || strings.ContainsAny(choice.Value, "\x00\r\n\t") { + continue + } + // Generated scripts restore the attached prefix when needed. + output.WriteString(choice.Value) + if choice.Description != "" { + output.WriteByte('\t') + output.WriteString(strings.NewReplacer("\x00", " ", "\r", " ", "\n", " ", "\t", " ").Replace(choice.Description)) + } + output.WriteByte('\n') + } + } + fmt.Fprintf(&output, ":%d\n", directive) + return output.String(), nil +} + +// CompletionErrorReply is the protocol response for a malformed query. +func CompletionErrorReply() string { + return fmt.Sprintf(":%d\n", spfcobra.ShellCompDirectiveError) +} + // CompletionScript generates a shell script for the installed executable. func CompletionScript(w io.Writer, executable, shell string) error { root := &spfcobra.Command{Use: executable} @@ -32,11 +77,10 @@ func CompletionScript(w io.Writer, executable, shell string) error { } script := generated.String() - if shell == "zsh" { - script, err = replaceCompletionText(script, `words=("${=words[1,CURRENT]}")`, `words=("${(@)words[1,CURRENT]}")`) - if err == nil { - script, err = replaceCompletionText(script, `=~ '-.*='`, `=~ '-[^=]*='`) - } + if shell == "bash" { + script, err = adjustBashCompletion(script, root.Name()) + } else if shell == "zsh" { + script, err = adjustZshCompletion(script) } else if shell == "fish" { script, err = adjustFishCompletion(script, root.Name()) } @@ -50,6 +94,82 @@ func CompletionScript(w io.Writer, executable, shell string) error { return err } +func adjustBashCompletion(script, name string) (string, error) { + var err error + script, err = replaceCompletionText(script, `local out directive`, `local out directive completionPrefix completionOriginalCur`) + if err != nil { + return "", err + } + errorDebug := fmt.Sprintf(`__%s_debug "Received error from custom completion go code"`, name) + script, err = replaceCompletionText(script, errorDebug, errorDebug+"\n compopt +o default") + if err != nil { + return "", err + } + parsePrefix := `if [[ ${out} == ` + completionPathPrefixMarker + `-* ]]; then + completionPrefix=${out%%$'\n'*} + completionPrefix=${completionPrefix#` + completionPathPrefixMarker + `} + completionOriginalCur=${words[${#words[@]}-1]} + if [[ $completionPrefix != --* && $completionOriginalCur == "$completionPrefix"* ]]; then + out=${out#*$'\n'} + cur=${completionOriginalCur#"$completionPrefix"} + else + completionPrefix="" + fi + fi + + # Separate activeHelp from normal completions` + script, err = replaceCompletionText(script, `# Separate activeHelp from normal completions`, parsePrefix) + if err != nil { + return "", err + } + script, err = replaceCompletionText(script, + `if (((directive & shellCompDirectiveFilterFileExt) != 0)); then`, + `if [[ -n $completionPrefix ]] && (((directive & shellCompDirectiveFilterDirs) == 0)) && (((directive & shellCompDirectiveNoFileComp) == 0)); then + compopt +o default + _filedir + elif (((directive & shellCompDirectiveFilterFileExt) != 0)); then`) + if err != nil { + return "", err + } + old := fmt.Sprintf("__%[1]s_handle_special_char \"$cur\" :\n __%[1]s_handle_special_char \"$cur\" =", name) + replacement := fmt.Sprintf(`if [[ -n $completionPrefix ]]; then + compopt +o default + local i + for i in "${!COMPREPLY[@]}"; do COMPREPLY[$i]="${completionPrefix}${COMPREPLY[$i]}"; done + cur=$completionOriginalCur + fi + __%[1]s_handle_special_char "$cur" : + __%[1]s_handle_special_char "$cur" =`, name) + return replaceCompletionText(script, old, replacement) +} + +func adjustZshCompletion(script string) (string, error) { + var err error + script, err = replaceCompletionText(script, `words=("${=words[1,CURRENT]}")`, `words=("${(@)words[1,CURRENT]}")`) + if err != nil { + return "", err + } + script, err = replaceCompletionText(script, `=~ '-.*='`, `=~ '-[^=]*='`) + if err != nil { + return "", err + } + return replaceCompletionText(script, `local activeHelpMarker="_activeHelp_ "`, `if [[ "$out" == `+completionPathPrefixMarker+`-* ]]; then + local pathPrefix="${out%%$'\n'*}" + pathPrefix="${pathPrefix#`+completionPathPrefixMarker+`}" + if [[ "$pathPrefix" != --* && "$lastParam" == "$pathPrefix"* ]]; then + out="${out#*$'\n'}" + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + flagPrefix="-P ${pathPrefix}" + else + flagPrefix="" + compset -P "${(b)pathPrefix}" + fi + fi + fi + + local activeHelpMarker="_activeHelp_ "`) +} + func replaceCompletionText(script, old, replacement string) (string, error) { if strings.Count(script, old) != 1 { return "", fmt.Errorf("unsupported generated completion script") @@ -77,10 +197,62 @@ func adjustFishCompletion(script, name string) (string, error) { return "", err } script, err = replaceCompletionText(script, `string match -r -- '-.*=' "$lastArg"`, `string match -r -- '-[^=]*=' "$lastArg"`) + if err != nil { + return "", err + } + script, err = replaceCompletionText(script, `for comp in $comps`, `if set -q comps[1]; and string match -q -- '`+completionPathPrefixMarker+`-*' "$comps[1]" + set -l candidatePrefix (string replace -- '`+completionPathPrefixMarker+`' '' "$comps[1]") + if not string match -q -- '--*' "$candidatePrefix"; and test (string sub -l (string length -- "$candidatePrefix") -- "$lastArg") = "$candidatePrefix" + set flagPrefix "$candidatePrefix" + set -e comps[1] + printf "%s%s\n" '`+completionPathPrefixMarker+`' "$flagPrefix" + end + end + for comp in $comps`) if err != nil { return "", err } variable := "__" + variableName + "_comp_results" + errorBranch := fmt.Sprintf(`__%[1]s_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1`, variableName) + errorReplacement := fmt.Sprintf(`__%[1]s_debug "Received error directive: aborting." + return 0`, variableName) + script, err = replaceCompletionText(script, errorBranch, errorReplacement) + if err != nil { + return "", err + } + pathContext := fmt.Sprintf(`set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) %% 2) + if test (count $%[1]s) -gt 0; and string match -q -- '%[2]s-*' "$%[1]s[1]" + set -l pathPrefix (string replace -- '%[2]s' '' "$%[1]s[1]") + set -l token (string unescape -- (commandline -ct)) + if not string match -q -- '--*' "$pathPrefix"; and test (string sub -l (string length -- "$pathPrefix") -- "$token") = "$pathPrefix" + set -e %[1]s[1] + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) %% 2) + if test $nofiles -eq 1 + return 0 + end + set -l value (string sub -s (math (string length -- "$pathPrefix") + 1) -- "$token") + set -q value[1]; or set value "" + set -l escaped (string escape --no-quoted -- "$value" | string replace -a -- '=' '\\=') + set --global %[1]s + if test $dirfilter -eq 1 + for entry in (__fish_complete_directories "$escaped") + set -a %[1]s "$pathPrefix$entry" + end + else + for entry in (complete -C "'' $escaped") + set -a %[1]s "$pathPrefix$entry" + end + end + __%[3]s_debug "Path completions: $%[1]s" + return 0 + end + end`, variable, completionPathPrefixMarker, variableName) + script, err = replaceCompletionText(script, `set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2)`, pathContext) + if err != nil { + return "", err + } 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 c9899f4..3d90dad 100644 --- a/internal/cobra/completion_test.go +++ b/internal/cobra/completion_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" spfcobra "github.com/spf13/cobra" ) @@ -33,6 +34,20 @@ func TestCompletionScriptGeneration(t *testing.T) { } } +func TestCompletionReplyFiltersUnrepresentableValues(t *testing.T) { + result := commandmeta.CompletionResult{Kind: commandmeta.CompletionChoices, Prefix: "-c", Choices: []commandmeta.CompletionChoice{ + {Value: "good", Description: "one\ntwo\tthree"}, {Value: ""}, {Value: "bad\nline"}, {Value: "bad\tfield"}, {Value: "bad\x00byte"}, + }} + reply, err := CompletionReply(result) + want := fmt.Sprintf("%s-c\ngood\tone two three\n:%d\n", completionPathPrefixMarker, spfcobra.ShellCompDirectiveNoFileComp) + if err != nil || reply != want { + t.Fatalf("reply=%q error=%v", reply, err) + } + if _, err := CompletionReply(commandmeta.CompletionResult{Kind: 255}); err == nil { + t.Fatal("unsupported strategy accepted") + } +} + type shortCompletionWriter struct{} func (shortCompletionWriter) Write(p []byte) (int, error) { return len(p) - 1, nil } @@ -61,9 +76,26 @@ sys.stdout.write(os.environ['PROBE_REPLY']) if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, "foo=bar"), nil, 0o644); err != nil { + t.Fatal(err) + } reply := func(candidates string, directive spfcobra.ShellCompDirective) string { return fmt.Sprintf("%s:%d\n", candidates, directive) } + pathReply := func(kind commandmeta.CompletionKind, prefix string) string { + result, err := CompletionReply(commandmeta.CompletionResult{Kind: kind, Prefix: prefix}) + if err != nil { + t.Fatal(err) + } + return result + } + choiceReply := func(prefix, value string) string { + result, err := CompletionReply(commandmeta.CompletionResult{Kind: commandmeta.CompletionChoices, Prefix: prefix, Choices: []commandmeta.CompletionChoice{{Value: value, Description: "value"}}}) + if err != nil { + t.Fatal(err) + } + return result + } tests := []struct { name, line, reply string query, executed []string @@ -74,8 +106,17 @@ sys.stdout.write(os.environ['PROBE_REPLY']) {"attached file", `probe --config=./partial\ f`, reply("", spfcobra.ShellCompDirectiveDefault), []string{"__complete", "--config=./partial f"}, []string{"--config=./partial file"}}, {"directory", "probe ./partial", reply("", spfcobra.ShellCompDirectiveFilterDirs), []string{"__complete", "./partial"}, []string{"./partial dir"}}, {"attached directory", "probe --config=./partial", reply("", spfcobra.ShellCompDirectiveFilterDirs), []string{"__complete", "--config=./partial"}, []string{"--config=./partial dir"}}, + {"short attached file", `probe -c./partial\ f`, pathReply(commandmeta.CompletionFiles, "-c"), []string{"__complete", "-c./partial f"}, []string{"-c./partial file"}}, + {"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 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"}}, + {"literal marker attached", "probe --config=__commandkit_path_pre", choiceReply("--config=", completionPathPrefixMarker+"-c"), []string{"__complete", "--config=__commandkit_path_pre"}, []string{"--config=" + completionPathPrefixMarker + "-c"}}, {"empty", "probe ", reply("", spfcobra.ShellCompDirectiveNoFileComp), []string{"__complete", ""}, []string{}}, {"no file fallback", `probe ./partial\ f`, reply("", spfcobra.ShellCompDirectiveNoFileComp), []string{"__complete", "./partial f"}, []string{"./partial f"}}, + {"error no fallback", `probe ./partial\ f`, reply("", spfcobra.ShellCompDirectiveError), []string{"__complete", "./partial f"}, []string{"./partial f"}}, {"cluster", "probe -ab", reply("-abc\tflag\n", spfcobra.ShellCompDirectiveNoFileComp), []string{"__complete", "-ab"}, []string{"-abc"}}, {"quoted argument", "probe 'alpha beta' --config=./par", reply("./partial file\tdescription\n", spfcobra.ShellCompDirectiveNoFileComp), []string{"__complete", "alpha beta", "--config=./par"}, []string{"alpha beta", "--config=./partial file"}}, } diff --git a/internal/commandmeta/metadata.go b/internal/commandmeta/metadata.go index 827c4d2..52f8bf4 100644 --- a/internal/commandmeta/metadata.go +++ b/internal/commandmeta/metadata.go @@ -67,6 +67,13 @@ type CompletionHint struct { Choices []CompletionChoice } +// CompletionResult contains neutral suggestions and the entered value prefix. +type CompletionResult struct { + Kind CompletionKind + Choices []CompletionChoice + Prefix string +} + var reportingFlags = [...]Flag{ {Name: "quiet", Usage: "suppress substantive output and optional messages; keep failures visible", Default: "false", Boolean: true}, {Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},