fix(completion): harden the __complete engine and shell wrappers

Engine:
- Emit DirectiveKeepOrder for task variables so the `requires` declaration
  order is preserved instead of being sorted by the shell.
- Return full `--flag=value` candidates for the inline `--output=` form so all
  shells match against the whole current token.
- Add `--no-aliases` / `--no-descriptions` completion flags (via complete.Options)
  parsed from the __complete invocation; the zsh wrapper maps its show-aliases
  and verbose zstyles onto them.
- Skip Taskfile setup when completing flags (NeedsTaskfile) and load the task
  list lazily; drop unused parameters; document exported identifiers.

Shell wrappers:
- zsh: reindent to tabs (.editorconfig), bridge zstyles to engine flags.
- fish: reindent to 2 spaces, handle every file-completion directive in the
  wrapper (--no-files disables the native fallback), drop the duplicated
  yml/yaml extension list now that it lives only in the engine.
- bash: prefix-filter by hand to preserve values containing spaces, exclude `=`
  from word breaks so `--output=` reaches the engine as one token.
- powershell: filter candidates by the current word, fall back to file
  completion for DirectiveDefault.

NoSpace is not representable in fish/PowerShell completion APIs; documented in
the wrappers.
This commit is contained in:
Valentin Maerten
2026-07-03 16:03:33 +02:00
parent f9f2ecb8be
commit 57bc6dd8cb
11 changed files with 413 additions and 158 deletions

View File

@@ -32,7 +32,9 @@
@kjasn).
- Unified Bash, Fish, Zsh and PowerShell completions behind a single `task
__complete` engine, so every shell offers the same suggestions: task names,
aliases, flags, flag values and per-task CLI variables (#2897 by @vmaerten).
aliases, flags, flag values and per-task CLI variables. The Zsh `show-aliases`
and `verbose` zstyles are preserved, now backed by the `--no-aliases` and
`--no-descriptions` completion flags (#2897 by @vmaerten).
## v3.51.1 - 2026-05-16

View File

@@ -11,6 +11,10 @@ import (
)
func runComplete(args []string) error {
// Strip the completion-control flags the wrapper prepends; the rest is the
// user's command line to complete.
opts, args := complete.ParseOptions(args)
dir, entrypoint, global := extractTaskfileFlags(args)
e := task.NewExecutor(
@@ -26,10 +30,14 @@ func runComplete(args []string) error {
}
}
// Loading the Taskfile parses YAML (and may hit the network for remote
// Taskfiles), so skip it entirely when completing flags or their values.
// Best-effort: a missing or broken Taskfile must not break completion.
_ = e.Setup()
if complete.NeedsTaskfile(args, pflag.CommandLine) {
_ = e.Setup()
}
suggs, dirv := complete.Complete(e, pflag.CommandLine, args)
suggs, dirv := complete.Complete(e, pflag.CommandLine, args, opts)
complete.Write(os.Stdout, suggs, dirv)
return nil
}

View File

@@ -7,7 +7,9 @@ TASK_CMD="${TASK_EXE:-task}"
_task() {
local cur prev words cword
_init_completion -n : || return
# Exclude both `=` and `:` from the word breaks so `--output=` and
# `docs:serve` reach the engine as single tokens.
_init_completion -n =: || return
local -a args=()
if (( cword > 0 )); then
@@ -48,13 +50,17 @@ _task() {
return
fi
local -a values=()
# Prefix-filter by hand instead of `compgen -W`: the latter joins/splits the
# word list on IFS, which mangles any suggestion value containing a space.
local value
COMPREPLY=()
for line in "${lines[@]}"; do
values+=( "${line%%$'\t'*}" )
value="${line%%$'\t'*}"
if [[ -z "$cur" || "$value" == "$cur"* ]]; then
COMPREPLY+=( "$value" )
fi
done
COMPREPLY=( $( compgen -W "${values[*]}" -- "$cur" ) )
if (( directive & 2 )); then
compopt -o nospace 2>/dev/null
fi

View File

@@ -4,43 +4,65 @@
set -l GO_TASK_PROGNAME (if set -q GO_TASK_PROGNAME; echo $GO_TASK_PROGNAME; else if set -q TASK_EXE; echo $TASK_EXE; else; echo task; end)
function __task_complete --inherit-variable GO_TASK_PROGNAME
set -l tokens (commandline -opc)
set -l current (commandline -ct)
set -l args
if test (count $tokens) -gt 1
set args $tokens[2..-1]
end
set args $args $current
set -l tokens (commandline -opc)
set -l current (commandline -ct)
set -l args
if test (count $tokens) -gt 1
set args $tokens[2..-1]
end
set args $args $current
set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
set -l count (count $output)
if test $count -eq 0
return
end
set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
set -l count (count $output)
if test $count -eq 0
return
end
set -l last $output[$count]
if not string match -q ':*' -- $last
# Protocol violation: emit raw lines as a fallback.
for line in $output
echo $line
end
return
end
set -l last $output[$count]
if not string match -q ':*' -- $last
# Protocol violation: emit raw lines as a fallback.
printf '%s\n' $output
return
end
set -l directive (string replace -r '^:' '' -- $last)
# FilterFileExt / FilterDirs are handled by fish's native file completion
# via the separate `complete` registrations below.
if test (math "$directive & 8") -ne 0; or test (math "$directive & 16") -ne 0
return
end
set -l directive (string replace -r '^:' '' -- $last)
set -l data
if test $count -gt 1
set data $output[1..(math $count - 1)]
end
if test $count -gt 1
for line in $output[1..(math $count - 1)]
echo $line
end
# The main completion is registered with `--no-files`, which disables fish's
# native file fallback. Every file-completion directive must therefore be
# served here, otherwise nothing is offered (e.g. `--cacert`, after `--`).
# FilterFileExt: the engine emits the allowed extensions as the data lines.
if test (math "$directive & 8") -ne 0
for ext in $data
__fish_complete_suffix ".$ext"
end
return
end
# FilterDirs: complete directories only.
if test (math "$directive & 16") -ne 0
__fish_complete_directories $current
return
end
# Emit the `value\tdescription` candidates (fish reads the tab as the
# separator between the completion and its description).
for line in $data
printf '%s\n' $line
end
# NoFileComp (bit 4) unset → also offer files, since `--no-files` suppressed
# the native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`).
if test (math "$directive & 4") -eq 0
__fish_complete_path $current
end
end
# Single registration: all task names, flags, flag values and file completion
# flow through the engine. `--no-files` prevents fish from mixing in files when
# the engine says not to (NoFileComp); `__task_complete` re-adds them otherwise.
complete -c $GO_TASK_PROGNAME --no-files -a "(__task_complete)"
complete -c $GO_TASK_PROGNAME -s t -l taskfile -r -k -a "(__fish_complete_suffix .yml .yaml)"
complete -c $GO_TASK_PROGNAME -s d -l dir -xa "(__fish_complete_directories)"

View File

@@ -21,10 +21,9 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
}
}
# The trailing word (possibly empty) must reach the engine so it knows
# the cursor sits on a fresh word.
if ($argsToPass.Count -gt 0 -and $argsToPass[-1] -eq $wordToComplete) {
$argsToPass[-1] = $wordToComplete
} else {
# the cursor sits on a fresh word. It is already present when it coincides
# with the last command element captured above.
if ($argsToPass.Count -eq 0 -or $argsToPass[-1] -ne $wordToComplete) {
$argsToPass += $wordToComplete
}
@@ -39,6 +38,10 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
$directive = [int]($last.Substring(1))
$data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() }
# Note: DirectiveNoSpace (bit 2) cannot be honored here — PowerShell's
# CompletionResult API has no per-item "no trailing space" option, so a
# suggestion like `VAR=` gets a trailing space. This is a PowerShell limit.
# FilterFileExt
if ($directive -band 8) {
$patterns = $data | ForEach-Object { "*.$_" }
@@ -52,10 +55,23 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) }
}
return $data | ForEach-Object {
# Build candidates, filtering by the current word. PowerShell does not filter
# native argument-completer results itself, so without this every suggestion
# would be offered regardless of what the user typed.
$results = @($data | ForEach-Object {
$parts = $_ -split "`t", 2
$value = $parts[0]
if ($wordToComplete -and -not $value.StartsWith($wordToComplete)) { return }
$desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value }
[CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc)
})
# NoFileComp (bit 4) unset and nothing matched → fall back to file completion,
# since the engine returned DirectiveDefault (e.g. --cacert, after `--`).
if ($results.Count -eq 0 -and -not ($directive -band 4)) {
return Get-ChildItem -Path . -ErrorAction SilentlyContinue |
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) }
}
return $results
}

View File

@@ -6,60 +6,66 @@
TASK_CMD="${TASK_EXE:-task}"
_task() {
local -a args lines completions opts
local output directive line
local -a args lines completions opts ctl
local output directive line
# (@) preserves a trailing empty string, which the engine relies on to
# know the cursor is on a fresh word.
args=("${(@)words[2,CURRENT]}")
(( ${#args} == 0 )) && args=("")
# Map the zsh completion zstyles to engine flags. `-T` is true when the
# style is unset (its default) or explicitly true, so a flag is only passed
# when the user turned the style off.
zstyle -T ":completion:${curcontext}:" show-aliases || ctl+=(--no-aliases)
zstyle -T ":completion:${curcontext}:" verbose || ctl+=(--no-descriptions)
output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
if [[ -z "$output" ]]; then
_files
return
fi
# (@) preserves a trailing empty string, which the engine relies on to
# know the cursor is on a fresh word.
args=("${(@)words[2,CURRENT]}")
(( ${#args} == 0 )) && args=("")
lines=("${(f)output}")
directive="${lines[-1]#:}"
lines=("${(@)lines[1,-2]}")
output=$("$TASK_CMD" __complete "${ctl[@]}" "${args[@]}" 2>/dev/null)
if [[ -z "$output" ]]; then
_files
return
fi
if (( directive & 8 )); then
local -a globs
for line in "${lines[@]}"; do
globs+=("*.${line}")
done
_files -g "(${(j:|:)globs})"
return
fi
lines=("${(f)output}")
directive="${lines[-1]#:}"
lines=("${(@)lines[1,-2]}")
if (( directive & 16 )); then
_path_files -/
return
fi
if (( directive & 8 )); then
local -a globs
for line in "${lines[@]}"; do
globs+=("*.${line}")
done
_files -g "(${(j:|:)globs})"
return
fi
# `:` inside the value must be escaped: _describe splits on the first
# unescaped colon (e.g. "docs:serve" would otherwise become value "docs").
local value desc
for line in "${lines[@]}"; do
if [[ "$line" == *$'\t'* ]]; then
value="${line%%$'\t'*}"
desc="${line#*$'\t'}"
completions+=("${value//:/\\:}:$desc")
else
completions+=("${line//:/\\:}")
fi
done
if (( directive & 16 )); then
_path_files -/
return
fi
(( directive & 2 )) && opts+=(-S '')
(( directive & 32 )) && opts+=(-V)
# `:` inside the value must be escaped: _describe splits on the first
# unescaped colon (e.g. "docs:serve" would otherwise become value "docs").
local value desc
for line in "${lines[@]}"; do
if [[ "$line" == *$'\t'* ]]; then
value="${line%%$'\t'*}"
desc="${line#*$'\t'}"
completions+=("${value//:/\\:}:$desc")
else
completions+=("${line//:/\\:}")
fi
done
if (( ${#completions} > 0 )); then
_describe -t tasks 'task' completions "${opts[@]}"
fi
(( directive & 2 )) && opts+=(-S '')
(( directive & 32 )) && opts+=(-V)
(( directive & 4 )) && return
_files
if (( ${#completions} > 0 )); then
_describe -t tasks 'task' completions "${opts[@]}"
fi
(( directive & 4 )) && return
_files
}
compdef _task "$TASK_CMD"

View File

@@ -5,26 +5,85 @@ package complete
import "os"
// CommandName is the hidden subcommand the shell wrappers invoke to drive
// completion: `task __complete <words...>`.
const CommandName = "__complete"
// IsActive reports whether the process was invoked in completion mode, i.e.
// the first argument is the __complete subcommand.
func IsActive() bool {
return len(os.Args) >= 2 && os.Args[1] == CommandName
}
// Directive mirrors cobra's ShellCompDirective bitfield.
// Directive mirrors cobra's ShellCompDirective bitfield. It is emitted on the
// final output line as `:<directive>` and tells the shell wrapper how to treat
// the suggestions (file fallback, trailing space, ordering, …).
type Directive int
const (
DirectiveDefault Directive = 0
DirectiveError Directive = 1 << 0
DirectiveNoSpace Directive = 1 << 1
DirectiveNoFileComp Directive = 1 << 2
// DirectiveDefault leaves the shell to perform its default file completion.
DirectiveDefault Directive = 0
// DirectiveError signals an error; the shell should not offer completion.
DirectiveError Directive = 1 << 0
// DirectiveNoSpace prevents the shell from appending a space after the
// suggestion (e.g. so `VAR=` can be followed by a value).
DirectiveNoSpace Directive = 1 << 1
// DirectiveNoFileComp disables the shell's fallback file completion.
DirectiveNoFileComp Directive = 1 << 2
// DirectiveFilterFileExt restricts file completion to the emitted extensions.
DirectiveFilterFileExt Directive = 1 << 3
DirectiveFilterDirs Directive = 1 << 4
DirectiveKeepOrder Directive = 1 << 5
// DirectiveFilterDirs restricts completion to directories.
DirectiveFilterDirs Directive = 1 << 4
// DirectiveKeepOrder tells the shell to preserve the emitted order instead
// of sorting alphabetically.
DirectiveKeepOrder Directive = 1 << 5
)
// Suggestion is a single completion candidate: the Value inserted on the
// command line and an optional human-readable Description.
type Suggestion struct {
Value string
Description string
}
// Options tunes what the engine emits. The zero value shows everything; use
// DefaultOptions for the default and flip fields off from the __complete flags.
type Options struct {
ShowAliases bool
ShowDescriptions bool
}
// DefaultOptions returns the options used when no completion-control flag is
// passed: aliases and descriptions are both shown.
func DefaultOptions() Options {
return Options{ShowAliases: true, ShowDescriptions: true}
}
// Completion-control flags. Shell wrappers prepend these to the __complete
// invocation to tune the output (e.g. zsh maps its show-aliases / verbose
// zstyles to them). They are consumed by ParseOptions before the remaining
// args are treated as the user's command line.
const (
FlagNoAliases = "--no-aliases"
FlagNoDescriptions = "--no-descriptions"
)
// ParseOptions peels the leading completion-control flags off args and returns
// the resulting Options together with the remaining args (the user's command
// line to complete). Only leading flags are consumed, so a `--no-aliases` typed
// by the user further down the line is left untouched.
func ParseOptions(args []string) (Options, []string) {
opts := DefaultOptions()
for len(args) > 0 {
switch args[0] {
case FlagNoAliases:
opts.ShowAliases = false
case FlagNoDescriptions:
opts.ShowDescriptions = false
default:
return opts, args
}
args = args[1:]
}
return opts, args
}

View File

@@ -92,7 +92,7 @@ func TestComplete_TaskNames(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.DefaultOptions())
require.ElementsMatch(t,
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
@@ -106,26 +106,26 @@ func TestComplete_AliasResolvesToTaskVars(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}, complete.DefaultOptions())
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
}
func TestComplete_StaticEnum(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""}, complete.DefaultOptions())
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
}
func TestComplete_EnumRef(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""})
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""}, complete.DefaultOptions())
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs))
}
@@ -133,7 +133,7 @@ func TestComplete_NoRequires(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}, complete.DefaultOptions())
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
@@ -142,7 +142,7 @@ func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}, complete.DefaultOptions())
require.ElementsMatch(t,
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
values(suggs),
@@ -154,7 +154,7 @@ func TestComplete_NamespacedTaskName(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""}, complete.DefaultOptions())
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
@@ -163,8 +163,10 @@ func TestComplete_FlagValueInlineEquals(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="})
require.Equal(t, []string{"interleaved", "group", "prefixed"}, values(suggs))
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, complete.DefaultOptions())
// Inline form returns full `--output=value` tokens so the shell can match
// against the whole current word.
require.Equal(t, []string{"--output=interleaved", "--output=group", "--output=prefixed"}, values(suggs))
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
@@ -172,7 +174,7 @@ func TestComplete_AfterDash(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""}, complete.DefaultOptions())
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveDefault, dir)
}
@@ -181,7 +183,7 @@ func TestComplete_FlagNames(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"}, complete.DefaultOptions())
require.NotEmpty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
@@ -195,7 +197,7 @@ func TestComplete_EnumFlagValue_Output(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output", ""}, complete.DefaultOptions())
require.Equal(t, []string{"interleaved", "group", "prefixed"}, values(suggs))
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
@@ -204,7 +206,7 @@ func TestComplete_EnumFlagValue_Sort(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""})
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""}, complete.DefaultOptions())
require.Equal(t, []string{"default", "alphanumeric", "none"}, values(suggs))
}
@@ -212,7 +214,7 @@ func TestComplete_PathFlag_Taskfile(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""}, complete.DefaultOptions())
require.Equal(t, []string{"yml", "yaml"}, values(suggs))
require.Equal(t, complete.DirectiveFilterFileExt, dir)
}
@@ -221,7 +223,7 @@ func TestComplete_PathFlag_Dir(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""}, complete.DefaultOptions())
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveFilterDirs, dir)
}
@@ -230,7 +232,7 @@ func TestComplete_PathFlag_Cacert(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""})
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""}, complete.DefaultOptions())
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveDefault, dir)
}
@@ -238,11 +240,97 @@ func TestComplete_PathFlag_Cacert(t *testing.T) {
func TestComplete_NilExecutor(t *testing.T) {
t.Parallel()
suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"})
suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"}, complete.DefaultOptions())
require.NotEmpty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
func TestComplete_NoAliases(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
opts := complete.Options{ShowAliases: false, ShowDescriptions: true}
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, opts)
require.ElementsMatch(t,
[]string{"build", "deploy", "dynenum", "docs:serve"},
values(suggs),
)
require.NotContains(t, values(suggs), "dep")
require.NotContains(t, values(suggs), "ship")
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
func TestComplete_NoDescriptions(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
opts := complete.Options{ShowAliases: true, ShowDescriptions: false}
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{""}, opts)
require.ElementsMatch(t,
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
values(suggs),
)
for _, d := range descriptions(suggs) {
require.Empty(t, d)
}
}
func TestParseOptions(t *testing.T) {
t.Parallel()
t.Run("defaults", func(t *testing.T) {
t.Parallel()
opts, rest := complete.ParseOptions([]string{"deploy", ""})
require.Equal(t, complete.DefaultOptions(), opts)
require.Equal(t, []string{"deploy", ""}, rest)
})
t.Run("both flags", func(t *testing.T) {
t.Parallel()
opts, rest := complete.ParseOptions([]string{"--no-aliases", "--no-descriptions", "deploy", ""})
require.False(t, opts.ShowAliases)
require.False(t, opts.ShowDescriptions)
require.Equal(t, []string{"deploy", ""}, rest)
})
t.Run("only leading flags consumed", func(t *testing.T) {
t.Parallel()
// A flag appearing after the user's words is left in the command line.
opts, rest := complete.ParseOptions([]string{"deploy", "--no-aliases"})
require.True(t, opts.ShowAliases)
require.Equal(t, []string{"deploy", "--no-aliases"}, rest)
})
}
func TestNeedsTaskfile(t *testing.T) {
t.Parallel()
tests := map[string]struct {
args []string
want bool
}{
"task name": {[]string{""}, true},
"partial task name": {[]string{"bui"}, true},
"task var": {[]string{"deploy", ""}, true},
"value flag then name": {[]string{"--dir", "/tmp", ""}, true},
"flag name": {[]string{"-"}, false},
"long flag name": {[]string{"--li"}, false},
"inline flag value": {[]string{"--output="}, false},
"flag value": {[]string{"--output", ""}, false},
"path flag value": {[]string{"--taskfile", ""}, false},
"after dash": {[]string{"deploy", "--", ""}, false},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, complete.NeedsTaskfile(tt.args, newTestFlagSet()))
})
}
}
func TestWrite_Format(t *testing.T) {
t.Parallel()

View File

@@ -9,14 +9,13 @@ import (
type completionContext struct {
toComplete string
prev string
taskName string
afterDash bool
}
// parseContext infers the cursor position from args. fs is needed to skip the
// word following a value-taking flag, otherwise `task --dir deploy` would
// mistake "deploy" (the directory) for a task name.
func parseContext(args []string, knownTasks []string, fs *pflag.FlagSet) completionContext {
// parseContext infers the cursor position from args alone. It deliberately
// avoids the task list so flag completion never pays to load it; the task word
// is resolved separately by detectTaskName only once a task context is reached.
func parseContext(args []string) completionContext {
ctx := completionContext{}
if len(args) == 0 {
return ctx
@@ -27,11 +26,31 @@ func parseContext(args []string, knownTasks []string, fs *pflag.FlagSet) complet
ctx.prev = args[len(args)-2]
}
for _, w := range args[:len(args)-1] {
if w == "--" {
ctx.afterDash = true
return ctx
}
}
return ctx
}
// detectTaskName scans args for the task word the cursor is completing under
// (e.g. "deploy" in `task deploy ENV=<tab>`). fs is needed to skip the word
// following a value-taking flag, otherwise `task --dir deploy` would mistake
// "deploy" (the directory) for a task name.
func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) string {
if len(args) <= 1 {
return ""
}
known := make(map[string]struct{}, len(knownTasks))
for _, t := range knownTasks {
known[t] = struct{}{}
}
taskName := ""
skipNext := false
for _, w := range args[:len(args)-1] {
if skipNext {
@@ -39,11 +58,7 @@ func parseContext(args []string, knownTasks []string, fs *pflag.FlagSet) complet
continue
}
if w == "--" {
ctx.afterDash = true
continue
}
if ctx.afterDash {
continue
return taskName
}
if strings.HasPrefix(w, "-") {
if !strings.Contains(w, "=") {
@@ -57,9 +72,9 @@ func parseContext(args []string, knownTasks []string, fs *pflag.FlagSet) complet
continue
}
if _, ok := known[w]; ok {
ctx.taskName = w
taskName = w
}
}
return ctx
return taskName
}

View File

@@ -12,9 +12,8 @@ import (
// Complete is the single entry point used by `task __complete`. e may be nil
// when the Taskfile failed to load; flag completion still works in that case.
func Complete(e *task.Executor, fs *pflag.FlagSet, args []string) ([]Suggestion, Directive) {
knownTasks := taskNames(e)
ctx := parseContext(args, knownTasks, fs)
func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) ([]Suggestion, Directive) {
ctx := parseContext(args)
if ctx.afterDash {
return nil, DirectiveDefault
@@ -22,26 +21,46 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string) ([]Suggestion,
if ctx.prev != "" {
if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) {
return completeFlagValue(flag.Name, ctx.toComplete)
return completeFlagValue(flag.Name, "")
}
}
if strings.HasPrefix(ctx.toComplete, "-") {
if eqIdx := strings.Index(ctx.toComplete, "="); eqIdx != -1 {
flagWord := ctx.toComplete[:eqIdx]
partial := ctx.toComplete[eqIdx+1:]
if f := matchFlagName(fs, flagWord); f != nil && flagTakesValue(f) {
return completeFlagValue(f.Name, partial)
// Return full `--flag=value` candidates: shells match/insert
// against the whole current token, so bare values never match.
return completeFlagValue(f.Name, flagWord+"=")
}
}
return listFlags(fs), DirectiveNoFileComp
}
if ctx.taskName != "" && e != nil && e.Taskfile != nil {
return completeTaskVars(e, ctx.taskName, ctx.toComplete)
// Only a task context needs the task list, so it is loaded lazily here.
if e != nil && e.Taskfile != nil {
if taskName := detectTaskName(args, taskNames(e), fs); taskName != "" {
return completeTaskVars(e, taskName)
}
}
return completeTaskNames(e), DirectiveNoFileComp
return completeTaskNames(e, opts), DirectiveNoFileComp
}
// NeedsTaskfile reports whether completing args requires a loaded Taskfile.
// Flag-name and flag-value completion (and words after `--`) do not, so the
// caller can skip the potentially expensive Taskfile parse for those keystrokes.
func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool {
ctx := parseContext(args)
if ctx.afterDash {
return false
}
if ctx.prev != "" {
if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) {
return false
}
}
return !strings.HasPrefix(ctx.toComplete, "-")
}
func taskNames(e *task.Executor) []string {
@@ -61,7 +80,7 @@ func taskNames(e *task.Executor) []string {
return out
}
func completeTaskNames(e *task.Executor) []Suggestion {
func completeTaskNames(e *task.Executor, opts Options) []Suggestion {
if e == nil || e.Taskfile == nil {
return nil
}
@@ -69,51 +88,61 @@ func completeTaskNames(e *task.Executor) []Suggestion {
if err != nil {
return nil
}
desc := func(t *ast.Task) string {
if !opts.ShowDescriptions {
return ""
}
return t.Desc
}
out := make([]Suggestion, 0, len(tasks))
for _, t := range tasks {
out = append(out, Suggestion{
Value: strings.TrimSuffix(t.Task, ":"),
Description: t.Desc,
Description: desc(t),
})
if !opts.ShowAliases {
continue
}
for _, alias := range t.Aliases {
out = append(out, Suggestion{
Value: strings.TrimSuffix(alias, ":"),
Description: t.Desc,
Description: desc(t),
})
}
}
return out
}
func completeFlagValue(flagName, toComplete string) ([]Suggestion, Directive) {
if dir, ok := flagDirective[flagName]; ok {
switch dir {
case DirectiveFilterFileExt:
suggs := make([]Suggestion, 0, len(taskfileExtensions))
for _, ext := range taskfileExtensions {
suggs = append(suggs, Suggestion{Value: ext})
}
return suggs, DirectiveFilterFileExt
case DirectiveFilterDirs:
return nil, DirectiveFilterDirs
default:
return nil, DirectiveDefault
// completeFlagValue completes the value of a value-taking flag. prefix is empty
// for the separate-argument form (`--output <TAB>`) and `<flag>=` for the inline
// form (`--output=<TAB>`), so enum candidates come back as full `--output=value`
// tokens the shell can match against the current word.
func completeFlagValue(flagName, prefix string) ([]Suggestion, Directive) {
// Absent keys yield the zero value (DirectiveDefault), which falls through
// to the enum lookup below.
switch flagDirective[flagName] {
case DirectiveFilterFileExt:
suggs := make([]Suggestion, 0, len(taskfileExtensions))
for _, ext := range taskfileExtensions {
suggs = append(suggs, Suggestion{Value: ext})
}
return suggs, DirectiveFilterFileExt
case DirectiveFilterDirs:
return nil, DirectiveFilterDirs
}
if values, ok := flagEnums[flagName]; ok {
out := make([]Suggestion, 0, len(values))
for _, v := range values {
out = append(out, Suggestion{Value: v})
out = append(out, Suggestion{Value: prefix + v})
}
_ = toComplete
return out, DirectiveNoFileComp
}
return nil, DirectiveDefault
}
func completeTaskVars(e *task.Executor, taskName, toComplete string) ([]Suggestion, Directive) {
func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directive) {
compiled, err := e.FastCompiledTask(&task.Call{Task: taskName})
if err != nil || compiled == nil || compiled.Requires == nil {
return nil, DirectiveNoFileComp
@@ -134,11 +163,12 @@ func completeTaskVars(e *task.Executor, taskName, toComplete string) ([]Suggesti
out = append(out, Suggestion{Value: v.Name + "=" + val})
}
}
_ = toComplete
if len(out) == 0 {
return nil, DirectiveNoFileComp
}
return out, DirectiveNoSpace | DirectiveNoFileComp
// KeepOrder preserves the declaration order of the `requires` block instead
// of letting the shell sort the variables alphabetically.
return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder
}
func enumValues(enum *ast.Enum, cache *templater.Cache) []string {

View File

@@ -15,6 +15,9 @@ var flagEnums = map[string][]string{
"completion": {"bash", "zsh", "fish", "powershell"},
}
// flagDirective maps value-taking flags to a file-completion directive.
// DirectiveDefault entries (and any flag absent here) fall back to the shell's
// default file completion.
var flagDirective = map[string]Directive{
"taskfile": DirectiveFilterFileExt,
"dir": DirectiveFilterDirs,