Deliver shell completion query replies
This commit is contained in:
53
completion_query.go
Normal file
53
completion_query.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
117
completion_query_test.go
Normal file
117
completion_query_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Shell completion implementation plan
|
# 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
|
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
|
||||||
@@ -229,6 +229,8 @@ with `GOWORK=off` and `GOTOOLCHAIN=local`.
|
|||||||
|
|
||||||
## Stage 6: Assemble candidates and deliver query replies
|
## Stage 6: Assemble candidates and deliver query replies
|
||||||
|
|
||||||
|
Status: complete.
|
||||||
|
|
||||||
1. Produce a neutral result of candidate replacements, optional descriptions,
|
1. Produce a neutral result of candidate replacements, optional descriptions,
|
||||||
or a filesystem strategy with value context. Keep prefix preservation
|
or a filesystem strategy with value context. Keep prefix preservation
|
||||||
distinct from matching; the adapter translates the neutral replacement
|
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,
|
Use counter fixtures to prove target handlers, converters, factories,
|
||||||
and providers are untouched. Verify no special dispatch without installation.
|
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
|
## Stage 7: Annotate maintained commands and examples
|
||||||
|
|
||||||
1. Annotate static format/scope flags where their choices already come from
|
1. Annotate static format/scope flags where their choices already come from
|
||||||
|
|||||||
@@ -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")
|
err := errors.New("invocation context must not be nil")
|
||||||
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
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
|
routedSnapshot := current.snapshot
|
||||||
current.route = resolveEnteredRoute(args, routedSnapshot)
|
current.route = resolveEnteredRoute(args, routedSnapshot)
|
||||||
current.recordRouteEvents()
|
current.recordRouteEvents()
|
||||||
|
|||||||
@@ -6,12 +6,57 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||||
spfcobra "github.com/spf13/cobra"
|
spfcobra "github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CompletionQueryToken is the hidden request understood by generated scripts.
|
// CompletionQueryToken is the hidden request understood by generated scripts.
|
||||||
const CompletionQueryToken = spfcobra.ShellCompRequestCmd
|
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.
|
// 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}
|
||||||
@@ -32,11 +77,10 @@ func CompletionScript(w io.Writer, executable, shell string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
script := generated.String()
|
script := generated.String()
|
||||||
if shell == "zsh" {
|
if shell == "bash" {
|
||||||
script, err = replaceCompletionText(script, `words=("${=words[1,CURRENT]}")`, `words=("${(@)words[1,CURRENT]}")`)
|
script, err = adjustBashCompletion(script, root.Name())
|
||||||
if err == nil {
|
} else if shell == "zsh" {
|
||||||
script, err = replaceCompletionText(script, `=~ '-.*='`, `=~ '-[^=]*='`)
|
script, err = adjustZshCompletion(script)
|
||||||
}
|
|
||||||
} else if shell == "fish" {
|
} else if shell == "fish" {
|
||||||
script, err = adjustFishCompletion(script, root.Name())
|
script, err = adjustFishCompletion(script, root.Name())
|
||||||
}
|
}
|
||||||
@@ -50,6 +94,82 @@ func CompletionScript(w io.Writer, executable, shell string) error {
|
|||||||
return err
|
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) {
|
func replaceCompletionText(script, old, replacement string) (string, error) {
|
||||||
if strings.Count(script, old) != 1 {
|
if strings.Count(script, old) != 1 {
|
||||||
return "", fmt.Errorf("unsupported generated completion script")
|
return "", fmt.Errorf("unsupported generated completion script")
|
||||||
@@ -77,10 +197,62 @@ func adjustFishCompletion(script, name string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
script, err = replaceCompletionText(script, `string match -r -- '-.*=' "$lastArg"`, `string match -r -- '-[^=]*=' "$lastArg"`)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
variable := "__" + variableName + "_comp_results"
|
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
|
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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||||
spfcobra "github.com/spf13/cobra"
|
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{}
|
type shortCompletionWriter struct{}
|
||||||
|
|
||||||
func (shortCompletionWriter) Write(p []byte) (int, error) { return len(p) - 1, nil }
|
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 {
|
if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
reply := func(candidates string, directive spfcobra.ShellCompDirective) string {
|
||||||
return fmt.Sprintf("%s:%d\n", candidates, directive)
|
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 {
|
tests := []struct {
|
||||||
name, line, reply string
|
name, line, reply string
|
||||||
query, executed []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"}},
|
{"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"}},
|
{"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"}},
|
{"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{}},
|
{"empty", "probe ", reply("", spfcobra.ShellCompDirectiveNoFileComp), []string{"__complete", ""}, []string{}},
|
||||||
{"no file fallback", `probe ./partial\ f`, reply("", spfcobra.ShellCompDirectiveNoFileComp), []string{"__complete", "./partial f"}, []string{"./partial f"}},
|
{"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"}},
|
{"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"}},
|
{"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"}},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ type CompletionHint struct {
|
|||||||
Choices []CompletionChoice
|
Choices []CompletionChoice
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompletionResult contains neutral suggestions and the entered value prefix.
|
||||||
|
type CompletionResult struct {
|
||||||
|
Kind CompletionKind
|
||||||
|
Choices []CompletionChoice
|
||||||
|
Prefix string
|
||||||
|
}
|
||||||
|
|
||||||
var reportingFlags = [...]Flag{
|
var reportingFlags = [...]Flag{
|
||||||
{Name: "quiet", Usage: "suppress substantive output and optional messages; keep failures visible", Default: "false", Boolean: true},
|
{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},
|
{Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},
|
||||||
|
|||||||
Reference in New Issue
Block a user