Deliver shell completion query replies

This commit is contained in:
2026-11-03 04:06:05 +00:00
parent 4c47a0ca0b
commit d1f31d2931
7 changed files with 412 additions and 6 deletions

View File

@@ -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

View File

@@ -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"}},
}

View File

@@ -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},