From 57bc6dd8cb153fa583b09b9c88d5c5bba97337db Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 16:03:33 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +- cmd/task/complete_cmd.go | 12 ++- completion/bash/task.bash | 16 ++-- completion/fish/task.fish | 86 ++++++++++++-------- completion/ps/task.ps1 | 26 ++++-- completion/zsh/_task | 96 +++++++++++----------- internal/complete/complete.go | 73 +++++++++++++++-- internal/complete/complete_test.go | 126 ++++++++++++++++++++++++----- internal/complete/context.go | 39 ++++++--- internal/complete/engine.go | 90 ++++++++++++++------- internal/complete/flags.go | 3 + 11 files changed, 413 insertions(+), 158 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c63c3c7..796ef189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 98fbc7be..f0fa13ff 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -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 } diff --git a/completion/bash/task.bash b/completion/bash/task.bash index 98f9ef78..991346d0 100644 --- a/completion/bash/task.bash +++ b/completion/bash/task.bash @@ -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 diff --git a/completion/fish/task.fish b/completion/fish/task.fish index db10d2aa..30ad0150 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -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)" diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 595287cd..5cf74adf 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -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 } diff --git a/completion/zsh/_task b/completion/zsh/_task index 4e2c2930..edce79f8 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -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" diff --git a/internal/complete/complete.go b/internal/complete/complete.go index 9870c401..86f0ca74 100644 --- a/internal/complete/complete.go +++ b/internal/complete/complete.go @@ -5,26 +5,85 @@ package complete import "os" +// CommandName is the hidden subcommand the shell wrappers invoke to drive +// completion: `task __complete `. 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 `:` 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 +} diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index 15b33f7d..746600f2 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -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() diff --git a/internal/complete/context.go b/internal/complete/context.go index f1954c34..d71c7026 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -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=`). 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 } diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 6e1c78ff..3df50921 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -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 `) and `=` for the inline +// form (`--output=`), 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 { diff --git a/internal/complete/flags.go b/internal/complete/flags.go index 45411cce..742ccf66 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -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,