From b83c0a0dfcd7e82905a6649efe96f61ea2258c23 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 3 Nov 2026 13:12:05 +0000 Subject: [PATCH] Separate completion metadata from literal choices --- docs/roadmap/remediation.md | 4 +- internal/cobra/completion.go | 79 ++++++++++++++++++------------- internal/cobra/completion_test.go | 5 +- 3 files changed, 53 insertions(+), 35 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 105507c..8660688 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -67,7 +67,9 @@ hints. Coordinate with finding 1 so the same context drives safe shell calls. ### 3. Separate transport control records from literal candidates -Priority: P2. Status: open. +Priority: P2. Status: complete. + +- [x] Completed: Successful replies frame replacement metadata in a dedicated first line. Bash and Zsh no longer interpret ActiveHelp-like candidate text. Interactive checks pass for literal control-like choices, attached choices, and empty replies. [CompletionReply](../../internal/cobra/completion.go#L19) emits literal choices in a stream where generated scripts also recognize Cobra's `_activeHelp_ ` diff --git a/internal/cobra/completion.go b/internal/cobra/completion.go index 2083ab9..d17f805 100644 --- a/internal/cobra/completion.go +++ b/internal/cobra/completion.go @@ -28,12 +28,15 @@ func CompletionReply(result commandmeta.CompletionResult) (string, error) { default: return "", fmt.Errorf("unsupported completion suggestion strategy") } - var output strings.Builder - if result.Kind != commandmeta.CompletionNone && result.Prefix != "" { - output.WriteString(completionPathPrefixMarker) - output.WriteString(result.Prefix) - output.WriteByte('\n') + // The first line is always metadata, even for a whole-word replacement. + // Remaining lines before the directive are exclusively literal candidates. + if strings.ContainsAny(result.Prefix, "\x00\r\n\t") { + return "", fmt.Errorf("unrepresentable completion prefix") } + var output strings.Builder + 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") { @@ -145,20 +148,29 @@ func adjustBashCompletion(script, name string) (string, error) { if err != nil { return "", err } - parsePrefix := `if [[ ${out} == ` + completionPathPrefixMarker + `-* ]]; then - completionPrefix=${out%%$'\n'*} - completionPrefix=${completionPrefix#` + completionPathPrefixMarker + `} - completionOriginalCur=$cur - if [[ $completionOriginalCur == "$completionPrefix"* ]]; then - if [[ $out == *$'\n'* ]]; then out=${out#*$'\n'}; else out=; fi - cur=${completionOriginalCur#"$completionPrefix"} - else - completionPrefix="" - fi + parsePrefix := `if [[ ${out} != ` + completionPathPrefixMarker + `* ]]; then + compopt +o default + return fi + completionPrefix=${out%%$'\n'*} + completionPrefix=${completionPrefix#` + completionPathPrefixMarker + `} + completionOriginalCur=$cur + if [[ $completionOriginalCur != "$completionPrefix"* ]]; then + compopt +o default + return + fi + if [[ $out == *$'\n'* ]]; then out=${out#*$'\n'}; else out=; fi + cur=${completionOriginalCur#"$completionPrefix"} # Separate activeHelp from normal completions` script, err = replaceCompletionText(script, `# Separate activeHelp from normal completions`, parsePrefix) + if err != nil { + return "", err + } + script, err = replaceCompletionSection(script, " local activeHelpMarker=", "\n}\n", ` while IFS='' read -r comp; do + [[ -z $comp ]] || completions+=("$comp") + done <<<"${out}" +`) if err != nil { return "", err } @@ -196,19 +208,23 @@ func adjustZshCompletion(script string) (string, error) { if err != nil { return "", err } - script, err = replaceCompletionText(script, `local activeHelpMarker="_activeHelp_ "`, `if [[ "$out" == `+completionPathPrefixMarker+`-* ]]; then - local pathPrefix="${out%%$'\n'*}" - pathPrefix="${pathPrefix#`+completionPathPrefixMarker+`}" - if [[ "$lastParam" == "$pathPrefix"* ]]; then - if [[ $out == *$'\n'* ]]; then out="${out#*$'\n'}"; else out=; fi - flagPrefix="$pathPrefix" - fi - fi + script, err = replaceCompletionSection(script, ` local activeHelpMarker=`, " if [ $((directive & shellCompDirectiveNoSpace))", ` [[ "$out" == `+completionPathPrefixMarker+`* ]] || return 1 + flagPrefix="${out%%$'\n'*}" + flagPrefix="${flagPrefix#`+completionPathPrefixMarker+`}" + [[ "$lastParam" == "$flagPrefix"* ]] || return 1 + if [[ $out == *$'\n'* ]]; then out="${out#*$'\n'}"; else out=; fi if [[ -n "$flagPrefix" ]]; then prefixArgs=(-P "$flagPrefix") fi + local tab="$(printf '\t')" + while IFS= read -r comp; do + if [[ -n "$comp" ]]; then + comp=${comp//:/\\:} + completions+=("${comp//$tab/:}") + fi + done <<<"$out" - local activeHelpMarker="_activeHelp_ "`) +`) if err != nil { return "", err } @@ -272,15 +288,14 @@ func adjustFishCompletion(script, name string) (string, error) { if err != nil { return "", err } - script, err = replaceCompletionSection(script, " # For Fish, when completing a flag", ` printf "%s\n" "$directiveLine"`, ` set -l flagPrefix "" - if set -q comps[1]; and string match -q -- '`+completionPathPrefixMarker+`-*' "$comps[1]" - set -l candidatePrefix (string replace -- '`+completionPathPrefixMarker+`' '' "$comps[1]") - if test (string sub -l (string length -- "$candidatePrefix") -- "$lastArg") = "$candidatePrefix" - set flagPrefix "$candidatePrefix" - set -e comps[1] - end + script, err = replaceCompletionSection(script, " # For Fish, when completing a flag", ` printf "%s\n" "$directiveLine"`, ` if not set -q comps[1]; or not string match -q -- '`+completionPathPrefixMarker+`*' "$comps[1]" + printf ":%s\n" `+fmt.Sprint(spfcobra.ShellCompDirectiveError)+` + return end - printf "%s%s\n" '`+completionPathPrefixMarker+`' "$flagPrefix" + set -l flagPrefix (string replace -- '`+completionPathPrefixMarker+`' '' "$comps[1]") + set -q flagPrefix[1]; or set flagPrefix "" + printf "%s\n" "$comps[1]" + set -e comps[1] for comp in $comps printf "%s%s\n" "$flagPrefix" "$comp" end diff --git a/internal/cobra/completion_test.go b/internal/cobra/completion_test.go index d2a048e..10a70e0 100644 --- a/internal/cobra/completion_test.go +++ b/internal/cobra/completion_test.go @@ -82,7 +82,7 @@ sys.stdout.write(os.environ['PROBE_REPLY']) t.Fatal(err) } reply := func(candidates string, directive spfcobra.ShellCompDirective) string { - return fmt.Sprintf("%s:%d\n", candidates, directive) + return fmt.Sprintf("%s\n%s:%d\n", completionPathPrefixMarker, candidates, directive) } pathReply := func(kind commandmeta.CompletionKind, prefix string) string { result, err := CompletionReply(commandmeta.CompletionResult{Kind: kind, Prefix: prefix}) @@ -115,6 +115,7 @@ sys.stdout.write(os.environ['PROBE_REPLY']) {"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 active help", "probe _act", choiceReply("", "_activeHelp_ choice"), []string{"__complete", "_act"}, []string{"_activeHelp_ choice"}}, {"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"}}, {"option-looking separate value", "probe --format --tag=ch", choiceReply("", "--tag=chosen"), []string{"__complete", "--format", "--tag=ch"}, []string{"--format", "--tag=chosen"}}, @@ -174,7 +175,7 @@ sys.stdout.write(os.environ['PROBE_REPLY']) t.Fatal(err) } cmd := exec.Command("python3", "testdata/completion_terminal.py", shell, script, tc.line) - cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_DIR="+dir, "PROBE_LOG="+log, "PROBE_TAB_ONLY=1", "PROBE_REPLY=:4\n") + cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_DIR="+dir, "PROBE_LOG="+log, "PROBE_TAB_ONLY=1", "PROBE_REPLY="+reply("", spfcobra.ShellCompDirectiveNoFileComp)) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("tab-only completion: %v\n%s", err, out) }