mirror of
https://github.com/go-task/task.git
synced 2026-07-09 20:09:14 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user