From 4a2f3bb56775b8d2c0a58539562f48b05eacd8b8 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 01/45] feat(completion): unify shell wrappers behind `task __complete` --- cmd/task/complete_cmd.go | 47 +++++ cmd/task/task.go | 7 + completion/bash/task.bash | 103 ++++++----- completion/fish/task.fish | 140 ++++----------- completion/ps/task.ps1 | 128 ++++++------- completion/zsh/_task | 187 +++++-------------- internal/complete/complete.go | 30 ++++ internal/complete/complete_test.go | 279 +++++++++++++++++++++++++++++ internal/complete/context.go | 65 +++++++ internal/complete/engine.go | 171 ++++++++++++++++++ internal/complete/flags.go | 71 ++++++++ internal/complete/output.go | 28 +++ internal/editors/output.go | 38 +++- internal/flags/flags.go | 8 + 14 files changed, 925 insertions(+), 377 deletions(-) create mode 100644 cmd/task/complete_cmd.go create mode 100644 internal/complete/complete.go create mode 100644 internal/complete/complete_test.go create mode 100644 internal/complete/context.go create mode 100644 internal/complete/engine.go create mode 100644 internal/complete/flags.go create mode 100644 internal/complete/output.go diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go new file mode 100644 index 0000000000..98fbc7be7b --- /dev/null +++ b/cmd/task/complete_cmd.go @@ -0,0 +1,47 @@ +package main + +import ( + "io" + "os" + + "github.com/spf13/pflag" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/complete" +) + +func runComplete(args []string) error { + dir, entrypoint, global := extractTaskfileFlags(args) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithEntrypoint(entrypoint), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithVersionCheck(false), + ) + if global { + if home, err := os.UserHomeDir(); err == nil { + e.Options(task.WithDir(home)) + } + } + + // Best-effort: a missing or broken Taskfile must not break completion. + _ = e.Setup() + + suggs, dirv := complete.Complete(e, pflag.CommandLine, args) + complete.Write(os.Stdout, suggs, dirv) + return nil +} + +func extractTaskfileFlags(args []string) (dir, entrypoint string, global bool) { + fs := pflag.NewFlagSet("complete", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.ParseErrorsAllowlist.UnknownFlags = true + fs.Usage = func() {} + fs.StringVarP(&dir, "dir", "d", "", "") + fs.StringVarP(&entrypoint, "taskfile", "t", "", "") + fs.BoolVarP(&global, "global", "g", false, "") + _ = fs.Parse(args) + return +} diff --git a/cmd/task/task.go b/cmd/task/task.go index b81e23dd5f..f35cf5361d 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -13,6 +13,7 @@ import ( "github.com/go-task/task/v3/args" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/experiments" + "github.com/go-task/task/v3/internal/complete" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/logger" @@ -58,6 +59,12 @@ func emitCIErrorAnnotation(err error) { } func run() error { + // Dispatched before flag validation: the args after __complete are the + // user's command line, not Task's own flags. + if complete.IsActive() { + return runComplete(os.Args[2:]) + } + log := &logger.Logger{ Stdout: os.Stdout, Stderr: os.Stderr, diff --git a/completion/bash/task.bash b/completion/bash/task.bash index 60e807aa43..98f9ef783d 100644 --- a/completion/bash/task.bash +++ b/completion/bash/task.bash @@ -1,60 +1,69 @@ # vim: set tabstop=2 shiftwidth=2 expandtab: +# +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. -_GO_TASK_COMPLETION_LIST_OPTION='--list-all' TASK_CMD="${TASK_EXE:-task}" -function _task() -{ +_task() { local cur prev words cword _init_completion -n : || return - # Check for `--` within command-line and quit or strip suffix. - local i - for i in "${!words[@]}"; do - if [ "${words[$i]}" == "--" ]; then - # Do not complete words following `--` passed to CLI_ARGS. - [ $cword -gt $i ] && return - # Remove the words following `--` to not put --list in CLI_ARGS. - words=( "${words[@]:0:$i}" ) - break - fi + local -a args=() + if (( cword > 0 )); then + args=( "${words[@]:1:cword}" ) + fi + if (( ${#args[@]} == 0 )); then + args=( "" ) + fi + + local output + output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _filedir + return + fi + + local -a lines=() + local line + while IFS= read -r line; do + lines+=( "$line" ) + done <<< "$output" + + local last_idx=$(( ${#lines[@]} - 1 )) + local directive="${lines[$last_idx]#:}" + unset 'lines[$last_idx]' + + if (( directive & 8 )); then + local exts="" + for line in "${lines[@]}"; do + exts+="${exts:+|}$line" + done + _filedir "@($exts)" + return + fi + + if (( directive & 16 )); then + _filedir -d + return + fi + + local -a values=() + for line in "${lines[@]}"; do + values+=( "${line%%$'\t'*}" ) done - # Handle special arguments of options. - case "$prev" in - -d|--dir|--remote-cache-dir) - _filedir -d - return $? - ;; - --cacert|--cert|--cert-key) - _filedir - return $? - ;; - -t|--taskfile) - _filedir yaml || return $? - _filedir yml - return $? - ;; - -o|--output) - COMPREPLY=( $( compgen -W "interleaved group prefixed" -- $cur ) ) - return 0 - ;; - esac - - # Handle normal options. - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) ) - return 0 - ;; - esac - - # Prepare task name completions. - local tasks=( $( "${words[@]}" --silent $_GO_TASK_COMPLETION_LIST_OPTION 2> /dev/null ) ) - COMPREPLY=( $( compgen -W "${tasks[*]}" -- "$cur" ) ) - - # Post-process because task names might contain colons. + COMPREPLY=( $( compgen -W "${values[*]}" -- "$cur" ) ) + + if (( directive & 2 )); then + compopt -o nospace 2>/dev/null + fi + __ltrim_colon_completions "$cur" + + if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then + _filedir + fi } complete -F _task "$TASK_CMD" diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 5fd9382c6b..db10d2aa61 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -1,116 +1,46 @@ -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) +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. -# Cache variables for experiments (global) -set -g __task_experiments_cache "" -set -g __task_experiments_cache_time 0 +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) -# Helper function to get experiments with 1-second cache -function __task_get_experiments --inherit-variable GO_TASK_PROGNAME - set -l now (date +%s) - set -l ttl 1 # Cache for 1 second only +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 - # Return cached value if still valid - if test (math "$now - $__task_experiments_cache_time") -lt $ttl - printf '%s\n' $__task_experiments_cache + set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null) + set -l count (count $output) + if test $count -eq 0 return end - # Refresh cache - set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null) - set -g __task_experiments_cache_time $now - printf '%s\n' $__task_experiments_cache -end - -# Helper function to check if an experiment is enabled -function __task_is_experiment_enabled - set -l experiment $argv[1] - __task_get_experiments | string match -qr "^\* $experiment:.*on" -end - -function __task_get_tasks --description "Prints all available tasks with their description" --inherit-variable GO_TASK_PROGNAME - # Check if the global task is requested - set -l global_task false - commandline --current-process | read --tokenize --list --local cmd_args - for arg in $cmd_args - if test "_$arg" = "_--" - break # ignore arguments to be passed to the task - end - if test "_$arg" = "_--global" -o "_$arg" = "_-g" - set global_task true - break + 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 - end - - # Read the list of tasks (and potential errors) - if $global_task - $GO_TASK_PROGNAME --global --list-all - else - $GO_TASK_PROGNAME --list-all - end 2>&1 | read -lz rawOutput - # Return on non-zero exit code (for cases when there is no Taskfile found or etc.) - if test $status -ne 0 - 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 - # Grab names and descriptions (if any) of the tasks - set -l output (echo $rawOutput | sed -e '1d; s/\* \(.*\):[[:space:]]\{2,\}\(.*\)[[:space:]]\{2,\}(\(aliases.*\))/\1\t\2\t\3/' -e 's/\* \(.*\):[[:space:]]\{2,\}\(.*\)/\1\t\2/'| string split0) - if test $output - echo $output - end + if test $count -gt 1 + for line in $output[1..(math $count - 1)] + echo $line + end + end end -complete -c $GO_TASK_PROGNAME \ - -d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was specified.' \ - -xa "(__task_get_tasks)" \ - -n "not __fish_seen_subcommand_from --" - -# Standard flags -complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks' -complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)' -complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks' -complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell nu" -complete -c $GO_TASK_PROGNAME -s d -l dir -d 'set directory of execution' -complete -c $GO_TASK_PROGNAME -l disable-fuzzy -d 'disable fuzzy matching for task names' -complete -c $GO_TASK_PROGNAME -s n -l dry -d 'compile and print tasks without executing' -complete -c $GO_TASK_PROGNAME -s x -l exit-code -d 'pass-through exit code of task command' -complete -c $GO_TASK_PROGNAME -l experiments -d 'list available experiments' -complete -c $GO_TASK_PROGNAME -s F -l failfast -d 'when running tasks in parallel, stop all tasks if one fails' -complete -c $GO_TASK_PROGNAME -s f -l force -d 'force execution even when up-to-date' -complete -c $GO_TASK_PROGNAME -s g -l global -d 'run global Taskfile from home directory' -complete -c $GO_TASK_PROGNAME -s h -l help -d 'show help' -complete -c $GO_TASK_PROGNAME -s i -l init -d 'create new Taskfile' -complete -c $GO_TASK_PROGNAME -l insecure -d 'allow insecure Taskfile downloads' -complete -c $GO_TASK_PROGNAME -s I -l interval -d 'interval to watch for changes' -complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task list as JSON' -complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' -complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' -complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' -complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables' -complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" -complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' -complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' -complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks' -complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel' -complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing' -complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none" -complete -c $GO_TASK_PROGNAME -l status -d 'exit non-zero if tasks not up-to-date' -complete -c $GO_TASK_PROGNAME -l summary -d 'show task summary' -complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose Taskfile to run' -complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose output' -complete -c $GO_TASK_PROGNAME -l version -d 'show version' -complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes' -complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts' -complete -c $GO_TASK_PROGNAME -l offline -d 'use only local or cached Taskfiles' -complete -c $GO_TASK_PROGNAME -l timeout -d 'timeout for remote Taskfile downloads' -complete -c $GO_TASK_PROGNAME -l expiry -d 'cache expiry duration' -complete -c $GO_TASK_PROGNAME -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)" -complete -c $GO_TASK_PROGNAME -l cacert -d 'custom CA certificate for TLS' -r -complete -c $GO_TASK_PROGNAME -l cert -d 'client certificate for mTLS' -r -complete -c $GO_TASK_PROGNAME -l cert-key -d 'client certificate private key' -r -complete -c $GO_TASK_PROGNAME -l download -d 'download remote Taskfile' -complete -c $GO_TASK_PROGNAME -l clear-cache -d 'clear remote Taskfile cache' - -# Experimental flags (dynamically checked at completion time via -n condition) -# GentleForce experiment -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies' +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 dd5ed32c23..595287cd0b 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -1,89 +1,61 @@ using namespace System.Management.Automation -$cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. -Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock { - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) +$cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique - if ($commandName.StartsWith('-')) { - $completions = @( - # Standard flags (alphabetical order) - [CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'list all tasks'), - [CompletionResult]::new('--list-all', '--list-all', [CompletionResultType]::ParameterName, 'list all tasks'), - [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'colored output'), - [CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'colored output'), - [CompletionResult]::new('-C', '-C', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), - [CompletionResult]::new('--concurrency', '--concurrency', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), - [CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'generate shell completion'), - [CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'set directory'), - [CompletionResult]::new('--dir', '--dir', [CompletionResultType]::ParameterName, 'set directory'), - [CompletionResult]::new('--disable-fuzzy', '--disable-fuzzy', [CompletionResultType]::ParameterName, 'disable fuzzy matching'), - [CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'dry run'), - [CompletionResult]::new('--dry', '--dry', [CompletionResultType]::ParameterName, 'dry run'), - [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'pass-through exit code'), - [CompletionResult]::new('--exit-code', '--exit-code', [CompletionResultType]::ParameterName, 'pass-through exit code'), - [CompletionResult]::new('--experiments', '--experiments', [CompletionResultType]::ParameterName, 'list experiments'), - [CompletionResult]::new('-F', '-F', [CompletionResultType]::ParameterName, 'fail fast on pallalel tasks'), - [CompletionResult]::new('--failfast', '--failfast', [CompletionResultType]::ParameterName, 'force execution'), - [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'force execution'), - [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'force execution'), - [CompletionResult]::new('-g', '-g', [CompletionResultType]::ParameterName, 'run global Taskfile'), - [CompletionResult]::new('--global', '--global', [CompletionResultType]::ParameterName, 'run global Taskfile'), - [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'show help'), - [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'show help'), - [CompletionResult]::new('-i', '-i', [CompletionResultType]::ParameterName, 'create new Taskfile'), - [CompletionResult]::new('--init', '--init', [CompletionResultType]::ParameterName, 'create new Taskfile'), - [CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'allow insecure downloads'), - [CompletionResult]::new('-I', '-I', [CompletionResultType]::ParameterName, 'watch interval'), - [CompletionResult]::new('--interval', '--interval', [CompletionResultType]::ParameterName, 'watch interval'), - [CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'format as JSON'), - [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'format as JSON'), - [CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'list tasks'), - [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), - [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), - [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), - [CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'), - [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), - [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), - [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), - [CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'), - [CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'), - [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'), - [CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'), - [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'), - [CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'silent mode'), - [CompletionResult]::new('--sort', '--sort', [CompletionResultType]::ParameterName, 'task sorting order'), - [CompletionResult]::new('--status', '--status', [CompletionResultType]::ParameterName, 'check task status'), - [CompletionResult]::new('--summary', '--summary', [CompletionResultType]::ParameterName, 'show task summary'), - [CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'choose Taskfile'), - [CompletionResult]::new('--taskfile', '--taskfile', [CompletionResultType]::ParameterName, 'choose Taskfile'), - [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'verbose output'), - [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'verbose output'), - [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'show version'), - [CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'), - [CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'), - [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'), - [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes'), - [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles'), - [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout'), - [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry'), - [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory'), - [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate'), - [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate'), - [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key'), - [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile'), - [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') - ) +Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) - # Experimental flags (dynamically added based on enabled experiments) - $experiments = & task --experiments 2>$null | Out-String + $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' } - if ($experiments -match '\* GENTLE_FORCE:.*on') { - $completions += [CompletionResult]::new('--force-all', '--force-all', [CompletionResultType]::ParameterName, 'force all dependencies') + # Words after the program name, truncated to the cursor. + $argsToPass = @() + $elements = $commandAst.CommandElements + if ($elements.Count -gt 1) { + for ($i = 1; $i -lt $elements.Count; $i++) { + $el = $elements[$i] + if ($el.Extent.StartOffset -ge $cursorPosition) { break } + $argsToPass += $el.ToString() } + } + # 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 { + $argsToPass += $wordToComplete + } + + $output = & $TaskExe __complete @argsToPass 2>$null + if (-not $output) { return } - return $completions.Where{ $_.CompletionText.StartsWith($commandName) } + $lines = @($output) + if ($lines.Count -eq 0) { return } + $last = $lines[-1] + if (-not $last.StartsWith(':')) { return } + + $directive = [int]($last.Substring(1)) + $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + + # FilterFileExt + if ($directive -band 8) { + $patterns = $data | ForEach-Object { "*.$_" } + return Get-ChildItem -Path . -Include $patterns -File -ErrorAction SilentlyContinue | + ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } } - return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " } + # FilterDirs + if ($directive -band 16) { + return Get-ChildItem -Path . -Directory -ErrorAction SilentlyContinue | + ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } + } + + return $data | ForEach-Object { + $parts = $_ -split "`t", 2 + $value = $parts[0] + $desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value } + [CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc) + } } diff --git a/completion/zsh/_task b/completion/zsh/_task index cd3e43a90d..4e2c2930c1 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -1,158 +1,65 @@ #compdef task -typeset -A opt_args -TASK_CMD="${TASK_EXE:-task}" -compdef _task "$TASK_CMD" - -_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" - -# Check if an experiment is enabled -function __task_is_experiment_enabled() { - local experiment=$1 - task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on" -} - -# Listing commands from Taskfile.yml -function __task_list() { - local -a scripts cmd task_aliases match mbegin mend - local -i enabled=0 - local taskfile item task desc task_alias - - cmd=($TASK_CMD) - taskfile=${(Qv)opt_args[(i)-t|--taskfile]} - taskfile=${taskfile//\~/$HOME} +# +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. - for arg in "${words[@]:0:$CURRENT}"; do - if [[ "$arg" = "--" ]]; then - # Use default completion for words after `--` as they are CLI_ARGS. - _default - return 0 - fi - done +TASK_CMD="${TASK_EXE:-task}" - if [[ -n "$taskfile" && -f "$taskfile" ]]; then - cmd+=(--taskfile "$taskfile") +_task() { + local -a args lines completions opts + 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=("") + + output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _files + return fi - # Check if global flag is set - if (( ${+opt_args[-g]} || ${+opt_args[--global]} )); then - cmd+=(--global) + lines=("${(f)output}") + directive="${lines[-1]#:}" + lines=("${(@)lines[1,-2]}") + + if (( directive & 8 )); then + local -a globs + for line in "${lines[@]}"; do + globs+=("*.${line}") + done + _files -g "(${(j:|:)globs})" + return fi - if output=$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION 2>/dev/null); then - enabled=1 + if (( directive & 16 )); then + _path_files -/ + return fi - (( enabled )) || return 0 - - scripts=() - - # Read zstyle verbose option (default = true via -T) - local show_desc - zstyle -T ":completion:${curcontext}:" verbose && show_desc=true || show_desc=false - - # Read zstyle show-aliases option (default = true via -T) - local show_aliases - zstyle -T ":completion:${curcontext}:" show-aliases && show_aliases=true || show_aliases=false - - for item in "${(@)${(f)output}[2,-1]#\* }"; do - task="${item%%:[[:space:]]*}" - - # Extract the aliases listed in the trailing "(aliases: a, b)" column. - # NB: `aliases` is a reserved zsh parameter, so use a different name. - task_aliases=() - if [[ "$show_aliases" == "true" && "$item" == (#b)*'(aliases: '(*)')' ]]; then - task_aliases=( "${(@s:, :)match[1]}" ) - fi - - if [[ "$show_desc" == "true" ]]; then - local desc="${item##[^[:space:]]##[[:space:]]##}" - scripts+=( "${task//:/\\:}:$desc" ) - for task_alias in $task_aliases; do - scripts+=( "${task_alias//:/\\:}:$desc (alias of $task)" ) - done + # `:` 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 - scripts+=( "$task" ) - for task_alias in $task_aliases; do - scripts+=( "$task_alias" ) - done + completions+=("${line//:/\\:}") fi done - if [[ "$show_desc" == "true" ]]; then - _describe 'Task to run' scripts - else - compadd -Q -a scripts - fi -} - -_task() { - local -a standard_args operation_args - - standard_args=( - '(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' - '(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' - '(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]' - '(-f --force)'{-f,--force}'[run even if task is up-to-date]' - '(-c --color)'{-c,--color}'[colored output]' - '(--completion)--completion[generate shell completion script]:shell:(bash zsh fish powershell nu)' - '(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs' - '(--disable-fuzzy)--disable-fuzzy[disable fuzzy matching for task names]' - '(-n --dry)'{-n,--dry}'[compiles and prints tasks without executing]' - '(--dry)--dry[dry-run mode, compile and print tasks only]' - '(-x --exit-code)'{-x,--exit-code}'[pass-through exit code of task command]' - '(--experiments)--experiments[list available experiments]' - '(-g --global)'{-g,--global}'[run global Taskfile from home directory]' - '(--insecure)--insecure[allow insecure Taskfile downloads]' - '(-I --interval)'{-I,--interval}'[interval to watch for changes]:duration: ' - '(-j --json)'{-j,--json}'[format task list as JSON]' - '(--nested)--nested[nest namespaces when listing as JSON]' - '(--no-status)--no-status[ignore status when listing as JSON]' - '(--interactive)--interactive[prompt for missing required variables]' - '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' - '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' - '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' - '(--output-group-error-only)--output-group-error-only[hide output from successful tasks]' - '(-s --silent)'{-s,--silent}'[disable echoing]' - '(--sort)--sort[set task sorting order]:order:(default alphanumeric none)' - '(--status)--status[exit non-zero if supplied tasks not up-to-date]' - '(--summary)--summary[show summary\: field from tasks instead of running them]' - '(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files' - '(-v --verbose)'{-v,--verbose}'[verbose mode]' - '(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]' - '(-y --yes)'{-y,--yes}'[assume yes to all prompts]' - '(--offline --clear-cache)--download[download remote Taskfile]' - '(--offline --download)--offline[use only local or cached Taskfiles]' - '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' - '(--expiry)--expiry[cache expiry duration]:duration: ' - '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' - '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' - '(--cert)--cert[client certificate for mTLS]:file:_files' - '(--cert-key)--cert-key[client certificate private key]:file:_files' - ) + (( directive & 2 )) && opts+=(-S '') + (( directive & 32 )) && opts+=(-V) - # Experimental flags (dynamically added based on enabled experiments) - # Options (modify behavior) - if __task_is_experiment_enabled "GENTLE_FORCE"; then - standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]') + if (( ${#completions} > 0 )); then + _describe -t tasks 'task' completions "${opts[@]}" fi - operation_args=( - # Task names completion (can be specified multiple times) - '(operation)*: :__task_list' - # Operational args completion (mutually exclusive) - + '(operation)' - '(*)'{-l,--list}'[list describable tasks]' - '(*)'{-a,--list-all}'[list all tasks]' - '(*)'{-i,--init}'[create new Taskfile.yml]' - '(- *)'{-h,--help}'[show help]' - '(- *)--version[show version and exit]' - '(* --download)--clear-cache[clear remote Taskfile cache]' - ) - - _arguments -S $standard_args $operation_args + (( directive & 4 )) && return + _files } -# don't run the completion function when being source-ed or eval-ed -if [ "$funcstack[1]" = "_task" ]; then - _task "$@" -fi +compdef _task "$TASK_CMD" diff --git a/internal/complete/complete.go b/internal/complete/complete.go new file mode 100644 index 0000000000..9870c40121 --- /dev/null +++ b/internal/complete/complete.go @@ -0,0 +1,30 @@ +// Package complete implements the `task __complete` protocol consumed by the +// shell completion wrappers. The protocol mirrors cobra v2 so a future +// migration stays cheap. +package complete + +import "os" + +const CommandName = "__complete" + +func IsActive() bool { + return len(os.Args) >= 2 && os.Args[1] == CommandName +} + +// Directive mirrors cobra's ShellCompDirective bitfield. +type Directive int + +const ( + DirectiveDefault Directive = 0 + DirectiveError Directive = 1 << 0 + DirectiveNoSpace Directive = 1 << 1 + DirectiveNoFileComp Directive = 1 << 2 + DirectiveFilterFileExt Directive = 1 << 3 + DirectiveFilterDirs Directive = 1 << 4 + DirectiveKeepOrder Directive = 1 << 5 +) + +type Suggestion struct { + Value string + Description string +} diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go new file mode 100644 index 0000000000..15b33f7df6 --- /dev/null +++ b/internal/complete/complete_test.go @@ -0,0 +1,279 @@ +package complete_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/complete" +) + +func newTestFlagSet() *pflag.FlagSet { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + var b bool + var s string + fs.BoolVarP(&b, "list-all", "a", false, "Lists all tasks") + fs.BoolVarP(&b, "list", "l", false, "Lists tasks with descriptions") + fs.BoolVarP(&b, "verbose", "v", false, "Verbose mode") + fs.StringVarP(&s, "taskfile", "t", "", "Taskfile path") + fs.StringVarP(&s, "dir", "d", "", "Run dir") + fs.StringVarP(&s, "output", "o", "", "Output style") + fs.StringVar(&s, "sort", "", "Sort order") + fs.StringVar(&s, "cacert", "", "CA cert path") + return fs +} + +const testTaskfile = `version: '3' + +vars: + ALLOWED_ENVS: + - dev + - staging + - prod + +tasks: + deploy: + desc: Deploy the application + aliases: [dep, ship] + requires: + vars: + - name: ENV + enum: + - dev + - staging + - prod + - REGION + cmds: + - 'echo {{.ENV}} {{.REGION}}' + + build: + desc: Build it + cmds: + - 'echo build' + + dynenum: + desc: Dynamic enum + requires: + vars: + - name: ENV + enum: + ref: .ALLOWED_ENVS + cmds: + - 'echo {{.ENV}}' + + docs:serve: + desc: Serve docs locally + cmds: + - 'echo serving' +` + +func setupExecutor(t *testing.T) *task.Executor { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(testTaskfile), 0o644)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithVersionCheck(false), + ) + require.NoError(t, e.Setup()) + return e +} + +func TestComplete_TaskNames(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}) + + require.ElementsMatch(t, + []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, + values(suggs), + ) + require.Equal(t, complete.DirectiveNoFileComp, dir) + require.Contains(t, descriptions(suggs), "Deploy the application") +} + +func TestComplete_AliasResolvesToTaskVars(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}) + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) +} + +func TestComplete_StaticEnum(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""}) + + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) +} + +func TestComplete_EnumRef(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""}) + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs)) +} + +func TestComplete_NoRequires(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}) + require.ElementsMatch(t, + []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, + values(suggs), + ) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_NamespacedTaskName(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +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)) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_AfterDash(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveDefault, dir) +} + +func TestComplete_FlagNames(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"}) + require.NotEmpty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) + + vals := values(suggs) + require.Contains(t, vals, "--list-all") + require.Contains(t, vals, "--taskfile") + require.Contains(t, vals, "-a") +} + +func TestComplete_EnumFlagValue_Output(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)) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_EnumFlagValue_Sort(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""}) + require.Equal(t, []string{"default", "alphanumeric", "none"}, values(suggs)) +} + +func TestComplete_PathFlag_Taskfile(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""}) + require.Equal(t, []string{"yml", "yaml"}, values(suggs)) + require.Equal(t, complete.DirectiveFilterFileExt, dir) +} + +func TestComplete_PathFlag_Dir(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveFilterDirs, dir) +} + +func TestComplete_PathFlag_Cacert(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveDefault, dir) +} + +func TestComplete_NilExecutor(t *testing.T) { + t.Parallel() + + suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"}) + require.NotEmpty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestWrite_Format(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + complete.Write(&buf, []complete.Suggestion{ + {Value: "deploy", Description: "Deploy the app"}, + {Value: "build"}, + }, complete.DirectiveNoSpace|complete.DirectiveNoFileComp) + require.Equal(t, "deploy\tDeploy the app\nbuild\n:6\n", buf.String()) +} + +func TestWrite_EmptyWithDirective(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + complete.Write(&buf, nil, complete.DirectiveFilterDirs) + require.Equal(t, ":16\n", buf.String()) +} + +func values(suggs []complete.Suggestion) []string { + out := make([]string, 0, len(suggs)) + for _, s := range suggs { + out = append(out, s.Value) + } + return out +} + +func descriptions(suggs []complete.Suggestion) []string { + out := make([]string, 0, len(suggs)) + for _, s := range suggs { + out = append(out, s.Description) + } + return out +} diff --git a/internal/complete/context.go b/internal/complete/context.go new file mode 100644 index 0000000000..f1954c34d9 --- /dev/null +++ b/internal/complete/context.go @@ -0,0 +1,65 @@ +package complete + +import ( + "strings" + + "github.com/spf13/pflag" +) + +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 { + ctx := completionContext{} + if len(args) == 0 { + return ctx + } + + ctx.toComplete = args[len(args)-1] + if len(args) >= 2 { + ctx.prev = args[len(args)-2] + } + + known := make(map[string]struct{}, len(knownTasks)) + for _, t := range knownTasks { + known[t] = struct{}{} + } + + skipNext := false + for _, w := range args[:len(args)-1] { + if skipNext { + skipNext = false + continue + } + if w == "--" { + ctx.afterDash = true + continue + } + if ctx.afterDash { + continue + } + if strings.HasPrefix(w, "-") { + if !strings.Contains(w, "=") { + if f := matchFlagName(fs, w); f != nil && flagTakesValue(f) { + skipNext = true + } + } + continue + } + if strings.Contains(w, "=") { + continue + } + if _, ok := known[w]; ok { + ctx.taskName = w + } + } + + return ctx +} diff --git a/internal/complete/engine.go b/internal/complete/engine.go new file mode 100644 index 0000000000..6e1c78ff7d --- /dev/null +++ b/internal/complete/engine.go @@ -0,0 +1,171 @@ +package complete + +import ( + "strings" + + "github.com/spf13/pflag" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile/ast" +) + +// 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) + + if ctx.afterDash { + return nil, DirectiveDefault + } + + if ctx.prev != "" { + if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) { + return completeFlagValue(flag.Name, ctx.toComplete) + } + } + + 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 listFlags(fs), DirectiveNoFileComp + } + + if ctx.taskName != "" && e != nil && e.Taskfile != nil { + return completeTaskVars(e, ctx.taskName, ctx.toComplete) + } + + return completeTaskNames(e), DirectiveNoFileComp +} + +func taskNames(e *task.Executor) []string { + if e == nil || e.Taskfile == nil { + return nil + } + var out []string + for t := range e.Taskfile.Tasks.Values(nil) { + if t.Internal { + continue + } + out = append(out, strings.TrimSuffix(t.Task, ":")) + for _, alias := range t.Aliases { + out = append(out, strings.TrimSuffix(alias, ":")) + } + } + return out +} + +func completeTaskNames(e *task.Executor) []Suggestion { + if e == nil || e.Taskfile == nil { + return nil + } + tasks, err := e.GetTaskList(task.FilterOutInternal) + if err != nil { + return nil + } + out := make([]Suggestion, 0, len(tasks)) + for _, t := range tasks { + out = append(out, Suggestion{ + Value: strings.TrimSuffix(t.Task, ":"), + Description: t.Desc, + }) + for _, alias := range t.Aliases { + out = append(out, Suggestion{ + Value: strings.TrimSuffix(alias, ":"), + Description: t.Desc, + }) + } + } + 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 + } + } + + if values, ok := flagEnums[flagName]; ok { + out := make([]Suggestion, 0, len(values)) + for _, v := range values { + out = append(out, Suggestion{Value: v}) + } + _ = toComplete + return out, DirectiveNoFileComp + } + + return nil, DirectiveDefault +} + +func completeTaskVars(e *task.Executor, taskName, toComplete string) ([]Suggestion, Directive) { + compiled, err := e.FastCompiledTask(&task.Call{Task: taskName}) + if err != nil || compiled == nil || compiled.Requires == nil { + return nil, DirectiveNoFileComp + } + + cache := &templater.Cache{Vars: compiled.Vars} + out := make([]Suggestion, 0, 8) + for _, v := range compiled.Requires.Vars { + if v == nil || v.Name == "" { + continue + } + values := enumValues(v.Enum, cache) + if len(values) == 0 { + out = append(out, Suggestion{Value: v.Name + "="}) + continue + } + for _, val := range values { + out = append(out, Suggestion{Value: v.Name + "=" + val}) + } + } + _ = toComplete + if len(out) == 0 { + return nil, DirectiveNoFileComp + } + return out, DirectiveNoSpace | DirectiveNoFileComp +} + +func enumValues(enum *ast.Enum, cache *templater.Cache) []string { + if enum == nil { + return nil + } + if len(enum.Value) > 0 { + return enum.Value + } + if enum.Ref == "" { + return nil + } + resolved := templater.ResolveRef(enum.Ref, cache) + if cache.Err() != nil { + return nil + } + arr, ok := resolved.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, item := range arr { + s, ok := item.(string) + if !ok { + return nil + } + out = append(out, s) + } + return out +} diff --git a/internal/complete/flags.go b/internal/complete/flags.go new file mode 100644 index 0000000000..45411cce4e --- /dev/null +++ b/internal/complete/flags.go @@ -0,0 +1,71 @@ +package complete + +import ( + "sort" + "strings" + + "github.com/spf13/pflag" +) + +// flagEnums lists allowed values for enum-style flags. Keep in sync with the +// help strings in internal/flags/flags.go. +var flagEnums = map[string][]string{ + "output": {"interleaved", "group", "prefixed"}, + "sort": {"default", "alphanumeric", "none"}, + "completion": {"bash", "zsh", "fish", "powershell"}, +} + +var flagDirective = map[string]Directive{ + "taskfile": DirectiveFilterFileExt, + "dir": DirectiveFilterDirs, + "remote-cache-dir": DirectiveFilterDirs, + "cacert": DirectiveDefault, + "cert": DirectiveDefault, + "cert-key": DirectiveDefault, +} + +var taskfileExtensions = []string{"yml", "yaml"} + +// flagTakesValue is false for boolean switches (NoOptDefVal == "true"). +func flagTakesValue(f *pflag.Flag) bool { + return f.NoOptDefVal == "" +} + +// listFlags walks fs at call time so experiment-gated flags appear or +// disappear based on the active experiments. +func listFlags(fs *pflag.FlagSet) []Suggestion { + if fs == nil { + return nil + } + out := make([]Suggestion, 0, 64) + fs.VisitAll(func(f *pflag.Flag) { + if f.Hidden || f.Deprecated != "" { + return + } + out = append(out, Suggestion{ + Value: "--" + f.Name, + Description: f.Usage, + }) + if f.Shorthand != "" { + out = append(out, Suggestion{ + Value: "-" + f.Shorthand, + Description: f.Usage, + }) + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].Value < out[j].Value }) + return out +} + +func matchFlagName(fs *pflag.FlagSet, word string) *pflag.Flag { + if fs == nil { + return nil + } + switch { + case strings.HasPrefix(word, "--"): + return fs.Lookup(strings.TrimPrefix(word, "--")) + case strings.HasPrefix(word, "-") && len(word) == 2: + return fs.ShorthandLookup(word[1:]) + } + return nil +} diff --git a/internal/complete/output.go b/internal/complete/output.go new file mode 100644 index 0000000000..59e07cf5c4 --- /dev/null +++ b/internal/complete/output.go @@ -0,0 +1,28 @@ +package complete + +import ( + "fmt" + "io" + "strings" +) + +// Write emits the cobra-v2 completion protocol: one `value\tdescription` (or +// bare `value`) per suggestion, followed by a trailing `:` line +// that shell wrappers split off even when there are zero suggestions. +func Write(w io.Writer, suggs []Suggestion, dir Directive) { + for _, s := range suggs { + value := sanitize(s.Value) + desc := sanitize(s.Description) + if desc == "" { + fmt.Fprintln(w, value) + continue + } + fmt.Fprintf(w, "%s\t%s\n", value, desc) + } + fmt.Fprintf(w, ":%d\n", dir) +} + +func sanitize(s string) string { + r := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + return r.Replace(s) +} diff --git a/internal/editors/output.go b/internal/editors/output.go index eff0a0cb3e..9d8639ee68 100644 --- a/internal/editors/output.go +++ b/internal/editors/output.go @@ -13,13 +13,18 @@ type ( } // Task describes a single task Task struct { - Name string `json:"name"` - Task string `json:"task"` - Desc string `json:"desc"` - Summary string `json:"summary"` - Aliases []string `json:"aliases"` - UpToDate *bool `json:"up_to_date,omitempty"` - Location *Location `json:"location"` + Name string `json:"name"` + Task string `json:"task"` + Desc string `json:"desc"` + Summary string `json:"summary"` + Aliases []string `json:"aliases"` + UpToDate *bool `json:"up_to_date,omitempty"` + Location *Location `json:"location"` + Requires []RequiredVar `json:"requires,omitempty"` + } + RequiredVar struct { + Name string `json:"name"` + Enum []string `json:"enum,omitempty"` } // Location describes a task's location in a taskfile Location struct { @@ -45,7 +50,26 @@ func NewTask(task *ast.Task) Task { Column: task.Location.Column, Taskfile: task.Location.Taskfile, }, + Requires: newRequiredVars(task.Requires), + } +} + +func newRequiredVars(requires *ast.Requires) []RequiredVar { + if requires == nil || len(requires.Vars) == 0 { + return nil + } + out := make([]RequiredVar, 0, len(requires.Vars)) + for _, v := range requires.Vars { + if v == nil { + continue + } + rv := RequiredVar{Name: v.Name} + if v.Enum != nil && len(v.Enum.Value) > 0 { + rv.Enum = append([]string{}, v.Enum.Value...) + } + out = append(out, rv) } + return out } func (parent *Namespace) AddNamespace(namespacePath []string, task Task) { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 9e43d4a943..4ddf5b6ea4 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -14,6 +14,7 @@ import ( "github.com/go-task/task/v3" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/experiments" + "github.com/go-task/task/v3/internal/complete" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/taskfile/ast" @@ -174,6 +175,13 @@ func init() { pflag.BoolVarP(&ForceAll, "force", "f", false, "Forces execution even when the task is up-to-date.") } + // In completion mode the user's `--flag` words must reach the engine + // untouched. The BoolVar/StringVar calls above already populated + // pflag.CommandLine, which is all the engine needs. + if complete.IsActive() { + return + } + pflag.Parse() // Auto-detect color based on environment when not explicitly configured From b37305e3ac3f34f51d0e9ce5304e3d05c4275e3d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 02/45] chore: changelog for completion engine --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d67a714e36..69302b0b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,9 @@ - Fixed malformed `includes:` entries (missing `taskfile`/`dir`) reporting a misleading "include cycle detected" error instead of a clear configuration error (#1881, #2892 by @Lewin671). +- 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). ## v3.51.1 - 2026-05-16 From e0c049c59121d7831be7357ddd9d1401ff06e82c Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 16:03:33 +0200 Subject: [PATCH 03/45] 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 69302b0b40..c0c182c5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,7 +99,9 @@ error (#1881, #2892 by @Lewin671). - 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 98fbc7be7b..f0fa13ff4f 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 98f9ef783d..991346d002 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 db10d2aa61..30ad015084 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 595287cd0b..5cf74adf4b 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 4e2c2930c1..edce79f85e 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 9870c40121..86f0ca747c 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 15b33f7df6..746600f2d0 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 f1954c34d9..d71c7026a8 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 6e1c78ff7d..3df509212a 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 45411cce4e..742ccf6623 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, From efeafcea52e39c587749b033cef5c4d00860f1b5 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 22:29:42 +0200 Subject: [PATCH 04/45] test(completion): add cross-shell completion test suite and CI job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add completion/tests/ harnesses that exercise the engine protocol and every shell wrapper without a TTY: the engine via `task __complete`, bash/zsh by stubbing the completion-system helpers, fish via `complete -C`, and PowerShell via the completion API. A `test:completion` task and a matching CI job (with a strict mode that fails when an expected shell is missing) run them all. Writing the suite surfaced and fixed real wrapper bugs: - fish: `math` has no bitwise `&`, so every directive check errored; test bits with integer division + modulo instead. Also filter FilterFileExt results ourselves, as __fish_complete_suffix only prioritizes the extension. - powershell: `Get-ChildItem -Include` is ignored without -Recurse, so file extension filtering returned nothing; filter with Where-Object instead. - bash: guard empty-array expansion so completion with zero candidates does not trip `set -u` on bash 3.2. Also name the completion directive bits (NO_SPACE, FILTER_FILE_EXT, …) in every wrapper instead of using raw numbers. --- .github/workflows/ci.yml | 36 ++++++++++++++ Taskfile.yml | 9 ++++ completion/bash/task.bash | 18 ++++--- completion/fish/task.fish | 39 ++++++++++++--- completion/ps/task.ps1 | 27 ++++++++--- completion/tests/engine.sh | 85 ++++++++++++++++++++++++++++++++ completion/tests/run.sh | 91 +++++++++++++++++++++++++++++++++++ completion/tests/wrapper.bash | 86 +++++++++++++++++++++++++++++++++ completion/tests/wrapper.fish | 61 +++++++++++++++++++++++ completion/tests/wrapper.ps1 | 62 ++++++++++++++++++++++++ completion/tests/wrapper.zsh | 81 +++++++++++++++++++++++++++++++ completion/zsh/_task | 13 +++-- 12 files changed, 582 insertions(+), 26 deletions(-) create mode 100755 completion/tests/engine.sh create mode 100755 completion/tests/run.sh create mode 100755 completion/tests/wrapper.bash create mode 100755 completion/tests/wrapper.fish create mode 100644 completion/tests/wrapper.ps1 create mode 100755 completion/tests/wrapper.zsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c46d1297..70c271c868 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,42 @@ jobs: - name: 🧪 Test run: task test --output group --output-group-begin '::group::{{.TASK}}' --output-group-end '::endgroup::' + completion: + name: 🐚 Completion (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: 📥 Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: ⬇️ Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.26.x + + - name: ⬇️ Setup Task + uses: go-task/setup-task@v2 + + # zsh and pwsh are preinstalled on the runners; only fish is missing + # (plus zsh on the Linux image). + - name: ⬇️ Install shells (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh fish + + - name: ⬇️ Install shells (macOS) + if: runner.os == 'macOS' + run: brew install fish + + - name: 🧪 Test completion + # Strict mode fails the run if any shell is missing, so we never get a + # false pass when a runner image stops shipping one (e.g. pwsh). + env: + TASK_COMPLETION_STRICT: "1" + run: task test:completion + lint: name: 🔍 Lint (${{ matrix.go-version }}) strategy: diff --git a/Taskfile.yml b/Taskfile.yml index 6b9b69a772..93976d5626 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -158,6 +158,15 @@ tasks: cmds: - go test -bench=. -benchmem -tags=fsbench -run=^$ ./... + test:completion: + desc: Tests the shell completion engine and wrappers (bash, zsh, fish, powershell) + sources: + - internal/complete/**/*.go + - cmd/task/**/*.go + - completion/**/* + cmds: + - bash completion/tests/run.sh + goreleaser:test: desc: Tests release process without publishing cmds: diff --git a/completion/bash/task.bash b/completion/bash/task.bash index 991346d002..4e7438f7fc 100644 --- a/completion/bash/task.bash +++ b/completion/bash/task.bash @@ -7,6 +7,10 @@ TASK_CMD="${TASK_EXE:-task}" _task() { local cur prev words cword + + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 + # Exclude both `=` and `:` from the word breaks so `--output=` and # `docs:serve` reach the engine as single tokens. _init_completion -n =: || return @@ -36,16 +40,18 @@ _task() { local directive="${lines[$last_idx]#:}" unset 'lines[$last_idx]' - if (( directive & 8 )); then + if (( directive & FILTER_FILE_EXT )); then local exts="" - for line in "${lines[@]}"; do + # ${arr[@]+…} guards against "unbound variable" on an empty array under + # `set -u` in bash 3.2 (macOS). + for line in ${lines[@]+"${lines[@]}"}; do exts+="${exts:+|}$line" done _filedir "@($exts)" return fi - if (( directive & 16 )); then + if (( directive & FILTER_DIRS )); then _filedir -d return fi @@ -54,20 +60,20 @@ _task() { # word list on IFS, which mangles any suggestion value containing a space. local value COMPREPLY=() - for line in "${lines[@]}"; do + for line in ${lines[@]+"${lines[@]}"}; do value="${line%%$'\t'*}" if [[ -z "$cur" || "$value" == "$cur"* ]]; then COMPREPLY+=( "$value" ) fi done - if (( directive & 2 )); then + if (( directive & NO_SPACE )); then compopt -o nospace 2>/dev/null fi __ltrim_colon_completions "$cur" - if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then + if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then _filedir fi } diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 30ad015084..9b643f411d 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -3,6 +3,19 @@ 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) +# Completion directives, mirroring internal/complete/complete.go. fish's `math` +# has no bitwise operators, so bits are stored as their power-of-two value and +# tested with integer division + modulo via __task_test_bit. +set -g __task_directive_no_space 2 +set -g __task_directive_no_file_comp 4 +set -g __task_directive_filter_file_ext 8 +set -g __task_directive_filter_dirs 16 +set -g __task_directive_keep_order 32 + +function __task_test_bit --argument-names value bit + test (math "floor($value / $bit) % 2") -eq 1 +end + function __task_complete --inherit-variable GO_TASK_PROGNAME set -l tokens (commandline -opc) set -l current (commandline -ct) @@ -36,15 +49,27 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME # 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" + # __fish_complete_suffix only *prioritizes* the extension, so filter the file + # list ourselves — keeping directories so the user can still descend into them. + if __task_test_bit $directive $__task_directive_filter_file_ext + for entry in (__fish_complete_path $current) + set -l name (string split -f1 \t -- $entry) + if string match -qr '/$' -- $name + printf '%s\n' $entry + continue + end + for ext in $data + if string match -qr "\.$ext\$" -- $name + printf '%s\n' $entry + break + end + end end return end # FilterDirs: complete directories only. - if test (math "$directive & 16") -ne 0 + if __task_test_bit $directive $__task_directive_filter_dirs __fish_complete_directories $current return end @@ -55,9 +80,9 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME 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 + # NoFileComp unset → also offer files, since `--no-files` suppressed the + # native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`). + if not __task_test_bit $directive $__task_directive_no_file_comp __fish_complete_path $current end end diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 5cf74adf4b..7e18991896 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -38,20 +38,31 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $directive = [int]($last.Substring(1)) $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + # Completion directives, mirroring internal/complete/complete.go. + $NoFileComp = 4 + $FilterFileExt = 8 + $FilterDirs = 16 + # 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 { "*.$_" } - return Get-ChildItem -Path . -Include $patterns -File -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } + # FilterFileExt: keep files whose extension matches, plus directories so the + # user can still descend into them. `-Include` is unreliable without + # `-Recurse`, so filter with Where-Object instead. + if ($directive -band $FilterFileExt) { + $exts = $data | ForEach-Object { ".$_" } + return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | + Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | + ForEach-Object { + $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } + [CompletionResult]::new($_.Name, $_.Name, $type, $_.Name) + } } # FilterDirs - if ($directive -band 16) { - return Get-ChildItem -Path . -Directory -ErrorAction SilentlyContinue | + if ($directive -band $FilterDirs) { + return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue | ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } } @@ -68,7 +79,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # 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)) { + if ($results.Count -eq 0 -and -not ($directive -band $NoFileComp)) { return Get-ChildItem -Path . -ErrorAction SilentlyContinue | ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } } diff --git a/completion/tests/engine.sh b/completion/tests/engine.sh new file mode 100755 index 0000000000..f4937453cf --- /dev/null +++ b/completion/tests/engine.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Tests the `task __complete` protocol directly (shell-agnostic). This is the +# backbone: it validates the candidates and directive the engine emits, which +# is what drives every shell wrapper. +# +# Requires: TASK_BIN (path to the task binary), TASK_FIXTURE (dir with a +# Taskfile.yml). Exits non-zero on the first failure. +set -u + +: "${TASK_BIN:?TASK_BIN must point to the task binary}" +: "${TASK_FIXTURE:?TASK_FIXTURE must point to the fixture directory}" +cd "$TASK_FIXTURE" || exit 1 + +fails=0 +out() { "$TASK_BIN" __complete "$@" 2>/dev/null; } +vals() { out "$@" | sed '$d' | cut -f1; } # candidate values, sans the :N line +dirv() { out "$@" | tail -1; } # the :N directive line + +has() { # LABEL VALUE ARGS... + local label=$1 value=$2; shift 2 + if vals "$@" | grep -qxF -- "$value"; then + echo " ok $label" + else + echo " FAIL $label — expected value '$value' among: $(vals "$@" | tr '\n' ' ')" + fails=$((fails + 1)) + fi +} +hasnot() { # LABEL VALUE ARGS... + local label=$1 value=$2; shift 2 + if vals "$@" | grep -qxF -- "$value"; then + echo " FAIL $label — value '$value' should be absent" + fails=$((fails + 1)) + else + echo " ok $label" + fi +} +directive() { # LABEL EXPECTED ARGS... + local label=$1 expected=$2; shift 2 + local got; got=$(dirv "$@") + if [[ "$got" == "$expected" ]]; then + echo " ok $label" + else + echo " FAIL $label — expected directive '$expected', got '$got'" + fails=$((fails + 1)) + fi +} + +echo "engine: task names" +has "lists tasks" build '' +has "lists aliases" dep '' +directive "NoFileComp" ':4' '' + +echo "engine: completion-control flags" +hasnot "--no-aliases drops aliases" dep --no-aliases '' +has "--no-aliases keeps tasks" deploy --no-aliases '' + +echo "engine: flags" +has "lists flags" --taskfile - +directive "flags NoFileComp" ':4' - + +echo "engine: flag values" +has "inline --output= is full form" --output=interleaved --output= +directive "inline NoFileComp" ':4' --output= +has "separate --output is bare" interleaved --output '' +has "--sort values" alphanumeric --sort '' + +echo "engine: file/dir directives" +has "--taskfile emits yml" yml --taskfile '' +has "--taskfile emits yaml" yaml --taskfile '' +directive "--taskfile FilterFileExt" ':8' --taskfile '' +directive "--dir FilterDirs" ':16' --dir '' + +echo "engine: task variables" +has "required var with enum" ENV=dev deploy '' +has "required var without enum" REGION= deploy '' +directive "vars NoSpace|NoFileComp|KeepOrder" ':38' deploy '' + +echo "engine: after --" +directive "after -- is default" ':0' deploy -- '' + +if (( fails )); then + echo "engine: $fails failure(s)" + exit 1 +fi +echo "engine: all passed" diff --git a/completion/tests/run.sh b/completion/tests/run.sh new file mode 100755 index 0000000000..f70be53810 --- /dev/null +++ b/completion/tests/run.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Runs the completion test suite: builds the task binary, creates a fixture +# Taskfile with sample files and directories, then exercises the engine and +# every installed shell wrapper. Skips shells that are not installed. +set -u + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +root=$(cd "$here/../.." && pwd) + +# Build the binary under test. +bindir=$(mktemp -d) +if ! go build -o "$bindir/task" "$root/cmd/task"; then + echo "failed to build task binary" >&2 + exit 1 +fi +export TASK_BIN="$bindir/task" +# fish and PowerShell register completion for the command name `task`, so make +# `task` on PATH resolve to the binary under test. +export PATH="$bindir:$PATH" + +# Fixture: a Taskfile plus files/dirs so file/dir completion has real entries. +fixture=$(mktemp -d) +cat > "$fixture/Taskfile.yml" <<'YML' +version: '3' + +tasks: + build: + desc: Build it + deploy: + desc: Deploy it + aliases: [dep] + requires: + vars: + - name: ENV + enum: [dev, prod] + - REGION + docs:serve: + desc: Serve docs +YML +touch "$fixture/extra.yaml" "$fixture/notes.txt" +mkdir -p "$fixture/sub" "$fixture/other" +export TASK_FIXTURE="$fixture" + +# In strict mode (set TASK_COMPLETION_STRICT=1, used in CI) a missing shell is +# a failure instead of a skip, so we never get a false pass when a shell the +# environment was expected to provide (e.g. pwsh on CI runners) is absent. +strict=${TASK_COMPLETION_STRICT:-} + +fails=0 +run() { # LABEL COMMAND... + echo "== $1 ==" + if "${@:2}"; then :; else fails=$((fails + 1)); fi + echo +} +skip() { # LABEL + if [[ -n "$strict" ]]; then + echo "== $1 == (MISSING — required under TASK_COMPLETION_STRICT)" + fails=$((fails + 1)) + else + echo "== $1 == (skipped: not installed)" + fi + echo +} + +run "engine" bash "$here/engine.sh" +run "bash wrapper" bash "$here/wrapper.bash" + +if command -v zsh >/dev/null 2>&1; then + run "zsh wrapper" zsh "$here/wrapper.zsh" +else + skip "zsh wrapper" +fi + +if command -v fish >/dev/null 2>&1; then + run "fish wrapper" fish "$here/wrapper.fish" +else + skip "fish wrapper" +fi + +pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true) +if [[ -n "$pwsh_bin" ]]; then + run "powershell wrapper" "$pwsh_bin" -NoProfile -File "$here/wrapper.ps1" +else + skip "powershell wrapper" +fi + +if ((fails)); then + echo "completion tests: $fails suite(s) failed" + exit 1 +fi +echo "completion tests: all suites passed" diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash new file mode 100755 index 0000000000..6ea0de9eb7 --- /dev/null +++ b/completion/tests/wrapper.bash @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Tests the bash wrapper by stubbing the bash-completion helpers it calls +# (_init_completion / _filedir / compopt / __ltrim_colon_completions) and +# asserting the resulting COMPREPLY and file routing. Deterministic, no TTY, +# and works without the bash-completion package installed. +# +# Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). +set -u + +: "${TASK_BIN:?}"; : "${TASK_FIXTURE:?}" +export TASK_EXE="$TASK_BIN" +cd "$TASK_FIXTURE" || exit 1 + +fails=0 +CAP="" + +# Stubs standing in for the bash-completion runtime. +_init_completion() { + words=("${TEST_WORDS[@]}") + cword=$TEST_CWORD + cur="${TEST_WORDS[$TEST_CWORD]}" + prev="${TEST_WORDS[$((TEST_CWORD - 1))]}" + return 0 +} +_filedir() { CAP+="filedir:$*"$'\n'; } +compopt() { CAP+="compopt:$*"$'\n'; } +__ltrim_colon_completions() { :; } + +source "$(dirname "${BASH_SOURCE[0]}")/../bash/task.bash" + +run() { + CAP="" + TEST_WORDS=("$@") + TEST_CWORD=$((${#TEST_WORDS[@]} - 1)) + COMPREPLY=() + _task +} + +reply_has() { # LABEL VALUE + local v + for v in "${COMPREPLY[@]}"; do [[ "$v" == "$2" ]] && { echo " ok $1"; return; }; done + echo " FAIL $1 — '$2' missing from COMPREPLY: ${COMPREPLY[*]}" + fails=$((fails + 1)) +} +cap_has() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then echo " ok $1"; else + echo " FAIL $1 — expected '$2' in: $CAP"; fails=$((fails + 1)); fi +} +cap_hasnot() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then + echo " FAIL $1 — '$2' should be absent in: $CAP"; fails=$((fails + 1)); else + echo " ok $1"; fi +} + +echo "bash: task names (no file fallback)" +run task '' +reply_has "lists tasks" build +reply_has "lists aliases" dep +cap_hasnot "no file fallback" "filedir:" + +echo "bash: task variables" +run task deploy '' +reply_has "required vars" "ENV=dev" +cap_has "NoSpace nospace" "compopt:-o nospace" + +echo "bash: inline --output= is full form" +run task '--output=' +reply_has "full-form value" "--output=interleaved" + +echo "bash: --dir routes to directory completion" +run task --dir '' +cap_has "filedir -d" "filedir:-d" + +echo "bash: --taskfile routes to extension-filtered files" +run task --taskfile '' +cap_has "filedir ext glob" "filedir:@(yml|yaml)" + +echo "bash: after -- falls back to files" +run task build -- '' +cap_has "filedir after --" "filedir:" + +if ((fails)); then + echo "bash: $fails failure(s)" + exit 1 +fi +echo "bash: all passed" diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish new file mode 100755 index 0000000000..787641719e --- /dev/null +++ b/completion/tests/wrapper.fish @@ -0,0 +1,61 @@ +#!/usr/bin/env fish +# Tests the fish wrapper end-to-end via `complete -C`, which asks fish for the +# real completions of a command line without a TTY. The `task` command must +# resolve to the binary under test (run.sh puts a symlink on PATH). +# +# Requires: TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). + +cd $TASK_FIXTURE +source (dirname (status -f))/../fish/task.fish + +set -g fails 0 + +function cands + complete -C $argv[1] | string split -f1 \t +end + +function has # LABEL LINE VALUE + if contains -- $argv[3] (cands $argv[2]) + echo " ok $argv[1]" + else + echo " FAIL $argv[1] — '$argv[3]' missing from: "(cands $argv[2]) + set fails (math $fails + 1) + end +end + +function hasnot # LABEL LINE VALUE + if contains -- $argv[3] (cands $argv[2]) + echo " FAIL $argv[1] — '$argv[3]' should be absent" + set fails (math $fails + 1) + else + echo " ok $argv[1]" + end +end + +echo "fish: task names (no files)" +has "lists tasks" 'task ' build +has "lists aliases" 'task ' dep +hasnot "no files for tasks" 'task ' notes.txt + +echo "fish: task variables" +has "required vars" 'task deploy ' ENV=dev + +echo "fish: flag values" +has "enum values" 'task --output ' interleaved + +echo "fish: --dir completes directories only" +has "dirs offered" 'task --dir ' sub/ +hasnot "no plain files" 'task --dir ' notes.txt + +echo "fish: --taskfile filters by extension" +has "yaml offered" 'task --taskfile ' Taskfile.yml +hasnot "txt filtered out" 'task --taskfile ' notes.txt + +echo "fish: after -- completes files" +has "files after --" 'task build -- ' notes.txt + +if test $fails -ne 0 + echo "fish: $fails failure(s)" + exit 1 +end +echo "fish: all passed" diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 new file mode 100644 index 0000000000..f98744b0e3 --- /dev/null +++ b/completion/tests/wrapper.ps1 @@ -0,0 +1,62 @@ +# Tests the PowerShell wrapper end-to-end via the completion API, which returns +# the real completions of a command line without a TTY. The `task` command must +# resolve to the binary under test (run.sh puts a symlink on PATH). +# +# Requires: $env:TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). + +Set-Location $env:TASK_FIXTURE +. "$PSScriptRoot/../ps/task.ps1" + +$fails = 0 + +function Cands($line) { + ([System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null)).CompletionMatches | + ForEach-Object { $_.CompletionText } +} + +function Has($label, $line, $value) { + if ((Cands $line) -contains $value) { + Write-Output " ok $label" + } else { + Write-Output " FAIL $label — '$value' missing from: $((Cands $line) -join ' ')" + $script:fails++ + } +} + +function HasNot($label, $line, $value) { + if ((Cands $line) -contains $value) { + Write-Output " FAIL $label — '$value' should be absent" + $script:fails++ + } else { + Write-Output " ok $label" + } +} + +Write-Output "powershell: task names (no files)" +Has "lists tasks" 'task ' 'build' +Has "lists aliases" 'task ' 'dep' +HasNot "no files for tasks" 'task ' 'notes.txt' + +Write-Output "powershell: prefix filtering" +Has "filters by prefix" 'task b' 'build' +HasNot "prefix excludes" 'task b' 'deploy' + +Write-Output "powershell: task variables" +Has "required vars" 'task deploy ' 'ENV=dev' + +Write-Output "powershell: flag values" +Has "enum values" 'task --output ' 'interleaved' + +Write-Output "powershell: --dir completes directories only" +Has "dirs offered" 'task --dir ' 'sub' +HasNot "no plain files" 'task --dir ' 'notes.txt' + +Write-Output "powershell: --taskfile filters by extension" +Has "yaml offered" 'task --taskfile ' 'Taskfile.yml' +HasNot "txt filtered out" 'task --taskfile ' 'notes.txt' + +if ($fails -ne 0) { + Write-Output "powershell: $fails failure(s)" + exit 1 +} +Write-Output "powershell: all passed" diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh new file mode 100755 index 0000000000..18af968f65 --- /dev/null +++ b/completion/tests/wrapper.zsh @@ -0,0 +1,81 @@ +#!/usr/bin/env zsh +# Tests the zsh wrapper by stubbing the completion-system functions it calls +# (_describe / _files / _path_files) and asserting how it routes each directive. +# This is deterministic and needs no TTY. +# +# Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). + +export TASK_EXE=$TASK_BIN +cd $TASK_FIXTURE + +integer fails=0 +local CAP +compdef() { } # no-op: we call _task directly, not through compinit + +_describe() { + local arr=$4 + CAP+="describe_opts:${@[5,-1]}"$'\n' + local c; for c in ${(P)arr}; do CAP+="cand:$c"$'\n'; done +} +_files() { CAP+="files:$*"$'\n' } +_path_files() { CAP+="path_files:$*"$'\n' } + +# Sourcing (not autoloading) defines _task and avoids the autoload first-call +# quirk; the trailing `compdef` call is stubbed above. +source ${0:A:h}/../zsh/_task + +run() { + CAP="" + local -a words=("$@") + integer CURRENT=$#words + local curcontext=":completion:complete:task:" + _task +} + +has() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then + echo " ok $1" + else + echo " FAIL $1 — expected '$2' in:"$'\n'"$CAP" + (( fails++ )) + fi +} +hasnot() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then + echo " FAIL $1 — '$2' should be absent in:"$'\n'"$CAP" + (( fails++ )) + else + echo " ok $1" + fi +} + +echo "zsh: task names (no file fallback)" +run task '' +has "lists tasks" "cand:build" +has "lists aliases" "cand:dep" +hasnot "no file fallback" "files:" + +echo "zsh: task variables" +run task deploy '' +has "required vars" "cand:ENV=dev" +has "NoSpace -> -S" "describe_opts:-S" +has "KeepOrder -> -V" "-V" + +echo "zsh: --dir routes to directory completion" +run task --dir '' +has "path_files -/" "path_files:-/" + +echo "zsh: --taskfile routes to extension-filtered files" +run task --taskfile '' +has "files glob" "files:" +has "yml in glob" "yml" + +echo "zsh: after -- falls back to files" +run task build -- '' +has "files after --" "files:" + +if (( fails )); then + echo "zsh: $fails failure(s)" + exit 1 +fi +echo "zsh: all passed" diff --git a/completion/zsh/_task b/completion/zsh/_task index edce79f85e..130493ffc8 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -9,6 +9,9 @@ _task() { local -a args lines completions opts ctl local output directive line + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 KEEP_ORDER=32 + # 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. @@ -30,7 +33,7 @@ _task() { directive="${lines[-1]#:}" lines=("${(@)lines[1,-2]}") - if (( directive & 8 )); then + if (( directive & FILTER_FILE_EXT )); then local -a globs for line in "${lines[@]}"; do globs+=("*.${line}") @@ -39,7 +42,7 @@ _task() { return fi - if (( directive & 16 )); then + if (( directive & FILTER_DIRS )); then _path_files -/ return fi @@ -57,14 +60,14 @@ _task() { fi done - (( directive & 2 )) && opts+=(-S '') - (( directive & 32 )) && opts+=(-V) + (( directive & NO_SPACE )) && opts+=(-S '') + (( directive & KEEP_ORDER )) && opts+=(-V) if (( ${#completions} > 0 )); then _describe -t tasks 'task' completions "${opts[@]}" fi - (( directive & 4 )) && return + (( directive & NO_FILE_COMP )) && return _files } From 0534045eaa33a8968477e8a7c3b6bb29d355ec02 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 22:33:27 +0200 Subject: [PATCH 05/45] test(completion): clean up temp dirs via EXIT trap in run.sh --- completion/tests/run.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/completion/tests/run.sh b/completion/tests/run.sh index f70be53810..fbee3b6a83 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -7,8 +7,13 @@ set -u here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) root=$(cd "$here/../.." && pwd) -# Build the binary under test. +# Temp dirs for the binary and the fixture; removed on exit (including on early +# failure via the trap). bindir=$(mktemp -d) +fixture=$(mktemp -d) +trap 'rm -rf "$bindir" "$fixture"' EXIT + +# Build the binary under test. if ! go build -o "$bindir/task" "$root/cmd/task"; then echo "failed to build task binary" >&2 exit 1 @@ -19,7 +24,6 @@ export TASK_BIN="$bindir/task" export PATH="$bindir:$PATH" # Fixture: a Taskfile plus files/dirs so file/dir completion has real entries. -fixture=$(mktemp -d) cat > "$fixture/Taskfile.yml" <<'YML' version: '3' From 8d77fd279411d1fdd2c60bcffa36da7a8578146b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 23:00:50 +0200 Subject: [PATCH 06/45] test(completion): test the __complete protocol in Go, thin shell smokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow cobra's testing philosophy: the completion protocol (candidates + directive) is business logic and belongs in Go, while the shell wrappers only need to be checked for how they interpret each directive. - Add completion/protocol_test.go: a table-driven black-box test that runs the real `task __complete` binary and asserts the offered values and the emitted directive. Unlike the in-process engine tests, it exercises the actual entrypoint dispatch, runComplete wiring and — crucially — the real flag set, so it catches drift between the completion enum/directive maps and the flag definitions. Runs on every OS, including Windows where the shell smokes don't. - Delete completion/tests/engine.sh (the protocol is now covered in Go) and drop it from run.sh. - Reduce the bash/zsh/fish/powershell smokes to directive routing only (files vs dirs vs no-files vs no-space), removing the task/alias/var assertions that the Go tests already own. --- completion/protocol_test.go | 174 ++++++++++++++++++++++++++++++++++ completion/tests/engine.sh | 85 ----------------- completion/tests/run.sh | 4 +- completion/tests/wrapper.bash | 39 ++++---- completion/tests/wrapper.fish | 37 ++++---- completion/tests/wrapper.ps1 | 39 ++++---- completion/tests/wrapper.zsh | 40 ++++---- 7 files changed, 247 insertions(+), 171 deletions(-) create mode 100644 completion/protocol_test.go delete mode 100755 completion/tests/engine.sh diff --git a/completion/protocol_test.go b/completion/protocol_test.go new file mode 100644 index 0000000000..6d15b3a573 --- /dev/null +++ b/completion/protocol_test.go @@ -0,0 +1,174 @@ +// Package completion_test black-box tests the `task __complete` wire protocol — +// the candidates and directive the engine emits for a given command line. This +// replaces the old completion/tests/engine.sh with readable, table-driven Go: +// the shell wrappers only need to be smoke-tested for how they *interpret* the +// directive (see completion/tests/wrapper.*), never for the suggestion logic, +// which is fully covered here and in internal/complete. +package completion_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/complete" +) + +// taskBin is the path to the task binary built once for the whole package. +var taskBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "task-completion-test") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + taskBin = filepath.Join(dir, "task") + if runtime.GOOS == "windows" { + taskBin += ".exe" + } + if out, err := exec.Command("go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out) + os.RemoveAll(dir) + os.Exit(1) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +const fixtureTaskfile = `version: '3' + +tasks: + build: + desc: Build it + deploy: + desc: Deploy the application + aliases: [dep, ship] + requires: + vars: + - name: ENV + enum: [dev, staging, prod] + - REGION + docs:serve: + desc: Serve docs locally +` + +// completeArgs runs `task __complete ` in a fresh fixture directory and +// returns the offered candidate values plus the emitted directive. +func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) { + t.Helper() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) + + cmd := exec.Command(taskBin, append([]string{complete.CommandName}, args...)...) + cmd.Dir = dir + out, err := cmd.Output() + require.NoError(t, err) + + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + require.NotEmpty(t, lines, "protocol output must end with a directive line") + + last := lines[len(lines)-1] + require.True(t, strings.HasPrefix(last, ":"), "last line must be the : line, got %q", last) + n, err := strconv.Atoi(strings.TrimPrefix(last, ":")) + require.NoError(t, err) + + values := make([]string, 0, len(lines)-1) + for _, line := range lines[:len(lines)-1] { + values = append(values, strings.SplitN(line, "\t", 2)[0]) + } + return values, complete.Directive(n) +} + +func TestProtocol(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []string // candidate values that must be offered + absent []string // candidate values that must NOT be offered + directive complete.Directive + }{ + { + name: "task names and aliases", + args: []string{""}, + want: []string{"build", "deploy", "dep", "ship", "docs:serve"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "no-aliases drops aliases", + args: []string{"--no-aliases", ""}, + want: []string{"build", "deploy"}, + absent: []string{"dep", "ship"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "flag names", + args: []string{"-"}, + want: []string{"--taskfile", "--dir", "--output"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "separate flag value is bare", + args: []string{"--output", ""}, + want: []string{"interleaved", "group", "prefixed"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "inline flag value is full form", + args: []string{"--output="}, + want: []string{"--output=interleaved", "--output=group", "--output=prefixed"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "sort enum values", + args: []string{"--sort", ""}, + want: []string{"default", "alphanumeric", "none"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "taskfile filters by extension", + args: []string{"--taskfile", ""}, + want: []string{"yml", "yaml"}, + directive: complete.DirectiveFilterFileExt, + }, + { + name: "dir filters to directories", + args: []string{"--dir", ""}, + directive: complete.DirectiveFilterDirs, + }, + { + name: "task variables keep order and suppress the space", + args: []string{"deploy", ""}, + want: []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, + directive: complete.DirectiveNoSpace | complete.DirectiveNoFileComp | complete.DirectiveKeepOrder, + }, + { + name: "after -- yields default file completion", + args: []string{"deploy", "--", ""}, + directive: complete.DirectiveDefault, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + values, directive := completeArgs(t, tt.args...) + require.Equal(t, tt.directive, directive) + require.Subset(t, values, tt.want) + for _, a := range tt.absent { + require.NotContains(t, values, a) + } + }) + } +} diff --git a/completion/tests/engine.sh b/completion/tests/engine.sh deleted file mode 100755 index f4937453cf..0000000000 --- a/completion/tests/engine.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# Tests the `task __complete` protocol directly (shell-agnostic). This is the -# backbone: it validates the candidates and directive the engine emits, which -# is what drives every shell wrapper. -# -# Requires: TASK_BIN (path to the task binary), TASK_FIXTURE (dir with a -# Taskfile.yml). Exits non-zero on the first failure. -set -u - -: "${TASK_BIN:?TASK_BIN must point to the task binary}" -: "${TASK_FIXTURE:?TASK_FIXTURE must point to the fixture directory}" -cd "$TASK_FIXTURE" || exit 1 - -fails=0 -out() { "$TASK_BIN" __complete "$@" 2>/dev/null; } -vals() { out "$@" | sed '$d' | cut -f1; } # candidate values, sans the :N line -dirv() { out "$@" | tail -1; } # the :N directive line - -has() { # LABEL VALUE ARGS... - local label=$1 value=$2; shift 2 - if vals "$@" | grep -qxF -- "$value"; then - echo " ok $label" - else - echo " FAIL $label — expected value '$value' among: $(vals "$@" | tr '\n' ' ')" - fails=$((fails + 1)) - fi -} -hasnot() { # LABEL VALUE ARGS... - local label=$1 value=$2; shift 2 - if vals "$@" | grep -qxF -- "$value"; then - echo " FAIL $label — value '$value' should be absent" - fails=$((fails + 1)) - else - echo " ok $label" - fi -} -directive() { # LABEL EXPECTED ARGS... - local label=$1 expected=$2; shift 2 - local got; got=$(dirv "$@") - if [[ "$got" == "$expected" ]]; then - echo " ok $label" - else - echo " FAIL $label — expected directive '$expected', got '$got'" - fails=$((fails + 1)) - fi -} - -echo "engine: task names" -has "lists tasks" build '' -has "lists aliases" dep '' -directive "NoFileComp" ':4' '' - -echo "engine: completion-control flags" -hasnot "--no-aliases drops aliases" dep --no-aliases '' -has "--no-aliases keeps tasks" deploy --no-aliases '' - -echo "engine: flags" -has "lists flags" --taskfile - -directive "flags NoFileComp" ':4' - - -echo "engine: flag values" -has "inline --output= is full form" --output=interleaved --output= -directive "inline NoFileComp" ':4' --output= -has "separate --output is bare" interleaved --output '' -has "--sort values" alphanumeric --sort '' - -echo "engine: file/dir directives" -has "--taskfile emits yml" yml --taskfile '' -has "--taskfile emits yaml" yaml --taskfile '' -directive "--taskfile FilterFileExt" ':8' --taskfile '' -directive "--dir FilterDirs" ':16' --dir '' - -echo "engine: task variables" -has "required var with enum" ENV=dev deploy '' -has "required var without enum" REGION= deploy '' -directive "vars NoSpace|NoFileComp|KeepOrder" ':38' deploy '' - -echo "engine: after --" -directive "after -- is default" ':0' deploy -- '' - -if (( fails )); then - echo "engine: $fails failure(s)" - exit 1 -fi -echo "engine: all passed" diff --git a/completion/tests/run.sh b/completion/tests/run.sh index fbee3b6a83..038d7c286c 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -66,7 +66,9 @@ skip() { # LABEL echo } -run "engine" bash "$here/engine.sh" +# The engine/protocol itself is covered by the Go tests (completion/protocol_test.go +# and internal/complete); these smokes only check how each shell wrapper +# interprets the directive. run "bash wrapper" bash "$here/wrapper.bash" if command -v zsh >/dev/null 2>&1; then diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash index 6ea0de9eb7..627b41f7c5 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Tests the bash wrapper by stubbing the bash-completion helpers it calls -# (_init_completion / _filedir / compopt / __ltrim_colon_completions) and -# asserting the resulting COMPREPLY and file routing. Deterministic, no TTY, -# and works without the bash-completion package installed. +# Smoke-tests how the bash wrapper INTERPRETS each directive by stubbing the +# bash-completion helpers it calls (_filedir / compopt / __ltrim_colon_completions) +# and asserting the routing. The suggestion logic (which tasks/aliases/vars) is +# covered by the Go tests; here we only check that each directive triggers the +# right shell behavior. Deterministic, no TTY, works without bash-completion. # # Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). set -u @@ -52,32 +53,26 @@ cap_hasnot() { # LABEL PATTERN echo " ok $1"; fi } -echo "bash: task names (no file fallback)" +echo "bash: :4 (NoFileComp) forwards candidates, no file fallback" run task '' -reply_has "lists tasks" build -reply_has "lists aliases" dep -cap_hasnot "no file fallback" "filedir:" +reply_has "candidate forwarded" build +cap_hasnot "no file fallback" "filedir:" -echo "bash: task variables" +echo "bash: :2 (NoSpace) disables the trailing space" run task deploy '' -reply_has "required vars" "ENV=dev" -cap_has "NoSpace nospace" "compopt:-o nospace" +cap_has "nospace applied" "compopt:-o nospace" -echo "bash: inline --output= is full form" -run task '--output=' -reply_has "full-form value" "--output=interleaved" +echo "bash: :8 (FilterFileExt) routes to extension-filtered files" +run task --taskfile '' +cap_has "filedir ext glob" "filedir:@(yml|yaml)" -echo "bash: --dir routes to directory completion" +echo "bash: :16 (FilterDirs) routes to directory completion" run task --dir '' -cap_has "filedir -d" "filedir:-d" - -echo "bash: --taskfile routes to extension-filtered files" -run task --taskfile '' -cap_has "filedir ext glob" "filedir:@(yml|yaml)" +cap_has "filedir -d" "filedir:-d" -echo "bash: after -- falls back to files" +echo "bash: :0 (Default) falls back to files" run task build -- '' -cap_has "filedir after --" "filedir:" +cap_has "filedir default" "filedir:" if ((fails)); then echo "bash: $fails failure(s)" diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish index 787641719e..735cf13570 100755 --- a/completion/tests/wrapper.fish +++ b/completion/tests/wrapper.fish @@ -1,7 +1,9 @@ #!/usr/bin/env fish -# Tests the fish wrapper end-to-end via `complete -C`, which asks fish for the -# real completions of a command line without a TTY. The `task` command must -# resolve to the binary under test (run.sh puts a symlink on PATH). +# Smoke-tests how the fish wrapper INTERPRETS each directive (files vs dirs vs +# no files) via `complete -C`, which asks fish for the real completions without +# a TTY. The suggestion logic (which tasks/aliases/vars) is covered by the Go +# tests; here we only check routing. `task` must resolve to the binary under +# test (run.sh puts a symlink on PATH). # # Requires: TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). @@ -32,27 +34,20 @@ function hasnot # LABEL LINE VALUE end end -echo "fish: task names (no files)" -has "lists tasks" 'task ' build -has "lists aliases" 'task ' dep -hasnot "no files for tasks" 'task ' notes.txt +echo "fish: :4 (NoFileComp) forwards candidates, offers no files" +has "candidate forwarded" 'task ' build +hasnot "no file fallback" 'task ' notes.txt -echo "fish: task variables" -has "required vars" 'task deploy ' ENV=dev +echo "fish: :16 (FilterDirs) offers directories only" +has "dir offered" 'task --dir ' sub/ +hasnot "no plain file" 'task --dir ' notes.txt -echo "fish: flag values" -has "enum values" 'task --output ' interleaved +echo "fish: :8 (FilterFileExt) filters by extension" +has "matching file" 'task --taskfile ' Taskfile.yml +hasnot "non-matching file" 'task --taskfile ' notes.txt -echo "fish: --dir completes directories only" -has "dirs offered" 'task --dir ' sub/ -hasnot "no plain files" 'task --dir ' notes.txt - -echo "fish: --taskfile filters by extension" -has "yaml offered" 'task --taskfile ' Taskfile.yml -hasnot "txt filtered out" 'task --taskfile ' notes.txt - -echo "fish: after -- completes files" -has "files after --" 'task build -- ' notes.txt +echo "fish: :0 (Default) falls back to files" +has "file offered" 'task build -- ' notes.txt if test $fails -ne 0 echo "fish: $fails failure(s)" diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index f98744b0e3..22b5589aa1 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -1,6 +1,8 @@ -# Tests the PowerShell wrapper end-to-end via the completion API, which returns -# the real completions of a command line without a TTY. The `task` command must -# resolve to the binary under test (run.sh puts a symlink on PATH). +# Smoke-tests how the PowerShell wrapper INTERPRETS each directive (files vs +# dirs vs no files) plus its own prefix filtering, via the completion API which +# returns real completions without a TTY. The suggestion logic (which +# tasks/aliases/vars) is covered by the Go tests; here we only check routing. +# `task` must resolve to the binary under test (run.sh puts a symlink on PATH). # # Requires: $env:TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). @@ -32,28 +34,21 @@ function HasNot($label, $line, $value) { } } -Write-Output "powershell: task names (no files)" -Has "lists tasks" 'task ' 'build' -Has "lists aliases" 'task ' 'dep' -HasNot "no files for tasks" 'task ' 'notes.txt' +Write-Output "powershell: :4 (NoFileComp) forwards candidates, offers no files" +Has "candidate forwarded" 'task ' 'build' +HasNot "no file fallback" 'task ' 'notes.txt' -Write-Output "powershell: prefix filtering" -Has "filters by prefix" 'task b' 'build' -HasNot "prefix excludes" 'task b' 'deploy' +Write-Output "powershell: filters candidates by the current word" +Has "prefix keeps match" 'task b' 'build' +HasNot "prefix drops others" 'task b' 'deploy' -Write-Output "powershell: task variables" -Has "required vars" 'task deploy ' 'ENV=dev' +Write-Output "powershell: :16 (FilterDirs) offers directories only" +Has "dir offered" 'task --dir ' 'sub' +HasNot "no plain file" 'task --dir ' 'notes.txt' -Write-Output "powershell: flag values" -Has "enum values" 'task --output ' 'interleaved' - -Write-Output "powershell: --dir completes directories only" -Has "dirs offered" 'task --dir ' 'sub' -HasNot "no plain files" 'task --dir ' 'notes.txt' - -Write-Output "powershell: --taskfile filters by extension" -Has "yaml offered" 'task --taskfile ' 'Taskfile.yml' -HasNot "txt filtered out" 'task --taskfile ' 'notes.txt' +Write-Output "powershell: :8 (FilterFileExt) filters by extension" +Has "matching file" 'task --taskfile ' 'Taskfile.yml' +HasNot "non-matching file" 'task --taskfile ' 'notes.txt' if ($fails -ne 0) { Write-Output "powershell: $fails failure(s)" diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh index 18af968f65..7816e548be 100755 --- a/completion/tests/wrapper.zsh +++ b/completion/tests/wrapper.zsh @@ -1,7 +1,9 @@ #!/usr/bin/env zsh -# Tests the zsh wrapper by stubbing the completion-system functions it calls -# (_describe / _files / _path_files) and asserting how it routes each directive. -# This is deterministic and needs no TTY. +# Smoke-tests how the zsh wrapper INTERPRETS each directive by stubbing the +# completion-system functions it calls (_describe / _files / _path_files) and +# asserting the routing. The suggestion logic (which tasks/aliases/vars) is +# covered by the Go tests; here we only check that each directive triggers the +# right shell behavior. Deterministic, no TTY. # # Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). @@ -49,30 +51,28 @@ hasnot() { # LABEL PATTERN fi } -echo "zsh: task names (no file fallback)" +echo "zsh: :4 (NoFileComp) forwards candidates, no file fallback" run task '' -has "lists tasks" "cand:build" -has "lists aliases" "cand:dep" -hasnot "no file fallback" "files:" +has "candidate forwarded" "cand:build" +hasnot "no file fallback" "files:" -echo "zsh: task variables" +echo "zsh: :2|:32 (NoSpace|KeepOrder) map to -S and -V" run task deploy '' -has "required vars" "cand:ENV=dev" -has "NoSpace -> -S" "describe_opts:-S" -has "KeepOrder -> -V" "-V" +has "NoSpace -> -S" "describe_opts:-S" +has "KeepOrder -> -V" "-V" -echo "zsh: --dir routes to directory completion" -run task --dir '' -has "path_files -/" "path_files:-/" - -echo "zsh: --taskfile routes to extension-filtered files" +echo "zsh: :8 (FilterFileExt) routes to extension-filtered files" run task --taskfile '' -has "files glob" "files:" -has "yml in glob" "yml" +has "files glob" "files:" +has "yml in glob" "yml" + +echo "zsh: :16 (FilterDirs) routes to directory completion" +run task --dir '' +has "path_files -/" "path_files:-/" -echo "zsh: after -- falls back to files" +echo "zsh: :0 (Default) falls back to files" run task build -- '' -has "files after --" "files:" +has "files default" "files:" if (( fails )); then echo "zsh: $fails failure(s)" From ff9c99a7360aeeb33f15fcf355b97cc64ae63287 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 23:29:43 +0200 Subject: [PATCH 07/45] chore(completion): trim redundant comments in tests and wrappers --- completion/fish/task.fish | 10 ++++------ completion/protocol_test.go | 11 ++++------- completion/tests/wrapper.bash | 10 +++------- completion/tests/wrapper.fish | 10 +++------- completion/tests/wrapper.ps1 | 11 ++++------- completion/tests/wrapper.zsh | 10 +++------- 6 files changed, 21 insertions(+), 41 deletions(-) diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 9b643f411d..76d503c030 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -48,9 +48,8 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME # 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. - # __fish_complete_suffix only *prioritizes* the extension, so filter the file - # list ourselves — keeping directories so the user can still descend into them. + # __fish_complete_suffix only *prioritizes* the extension rather than + # filtering, so filter the file list ourselves (keeping dirs to descend into). if __task_test_bit $directive $__task_directive_filter_file_ext for entry in (__fish_complete_path $current) set -l name (string split -f1 \t -- $entry) @@ -68,14 +67,13 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME return end - # FilterDirs: complete directories only. if __task_test_bit $directive $__task_directive_filter_dirs __fish_complete_directories $current return end - # Emit the `value\tdescription` candidates (fish reads the tab as the - # separator between the completion and its description). + # Emit the candidates verbatim; fish reads the tab as the value/description + # separator. for line in $data printf '%s\n' $line end diff --git a/completion/protocol_test.go b/completion/protocol_test.go index 6d15b3a573..c14c3ebf18 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -1,9 +1,7 @@ -// Package completion_test black-box tests the `task __complete` wire protocol — -// the candidates and directive the engine emits for a given command line. This -// replaces the old completion/tests/engine.sh with readable, table-driven Go: -// the shell wrappers only need to be smoke-tested for how they *interpret* the -// directive (see completion/tests/wrapper.*), never for the suggestion logic, -// which is fully covered here and in internal/complete. +// Package completion_test black-box tests the `task __complete` wire protocol: +// the candidates and directive the real binary emits for a command line. The +// shell wrappers only need to be smoke-tested for how they interpret the +// directive (see completion/tests/wrapper.*). package completion_test import ( @@ -21,7 +19,6 @@ import ( "github.com/go-task/task/v3/internal/complete" ) -// taskBin is the path to the task binary built once for the whole package. var taskBin string func TestMain(m *testing.M) { diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash index 627b41f7c5..be40f5824e 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -1,11 +1,7 @@ #!/usr/bin/env bash -# Smoke-tests how the bash wrapper INTERPRETS each directive by stubbing the -# bash-completion helpers it calls (_filedir / compopt / __ltrim_colon_completions) -# and asserting the routing. The suggestion logic (which tasks/aliases/vars) is -# covered by the Go tests; here we only check that each directive triggers the -# right shell behavior. Deterministic, no TTY, works without bash-completion. -# -# Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). +# Smoke-tests how the bash wrapper routes each directive by stubbing the +# bash-completion helpers (_filedir / compopt / …) and asserting what it calls. +# Suggestion logic lives in the Go tests. Requires TASK_BIN and TASK_FIXTURE. set -u : "${TASK_BIN:?}"; : "${TASK_FIXTURE:?}" diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish index 735cf13570..4d73b610c4 100755 --- a/completion/tests/wrapper.fish +++ b/completion/tests/wrapper.fish @@ -1,11 +1,7 @@ #!/usr/bin/env fish -# Smoke-tests how the fish wrapper INTERPRETS each directive (files vs dirs vs -# no files) via `complete -C`, which asks fish for the real completions without -# a TTY. The suggestion logic (which tasks/aliases/vars) is covered by the Go -# tests; here we only check routing. `task` must resolve to the binary under -# test (run.sh puts a symlink on PATH). -# -# Requires: TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). +# Smoke-tests how the fish wrapper routes each directive, via `complete -C` +# (real completions, no TTY). Suggestion logic lives in the Go tests. +# Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test. cd $TASK_FIXTURE source (dirname (status -f))/../fish/task.fish diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index 22b5589aa1..c7532fa8cb 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -1,10 +1,7 @@ -# Smoke-tests how the PowerShell wrapper INTERPRETS each directive (files vs -# dirs vs no files) plus its own prefix filtering, via the completion API which -# returns real completions without a TTY. The suggestion logic (which -# tasks/aliases/vars) is covered by the Go tests; here we only check routing. -# `task` must resolve to the binary under test (run.sh puts a symlink on PATH). -# -# Requires: $env:TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). +# Smoke-tests how the PowerShell wrapper routes each directive (plus its own +# prefix filtering), via the completion API (real completions, no TTY). +# Suggestion logic lives in the Go tests. Set up by run.sh: $env:TASK_FIXTURE, +# and `task` on PATH = the binary under test. Set-Location $env:TASK_FIXTURE . "$PSScriptRoot/../ps/task.ps1" diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh index 7816e548be..a2c3ab619e 100755 --- a/completion/tests/wrapper.zsh +++ b/completion/tests/wrapper.zsh @@ -1,11 +1,7 @@ #!/usr/bin/env zsh -# Smoke-tests how the zsh wrapper INTERPRETS each directive by stubbing the -# completion-system functions it calls (_describe / _files / _path_files) and -# asserting the routing. The suggestion logic (which tasks/aliases/vars) is -# covered by the Go tests; here we only check that each directive triggers the -# right shell behavior. Deterministic, no TTY. -# -# Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). +# Smoke-tests how the zsh wrapper routes each directive by stubbing the +# completion functions (_describe / _files / _path_files) and asserting what it +# calls. Suggestion logic lives in the Go tests. Requires TASK_BIN, TASK_FIXTURE. export TASK_EXE=$TASK_BIN cd $TASK_FIXTURE From 43d29073ba4e92dcd16ec503efd7531e246d38de Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 17:37:34 +0200 Subject: [PATCH 08/45] refactor(completion): serve the __complete engine from completion/next/ Move the thin __complete wrappers under completion/next/ and restore the stable, hand-written scripts at their canonical paths so the default output of `task --completion ` (and the goreleaser/brew packaged scripts) is unchanged. Repoint the cross-shell test harness at completion/next/. This is the groundwork for offering the new engine as opt-in before it becomes the default. --- completion/bash/task.bash | 117 +++++++---------- completion/fish/task.fish | 177 ++++++++++++++----------- completion/next/bash/task.bash | 81 ++++++++++++ completion/next/fish/task.fish | 91 +++++++++++++ completion/next/ps/task.ps1 | 88 +++++++++++++ completion/next/zsh/_task | 74 +++++++++++ completion/ps/task.ps1 | 158 +++++++++++----------- completion/tests/wrapper.bash | 2 +- completion/tests/wrapper.fish | 2 +- completion/tests/wrapper.ps1 | 2 +- completion/tests/wrapper.zsh | 2 +- completion/zsh/_task | 233 +++++++++++++++++++++++---------- 12 files changed, 736 insertions(+), 291 deletions(-) create mode 100644 completion/next/bash/task.bash create mode 100644 completion/next/fish/task.fish create mode 100644 completion/next/ps/task.ps1 create mode 100755 completion/next/zsh/_task diff --git a/completion/bash/task.bash b/completion/bash/task.bash index 4e7438f7fc..60e807aa43 100644 --- a/completion/bash/task.bash +++ b/completion/bash/task.bash @@ -1,81 +1,60 @@ # vim: set tabstop=2 shiftwidth=2 expandtab: -# -# Thin wrapper around `task __complete`. All suggestion logic lives in the -# Go engine — do not add completion logic here. +_GO_TASK_COMPLETION_LIST_OPTION='--list-all' TASK_CMD="${TASK_EXE:-task}" -_task() { +function _task() +{ local cur prev words cword - - # Completion directives, mirroring internal/complete/complete.go. - local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 - - # 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 - args=( "${words[@]:1:cword}" ) - fi - if (( ${#args[@]} == 0 )); then - args=( "" ) - fi - - local output - output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) - if [[ -z "$output" ]]; then - _filedir - return - fi - - local -a lines=() - local line - while IFS= read -r line; do - lines+=( "$line" ) - done <<< "$output" - - local last_idx=$(( ${#lines[@]} - 1 )) - local directive="${lines[$last_idx]#:}" - unset 'lines[$last_idx]' - - if (( directive & FILTER_FILE_EXT )); then - local exts="" - # ${arr[@]+…} guards against "unbound variable" on an empty array under - # `set -u` in bash 3.2 (macOS). - for line in ${lines[@]+"${lines[@]}"}; do - exts+="${exts:+|}$line" - done - _filedir "@($exts)" - return - fi - - if (( directive & FILTER_DIRS )); then - _filedir -d - return - fi - - # 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[@]+"${lines[@]}"}; do - value="${line%%$'\t'*}" - if [[ -z "$cur" || "$value" == "$cur"* ]]; then - COMPREPLY+=( "$value" ) + _init_completion -n : || return + + # Check for `--` within command-line and quit or strip suffix. + local i + for i in "${!words[@]}"; do + if [ "${words[$i]}" == "--" ]; then + # Do not complete words following `--` passed to CLI_ARGS. + [ $cword -gt $i ] && return + # Remove the words following `--` to not put --list in CLI_ARGS. + words=( "${words[@]:0:$i}" ) + break fi done - if (( directive & NO_SPACE )); then - compopt -o nospace 2>/dev/null - fi - + # Handle special arguments of options. + case "$prev" in + -d|--dir|--remote-cache-dir) + _filedir -d + return $? + ;; + --cacert|--cert|--cert-key) + _filedir + return $? + ;; + -t|--taskfile) + _filedir yaml || return $? + _filedir yml + return $? + ;; + -o|--output) + COMPREPLY=( $( compgen -W "interleaved group prefixed" -- $cur ) ) + return 0 + ;; + esac + + # Handle normal options. + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) ) + return 0 + ;; + esac + + # Prepare task name completions. + local tasks=( $( "${words[@]}" --silent $_GO_TASK_COMPLETION_LIST_OPTION 2> /dev/null ) ) + COMPREPLY=( $( compgen -W "${tasks[*]}" -- "$cur" ) ) + + # Post-process because task names might contain colons. __ltrim_colon_completions "$cur" - - if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then - _filedir - fi } complete -F _task "$TASK_CMD" diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 76d503c030..6b3c4e6c75 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -1,91 +1,120 @@ -# Thin wrapper around `task __complete`. All suggestion logic lives in the -# Go engine — do not add completion logic here. - 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) -# Completion directives, mirroring internal/complete/complete.go. fish's `math` -# has no bitwise operators, so bits are stored as their power-of-two value and -# tested with integer division + modulo via __task_test_bit. -set -g __task_directive_no_space 2 -set -g __task_directive_no_file_comp 4 -set -g __task_directive_filter_file_ext 8 -set -g __task_directive_filter_dirs 16 -set -g __task_directive_keep_order 32 +# Cache variables for experiments (global) +set -g __task_experiments_cache "" +set -g __task_experiments_cache_time 0 -function __task_test_bit --argument-names value bit - test (math "floor($value / $bit) % 2") -eq 1 -end +# Helper function to get experiments with 1-second cache +function __task_get_experiments --inherit-variable GO_TASK_PROGNAME + set -l now (date +%s) + set -l ttl 1 # Cache for 1 second only -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 + # Return cached value if still valid + if test (math "$now - $__task_experiments_cache_time") -lt $ttl + printf '%s\n' $__task_experiments_cache + 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 + # Refresh cache + set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null) + set -g __task_experiments_cache_time $now + printf '%s\n' $__task_experiments_cache +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 +# Helper function to check if an experiment is enabled +function __task_is_experiment_enabled + set -l experiment $argv[1] + __task_get_experiments | string match -qr "^\* $experiment:.*on" +end - set -l directive (string replace -r '^:' '' -- $last) - set -l data - if test $count -gt 1 - set data $output[1..(math $count - 1)] +function __task_get_tasks --description "Prints all available tasks with their description" --inherit-variable GO_TASK_PROGNAME + # Check if the global task is requested + set -l global_task false + commandline --current-process | read --tokenize --list --local cmd_args + for arg in $cmd_args + if test "_$arg" = "_--" + break # ignore arguments to be passed to the task + end + if test "_$arg" = "_--global" -o "_$arg" = "_-g" + set global_task true + break + end 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 `--`). + # Read the list of tasks (and potential errors) + if $global_task + $GO_TASK_PROGNAME --global --list-all + else + $GO_TASK_PROGNAME --list-all + end 2>&1 | read -lz rawOutput - # __fish_complete_suffix only *prioritizes* the extension rather than - # filtering, so filter the file list ourselves (keeping dirs to descend into). - if __task_test_bit $directive $__task_directive_filter_file_ext - for entry in (__fish_complete_path $current) - set -l name (string split -f1 \t -- $entry) - if string match -qr '/$' -- $name - printf '%s\n' $entry - continue - end - for ext in $data - if string match -qr "\.$ext\$" -- $name - printf '%s\n' $entry - break - end - end - end + # Return on non-zero exit code (for cases when there is no Taskfile found or etc.) + if test $status -ne 0 return end - if __task_test_bit $directive $__task_directive_filter_dirs - __fish_complete_directories $current - return + # Grab names and descriptions (if any) of the tasks + set -l output (echo $rawOutput | sed -e '1d; s/\* \(.*\):[[:space:]]\{2,\}\(.*\)[[:space:]]\{2,\}(\(aliases.*\))/\1\t\2\t\3/' -e 's/\* \(.*\):[[:space:]]\{2,\}\(.*\)/\1\t\2/'| string split0) + if test $output + echo $output end +end - # Emit the candidates verbatim; fish reads the tab as the value/description - # separator. - for line in $data - printf '%s\n' $line - end +complete -c $GO_TASK_PROGNAME \ + -d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was specified.' \ + -xa "(__task_get_tasks)" \ + -n "not __fish_seen_subcommand_from --" - # NoFileComp unset → also offer files, since `--no-files` suppressed the - # native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`). - if not __task_test_bit $directive $__task_directive_no_file_comp - __fish_complete_path $current - end -end +# Standard flags +complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks' +complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)' +complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks' +complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell nu" +complete -c $GO_TASK_PROGNAME -s d -l dir -d 'set directory of execution' +complete -c $GO_TASK_PROGNAME -l disable-fuzzy -d 'disable fuzzy matching for task names' +complete -c $GO_TASK_PROGNAME -s n -l dry -d 'compile and print tasks without executing' +complete -c $GO_TASK_PROGNAME -s x -l exit-code -d 'pass-through exit code of task command' +complete -c $GO_TASK_PROGNAME -l experiments -d 'list available experiments' +complete -c $GO_TASK_PROGNAME -s F -l failfast -d 'when running tasks in parallel, stop all tasks if one fails' +complete -c $GO_TASK_PROGNAME -s f -l force -d 'force execution even when up-to-date' +complete -c $GO_TASK_PROGNAME -s g -l global -d 'run global Taskfile from home directory' +complete -c $GO_TASK_PROGNAME -s h -l help -d 'show help' +complete -c $GO_TASK_PROGNAME -s i -l init -d 'create new Taskfile' +complete -c $GO_TASK_PROGNAME -l insecure -d 'allow insecure Taskfile downloads' +complete -c $GO_TASK_PROGNAME -s I -l interval -d 'interval to watch for changes' +complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task list as JSON' +complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' +complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' +complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' +complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables' +complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" +complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' +complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' +complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks' +complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel' +complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing' +complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none" +complete -c $GO_TASK_PROGNAME -l status -d 'exit non-zero if tasks not up-to-date' +complete -c $GO_TASK_PROGNAME -l summary -d 'show task summary' +complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose Taskfile to run' +complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose output' +complete -c $GO_TASK_PROGNAME -l version -d 'show version' +complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes' +complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts' + +# Experimental flags (dynamically checked at completion time via -n condition) +# GentleForce experiment +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies' + +# RemoteTaskfiles experiment - Options +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l offline -d 'use only local or cached Taskfiles' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)" +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cacert -d 'custom CA certificate for TLS' -r +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert -d 'client certificate for mTLS' -r +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key -d 'client certificate private key' -r -# 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)" +# RemoteTaskfiles experiment - Operations +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l clear-cache -d 'clear remote Taskfile cache' diff --git a/completion/next/bash/task.bash b/completion/next/bash/task.bash new file mode 100644 index 0000000000..4e7438f7fc --- /dev/null +++ b/completion/next/bash/task.bash @@ -0,0 +1,81 @@ +# vim: set tabstop=2 shiftwidth=2 expandtab: +# +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + +TASK_CMD="${TASK_EXE:-task}" + +_task() { + local cur prev words cword + + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 + + # 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 + args=( "${words[@]:1:cword}" ) + fi + if (( ${#args[@]} == 0 )); then + args=( "" ) + fi + + local output + output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _filedir + return + fi + + local -a lines=() + local line + while IFS= read -r line; do + lines+=( "$line" ) + done <<< "$output" + + local last_idx=$(( ${#lines[@]} - 1 )) + local directive="${lines[$last_idx]#:}" + unset 'lines[$last_idx]' + + if (( directive & FILTER_FILE_EXT )); then + local exts="" + # ${arr[@]+…} guards against "unbound variable" on an empty array under + # `set -u` in bash 3.2 (macOS). + for line in ${lines[@]+"${lines[@]}"}; do + exts+="${exts:+|}$line" + done + _filedir "@($exts)" + return + fi + + if (( directive & FILTER_DIRS )); then + _filedir -d + return + fi + + # 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[@]+"${lines[@]}"}; do + value="${line%%$'\t'*}" + if [[ -z "$cur" || "$value" == "$cur"* ]]; then + COMPREPLY+=( "$value" ) + fi + done + + if (( directive & NO_SPACE )); then + compopt -o nospace 2>/dev/null + fi + + __ltrim_colon_completions "$cur" + + if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then + _filedir + fi +} + +complete -F _task "$TASK_CMD" diff --git a/completion/next/fish/task.fish b/completion/next/fish/task.fish new file mode 100644 index 0000000000..76d503c030 --- /dev/null +++ b/completion/next/fish/task.fish @@ -0,0 +1,91 @@ +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + +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) + +# Completion directives, mirroring internal/complete/complete.go. fish's `math` +# has no bitwise operators, so bits are stored as their power-of-two value and +# tested with integer division + modulo via __task_test_bit. +set -g __task_directive_no_space 2 +set -g __task_directive_no_file_comp 4 +set -g __task_directive_filter_file_ext 8 +set -g __task_directive_filter_dirs 16 +set -g __task_directive_keep_order 32 + +function __task_test_bit --argument-names value bit + test (math "floor($value / $bit) % 2") -eq 1 +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 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. + printf '%s\n' $output + 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 + + # 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 `--`). + + # __fish_complete_suffix only *prioritizes* the extension rather than + # filtering, so filter the file list ourselves (keeping dirs to descend into). + if __task_test_bit $directive $__task_directive_filter_file_ext + for entry in (__fish_complete_path $current) + set -l name (string split -f1 \t -- $entry) + if string match -qr '/$' -- $name + printf '%s\n' $entry + continue + end + for ext in $data + if string match -qr "\.$ext\$" -- $name + printf '%s\n' $entry + break + end + end + end + return + end + + if __task_test_bit $directive $__task_directive_filter_dirs + __fish_complete_directories $current + return + end + + # Emit the candidates verbatim; fish reads the tab as the value/description + # separator. + for line in $data + printf '%s\n' $line + end + + # NoFileComp unset → also offer files, since `--no-files` suppressed the + # native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`). + if not __task_test_bit $directive $__task_directive_no_file_comp + __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)" diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 new file mode 100644 index 0000000000..7e18991896 --- /dev/null +++ b/completion/next/ps/task.ps1 @@ -0,0 +1,88 @@ +using namespace System.Management.Automation + +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + +$cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique + +Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' } + + # Words after the program name, truncated to the cursor. + $argsToPass = @() + $elements = $commandAst.CommandElements + if ($elements.Count -gt 1) { + for ($i = 1; $i -lt $elements.Count; $i++) { + $el = $elements[$i] + if ($el.Extent.StartOffset -ge $cursorPosition) { break } + $argsToPass += $el.ToString() + } + } + # The trailing word (possibly empty) must reach the engine so it knows + # 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 + } + + $output = & $TaskExe __complete @argsToPass 2>$null + if (-not $output) { return } + + $lines = @($output) + if ($lines.Count -eq 0) { return } + $last = $lines[-1] + if (-not $last.StartsWith(':')) { return } + + $directive = [int]($last.Substring(1)) + $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + + # Completion directives, mirroring internal/complete/complete.go. + $NoFileComp = 4 + $FilterFileExt = 8 + $FilterDirs = 16 + + # 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: keep files whose extension matches, plus directories so the + # user can still descend into them. `-Include` is unreliable without + # `-Recurse`, so filter with Where-Object instead. + if ($directive -band $FilterFileExt) { + $exts = $data | ForEach-Object { ".$_" } + return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | + Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | + ForEach-Object { + $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } + [CompletionResult]::new($_.Name, $_.Name, $type, $_.Name) + } + } + + # FilterDirs + if ($directive -band $FilterDirs) { + return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue | + ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } + } + + # 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 $NoFileComp)) { + return Get-ChildItem -Path . -ErrorAction SilentlyContinue | + ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } + } + + return $results +} diff --git a/completion/next/zsh/_task b/completion/next/zsh/_task new file mode 100755 index 0000000000..130493ffc8 --- /dev/null +++ b/completion/next/zsh/_task @@ -0,0 +1,74 @@ +#compdef task +# +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + +TASK_CMD="${TASK_EXE:-task}" + +_task() { + local -a args lines completions opts ctl + local output directive line + + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 KEEP_ORDER=32 + + # 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) + + # (@) 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=("") + + output=$("$TASK_CMD" __complete "${ctl[@]}" "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _files + return + fi + + lines=("${(f)output}") + directive="${lines[-1]#:}" + lines=("${(@)lines[1,-2]}") + + if (( directive & FILTER_FILE_EXT )); then + local -a globs + for line in "${lines[@]}"; do + globs+=("*.${line}") + done + _files -g "(${(j:|:)globs})" + return + fi + + if (( directive & FILTER_DIRS )); then + _path_files -/ + 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 + + (( directive & NO_SPACE )) && opts+=(-S '') + (( directive & KEEP_ORDER )) && opts+=(-V) + + if (( ${#completions} > 0 )); then + _describe -t tasks 'task' completions "${opts[@]}" + fi + + (( directive & NO_FILE_COMP )) && return + _files +} + +compdef _task "$TASK_CMD" diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 7e18991896..71b58b88f1 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -1,88 +1,94 @@ using namespace System.Management.Automation -# Thin wrapper around `task __complete`. All suggestion logic lives in the -# Go engine — do not add completion logic here. - $cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique -Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { - param($wordToComplete, $commandAst, $cursorPosition) - - $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' } - - # Words after the program name, truncated to the cursor. - $argsToPass = @() - $elements = $commandAst.CommandElements - if ($elements.Count -gt 1) { - for ($i = 1; $i -lt $elements.Count; $i++) { - $el = $elements[$i] - if ($el.Extent.StartOffset -ge $cursorPosition) { break } - $argsToPass += $el.ToString() - } - } - # The trailing word (possibly empty) must reach the engine so it knows - # 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 - } - - $output = & $TaskExe __complete @argsToPass 2>$null - if (-not $output) { return } +Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - $lines = @($output) - if ($lines.Count -eq 0) { return } - $last = $lines[-1] - if (-not $last.StartsWith(':')) { return } + if ($commandName.StartsWith('-')) { + $completions = @( + # Standard flags (alphabetical order) + [CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'list all tasks'), + [CompletionResult]::new('--list-all', '--list-all', [CompletionResultType]::ParameterName, 'list all tasks'), + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'colored output'), + [CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'colored output'), + [CompletionResult]::new('-C', '-C', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), + [CompletionResult]::new('--concurrency', '--concurrency', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), + [CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'generate shell completion'), + [CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'set directory'), + [CompletionResult]::new('--dir', '--dir', [CompletionResultType]::ParameterName, 'set directory'), + [CompletionResult]::new('--disable-fuzzy', '--disable-fuzzy', [CompletionResultType]::ParameterName, 'disable fuzzy matching'), + [CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'dry run'), + [CompletionResult]::new('--dry', '--dry', [CompletionResultType]::ParameterName, 'dry run'), + [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'pass-through exit code'), + [CompletionResult]::new('--exit-code', '--exit-code', [CompletionResultType]::ParameterName, 'pass-through exit code'), + [CompletionResult]::new('--experiments', '--experiments', [CompletionResultType]::ParameterName, 'list experiments'), + [CompletionResult]::new('-F', '-F', [CompletionResultType]::ParameterName, 'fail fast on pallalel tasks'), + [CompletionResult]::new('--failfast', '--failfast', [CompletionResultType]::ParameterName, 'force execution'), + [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'force execution'), + [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'force execution'), + [CompletionResult]::new('-g', '-g', [CompletionResultType]::ParameterName, 'run global Taskfile'), + [CompletionResult]::new('--global', '--global', [CompletionResultType]::ParameterName, 'run global Taskfile'), + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'show help'), + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'show help'), + [CompletionResult]::new('-i', '-i', [CompletionResultType]::ParameterName, 'create new Taskfile'), + [CompletionResult]::new('--init', '--init', [CompletionResultType]::ParameterName, 'create new Taskfile'), + [CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'allow insecure downloads'), + [CompletionResult]::new('-I', '-I', [CompletionResultType]::ParameterName, 'watch interval'), + [CompletionResult]::new('--interval', '--interval', [CompletionResultType]::ParameterName, 'watch interval'), + [CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'format as JSON'), + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'format as JSON'), + [CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'list tasks'), + [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), + [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), + [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), + [CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'), + [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), + [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), + [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), + [CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'), + [CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'), + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'), + [CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'), + [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'), + [CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'silent mode'), + [CompletionResult]::new('--sort', '--sort', [CompletionResultType]::ParameterName, 'task sorting order'), + [CompletionResult]::new('--status', '--status', [CompletionResultType]::ParameterName, 'check task status'), + [CompletionResult]::new('--summary', '--summary', [CompletionResultType]::ParameterName, 'show task summary'), + [CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'choose Taskfile'), + [CompletionResult]::new('--taskfile', '--taskfile', [CompletionResultType]::ParameterName, 'choose Taskfile'), + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'verbose output'), + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'verbose output'), + [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'show version'), + [CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'), + [CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'), + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'), + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes') + ) - $directive = [int]($last.Substring(1)) - $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + # Experimental flags (dynamically added based on enabled experiments) + $experiments = & task --experiments 2>$null | Out-String - # Completion directives, mirroring internal/complete/complete.go. - $NoFileComp = 4 - $FilterFileExt = 8 - $FilterDirs = 16 - - # 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: keep files whose extension matches, plus directories so the - # user can still descend into them. `-Include` is unreliable without - # `-Recurse`, so filter with Where-Object instead. - if ($directive -band $FilterFileExt) { - $exts = $data | ForEach-Object { ".$_" } - return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | - Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | - ForEach-Object { - $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } - [CompletionResult]::new($_.Name, $_.Name, $type, $_.Name) - } - } - - # FilterDirs - if ($directive -band $FilterDirs) { - return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } - } + if ($experiments -match '\* GENTLE_FORCE:.*on') { + $completions += [CompletionResult]::new('--force-all', '--force-all', [CompletionResultType]::ParameterName, 'force all dependencies') + } - # 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) - }) + if ($experiments -match '\* REMOTE_TASKFILES:.*on') { + # Options + $completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles') + $completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout') + $completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry') + $completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory') + $completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate') + $completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate') + $completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key') + # Operations + $completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile') + $completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') + } - # 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 $NoFileComp)) { - return Get-ChildItem -Path . -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } + return $completions.Where{ $_.CompletionText.StartsWith($commandName) } } - return $results + return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " } } diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash index be40f5824e..6f31a04ee9 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -23,7 +23,7 @@ _filedir() { CAP+="filedir:$*"$'\n'; } compopt() { CAP+="compopt:$*"$'\n'; } __ltrim_colon_completions() { :; } -source "$(dirname "${BASH_SOURCE[0]}")/../bash/task.bash" +source "$(dirname "${BASH_SOURCE[0]}")/../next/bash/task.bash" run() { CAP="" diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish index 4d73b610c4..044b704002 100755 --- a/completion/tests/wrapper.fish +++ b/completion/tests/wrapper.fish @@ -4,7 +4,7 @@ # Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test. cd $TASK_FIXTURE -source (dirname (status -f))/../fish/task.fish +source (dirname (status -f))/../next/fish/task.fish set -g fails 0 diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index c7532fa8cb..1e7537aac1 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -4,7 +4,7 @@ # and `task` on PATH = the binary under test. Set-Location $env:TASK_FIXTURE -. "$PSScriptRoot/../ps/task.ps1" +. "$PSScriptRoot/../next/ps/task.ps1" $fails = 0 diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh index a2c3ab619e..ddbfa11ae3 100755 --- a/completion/tests/wrapper.zsh +++ b/completion/tests/wrapper.zsh @@ -20,7 +20,7 @@ _path_files() { CAP+="path_files:$*"$'\n' } # Sourcing (not autoloading) defines _task and avoids the autoload first-call # quirk; the trailing `compdef` call is stubbed above. -source ${0:A:h}/../zsh/_task +source ${0:A:h}/../next/zsh/_task run() { CAP="" diff --git a/completion/zsh/_task b/completion/zsh/_task index 130493ffc8..7e3082e7ce 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -1,74 +1,171 @@ #compdef task -# -# Thin wrapper around `task __complete`. All suggestion logic lives in the -# Go engine — do not add completion logic here. - +typeset -A opt_args TASK_CMD="${TASK_EXE:-task}" +compdef _task "$TASK_CMD" + +_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" + +# Check if an experiment is enabled +function __task_is_experiment_enabled() { + local experiment=$1 + task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on" +} + +# Listing commands from Taskfile.yml +function __task_list() { + local -a scripts cmd task_aliases match mbegin mend + local -i enabled=0 + local taskfile item task desc task_alias + + cmd=($TASK_CMD) + taskfile=${(Qv)opt_args[(i)-t|--taskfile]} + taskfile=${taskfile//\~/$HOME} + + for arg in "${words[@]:0:$CURRENT}"; do + if [[ "$arg" = "--" ]]; then + # Use default completion for words after `--` as they are CLI_ARGS. + _default + return 0 + fi + done + + if [[ -n "$taskfile" && -f "$taskfile" ]]; then + cmd+=(--taskfile "$taskfile") + fi + + # Check if global flag is set + if (( ${+opt_args[-g]} || ${+opt_args[--global]} )); then + cmd+=(--global) + fi + + if output=$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION 2>/dev/null); then + enabled=1 + fi + + (( enabled )) || return 0 + + scripts=() + + # Read zstyle verbose option (default = true via -T) + local show_desc + zstyle -T ":completion:${curcontext}:" verbose && show_desc=true || show_desc=false + + # Read zstyle show-aliases option (default = true via -T) + local show_aliases + zstyle -T ":completion:${curcontext}:" show-aliases && show_aliases=true || show_aliases=false + + for item in "${(@)${(f)output}[2,-1]#\* }"; do + task="${item%%:[[:space:]]*}" + + # Extract the aliases listed in the trailing "(aliases: a, b)" column. + # NB: `aliases` is a reserved zsh parameter, so use a different name. + task_aliases=() + if [[ "$show_aliases" == "true" && "$item" == (#b)*'(aliases: '(*)')' ]]; then + task_aliases=( "${(@s:, :)match[1]}" ) + fi + + if [[ "$show_desc" == "true" ]]; then + local desc="${item##[^[:space:]]##[[:space:]]##}" + scripts+=( "${task//:/\\:}:$desc" ) + for task_alias in $task_aliases; do + scripts+=( "${task_alias//:/\\:}:$desc (alias of $task)" ) + done + else + scripts+=( "$task" ) + for task_alias in $task_aliases; do + scripts+=( "$task_alias" ) + done + fi + done + + if [[ "$show_desc" == "true" ]]; then + _describe 'Task to run' scripts + else + compadd -Q -a scripts + fi +} _task() { - local -a args lines completions opts ctl - local output directive line - - # Completion directives, mirroring internal/complete/complete.go. - local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 KEEP_ORDER=32 - - # 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) - - # (@) 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=("") - - output=$("$TASK_CMD" __complete "${ctl[@]}" "${args[@]}" 2>/dev/null) - if [[ -z "$output" ]]; then - _files - return - fi - - lines=("${(f)output}") - directive="${lines[-1]#:}" - lines=("${(@)lines[1,-2]}") - - if (( directive & FILTER_FILE_EXT )); then - local -a globs - for line in "${lines[@]}"; do - globs+=("*.${line}") - done - _files -g "(${(j:|:)globs})" - return - fi - - if (( directive & FILTER_DIRS )); then - _path_files -/ - 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 - - (( directive & NO_SPACE )) && opts+=(-S '') - (( directive & KEEP_ORDER )) && opts+=(-V) - - if (( ${#completions} > 0 )); then - _describe -t tasks 'task' completions "${opts[@]}" - fi - - (( directive & NO_FILE_COMP )) && return - _files + local -a standard_args operation_args + + standard_args=( + '(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' + '(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' + '(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]' + '(-f --force)'{-f,--force}'[run even if task is up-to-date]' + '(-c --color)'{-c,--color}'[colored output]' + '(--completion)--completion[generate shell completion script]:shell:(bash zsh fish powershell nu)' + '(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs' + '(--disable-fuzzy)--disable-fuzzy[disable fuzzy matching for task names]' + '(-n --dry)'{-n,--dry}'[compiles and prints tasks without executing]' + '(--dry)--dry[dry-run mode, compile and print tasks only]' + '(-x --exit-code)'{-x,--exit-code}'[pass-through exit code of task command]' + '(--experiments)--experiments[list available experiments]' + '(-g --global)'{-g,--global}'[run global Taskfile from home directory]' + '(--insecure)--insecure[allow insecure Taskfile downloads]' + '(-I --interval)'{-I,--interval}'[interval to watch for changes]:duration: ' + '(-j --json)'{-j,--json}'[format task list as JSON]' + '(--nested)--nested[nest namespaces when listing as JSON]' + '(--no-status)--no-status[ignore status when listing as JSON]' + '(--interactive)--interactive[prompt for missing required variables]' + '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' + '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' + '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' + '(--output-group-error-only)--output-group-error-only[hide output from successful tasks]' + '(-s --silent)'{-s,--silent}'[disable echoing]' + '(--sort)--sort[set task sorting order]:order:(default alphanumeric none)' + '(--status)--status[exit non-zero if supplied tasks not up-to-date]' + '(--summary)--summary[show summary\: field from tasks instead of running them]' + '(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files' + '(-v --verbose)'{-v,--verbose}'[verbose mode]' + '(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]' + '(-y --yes)'{-y,--yes}'[assume yes to all prompts]' + ) + + # Experimental flags (dynamically added based on enabled experiments) + # Options (modify behavior) + if __task_is_experiment_enabled "GENTLE_FORCE"; then + standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]') + fi + + if __task_is_experiment_enabled "REMOTE_TASKFILES"; then + standard_args+=( + '(--offline --download)--offline[use only local or cached Taskfiles]' + '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' + '(--expiry)--expiry[cache expiry duration]:duration: ' + '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' + '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' + '(--cert)--cert[client certificate for mTLS]:file:_files' + '(--cert-key)--cert-key[client certificate private key]:file:_files' + ) + fi + + operation_args=( + # Task names completion (can be specified multiple times) + '(operation)*: :__task_list' + # Operational args completion (mutually exclusive) + + '(operation)' + '(*)'{-l,--list}'[list describable tasks]' + '(*)'{-a,--list-all}'[list all tasks]' + '(*)'{-i,--init}'[create new Taskfile.yml]' + '(- *)'{-h,--help}'[show help]' + '(- *)--version[show version and exit]' + ) + + # Experimental operations (dynamically added based on enabled experiments) + if __task_is_experiment_enabled "REMOTE_TASKFILES"; then + standard_args+=( + '(--offline --clear-cache)--download[download remote Taskfile]' + ) + operation_args+=( + '(* --download)--clear-cache[clear remote Taskfile cache]' + ) + fi + + _arguments -S $standard_args $operation_args } -compdef _task "$TASK_CMD" +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_task" ]; then + _task "$@" +fi From 20b1153d0c5ced4f62a5d90d91af778181f0f29f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 17:37:40 +0200 Subject: [PATCH 09/45] feat(completion): add --new-completion to opt into the new engine Embed the completion/next/ wrappers and expose them through a new `task --new-completion ` flag, alongside the unchanged `task --completion `. This lets users try the unified __complete engine (same suggestions across bash/zsh/fish/powershell: task names, aliases, flags, flag values and requires vars) while it is opt-in; it will become the default of --completion in a future release. --- CHANGELOG.md | 13 ++++++++----- cmd/task/task.go | 9 +++++++++ completion.go | 41 +++++++++++++++++++++++++++++++++++++---- internal/flags/flags.go | 2 ++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c182c5a8..818d8c0b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,14 @@ reports exit code `124`. Callers that join a `run: once` or `when_changed` task already running now honor their own `timeout`, and inherit that task's failure instead of being told it succeeded (#1569, #2898 by @vmaerten). +- Added a new completion engine that unifies Bash, Fish, Zsh and PowerShell + behind a single `task __complete` command, so every shell offers the same + suggestions: task names, aliases, flags, flag values and per-task CLI + variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now + backed by the `--no-aliases` and `--no-descriptions` completion flags. It is + opt-in for now via `task --new-completion `, leaving `--completion` + unchanged, and will become the default in a future release (#2897 by + @vmaerten). ## v3.52.0 - 2026-07-02 @@ -97,11 +105,6 @@ - Fixed malformed `includes:` entries (missing `taskfile`/`dir`) reporting a misleading "include cycle detected" error instead of a clear configuration error (#1881, #2892 by @Lewin671). -- 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. 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/task.go b/cmd/task/task.go index f35cf5361d..2332845199 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -133,6 +133,15 @@ func run() error { return nil } + if flags.NewCompletion != "" { + script, err := task.CompletionNext(flags.NewCompletion) + if err != nil { + return err + } + fmt.Println(script) + return nil + } + e := task.NewExecutor( flags.WithFlags(), task.WithVersionCheck(true), diff --git a/completion.go b/completion.go index ab333b7ad4..8f91166a5b 100644 --- a/completion.go +++ b/completion.go @@ -20,9 +20,25 @@ var completionPowershell string //go:embed completion/zsh/_task var completionZsh string -func Completion(completion string) (string, error) { - // Get the file extension for the selected shell - switch completion { +// The completion/next/* scripts are thin wrappers around the `task __complete` +// engine. They are served only via `--new-completion` for now (opt-in) and will +// replace the scripts above once the engine becomes the default. + +//go:embed completion/next/bash/task.bash +var completionBashNext string + +//go:embed completion/next/fish/task.fish +var completionFishNext string + +//go:embed completion/next/ps/task.ps1 +var completionPowershellNext string + +//go:embed completion/next/zsh/_task +var completionZshNext string + +// Completion returns the default (stable) completion script for the given shell. +func Completion(shell string) (string, error) { + switch shell { case "bash": return completionBash, nil case "fish": @@ -34,6 +50,23 @@ func Completion(completion string) (string, error) { case "zsh": return completionZsh, nil default: - return "", fmt.Errorf("unknown shell: %s", completion) + return "", fmt.Errorf("unknown shell: %s", shell) + } +} + +// CompletionNext returns the new `task __complete` engine wrapper for the given +// shell, exposed via `--new-completion` while the engine is opt-in. +func CompletionNext(shell string) (string, error) { + switch shell { + case "bash": + return completionBashNext, nil + case "fish": + return completionFishNext, nil + case "powershell": + return completionPowershellNext, nil + case "zsh": + return completionZshNext, nil + default: + return "", fmt.Errorf("unknown shell: %s", shell) } } diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 4ddf5b6ea4..36fcaff763 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -49,6 +49,7 @@ var ( Help bool Init bool Completion string + NewCompletion string List bool ListAll bool ListJson bool @@ -125,6 +126,7 @@ func init() { pflag.BoolVarP(&Help, "help", "h", false, "Shows Task usage.") pflag.BoolVarP(&Init, "init", "i", false, "Creates a new Taskfile.yml in the current folder.") pflag.StringVar(&Completion, "completion", "", "Generates shell completion script.") + pflag.StringVar(&NewCompletion, "new-completion", "", "Generates the new (experimental) shell completion script, powered by the `task __complete` engine.") pflag.BoolVarP(&List, "list", "l", false, "Lists tasks with description of current Taskfile.") pflag.BoolVarP(&ListAll, "list-all", "a", false, "Lists tasks with or without a description.") pflag.BoolVarP(&ListJson, "json", "j", false, "Formats task list as JSON.") From eb9784c60cf541e18ac5dba60c9fadb7c38fa2e0 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 17:42:33 +0200 Subject: [PATCH 10/45] docs(completion): document the opt-in --new-completion engine Add a subsection to the installation guide explaining how to try the new completion engine by swapping --completion for --new-completion, and noting it will become the default in a future release. --- website/src/docs/installation.md | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/website/src/docs/installation.md b/website/src/docs/installation.md index 3d3f660c54..0cb6aef8dd 100644 --- a/website/src/docs/installation.md +++ b/website/src/docs/installation.md @@ -486,3 +486,41 @@ requires to be static. Three consequences are worth knowing: use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") * alias go-task = task ``` + +### Trying the new completion engine (experimental) + +Task is migrating to a new completion engine, where every shell shares a single +source of truth: the `task __complete` command. This gives Bash, Zsh, Fish and +PowerShell the exact same suggestions (task names, aliases, flags, flag values +and `requires` vars, including their enums). It is currently **opt-in** and will +become the default of `--completion` in a future release. + +To try it, swap `--completion` for `--new-completion` in any of the snippets +above, for example: + +::: code-group + +```shell [bash] +# ~/.bashrc +eval "$(task --new-completion bash)" +``` + +```shell [zsh] +# ~/.zshrc +eval "$(task --new-completion zsh)" +``` + +```shell [fish] +# ~/.config/fish/config.fish +task --new-completion fish | source +``` + +```powershell [powershell] +# $PROFILE\Microsoft.PowerShell_profile.ps1 +Invoke-Expression (&task --new-completion powershell | Out-String) +``` + +::: + +The `verbose` and `show-aliases` zstyles documented above work with the new Zsh +completion too. From 449e8248c2a2fbf07900e5c4a0a12fa70c097beb Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:08:42 +0200 Subject: [PATCH 11/45] fix(completion): keep the directory prefix in PowerShell path completion PowerShell replaces the whole token being completed with the CompletionResult text, so returning `$_.Name` (the basename) for file/dir suggestions dropped any directory the user had already typed (e.g. `task --dir sub/` turned `sub/` into `foo`). Prepend the typed path prefix to each candidate and make the default file fallback honor the current word instead of always listing the working directory. Covered by a nested-path smoke assertion. --- completion/next/ps/task.ps1 | 16 ++++++++++++---- completion/tests/run.sh | 2 ++ completion/tests/wrapper.ps1 | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 index 7e18991896..6a93089c8c 100644 --- a/completion/next/ps/task.ps1 +++ b/completion/next/ps/task.ps1 @@ -43,6 +43,11 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $FilterFileExt = 8 $FilterDirs = 16 + # PowerShell replaces the whole token with the completion text, so any + # directory the user already typed (e.g. `sub/`) must be prepended to the + # basename returned by Get-ChildItem, otherwise the prefix is dropped. + $pathPrefix = $wordToComplete -replace '[^\\/]*$', '' + # 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. @@ -56,14 +61,14 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | ForEach-Object { $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } - [CompletionResult]::new($_.Name, $_.Name, $type, $_.Name) + [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, $type, $_.Name) } } # FilterDirs if ($directive -band $FilterDirs) { return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } + ForEach-Object { [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } } # Build candidates, filtering by the current word. PowerShell does not filter @@ -80,8 +85,11 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # 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 $NoFileComp)) { - return Get-ChildItem -Path . -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } + return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | + ForEach-Object { + $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } + [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, $type, $_.Name) + } } return $results diff --git a/completion/tests/run.sh b/completion/tests/run.sh index 038d7c286c..4f9fe154f5 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -43,6 +43,8 @@ tasks: YML touch "$fixture/extra.yaml" "$fixture/notes.txt" mkdir -p "$fixture/sub" "$fixture/other" +# A file inside sub/ so nested-path completion (keeping the dir prefix) is tested. +touch "$fixture/sub/nested.yml" export TASK_FIXTURE="$fixture" # In strict mode (set TASK_COMPLETION_STRICT=1, used in CI) a missing shell is diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index 1e7537aac1..6c213d2a43 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -47,6 +47,9 @@ Write-Output "powershell: :8 (FilterFileExt) filters by extension" Has "matching file" 'task --taskfile ' 'Taskfile.yml' HasNot "non-matching file" 'task --taskfile ' 'notes.txt' +Write-Output "powershell: nested path completion keeps the directory prefix" +Has "prefix kept" 'task --taskfile sub/' 'sub/nested.yml' + if ($fails -ne 0) { Write-Output "powershell: $fails failure(s)" exit 1 From 4d7767523f92c8981c7939f8a858d812ee8898ae Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:08:48 +0200 Subject: [PATCH 12/45] perf(completion): skip building the task list when completing the first word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task-var detection evaluated taskNames(e) — a full walk of every task and alias — unconditionally, even though detectTaskName bails out when there is no prior word. Guarding on len(args) > 1 avoids that walk (and a second one via GetTaskList) for the common `task ` case, which matters on large monorepo Taskfiles where completion latency is user-visible. --- internal/complete/engine.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 3df509212a..a39674f0bc 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -37,8 +37,10 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) return listFlags(fs), DirectiveNoFileComp } - // Only a task context needs the task list, so it is loaded lazily here. - if e != nil && e.Taskfile != nil { + // A task-var context needs the task list to spot the task word under the + // cursor, but that only exists once a prior word is present. Guard on + // len(args) > 1 so `task ` / `task buil` never pay to build it. + if e != nil && e.Taskfile != nil && len(args) > 1 { if taskName := detectTaskName(args, taskNames(e), fs); taskName != "" { return completeTaskVars(e, taskName) } From 6457585a2c6b2237885389a42c9179f58a054aef Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:41:54 +0200 Subject: [PATCH 13/45] fix(completion): complete shell values for --new-completion The --new-completion flag takes a shell name (bash/zsh/fish/powershell) just like --completion, but it was missing from flagEnums, so `task --new-completion ` offered nothing. Add the matching entry. --- internal/complete/flags.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/complete/flags.go b/internal/complete/flags.go index 742ccf6623..e8a895d9b3 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -10,9 +10,10 @@ import ( // flagEnums lists allowed values for enum-style flags. Keep in sync with the // help strings in internal/flags/flags.go. var flagEnums = map[string][]string{ - "output": {"interleaved", "group", "prefixed"}, - "sort": {"default", "alphanumeric", "none"}, - "completion": {"bash", "zsh", "fish", "powershell"}, + "output": {"interleaved", "group", "prefixed"}, + "sort": {"default", "alphanumeric", "none"}, + "completion": {"bash", "zsh", "fish", "powershell"}, + "new-completion": {"bash", "zsh", "fish", "powershell"}, } // flagDirective maps value-taking flags to a file-completion directive. From 8e12d5659f0999f93dfbad5eeba526abdb43db58 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:41:54 +0200 Subject: [PATCH 14/45] perf(completion): reuse a package-level output sanitizer sanitize built a new strings.Replacer on every call (twice per suggestion). Hoist it to a package var. --- internal/complete/output.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/complete/output.go b/internal/complete/output.go index 59e07cf5c4..158a539c6b 100644 --- a/internal/complete/output.go +++ b/internal/complete/output.go @@ -22,7 +22,10 @@ func Write(w io.Writer, suggs []Suggestion, dir Directive) { fmt.Fprintf(w, ":%d\n", dir) } +// completionSanitizer collapses the bytes that would corrupt the line-based +// protocol (a value's tab/newline would be read as a field/record separator). +var completionSanitizer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + func sanitize(s string) string { - r := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") - return r.Replace(s) + return completionSanitizer.Replace(s) } From 2ed7918ec733a12cca4fedcbb8ccb512e27a32f4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:42:04 +0200 Subject: [PATCH 15/45] fix(completion): support inline --flag=path completion across shells Inline path flags (`--dir=foo`, `--taskfile=foo`, `--cacert=foo`) were broken: the shells ran file/dir completion against the whole `--flag=value` token, so nothing matched. Each wrapper now strips the `--flag=` prefix before completing and re-applies it to the results (zsh via `compset -P '*='`, the others by hand). Also fixes two smaller wrapper issues in the process: PowerShell prefix matching is now case-insensitive, and the fish wrapper erases inherited completion rules before registering (fish accumulates them, unlike bash/zsh/PowerShell). Adds inline smoke assertions for bash, fish and PowerShell. --- completion/next/bash/task.bash | 25 +++++++++++++++++++++---- completion/next/fish/task.fish | 29 ++++++++++++++++++++++++----- completion/next/ps/task.ps1 | 23 +++++++++++++++-------- completion/next/zsh/_task | 6 ++++++ completion/tests/wrapper.bash | 8 +++++++- completion/tests/wrapper.fish | 4 ++++ completion/tests/wrapper.ps1 | 4 ++++ 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/completion/next/bash/task.bash b/completion/next/bash/task.bash index 4e7438f7fc..44e8d37777 100644 --- a/completion/next/bash/task.bash +++ b/completion/next/bash/task.bash @@ -5,6 +5,23 @@ TASK_CMD="${TASK_EXE:-task}" +# Wraps _filedir so an inline `--flag=` prefix is stripped before completion and +# re-applied to the results. `=` is kept inside the current word (see the +# `_init_completion -n =:` below), so the whole `--flag=value` token would +# otherwise be treated as the path and never match. +_task_filedir() { + local fpfx="" savecur="$cur" + if [[ "$cur" == -*=* ]]; then + fpfx="${cur%%=*}=" + cur="${cur#*=}" + fi + _filedir ${1:+"$1"} + cur="$savecur" + if [[ -n "$fpfx" ]]; then + COMPREPLY=( ${COMPREPLY[@]+"${COMPREPLY[@]/#/$fpfx}"} ) + fi +} + _task() { local cur prev words cword @@ -26,7 +43,7 @@ _task() { local output output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) if [[ -z "$output" ]]; then - _filedir + _task_filedir return fi @@ -47,12 +64,12 @@ _task() { for line in ${lines[@]+"${lines[@]}"}; do exts+="${exts:+|}$line" done - _filedir "@($exts)" + _task_filedir "@($exts)" return fi if (( directive & FILTER_DIRS )); then - _filedir -d + _task_filedir -d return fi @@ -74,7 +91,7 @@ _task() { __ltrim_colon_completions "$cur" if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then - _filedir + _task_filedir fi } diff --git a/completion/next/fish/task.fish b/completion/next/fish/task.fish index 76d503c030..d323b4ad98 100644 --- a/completion/next/fish/task.fish +++ b/completion/next/fish/task.fish @@ -48,18 +48,28 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME # native file fallback. Every file-completion directive must therefore be # served here, otherwise nothing is offered (e.g. `--cacert`, after `--`). + # For an inline `--flag=path`, complete against the path part but keep the + # `--flag=` prefix on every candidate (fish replaces the whole token). flagpfx + # is empty for the normal case, so the prefixing below is a no-op then. + set -l flagpfx "" + set -l pathcur $current + if string match -qr '^--?[^=]+=' -- $current + set flagpfx (string replace -r '=.*$' '=' -- $current) + set pathcur (string replace -r '^--?[^=]+=' '' -- $current) + end + # __fish_complete_suffix only *prioritizes* the extension rather than # filtering, so filter the file list ourselves (keeping dirs to descend into). if __task_test_bit $directive $__task_directive_filter_file_ext - for entry in (__fish_complete_path $current) + for entry in (__fish_complete_path $pathcur) set -l name (string split -f1 \t -- $entry) if string match -qr '/$' -- $name - printf '%s\n' $entry + printf '%s%s\n' $flagpfx $entry continue end for ext in $data if string match -qr "\.$ext\$" -- $name - printf '%s\n' $entry + printf '%s%s\n' $flagpfx $entry break end end @@ -68,7 +78,9 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME end if __task_test_bit $directive $__task_directive_filter_dirs - __fish_complete_directories $current + for entry in (__fish_complete_directories $pathcur) + printf '%s%s\n' $flagpfx $entry + end return end @@ -81,10 +93,17 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME # NoFileComp unset → also offer files, since `--no-files` suppressed the # native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`). if not __task_test_bit $directive $__task_directive_no_file_comp - __fish_complete_path $current + for entry in (__fish_complete_path $pathcur) + printf '%s%s\n' $flagpfx $entry + end end end +# Erase any inherited rules first: fish accumulates `complete` entries (unlike +# bash/zsh/PowerShell which replace), so a previously loaded completion would +# otherwise keep contributing alongside the engine. +complete -c $GO_TASK_PROGNAME -e + # 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. diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 index 6a93089c8c..a8a5226bc6 100644 --- a/completion/next/ps/task.ps1 +++ b/completion/next/ps/task.ps1 @@ -43,10 +43,17 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $FilterFileExt = 8 $FilterDirs = 16 - # PowerShell replaces the whole token with the completion text, so any - # directory the user already typed (e.g. `sub/`) must be prepended to the - # basename returned by Get-ChildItem, otherwise the prefix is dropped. - $pathPrefix = $wordToComplete -replace '[^\\/]*$', '' + # PowerShell replaces the whole token with the completion text, so both an + # inline `--flag=` and any directory the user already typed (e.g. `sub/`) + # must be preserved. Query the filesystem with the path portion only + # ($pathArg), but prepend the flag + directory prefix to every candidate. + $flagPrefix = '' + $pathArg = $wordToComplete + if ($wordToComplete -match '^(--?[^=]+=)(.*)$') { + $flagPrefix = $Matches[1] + $pathArg = $Matches[2] + } + $pathPrefix = $flagPrefix + ($pathArg -replace '[^\\/]*$', '') # Note: DirectiveNoSpace (bit 2) cannot be honored here — PowerShell's # CompletionResult API has no per-item "no trailing space" option, so a @@ -57,7 +64,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # `-Recurse`, so filter with Where-Object instead. if ($directive -band $FilterFileExt) { $exts = $data | ForEach-Object { ".$_" } - return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | + return Get-ChildItem -Path "$pathArg*" -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | ForEach-Object { $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } @@ -67,7 +74,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # FilterDirs if ($directive -band $FilterDirs) { - return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue | + return Get-ChildItem -Path "$pathArg*" -Directory -ErrorAction SilentlyContinue | ForEach-Object { [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } } @@ -77,7 +84,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $results = @($data | ForEach-Object { $parts = $_ -split "`t", 2 $value = $parts[0] - if ($wordToComplete -and -not $value.StartsWith($wordToComplete)) { return } + if ($wordToComplete -and -not $value.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) { return } $desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value } [CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc) }) @@ -85,7 +92,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # 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 $NoFileComp)) { - return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | + return Get-ChildItem -Path "$pathArg*" -ErrorAction SilentlyContinue | ForEach-Object { $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, $type, $_.Name) diff --git a/completion/next/zsh/_task b/completion/next/zsh/_task index 130493ffc8..a4d5d92e99 100755 --- a/completion/next/zsh/_task +++ b/completion/next/zsh/_task @@ -38,11 +38,16 @@ _task() { for line in "${lines[@]}"; do globs+=("*.${line}") done + # Strip an inline `--flag=` into IPREFIX so file completion runs on the + # value; zsh re-inserts the prefix. Only in the file branches — doing it + # globally would break `_describe` matching for inline enums. + compset -P '*=' _files -g "(${(j:|:)globs})" return fi if (( directive & FILTER_DIRS )); then + compset -P '*=' _path_files -/ return fi @@ -68,6 +73,7 @@ _task() { fi (( directive & NO_FILE_COMP )) && return + compset -P '*=' _files } diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash index 6f31a04ee9..1d6599a18e 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -19,7 +19,9 @@ _init_completion() { prev="${TEST_WORDS[$((TEST_CWORD - 1))]}" return 0 } -_filedir() { CAP+="filedir:$*"$'\n'; } +# Records the extension arg and the value of $cur it was called with, so tests +# can assert the inline `--flag=` prefix was stripped before file completion. +_filedir() { CAP+="filedir:$* cur=$cur"$'\n'; } compopt() { CAP+="compopt:$*"$'\n'; } __ltrim_colon_completions() { :; } @@ -70,6 +72,10 @@ echo "bash: :0 (Default) falls back to files" run task build -- '' cap_has "filedir default" "filedir:" +echo "bash: inline --flag= strips the prefix before file completion" +run task --taskfile=sub/x +cap_has "inline cur stripped" "cur=sub/x" + if ((fails)); then echo "bash: $fails failure(s)" exit 1 diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish index 044b704002..373406f81c 100755 --- a/completion/tests/wrapper.fish +++ b/completion/tests/wrapper.fish @@ -45,6 +45,10 @@ hasnot "non-matching file" 'task --taskfile ' notes.txt echo "fish: :0 (Default) falls back to files" has "file offered" 'task build -- ' notes.txt +echo "fish: inline --flag=path keeps the --flag= prefix" +has "inline nested" 'task --taskfile=sub/' --taskfile=sub/nested.yml +hasnot "inline non-matching" 'task --taskfile=' --taskfile=notes.txt + if test $fails -ne 0 echo "fish: $fails failure(s)" exit 1 diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index 6c213d2a43..46b2d4b544 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -50,6 +50,10 @@ HasNot "non-matching file" 'task --taskfile ' 'notes.txt' Write-Output "powershell: nested path completion keeps the directory prefix" Has "prefix kept" 'task --taskfile sub/' 'sub/nested.yml' +Write-Output "powershell: inline --flag=path keeps the --flag= prefix" +Has "inline nested" 'task --taskfile=sub/' '--taskfile=sub/nested.yml' +HasNot "inline non-matching" 'task --taskfile=' '--taskfile=notes.txt' + if ($fails -ne 0) { Write-Output "powershell: $fails failure(s)" exit 1 From 10136df2c42c449e5d9492430dd641ecc99de4d2 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:42:04 +0200 Subject: [PATCH 16/45] chore(editors): drop the unused requires field from --json task output The requires field added to the --list --json output is unused by the new completion engine (which reads requires via FastCompiledTask), untested, and did not handle enum.ref. Revert it to keep the JSON API change out of this branch. --- internal/editors/output.go | 38 +++++++------------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/internal/editors/output.go b/internal/editors/output.go index 9d8639ee68..eff0a0cb3e 100644 --- a/internal/editors/output.go +++ b/internal/editors/output.go @@ -13,18 +13,13 @@ type ( } // Task describes a single task Task struct { - Name string `json:"name"` - Task string `json:"task"` - Desc string `json:"desc"` - Summary string `json:"summary"` - Aliases []string `json:"aliases"` - UpToDate *bool `json:"up_to_date,omitempty"` - Location *Location `json:"location"` - Requires []RequiredVar `json:"requires,omitempty"` - } - RequiredVar struct { - Name string `json:"name"` - Enum []string `json:"enum,omitempty"` + Name string `json:"name"` + Task string `json:"task"` + Desc string `json:"desc"` + Summary string `json:"summary"` + Aliases []string `json:"aliases"` + UpToDate *bool `json:"up_to_date,omitempty"` + Location *Location `json:"location"` } // Location describes a task's location in a taskfile Location struct { @@ -50,26 +45,7 @@ func NewTask(task *ast.Task) Task { Column: task.Location.Column, Taskfile: task.Location.Taskfile, }, - Requires: newRequiredVars(task.Requires), - } -} - -func newRequiredVars(requires *ast.Requires) []RequiredVar { - if requires == nil || len(requires.Vars) == 0 { - return nil - } - out := make([]RequiredVar, 0, len(requires.Vars)) - for _, v := range requires.Vars { - if v == nil { - continue - } - rv := RequiredVar{Name: v.Name} - if v.Enum != nil && len(v.Enum.Value) > 0 { - rv.Enum = append([]string{}, v.Enum.Value...) - } - out = append(out, rv) } - return out } func (parent *Namespace) AddNamespace(namespacePath []string, task Task) { From 8505530f2e29d35121253d2444d22de179ff0c7e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 19:43:33 +0200 Subject: [PATCH 17/45] chore(completion): satisfy golangci-lint (slices.Contains, CommandContext) Use slices.Contains for the after-dash scan (modernize), and exec.CommandContext in the protocol test (noctx), with a targeted gosec exception for launching the test-built binary with test-controlled args. --- completion/protocol_test.go | 6 ++++-- internal/complete/context.go | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/completion/protocol_test.go b/completion/protocol_test.go index c14c3ebf18..54a35f6459 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -5,6 +5,7 @@ package completion_test import ( + "context" "fmt" "os" "os/exec" @@ -31,7 +32,7 @@ func TestMain(m *testing.M) { if runtime.GOOS == "windows" { taskBin += ".exe" } - if out, err := exec.Command("go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil { + if out, err := exec.CommandContext(context.Background(), "go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out) os.RemoveAll(dir) os.Exit(1) @@ -66,7 +67,8 @@ func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) - cmd := exec.Command(taskBin, append([]string{complete.CommandName}, args...)...) + // taskBin is the test-built binary and args are test-controlled literals. + cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec cmd.Dir = dir out, err := cmd.Output() require.NoError(t, err) diff --git a/internal/complete/context.go b/internal/complete/context.go index d71c7026a8..06c6ba7832 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -1,6 +1,7 @@ package complete import ( + "slices" "strings" "github.com/spf13/pflag" @@ -26,11 +27,8 @@ func parseContext(args []string) completionContext { ctx.prev = args[len(args)-2] } - for _, w := range args[:len(args)-1] { - if w == "--" { - ctx.afterDash = true - return ctx - } + if slices.Contains(args[:len(args)-1], "--") { + ctx.afterDash = true } return ctx From f37f0f1833be413da2b8ef4e88e013d75ff73f05 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 20:08:48 +0200 Subject: [PATCH 18/45] chore(completion): fix a wrong Options doc comment and drop a redundant label The Options doc claimed the zero value shows everything, but it shows neither aliases nor descriptions (DefaultOptions enables both). Also remove the redundant `# FilterDirs` label in the PowerShell wrapper. --- completion/next/ps/task.ps1 | 1 - internal/complete/complete.go | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 index a8a5226bc6..ca30d30dd0 100644 --- a/completion/next/ps/task.ps1 +++ b/completion/next/ps/task.ps1 @@ -72,7 +72,6 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { } } - # FilterDirs if ($directive -band $FilterDirs) { return Get-ChildItem -Path "$pathArg*" -Directory -ErrorAction SilentlyContinue | ForEach-Object { [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } diff --git a/internal/complete/complete.go b/internal/complete/complete.go index 86f0ca747c..2cbc9c99c9 100644 --- a/internal/complete/complete.go +++ b/internal/complete/complete.go @@ -46,8 +46,9 @@ type Suggestion struct { 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. +// Options tunes what the engine emits. Its zero value shows neither aliases nor +// descriptions; DefaultOptions returns the standard set (both shown), which +// ParseOptions then flips off per the __complete control flags. type Options struct { ShowAliases bool ShowDescriptions bool From ebe91bc058f5fd98836f02975ed97c6bcc01524f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 10 Aug 2026 17:26:29 +0200 Subject: [PATCH 19/45] feat(completion): add a Nushell wrapper for the completion engine Nushell exposes a single global external completer rather than a per-command registration, so the wrapper chains to whatever completer is already installed and delegates command lines that do not start with Task. Two directives are free here and one is unavailable: Nushell never appends a space after an external completion (NoSpace) and never re-sorts the results (KeepOrder), while `{completions, options}` records are rejected for an external completer, so prefix filtering is done in the wrapper as PowerShell does. `flagEnums["completion"]` gains `nu` too, which assumes the standalone Nushell script lands on main first. --- .github/workflows/ci.yml | 7 ++ CHANGELOG.md | 6 +- Taskfile.yml | 2 +- completion.go | 5 ++ completion/next/nu/task-completions.nu | 113 +++++++++++++++++++++++++ completion/tests/run.sh | 7 ++ completion/tests/wrapper.nu | 97 +++++++++++++++++++++ internal/complete/flags.go | 4 +- website/src/docs/installation.md | 33 +++++++- 9 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 completion/next/nu/task-completions.nu create mode 100644 completion/tests/wrapper.nu diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70c271c868..fec424786c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,13 @@ jobs: if: runner.os == 'macOS' run: brew install fish + # Nushell ships in no runner image and is not packaged by apt, so it comes + # from its own release archives. + - name: ⬇️ Install Nushell + uses: hustcer/setup-nu@f3fd65374ffc4d60974c0dd2f7263c6c5c285f81 # v3.26 + with: + version: "*" + - name: 🧪 Test completion # Strict mode fails the run if any shell is missing, so we never get a # false pass when a runner image stops shipping one (e.g. pwsh). diff --git a/CHANGELOG.md b/CHANGELOG.md index 818d8c0b6c..6fc7364458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,9 +56,9 @@ reports exit code `124`. Callers that join a `run: once` or `when_changed` task already running now honor their own `timeout`, and inherit that task's failure instead of being told it succeeded (#1569, #2898 by @vmaerten). -- Added a new completion engine that unifies Bash, Fish, Zsh and PowerShell - behind a single `task __complete` command, so every shell offers the same - suggestions: task names, aliases, flags, flag values and per-task CLI +- Added a new completion engine that unifies Bash, Fish, Zsh, Nushell and + PowerShell behind a single `task __complete` command, so every shell offers + the same suggestions: task names, aliases, flags, flag values and per-task CLI variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now backed by the `--no-aliases` and `--no-descriptions` completion flags. It is opt-in for now via `task --new-completion `, leaving `--completion` diff --git a/Taskfile.yml b/Taskfile.yml index 93976d5626..1b90492e5a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -159,7 +159,7 @@ tasks: - go test -bench=. -benchmem -tags=fsbench -run=^$ ./... test:completion: - desc: Tests the shell completion engine and wrappers (bash, zsh, fish, powershell) + desc: Tests the shell completion engine and wrappers (bash, zsh, fish, nu, powershell) sources: - internal/complete/**/*.go - cmd/task/**/*.go diff --git a/completion.go b/completion.go index 8f91166a5b..ce39697012 100644 --- a/completion.go +++ b/completion.go @@ -30,6 +30,9 @@ var completionBashNext string //go:embed completion/next/fish/task.fish var completionFishNext string +//go:embed completion/next/nu/task-completions.nu +var completionNuNext string + //go:embed completion/next/ps/task.ps1 var completionPowershellNext string @@ -62,6 +65,8 @@ func CompletionNext(shell string) (string, error) { return completionBashNext, nil case "fish": return completionFishNext, nil + case "nu", "nushell": + return completionNuNext, nil case "powershell": return completionPowershellNext, nil case "zsh": diff --git a/completion/next/nu/task-completions.nu b/completion/next/nu/task-completions.nu new file mode 100644 index 0000000000..c78cdc72eb --- /dev/null +++ b/completion/next/nu/task-completions.nu @@ -0,0 +1,113 @@ +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. +# +# Nushell has a single, global external completer instead of a per-command +# registration, so this script chains to the one already installed rather than +# replacing it: command lines that do not start with Task are handed back. + +# Completes a Task command line. `spans` is the tokenised command line Nushell +# hands to an external completer: the command name, then every argument up to +# the cursor — the last one empty when the cursor sits on a fresh word. +# +# Returns a list of `{value, description}` records, or null to let Nushell run +# its own file completion (the engine's DirectiveDefault). +# +# Only a list or null may be returned here: the `{completions, options}` record +# documented for `def` completers is rejected for an external completer and +# yields no suggestion at all. +def task-external-completer [spans: list] { + let exe = ($env.TASK_EXE? | default "task") + + # Words after the program name. The trailing empty word must be preserved: + # the engine relies on it to know the cursor sits on a fresh word. + let words = ($spans | skip 1) + let args = (if ($words | is-empty) { [""] } else { $words }) + let current = ($args | last) + + # `complete` captures the exit code and keeps stderr off the prompt. A missing + # binary raises, hence the `try`. + let result = (try { do { ^$exe "__complete" ...$args } | complete } catch { null }) + if ($result | is-empty) or $result.exit_code != 0 { + return null + } + + let lines = ($result.stdout | lines) + let last = ($lines | last) + # Protocol violation: offer nothing rather than garbage. + if ($last | is-empty) or (not ($last | str starts-with ":")) { + return null + } + let directive = (try { $last | str substring 1.. | into int } catch { 0 }) + let data = ($lines | drop 1) + + # Completion directives, mirroring internal/complete/complete.go. + # DirectiveNoSpace (2) needs no handling: Nushell never appends a space after + # an external completion. DirectiveKeepOrder (32) needs none either: results + # are offered in the order they are returned, unsorted. + let no_file_comp = (($directive | bits and 4) != 0) + let filter_file_ext = (($directive | bits and 8) != 0) + let filter_dirs = (($directive | bits and 16) != 0) + + # Nushell replaces the whole token being completed, so an inline `--flag=` + # prefix must be re-applied to every path candidate. The directory already + # typed is kept by `ls`, which returns paths as matched (`sub/nested.yml`). + let inline = ($current | parse --regex '^(?--?[^=]+=)(?.*)$') + let flag_prefix = (if ($inline | is-empty) { "" } else { $inline.0.flag }) + let path_arg = (if ($inline | is-empty) { $current } else { $inline.0.path }) + + if $filter_file_ext or $filter_dirs { + # A string variable is a literal path for `ls`; `into glob` turns it into a + # pattern. A pattern matching nothing raises, hence the `try`. + let entries = (try { ls ($"($path_arg)*" | into glob) } catch { [] }) + # FilterFileExt keeps directories too, so the user can descend into them. + let matched = (if $filter_file_ext { + $entries | where {|entry| $entry.type == "dir" or ($entry.name | path parse | get extension) in $data } + } else { + $entries | where type == "dir" + }) + return ($matched | each {|entry| + # Directories get a trailing separator: without it a second would + # match the directory again instead of descending into it. + let name = (if $entry.type == "dir" { $"($entry.name)(char path_sep)" } else { $entry.name }) + { value: $"($flag_prefix)($name)" } + }) + } + + # Nushell does not filter the results of an external completer, so match the + # current word here — case-insensitively, like Nushell's own default. + let candidates = ($data + | each {|line| + let parts = ($line | split row --number 2 "\t") + let value = ($parts | first) + if ($parts | length) > 1 { { value: $value, description: ($parts | last) } } else { { value: $value } } + } + | where {|candidate| $candidate.value | str starts-with --ignore-case $current }) + + # NoFileComp unset and nothing to offer → null hands the word back to + # Nushell's file completion (DirectiveDefault: `--cacert`, after `--`, …). + if ($candidates | is-empty) and (not $no_file_comp) { + return null + } + + $candidates +} + +# Chain to the completer already installed, if any: Nushell shares a single +# external completer between every command, so replacing it outright would break +# the completions of every other tool. Autoload files are loaded after config.nu, +# so a completer configured there is picked up here. +let task_previous_completer = ($env.config.completions.external.completer? | default null) + +$env.config.completions.external.completer = {|spans| + let exe = ($env.TASK_EXE? | default "task") + # Compare basenames so `./task`, `/usr/local/bin/task` and `task.exe` all match. + let head = ($spans | first | path basename | str replace --regex '(?i)\.exe$' '') + let name = ($exe | path basename | str replace --regex '(?i)\.exe$' '') + if $head == $name { + task-external-completer $spans + } else if $task_previous_completer != null { + do $task_previous_completer $spans + } else { + null + } +} diff --git a/completion/tests/run.sh b/completion/tests/run.sh index 4f9fe154f5..fb1aa877ec 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -85,6 +85,13 @@ else skip "fish wrapper" fi +if command -v nu >/dev/null 2>&1; then + # --no-config-file: the user's own external completer must not interfere. + run "nu wrapper" nu --no-config-file "$here/wrapper.nu" +else + skip "nu wrapper" +fi + pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true) if [[ -n "$pwsh_bin" ]]; then run "powershell wrapper" "$pwsh_bin" -NoProfile -File "$here/wrapper.ps1" diff --git a/completion/tests/wrapper.nu b/completion/tests/wrapper.nu new file mode 100644 index 0000000000..4c030c1337 --- /dev/null +++ b/completion/tests/wrapper.nu @@ -0,0 +1,97 @@ +#!/usr/bin/env nu +# Smoke-tests how the Nushell wrapper routes each directive (plus its own prefix +# filtering), by driving the external completer it installs. Nushell only runs +# external completers in the interactive REPL, so the closure is called directly. +# Suggestion logic lives in the Go tests. Set up by run.sh: $env.TASK_FIXTURE, +# and `task` on PATH = the binary under test. + +# `source` needs a parse-time constant path. +const TASK_NU = (path self "../next/nu/task-completions.nu") + +# A completer installed before the wrapper is sourced, so the delegation path +# can be asserted. +$env.config.completions.external.completer = {|spans| [{ value: $"prev:($spans | first)" }] } + +source $TASK_NU + +cd $env.TASK_FIXTURE + +let completer = $env.config.completions.external.completer + +def cands [spans: list] { + let out = (do $completer $spans) + if $out == null { [] } else { $out | get value } +} + +def has [label: string, spans: list, value: string] { + let values = (cands $spans) + if $value in $values { + print $" ok ($label)" + 0 + } else { + print $" FAIL ($label) — '($value)' missing from: ($values | str join ' ')" + 1 + } +} + +def hasnot [label: string, spans: list, value: string] { + if $value in (cands $spans) { + print $" FAIL ($label) — '($value)' should be absent" + 1 + } else { + print $" ok ($label)" + 0 + } +} + +def check [label: string, ok: bool] { + if $ok { + print $" ok ($label)" + 0 + } else { + print $" FAIL ($label)" + 1 + } +} + +mut fails = 0 + +print "nu: :4 (NoFileComp) forwards candidates, offers no files" +$fails += (has "candidate forwarded" [task ""] "build") +$fails += (hasnot "no file fallback" [task ""] "notes.txt") + +print "nu: filters candidates by the current word" +$fails += (has "prefix keeps match" [task b] "build") +$fails += (hasnot "prefix drops others" [task b] "deploy") + +print "nu: :16 (FilterDirs) offers directories only" +$fails += (has "dir offered" [task --dir ""] $"sub(char path_sep)") +$fails += (hasnot "no plain file" [task --dir ""] "notes.txt") + +print "nu: :8 (FilterFileExt) filters by extension" +$fails += (has "matching file" [task --taskfile ""] "Taskfile.yml") +$fails += (hasnot "non-matching file" [task --taskfile ""] "notes.txt") + +print "nu: nested path completion keeps the directory prefix" +$fails += (has "prefix kept" [task --taskfile $"sub(char path_sep)"] $"sub(char path_sep)nested.yml") + +print "nu: inline --flag=path keeps the --flag= prefix" +$fails += (has "inline nested" [task $"--taskfile=sub(char path_sep)"] $"--taskfile=sub(char path_sep)nested.yml") +$fails += (hasnot "inline non-matching" [task "--taskfile="] "--taskfile=notes.txt") + +print "nu: :2|:32 (NoSpace|KeepOrder) keep the order the engine emitted" +let vars = (cands [task deploy ""]) +$fails += (has "required var offered" [task deploy ""] "ENV=dev") +$fails += (check "declaration order kept" (($vars | enumerate | where item == "ENV=dev" | get 0.index) < ($vars | enumerate | where item == "REGION=" | get 0.index))) + +print "nu: :0 (Default) returns null so Nushell completes files itself" +$fails += (check "null returned" ((do $completer [task build "--" ""]) == null)) + +print "nu: other commands go to the previously installed completer" +$fails += (has "delegated" [git status ""] "prev:git") + +if $fails != 0 { + print $"nu: ($fails) failure\(s\)" + exit 1 +} +print "nu: all passed" diff --git a/internal/complete/flags.go b/internal/complete/flags.go index e8a895d9b3..fac17ac730 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -12,8 +12,8 @@ import ( var flagEnums = map[string][]string{ "output": {"interleaved", "group", "prefixed"}, "sort": {"default", "alphanumeric", "none"}, - "completion": {"bash", "zsh", "fish", "powershell"}, - "new-completion": {"bash", "zsh", "fish", "powershell"}, + "completion": {"bash", "zsh", "fish", "powershell", "nu"}, + "new-completion": {"bash", "zsh", "fish", "powershell", "nu"}, } // flagDirective maps value-taking flags to a file-completion directive. diff --git a/website/src/docs/installation.md b/website/src/docs/installation.md index 0cb6aef8dd..39ec242216 100644 --- a/website/src/docs/installation.md +++ b/website/src/docs/installation.md @@ -490,10 +490,10 @@ alias go-task = task ### Trying the new completion engine (experimental) Task is migrating to a new completion engine, where every shell shares a single -source of truth: the `task __complete` command. This gives Bash, Zsh, Fish and -PowerShell the exact same suggestions (task names, aliases, flags, flag values -and `requires` vars, including their enums). It is currently **opt-in** and will -become the default of `--completion` in a future release. +source of truth: the `task __complete` command. This gives Bash, Zsh, Fish, +Nushell and PowerShell the exact same suggestions (task names, aliases, flags, +flag values and `requires` vars, including their enums). It is currently +**opt-in** and will become the default of `--completion` in a future release. To try it, swap `--completion` for `--new-completion` in any of the snippets above, for example: @@ -520,7 +520,32 @@ task --new-completion fish | source Invoke-Expression (&task --new-completion powershell | Out-String) ``` +```nu [nushell] +# ~/.config/nushell/config.nu +mkdir ($nu.data-dir | path join "vendor/autoload") +task --new-completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") +``` + ::: The `verbose` and `show-aliases` zstyles documented above work with the new Zsh completion too. + +Nushell shares a single external completer between every command, so the script +chains to the one already configured — carapace and friends keep working. Load +it from an autoload directory as shown above rather than from `config.nu`, so +that your own completer is the one being chained to. If you would rather wire it +yourself, the script also exposes a `task-external-completer` command: + +```nu +$env.config.completions.external.completer = {|spans| + match ($spans | first) { + task => (task-external-completer $spans) + _ => (do $my_other_completer $spans) + } +} +``` + +Two engine directives behave differently under Nushell by design: it never +appends a space after an external completion (so `NoSpace` is a no-op) and never +re-sorts the results (so `KeepOrder` is always honoured). From ab01cf5063f003860f05ded791c8dae7ee5536a0 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:25:18 +0200 Subject: [PATCH 20/45] refactor(complete): make the Options zero value the default set Naming the fields after the --no-aliases / --no-descriptions flags removes DefaultOptions and the trap of a zero value that means the opposite of the default. --- internal/complete/complete.go | 22 ++++++--------- internal/complete/complete_test.go | 44 +++++++++++++++--------------- internal/complete/engine.go | 4 +-- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/internal/complete/complete.go b/internal/complete/complete.go index 2cbc9c99c9..ead5389e47 100644 --- a/internal/complete/complete.go +++ b/internal/complete/complete.go @@ -46,18 +46,12 @@ type Suggestion struct { Description string } -// Options tunes what the engine emits. Its zero value shows neither aliases nor -// descriptions; DefaultOptions returns the standard set (both shown), which -// ParseOptions then flips off per the __complete control flags. +// Options tunes what the engine emits. The fields are named after the +// __complete control flags so the zero value is the standard set: aliases and +// descriptions shown. 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} + NoAliases bool + NoDescriptions bool } // Completion-control flags. Shell wrappers prepend these to the __complete @@ -74,13 +68,13 @@ const ( // 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() + var opts Options for len(args) > 0 { switch args[0] { case FlagNoAliases: - opts.ShowAliases = false + opts.NoAliases = true case FlagNoDescriptions: - opts.ShowDescriptions = false + opts.NoDescriptions = true default: return opts, args } diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index 746600f2d0..0a8a5300e9 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{""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{}) require.ElementsMatch(t, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, @@ -106,7 +106,7 @@ func TestComplete_AliasResolvesToTaskVars(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}, complete.Options{}) require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) } @@ -115,7 +115,7 @@ func TestComplete_StaticEnum(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""}, complete.Options{}) require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) @@ -125,7 +125,7 @@ func TestComplete_EnumRef(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""}, complete.DefaultOptions()) + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""}, complete.Options{}) 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", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}, complete.Options{}) 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", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}, complete.Options{}) 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", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""}, complete.Options{}) require.Empty(t, suggs) require.Equal(t, complete.DirectiveNoFileComp, dir) } @@ -163,7 +163,7 @@ func TestComplete_FlagValueInlineEquals(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, complete.Options{}) // 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)) @@ -174,7 +174,7 @@ func TestComplete_AfterDash(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""}, complete.Options{}) require.Empty(t, suggs) require.Equal(t, complete.DirectiveDefault, dir) } @@ -183,7 +183,7 @@ func TestComplete_FlagNames(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"}, complete.Options{}) require.NotEmpty(t, suggs) require.Equal(t, complete.DirectiveNoFileComp, dir) @@ -197,7 +197,7 @@ func TestComplete_EnumFlagValue_Output(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output", ""}, complete.Options{}) require.Equal(t, []string{"interleaved", "group", "prefixed"}, values(suggs)) require.Equal(t, complete.DirectiveNoFileComp, dir) } @@ -206,7 +206,7 @@ func TestComplete_EnumFlagValue_Sort(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""}, complete.DefaultOptions()) + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""}, complete.Options{}) require.Equal(t, []string{"default", "alphanumeric", "none"}, values(suggs)) } @@ -214,7 +214,7 @@ func TestComplete_PathFlag_Taskfile(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""}, complete.Options{}) require.Equal(t, []string{"yml", "yaml"}, values(suggs)) require.Equal(t, complete.DirectiveFilterFileExt, dir) } @@ -223,7 +223,7 @@ func TestComplete_PathFlag_Dir(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""}, complete.Options{}) require.Empty(t, suggs) require.Equal(t, complete.DirectiveFilterDirs, dir) } @@ -232,7 +232,7 @@ func TestComplete_PathFlag_Cacert(t *testing.T) { t.Parallel() e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""}, complete.DefaultOptions()) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""}, complete.Options{}) require.Empty(t, suggs) require.Equal(t, complete.DirectiveDefault, dir) } @@ -240,7 +240,7 @@ func TestComplete_PathFlag_Cacert(t *testing.T) { func TestComplete_NilExecutor(t *testing.T) { t.Parallel() - suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"}, complete.DefaultOptions()) + suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"}, complete.Options{}) require.NotEmpty(t, suggs) require.Equal(t, complete.DirectiveNoFileComp, dir) } @@ -249,7 +249,7 @@ func TestComplete_NoAliases(t *testing.T) { t.Parallel() e := setupExecutor(t) - opts := complete.Options{ShowAliases: false, ShowDescriptions: true} + opts := complete.Options{NoAliases: true} suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, opts) require.ElementsMatch(t, @@ -265,7 +265,7 @@ func TestComplete_NoDescriptions(t *testing.T) { t.Parallel() e := setupExecutor(t) - opts := complete.Options{ShowAliases: true, ShowDescriptions: false} + opts := complete.Options{NoDescriptions: true} suggs, _ := complete.Complete(e, newTestFlagSet(), []string{""}, opts) require.ElementsMatch(t, @@ -283,15 +283,15 @@ func TestParseOptions(t *testing.T) { t.Run("defaults", func(t *testing.T) { t.Parallel() opts, rest := complete.ParseOptions([]string{"deploy", ""}) - require.Equal(t, complete.DefaultOptions(), opts) + require.Equal(t, complete.Options{}, 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.True(t, opts.NoAliases) + require.True(t, opts.NoDescriptions) require.Equal(t, []string{"deploy", ""}, rest) }) @@ -299,7 +299,7 @@ func TestParseOptions(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.False(t, opts.NoAliases) require.Equal(t, []string{"deploy", "--no-aliases"}, rest) }) } diff --git a/internal/complete/engine.go b/internal/complete/engine.go index a39674f0bc..16367e2ffa 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -91,7 +91,7 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { return nil } desc := func(t *ast.Task) string { - if !opts.ShowDescriptions { + if opts.NoDescriptions { return "" } return t.Desc @@ -102,7 +102,7 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { Value: strings.TrimSuffix(t.Task, ":"), Description: desc(t), }) - if !opts.ShowAliases { + if opts.NoAliases { continue } for _, alias := range t.Aliases { From 738e80e24b9061d70982400769ccbe752b4b6a58 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:25:49 +0200 Subject: [PATCH 21/45] refactor(complete): strip the trailing colon in one helper The four call sites also used TrimSuffix, which drops a single colon where --list uses TrimRight. --- internal/complete/engine.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 16367e2ffa..8778451657 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -74,9 +74,9 @@ func taskNames(e *task.Executor) []string { if t.Internal { continue } - out = append(out, strings.TrimSuffix(t.Task, ":")) + out = append(out, suggestedName(t.Task)) for _, alias := range t.Aliases { - out = append(out, strings.TrimSuffix(alias, ":")) + out = append(out, suggestedName(alias)) } } return out @@ -99,7 +99,7 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { out := make([]Suggestion, 0, len(tasks)) for _, t := range tasks { out = append(out, Suggestion{ - Value: strings.TrimSuffix(t.Task, ":"), + Value: suggestedName(t.Task), Description: desc(t), }) if opts.NoAliases { @@ -107,7 +107,7 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { } for _, alias := range t.Aliases { out = append(out, Suggestion{ - Value: strings.TrimSuffix(alias, ":"), + Value: suggestedName(alias), Description: desc(t), }) } @@ -115,6 +115,10 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { return out } +func suggestedName(name string) string { + return strings.TrimRight(name, ":") +} + // 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` From 65e31998792e9f2beb758a7b2ea1b5edb618d388 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:26:10 +0200 Subject: [PATCH 22/45] perf(complete): only compile tasks when a description is templated GetTaskList compiles every task (vars, labels, prompts) on every keystroke, while the suggestions read Task, Aliases and Desc. Walking the Taskfile directly and falling back to a full compilation only for templated descriptions cuts most of the per-TAB work, and a single broken task no longer empties the list. --- internal/complete/engine.go | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 8778451657..31835e8624 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/pflag" "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile/ast" ) @@ -86,10 +87,7 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { if e == nil || e.Taskfile == nil { return nil } - tasks, err := e.GetTaskList(task.FilterOutInternal) - if err != nil { - return nil - } + tasks := listTasks(e, opts) desc := func(t *ast.Task) string { if opts.NoDescriptions { return "" @@ -115,6 +113,35 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { return out } +// listTasks returns the tasks to suggest. Descriptions are the only compiled +// field read, so GetTaskList — which compiles every task, on every keystroke — +// is only worth its cost when a description holds a template. +func listTasks(e *task.Executor, opts Options) []*ast.Task { + sorter := e.TaskSorter + if sorter == nil { + sorter = sort.AlphaNumericWithRootTasksFirst + } + + out := make([]*ast.Task, 0, e.Taskfile.Tasks.Len()) + templated := false + for t := range e.Taskfile.Tasks.Values(sorter) { + if t.Internal { + continue + } + templated = templated || strings.Contains(t.Desc, "{{") + out = append(out, t) + } + + if !opts.NoDescriptions && templated { + // On error, the uncompiled tasks keep a single broken task from emptying + // the whole suggestion list. + if compiled, err := e.GetTaskList(task.FilterOutInternal); err == nil { + return compiled + } + } + return out +} + func suggestedName(name string) string { return strings.TrimRight(name, ":") } From d74e4a45a960a3f28d53f078c74a0d2d89241ae1 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:26:32 +0200 Subject: [PATCH 23/45] refactor(complete): reuse the enum ref resolution of the root package The engine had its own copy, which only accepted a []any and so completed nothing for a ref resolving to the []string or []int that resolvedAsAnySlice already handles for the runtime. --- internal/complete/engine.go | 35 +++++++---------------------------- requires.go | 11 ++++++----- requires_internal_test.go | 8 ++++---- 3 files changed, 17 insertions(+), 37 deletions(-) diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 31835e8624..c93ad04456 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -7,7 +7,6 @@ import ( "github.com/go-task/task/v3" "github.com/go-task/task/v3/internal/sort" - "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile/ast" ) @@ -181,13 +180,12 @@ func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directiv return nil, DirectiveNoFileComp } - cache := &templater.Cache{Vars: compiled.Vars} out := make([]Suggestion, 0, 8) for _, v := range compiled.Requires.Vars { if v == nil || v.Name == "" { continue } - values := enumValues(v.Enum, cache) + values := enumValues(v, compiled.Vars) if len(values) == 0 { out = append(out, Suggestion{Value: v.Name + "="}) continue @@ -204,31 +202,12 @@ func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directiv return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder } -func enumValues(enum *ast.Enum, cache *templater.Cache) []string { - if enum == nil { +// enumValues returns the allowed values of a required var, resolving an +// `enum.ref` against vars. +func enumValues(v *ast.VarsWithValidation, vars *ast.Vars) []string { + resolved := task.ResolveEnumRef(v, vars) + if resolved.Enum == nil { return nil } - if len(enum.Value) > 0 { - return enum.Value - } - if enum.Ref == "" { - return nil - } - resolved := templater.ResolveRef(enum.Ref, cache) - if cache.Err() != nil { - return nil - } - arr, ok := resolved.([]any) - if !ok { - return nil - } - out := make([]string, 0, len(arr)) - for _, item := range arr { - s, ok := item.(string) - if !ok { - return nil - } - out = append(out, s) - } - return out + return resolved.Enum.Value } diff --git a/requires.go b/requires.go index e425f83ce3..7a5526abb0 100644 --- a/requires.go +++ b/requires.go @@ -46,7 +46,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { for _, v := range getMissingRequiredVars(compiledTask) { if !varsMap.Has(v.Name) { - varsMap.Set(v.Name, resolveEnumRefForPrompt(v, compiledTask.Vars)) + varsMap.Set(v.Name, ResolveEnumRef(v, compiledTask.Vars)) } } @@ -218,10 +218,11 @@ func getEnumValues(e *ast.Enum) []string { return e.Value } -// resolveEnumRefForPrompt returns a copy of v with its enum ref resolved into -// concrete values, so the interactive prompter can show a Select. Refs that -// depend on dynamic vars may not resolve here and fall back to free-form input. -func resolveEnumRefForPrompt(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation { +// ResolveEnumRef returns a copy of v with its enum ref resolved into concrete +// values, so a caller can offer them as a list. Refs that depend on dynamic +// vars may not resolve here: v is then returned with its enum values empty, +// which the interactive prompter treats as free-form input. +func ResolveEnumRef(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation { if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 { return v } diff --git a/requires_internal_test.go b/requires_internal_test.go index fcfd6a1af9..f90b129479 100644 --- a/requires_internal_test.go +++ b/requires_internal_test.go @@ -8,7 +8,7 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -func TestResolveEnumRefForPrompt(t *testing.T) { +func TestResolveEnumRef(t *testing.T) { t.Parallel() vars := ast.NewVars() @@ -19,7 +19,7 @@ func TestResolveEnumRefForPrompt(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".ALLOWED_ENVS"}} - resolved := resolveEnumRefForPrompt(v, vars) + resolved := ResolveEnumRef(v, vars) require.Equal(t, []string{"dev", "staging", "prod"}, getEnumValues(resolved.Enum)) require.Empty(t, v.Enum.Value, "input var must not be mutated") @@ -31,7 +31,7 @@ func TestResolveEnumRefForPrompt(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".NONEXISTENT"}} - require.Empty(t, getEnumValues(resolveEnumRefForPrompt(v, vars).Enum)) + require.Empty(t, getEnumValues(ResolveEnumRef(v, vars).Enum)) }) t.Run("passes through a static enum unchanged", func(t *testing.T) { @@ -39,6 +39,6 @@ func TestResolveEnumRefForPrompt(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Value: []string{"a", "b"}}} - require.Same(t, v, resolveEnumRefForPrompt(v, vars)) + require.Same(t, v, ResolveEnumRef(v, vars)) }) } From 6be2fada158669c10980220728839f5b870dc60d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:26:47 +0200 Subject: [PATCH 24/45] refactor(complete): classify the completion context in one place NeedsTaskfile repeated three of Complete's branch conditions, so a new context had to be added to both or the Taskfile would silently stop loading. --- internal/complete/context.go | 15 +++++++++++++++ internal/complete/engine.go | 17 +++-------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/complete/context.go b/internal/complete/context.go index 06c6ba7832..cebeac862a 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -34,6 +34,21 @@ func parseContext(args []string) completionContext { return ctx } +// flagValue returns the flag whose value the cursor is completing, as in +// `task --output `. +func (ctx completionContext) flagValue(fs *pflag.FlagSet) *pflag.Flag { + if f := matchFlagName(fs, ctx.prev); f != nil && flagTakesValue(f) { + return f + } + return nil +} + +// inTaskContext reports whether the cursor completes a task name or a task +// variable, rather than a flag, a flag value or a word after `--`. +func (ctx completionContext) inTaskContext(fs *pflag.FlagSet) bool { + return !ctx.afterDash && ctx.flagValue(fs) == nil && !strings.HasPrefix(ctx.toComplete, "-") +} + // 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 diff --git a/internal/complete/engine.go b/internal/complete/engine.go index c93ad04456..05acb054f3 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -19,10 +19,8 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) return nil, DirectiveDefault } - if ctx.prev != "" { - if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) { - return completeFlagValue(flag.Name, "") - } + if flag := ctx.flagValue(fs); flag != nil { + return completeFlagValue(flag.Name, "") } if strings.HasPrefix(ctx.toComplete, "-") { @@ -53,16 +51,7 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) // 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, "-") + return parseContext(args).inTaskContext(fs) } func taskNames(e *task.Executor) []string { From 092875a8b9a8b6e588ec327dd5b4bca1b91ebedc Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:26:58 +0200 Subject: [PATCH 25/45] chore(complete): drop the no-op flagDirective entries A DirectiveDefault entry is what an absent key already yields, so the three had to be kept in sync with the flag definitions for no effect. --- internal/complete/flags.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/complete/flags.go b/internal/complete/flags.go index fac17ac730..76e62e01b2 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -16,16 +16,12 @@ var flagEnums = map[string][]string{ "new-completion": {"bash", "zsh", "fish", "powershell", "nu"}, } -// 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. +// flagDirective maps value-taking flags to a file-completion directive. Any +// flag absent here falls back to the shell's default file completion. var flagDirective = map[string]Directive{ "taskfile": DirectiveFilterFileExt, "dir": DirectiveFilterDirs, "remote-cache-dir": DirectiveFilterDirs, - "cacert": DirectiveDefault, - "cert": DirectiveDefault, - "cert-key": DirectiveDefault, } var taskfileExtensions = []string{"yml", "yaml"} From f4a4adfa6ef0a5321e4ff46bcbec9364d8628f43 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:27:20 +0200 Subject: [PATCH 26/45] refactor: serve both completion generations from one shell list The five shells were spelled out in four places: two identical switches and the two completion flag enums. A shell can no longer be advertised by the completion of --completion while the lookup rejects it. --- completion.go | 56 ++++++++++++++++++++------------------ internal/complete/flags.go | 6 ++-- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/completion.go b/completion.go index ce39697012..2416c04a41 100644 --- a/completion.go +++ b/completion.go @@ -39,39 +39,43 @@ var completionPowershellNext string //go:embed completion/next/zsh/_task var completionZshNext string +// CompletionShells lists the shells `--completion` accepts, in the order they +// are offered as candidates. The maps below also accept `nushell` for `nu`. +var CompletionShells = []string{"bash", "zsh", "fish", "powershell", "nu"} + +var completionScripts = map[string]string{ + "bash": completionBash, + "fish": completionFish, + "nu": completionNu, + "nushell": completionNu, + "powershell": completionPowershell, + "zsh": completionZsh, +} + +var completionScriptsNext = map[string]string{ + "bash": completionBashNext, + "fish": completionFishNext, + "nu": completionNuNext, + "nushell": completionNuNext, + "powershell": completionPowershellNext, + "zsh": completionZshNext, +} + // Completion returns the default (stable) completion script for the given shell. func Completion(shell string) (string, error) { - switch shell { - case "bash": - return completionBash, nil - case "fish": - return completionFish, nil - case "nu", "nushell": - return completionNu, nil - case "powershell": - return completionPowershell, nil - case "zsh": - return completionZsh, nil - default: - return "", fmt.Errorf("unknown shell: %s", shell) - } + return completionScript(completionScripts, shell) } // CompletionNext returns the new `task __complete` engine wrapper for the given // shell, exposed via `--new-completion` while the engine is opt-in. func CompletionNext(shell string) (string, error) { - switch shell { - case "bash": - return completionBashNext, nil - case "fish": - return completionFishNext, nil - case "nu", "nushell": - return completionNuNext, nil - case "powershell": - return completionPowershellNext, nil - case "zsh": - return completionZshNext, nil - default: + return completionScript(completionScriptsNext, shell) +} + +func completionScript(scripts map[string]string, shell string) (string, error) { + script, ok := scripts[shell] + if !ok { return "", fmt.Errorf("unknown shell: %s", shell) } + return script, nil } diff --git a/internal/complete/flags.go b/internal/complete/flags.go index 76e62e01b2..a24ca3bedc 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -5,6 +5,8 @@ import ( "strings" "github.com/spf13/pflag" + + "github.com/go-task/task/v3" ) // flagEnums lists allowed values for enum-style flags. Keep in sync with the @@ -12,8 +14,8 @@ import ( var flagEnums = map[string][]string{ "output": {"interleaved", "group", "prefixed"}, "sort": {"default", "alphanumeric", "none"}, - "completion": {"bash", "zsh", "fish", "powershell", "nu"}, - "new-completion": {"bash", "zsh", "fish", "powershell", "nu"}, + "completion": task.CompletionShells, + "new-completion": task.CompletionShells, } // flagDirective maps value-taking flags to a file-completion directive. Any From 43c3f0e186903d5534f194206687b37c7ebab50e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:27:43 +0200 Subject: [PATCH 27/45] refactor(complete): lean on stdlib and slicesext helpers strings.Cut for the inline --flag=value split, slices.Contains instead of a set built to answer a few lookups, slices.SortFunc so the stdlib sort no longer shadows internal/sort, slicesext.Convert for the candidate mapping. --- internal/complete/context.go | 11 ++--------- internal/complete/engine.go | 20 +++++++++----------- internal/complete/flags.go | 4 ++-- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/internal/complete/context.go b/internal/complete/context.go index cebeac862a..56c8397dec 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -27,9 +27,7 @@ func parseContext(args []string) completionContext { ctx.prev = args[len(args)-2] } - if slices.Contains(args[:len(args)-1], "--") { - ctx.afterDash = true - } + ctx.afterDash = slices.Contains(args[:len(args)-1], "--") return ctx } @@ -58,11 +56,6 @@ func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) strin 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] { @@ -84,7 +77,7 @@ func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) strin if strings.Contains(w, "=") { continue } - if _, ok := known[w]; ok { + if slices.Contains(knownTasks, w) { taskName = w } } diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 05acb054f3..5e550091bf 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/pflag" "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/slicesext" "github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/taskfile/ast" ) @@ -24,8 +25,7 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) } if strings.HasPrefix(ctx.toComplete, "-") { - if eqIdx := strings.Index(ctx.toComplete, "="); eqIdx != -1 { - flagWord := ctx.toComplete[:eqIdx] + if flagWord, _, ok := strings.Cut(ctx.toComplete, "="); ok { if f := matchFlagName(fs, flagWord); f != nil && flagTakesValue(f) { // Return full `--flag=value` candidates: shells match/insert // against the whole current token, so bare values never match. @@ -143,20 +143,18 @@ func completeFlagValue(flagName, prefix string) ([]Suggestion, Directive) { // 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 + exts := slicesext.Convert(taskfileExtensions, func(ext string) Suggestion { + return Suggestion{Value: ext} + }) + return exts, 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: prefix + v}) - } + out := slicesext.Convert(values, func(v string) Suggestion { + return Suggestion{Value: prefix + v} + }) return out, DirectiveNoFileComp } diff --git a/internal/complete/flags.go b/internal/complete/flags.go index a24ca3bedc..fa8704f8ca 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -1,7 +1,7 @@ package complete import ( - "sort" + "slices" "strings" "github.com/spf13/pflag" @@ -55,7 +55,7 @@ func listFlags(fs *pflag.FlagSet) []Suggestion { }) } }) - sort.Slice(out, func(i, j int) bool { return out[i].Value < out[j].Value }) + slices.SortFunc(out, func(a, b Suggestion) int { return strings.Compare(a.Value, b.Value) }) return out } From 263f53a944dec14d6219293ef770b6b2825aa1b1 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:27:52 +0200 Subject: [PATCH 28/45] perf(complete): buffer the suggestion output One write syscall per suggestion, for a list emitted in full on every keystroke. --- cmd/task/complete_cmd.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index f0fa13ff4f..da6dde2439 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "io" "os" @@ -38,8 +39,11 @@ func runComplete(args []string) error { } suggs, dirv := complete.Complete(e, pflag.CommandLine, args, opts) - complete.Write(os.Stdout, suggs, dirv) - return nil + + // Buffered: the whole candidate list is written on every keystroke. + out := bufio.NewWriter(os.Stdout) + complete.Write(out, suggs, dirv) + return out.Flush() } func extractTaskfileFlags(args []string) (dir, entrypoint string, global bool) { From a16d4e30c488e860803ccbcc41931779b26aceff Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:28:11 +0200 Subject: [PATCH 29/45] refactor(complete): resolve the completion dir before building the executor The dir was set twice, once in the constructor and once over it. --- cmd/task/complete_cmd.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index da6dde2439..5dc008ef02 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -17,6 +17,11 @@ func runComplete(args []string) error { opts, args := complete.ParseOptions(args) dir, entrypoint, global := extractTaskfileFlags(args) + if global { + if home, err := os.UserHomeDir(); err == nil { + dir = home + } + } e := task.NewExecutor( task.WithDir(dir), @@ -25,11 +30,6 @@ func runComplete(args []string) error { task.WithStderr(io.Discard), task.WithVersionCheck(false), ) - if global { - if home, err := os.UserHomeDir(); err == nil { - e.Options(task.WithDir(home)) - } - } // Loading the Taskfile parses YAML (and may hit the network for remote // Taskfiles), so skip it entirely when completing flags or their values. From aefa8cc01c1b84e783b7823885288edb67f18831 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:28:12 +0200 Subject: [PATCH 30/45] chore(complete): drop the redundant flagset usage stub SetOutput(io.Discard) already silences the only path that reaches usage. --- cmd/task/complete_cmd.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 5dc008ef02..c239d9bdd7 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -50,7 +50,6 @@ func extractTaskfileFlags(args []string) (dir, entrypoint string, global bool) { fs := pflag.NewFlagSet("complete", pflag.ContinueOnError) fs.SetOutput(io.Discard) fs.ParseErrorsAllowlist.UnknownFlags = true - fs.Usage = func() {} fs.StringVarP(&dir, "dir", "d", "", "") fs.StringVarP(&entrypoint, "taskfile", "t", "", "") fs.BoolVarP(&global, "global", "g", false, "") From f07c8f79df2c66b6948dad5eef5c5790f73024fa Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:28:36 +0200 Subject: [PATCH 31/45] chore(completion): drop unreachable guards and unread constants from the wrappers The fish NoSpace and KeepOrder variables were never tested, the bash cword guard is covered by the empty-args fallback right below, and the PowerShell element-count and line-count checks cannot be false. --- completion/next/bash/task.bash | 5 +---- completion/next/fish/task.fish | 5 ++--- completion/next/ps/task.ps1 | 11 ++++------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/completion/next/bash/task.bash b/completion/next/bash/task.bash index 44e8d37777..f9b8cfaeaa 100644 --- a/completion/next/bash/task.bash +++ b/completion/next/bash/task.bash @@ -32,10 +32,7 @@ _task() { # `docs:serve` reach the engine as single tokens. _init_completion -n =: || return - local -a args=() - if (( cword > 0 )); then - args=( "${words[@]:1:cword}" ) - fi + local -a args=( "${words[@]:1:cword}" ) if (( ${#args[@]} == 0 )); then args=( "" ) fi diff --git a/completion/next/fish/task.fish b/completion/next/fish/task.fish index d323b4ad98..66f342190c 100644 --- a/completion/next/fish/task.fish +++ b/completion/next/fish/task.fish @@ -5,12 +5,11 @@ set -l GO_TASK_PROGNAME (if set -q GO_TASK_PROGNAME; echo $GO_TASK_PROGNAME; els # Completion directives, mirroring internal/complete/complete.go. fish's `math` # has no bitwise operators, so bits are stored as their power-of-two value and -# tested with integer division + modulo via __task_test_bit. -set -g __task_directive_no_space 2 +# tested with integer division + modulo via __task_test_bit. NoSpace (2) and +# KeepOrder (32) need no handling: fish appends no space and keeps the order. set -g __task_directive_no_file_comp 4 set -g __task_directive_filter_file_ext 8 set -g __task_directive_filter_dirs 16 -set -g __task_directive_keep_order 32 function __task_test_bit --argument-names value bit test (math "floor($value / $bit) % 2") -eq 1 diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 index ca30d30dd0..e9264c3279 100644 --- a/completion/next/ps/task.ps1 +++ b/completion/next/ps/task.ps1 @@ -13,12 +13,10 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # Words after the program name, truncated to the cursor. $argsToPass = @() $elements = $commandAst.CommandElements - if ($elements.Count -gt 1) { - for ($i = 1; $i -lt $elements.Count; $i++) { - $el = $elements[$i] - if ($el.Extent.StartOffset -ge $cursorPosition) { break } - $argsToPass += $el.ToString() - } + for ($i = 1; $i -lt $elements.Count; $i++) { + $el = $elements[$i] + if ($el.Extent.StartOffset -ge $cursorPosition) { break } + $argsToPass += $el.ToString() } # The trailing word (possibly empty) must reach the engine so it knows # the cursor sits on a fresh word. It is already present when it coincides @@ -31,7 +29,6 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { if (-not $output) { return } $lines = @($output) - if ($lines.Count -eq 0) { return } $last = $lines[-1] if (-not $last.StartsWith(':')) { return } From 936a266d62d7e87f239f960381d4ad56ced94246 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:29:06 +0200 Subject: [PATCH 32/45] refactor(completion): build the PowerShell path candidates in one place The Get-ChildItem to CompletionResult conversion was written three times, twice verbatim. --- completion/next/ps/task.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 index e9264c3279..47ff783929 100644 --- a/completion/next/ps/task.ps1 +++ b/completion/next/ps/task.ps1 @@ -56,6 +56,12 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # CompletionResult API has no per-item "no trailing space" option, so a # suggestion like `VAR=` gets a trailing space. This is a PowerShell limit. + $asPathResult = { + param($item) + $type = if ($item.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } + [CompletionResult]::new("$pathPrefix$($item.Name)", $item.Name, $type, $item.Name) + } + # FilterFileExt: keep files whose extension matches, plus directories so the # user can still descend into them. `-Include` is unreliable without # `-Recurse`, so filter with Where-Object instead. @@ -63,15 +69,12 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $exts = $data | ForEach-Object { ".$_" } return Get-ChildItem -Path "$pathArg*" -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | - ForEach-Object { - $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } - [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, $type, $_.Name) - } + ForEach-Object { & $asPathResult $_ } } if ($directive -band $FilterDirs) { return Get-ChildItem -Path "$pathArg*" -Directory -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } + ForEach-Object { & $asPathResult $_ } } # Build candidates, filtering by the current word. PowerShell does not filter @@ -89,10 +92,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # since the engine returned DirectiveDefault (e.g. --cacert, after `--`). if ($results.Count -eq 0 -and -not ($directive -band $NoFileComp)) { return Get-ChildItem -Path "$pathArg*" -ErrorAction SilentlyContinue | - ForEach-Object { - $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } - [CompletionResult]::new("$pathPrefix$($_.Name)", $_.Name, $type, $_.Name) - } + ForEach-Object { & $asPathResult $_ } } return $results From d3a8ece4baf3ff79f3263797afeb365e931d8deb Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 21:29:16 +0200 Subject: [PATCH 33/45] test(completion): dedupe the shell availability checks Four copies of the same command -v / run / skip block. --- completion/tests/run.sh | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/completion/tests/run.sh b/completion/tests/run.sh index fb1aa877ec..ab74cdfc5a 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -55,9 +55,12 @@ strict=${TASK_COMPLETION_STRICT:-} fails=0 run() { # LABEL COMMAND... echo "== $1 ==" - if "${@:2}"; then :; else fails=$((fails + 1)); fi + "${@:2}" || fails=$((fails + 1)) echo } +run_if() { # BIN LABEL COMMAND... + if command -v "$1" >/dev/null 2>&1; then run "${@:2}"; else skip "$2"; fi +} skip() { # LABEL if [[ -n "$strict" ]]; then echo "== $1 == (MISSING — required under TASK_COMPLETION_STRICT)" @@ -71,26 +74,11 @@ skip() { # LABEL # The engine/protocol itself is covered by the Go tests (completion/protocol_test.go # and internal/complete); these smokes only check how each shell wrapper # interprets the directive. -run "bash wrapper" bash "$here/wrapper.bash" - -if command -v zsh >/dev/null 2>&1; then - run "zsh wrapper" zsh "$here/wrapper.zsh" -else - skip "zsh wrapper" -fi - -if command -v fish >/dev/null 2>&1; then - run "fish wrapper" fish "$here/wrapper.fish" -else - skip "fish wrapper" -fi - -if command -v nu >/dev/null 2>&1; then - # --no-config-file: the user's own external completer must not interfere. - run "nu wrapper" nu --no-config-file "$here/wrapper.nu" -else - skip "nu wrapper" -fi +run "bash wrapper" bash "$here/wrapper.bash" +run_if zsh "zsh wrapper" zsh "$here/wrapper.zsh" +run_if fish "fish wrapper" fish "$here/wrapper.fish" +# --no-config-file: the user's own external completer must not interfere. +run_if nu "nu wrapper" nu --no-config-file "$here/wrapper.nu" pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true) if [[ -n "$pwsh_bin" ]]; then From f5c0f133529bc837146a650d3ed7a91b4ab1b104 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:17:06 +0200 Subject: [PATCH 34/45] fix(completion): pass quoted PowerShell arguments to the engine unquoted CommandElements were forwarded via ToString(), i.e. their source text, so `task --dir "/a b" ` reached the engine with the quotes, found no Taskfile and fell back to file completion. A string element now yields its Value, the current word is unquoted before use, and a candidate holding a space is quoted back so insertion does not split it into several arguments. --- completion/next/ps/task.ps1 | 43 ++++++++++++++++++++++++++++-------- completion/tests/run.sh | 10 +++++++++ completion/tests/wrapper.ps1 | 7 ++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 index 47ff783929..185f185744 100644 --- a/completion/next/ps/task.ps1 +++ b/completion/next/ps/task.ps1 @@ -1,4 +1,5 @@ using namespace System.Management.Automation +using namespace System.Management.Automation.Language # Thin wrapper around `task __complete`. All suggestion logic lives in the # Go engine — do not add completion logic here. @@ -10,19 +11,36 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' } - # Words after the program name, truncated to the cursor. + # The current word arrives with the quote the user opened; the engine wants + # the value, and so does every path we build from it below. + $current = $wordToComplete + if ($current.Length -ge 1 -and ($current[0] -eq '"' -or $current[0] -eq "'")) { + $quoteChar = $current[0] + $current = $current.Substring(1) + if ($current.EndsWith($quoteChar)) { + $current = $current.Substring(0, $current.Length - 1) + } + } + + # Words after the program name, truncated to the cursor. A string element + # yields its Value, not its source text, so `--dir "a b"` does not reach the + # engine with its quotes. $argsToPass = @() $elements = $commandAst.CommandElements for ($i = 1; $i -lt $elements.Count; $i++) { $el = $elements[$i] if ($el.Extent.StartOffset -ge $cursorPosition) { break } - $argsToPass += $el.ToString() + $argsToPass += if ($el -is [StringConstantExpressionAst] -or $el -is [ExpandableStringExpressionAst]) { + $el.Value + } else { + $el.ToString() + } } # The trailing word (possibly empty) must reach the engine so it knows # 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 + if ($argsToPass.Count -eq 0 -or $argsToPass[-1] -ne $current) { + $argsToPass += $current } $output = & $TaskExe __complete @argsToPass 2>$null @@ -45,8 +63,8 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # must be preserved. Query the filesystem with the path portion only # ($pathArg), but prepend the flag + directory prefix to every candidate. $flagPrefix = '' - $pathArg = $wordToComplete - if ($wordToComplete -match '^(--?[^=]+=)(.*)$') { + $pathArg = $current + if ($current -match '^(--?[^=]+=)(.*)$') { $flagPrefix = $Matches[1] $pathArg = $Matches[2] } @@ -56,10 +74,17 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # CompletionResult API has no per-item "no trailing space" option, so a # suggestion like `VAR=` gets a trailing space. This is a PowerShell limit. + # The completion text replaces the token as-is, so anything holding a space + # has to be quoted or it would come back as several arguments. + $asCompletionText = { + param($text) + if ($text -match '[\s'']') { "'" + $text.Replace("'", "''") + "'" } else { $text } + } + $asPathResult = { param($item) $type = if ($item.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } - [CompletionResult]::new("$pathPrefix$($item.Name)", $item.Name, $type, $item.Name) + [CompletionResult]::new((& $asCompletionText "$pathPrefix$($item.Name)"), $item.Name, $type, $item.Name) } # FilterFileExt: keep files whose extension matches, plus directories so the @@ -83,9 +108,9 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $results = @($data | ForEach-Object { $parts = $_ -split "`t", 2 $value = $parts[0] - if ($wordToComplete -and -not $value.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) { return } + if ($current -and -not $value.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase)) { return } $desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value } - [CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc) + [CompletionResult]::new((& $asCompletionText $value), $value, [CompletionResultType]::ParameterValue, $desc) }) # NoFileComp (bit 4) unset and nothing matched → fall back to file completion, diff --git a/completion/tests/run.sh b/completion/tests/run.sh index ab74cdfc5a..762d752920 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -45,6 +45,16 @@ touch "$fixture/extra.yaml" "$fixture/notes.txt" mkdir -p "$fixture/sub" "$fixture/other" # A file inside sub/ so nested-path completion (keeping the dir prefix) is tested. touch "$fixture/sub/nested.yml" +# A directory whose name holds a space, with its own Taskfile: shells must pass +# the quoted `--dir` value to the engine unquoted, and quote it back on insert. +mkdir -p "$fixture/with space" +cat > "$fixture/with space/Taskfile.yml" <<'YML' +version: '3' + +tasks: + spaced: + desc: Task from the spaced dir +YML export TASK_FIXTURE="$fixture" # In strict mode (set TASK_COMPLETION_STRICT=1, used in CI) a missing shell is diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index 46b2d4b544..8098cf7214 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -54,6 +54,13 @@ Write-Output "powershell: inline --flag=path keeps the --flag= prefix" Has "inline nested" 'task --taskfile=sub/' '--taskfile=sub/nested.yml' HasNot "inline non-matching" 'task --taskfile=' '--taskfile=notes.txt' +Write-Output "powershell: a quoted argument reaches the engine unquoted" +Has "single-quoted dir" "task --dir 'with space' " 'spaced' +Has "double-quoted dir" 'task --dir "with space" ' 'spaced' + +Write-Output "powershell: a candidate holding a space is quoted for insertion" +Has "dir quoted" 'task --dir w' "'with space'" + if ($fails -ne 0) { Write-Output "powershell: $fails failure(s)" exit 1 From 1a873666a316c5b6cda8b6f4f48186bf80c9ad49 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:22:34 +0200 Subject: [PATCH 35/45] fix(completion): honor every flag that decides how the Taskfile is loaded Completion read only --dir, --taskfile and --global, dropping --sort, --insecure, --trusted-hosts, the certificates, the timeout and the remote cache options: a Taskfile that loads fine on a real run could complete nothing. The flag package now parses the words being completed leniently into pflag.CommandLine, and the executor is built from flags.WithFlags(), so a new flag needs no change here. --- cmd/task/complete_cmd.go | 24 +++------------ completion/protocol_test.go | 59 +++++++++++++++++++++++++++++++++++++ internal/flags/flags.go | 12 ++++++-- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index c239d9bdd7..9232f5aa12 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -9,6 +9,7 @@ import ( "github.com/go-task/task/v3" "github.com/go-task/task/v3/internal/complete" + "github.com/go-task/task/v3/internal/flags" ) func runComplete(args []string) error { @@ -16,16 +17,10 @@ func runComplete(args []string) error { // user's command line to complete. opts, args := complete.ParseOptions(args) - dir, entrypoint, global := extractTaskfileFlags(args) - if global { - if home, err := os.UserHomeDir(); err == nil { - dir = home - } - } - + // WithFlags carries every flag that decides which Taskfile is loaded, as the + // flag package parsed them from the words being completed. e := task.NewExecutor( - task.WithDir(dir), - task.WithEntrypoint(entrypoint), + flags.WithFlags(), task.WithStdout(io.Discard), task.WithStderr(io.Discard), task.WithVersionCheck(false), @@ -45,14 +40,3 @@ func runComplete(args []string) error { complete.Write(out, suggs, dirv) return out.Flush() } - -func extractTaskfileFlags(args []string) (dir, entrypoint string, global bool) { - fs := pflag.NewFlagSet("complete", pflag.ContinueOnError) - fs.SetOutput(io.Discard) - fs.ParseErrorsAllowlist.UnknownFlags = true - fs.StringVarP(&dir, "dir", "d", "", "") - fs.StringVarP(&entrypoint, "taskfile", "t", "", "") - fs.BoolVarP(&global, "global", "g", false, "") - _ = fs.Parse(args) - return -} diff --git a/completion/protocol_test.go b/completion/protocol_test.go index 54a35f6459..249f3adf93 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -73,6 +73,14 @@ func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) { out, err := cmd.Output() require.NoError(t, err) + return parseProtocol(t, out) +} + +// parseProtocol splits the protocol output into candidate values and the +// trailing directive. +func parseProtocol(t *testing.T, out []byte) ([]string, complete.Directive) { + t.Helper() + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") require.NotEmpty(t, lines, "protocol output must end with a directive line") @@ -171,3 +179,54 @@ func TestProtocol(t *testing.T) { }) } } + +// TestProtocol_SortFlagIsApplied checks that the flags deciding how the Taskfile +// is read reach the engine, --sort being the one with a visible order. +func TestProtocol_SortFlagIsApplied(t *testing.T) { + t.Parallel() + + const taskfile = `version: '3' + +tasks: + zebra: + desc: Declared first, last alphabetically + alpha: + desc: Declared last, first alphabetically +` + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) + + sorted, _ := completeInDir(t, dir, nil, "") + require.Equal(t, []string{"alpha", "zebra"}, sorted) + + declared, _ := completeInDir(t, dir, nil, "--sort", "none", "") + require.Equal(t, []string{"zebra", "alpha"}, declared) +} + +// TestProtocol_ExperimentGatedFlag checks that a flag only registered under an +// experiment is parsed instead of breaking the whole command line. +func TestProtocol_ExperimentGatedFlag(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) + + values, directive := completeInDir(t, dir, []string{"TASK_X_REMOTE_TASKFILES=1"}, "--offline", "") + require.Equal(t, complete.DirectiveNoFileComp, directive) + require.Subset(t, values, []string{"build", "deploy"}) +} + +// completeInDir runs `task __complete ` in dir, with extra environment +// variables appended to the current environment. +func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) { + t.Helper() + + // taskBin is the test-built binary and args are test-controlled literals. + cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + out, err := cmd.Output() + require.NoError(t, err) + + return parseProtocol(t, out) +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 36fcaff763..13f28b536e 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -177,10 +177,16 @@ func init() { pflag.BoolVarP(&ForceAll, "force", "f", false, "Forces execution even when the task is up-to-date.") } - // In completion mode the user's `--flag` words must reach the engine - // untouched. The BoolVar/StringVar calls above already populated - // pflag.CommandLine, which is all the engine needs. + // In completion mode the words being completed are parsed leniently: they + // hold partially typed and unknown flags, yet the values of the flags that + // decide which Taskfile is loaded (--dir, --taskfile, the remote options, …) + // must reach the engine. ContinueOnError returns the error without printing + // anything, and flags parsed before it are kept. if complete.IsActive() { + _, words := complete.ParseOptions(os.Args[2:]) + pflag.CommandLine.Init(pflag.CommandLine.Name(), pflag.ContinueOnError) + pflag.CommandLine.ParseErrorsAllowlist.UnknownFlags = true + _ = pflag.CommandLine.Parse(words) return } From 04dc861553c120a66cd0b58b3310f74809354a17 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:22:53 +0200 Subject: [PATCH 36/45] fix(completion): never reach the network on a keystroke A remote Taskfile whose cache was missing or expired made TAB download it, which could freeze the shell for up to --timeout (10s by default), prompt for trust and write the cache. Completion now runs offline: the cache is served when present, and a missing one fails instantly. The empty stdin keeps a prompt from ever reading the terminal. --- cmd/task/complete_cmd.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 9232f5aa12..0c38d6ddd9 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -4,6 +4,7 @@ import ( "bufio" "io" "os" + "strings" "github.com/spf13/pflag" @@ -18,17 +19,23 @@ func runComplete(args []string) error { opts, args := complete.ParseOptions(args) // WithFlags carries every flag that decides which Taskfile is loaded, as the - // flag package parsed them from the words being completed. + // flag package parsed them from the words being completed. The overrides come + // after it: a keystroke must stay silent and must never hit the network, + // prompt for trust or write to the remote cache, whatever remote flags the + // user typed. e := task.NewExecutor( flags.WithFlags(), task.WithStdout(io.Discard), task.WithStderr(io.Discard), + task.WithStdin(strings.NewReader("")), task.WithVersionCheck(false), + task.WithOffline(true), + task.WithDownload(false), ) - // 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. + // Loading the Taskfile parses YAML, so skip it entirely when completing flags + // or their values. Best-effort: a missing or broken Taskfile must not break + // completion. if complete.NeedsTaskfile(args, pflag.CommandLine) { _ = e.Setup() } From b2ab9c707eb2c0d033bcd23f0ec7f7fc69710cec Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:23:13 +0200 Subject: [PATCH 37/45] fix(completion): skip a stdin entrypoint instead of hanging the shell `task -t - ` reached NewRootNode with a "-" entrypoint, whose StdinNode reads os.Stdin to completion: the shell stayed frozen until the completion was killed. The test drives it with an unwritten pipe. --- cmd/task/complete_cmd.go | 5 +++-- completion/protocol_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 0c38d6ddd9..1be8c39cf1 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -35,8 +35,9 @@ func runComplete(args []string) error { // Loading the Taskfile parses YAML, so skip it entirely when completing flags // or their values. Best-effort: a missing or broken Taskfile must not break - // completion. - if complete.NeedsTaskfile(args, pflag.CommandLine) { + // completion. A `-` entrypoint is skipped as well, since reading the Taskfile + // from standard input would hang the shell on a keystroke. + if complete.NeedsTaskfile(args, pflag.CommandLine) && flags.Entrypoint != "-" { _ = e.Setup() } diff --git a/completion/protocol_test.go b/completion/protocol_test.go index 249f3adf93..7e967ee39c 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -216,6 +217,32 @@ func TestProtocol_ExperimentGatedFlag(t *testing.T) { require.Subset(t, values, []string{"build", "deploy"}) } +// TestProtocol_StdinEntrypointDoesNotHang guards the keystroke path against +// `--taskfile -`, which would otherwise read the Taskfile from the terminal. +func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) { + t.Parallel() + + // An unwritten pipe: reading it would block until the context expires. + r, w, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { + r.Close() + w.Close() + }) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") + cmd.Dir = t.TempDir() + cmd.Stdin = r + out, err := cmd.Output() + require.NoError(t, err, "completion must not read the Taskfile from stdin") + + _, directive := parseProtocol(t, out) + require.Equal(t, complete.DirectiveNoFileComp, directive) +} + // completeInDir runs `task __complete ` in dir, with extra environment // variables appended to the current environment. func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) { From a24a0af7cc3044e2f08c15ed2edc8136406f4534 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:25:48 +0200 Subject: [PATCH 38/45] refactor: move ref resolution to internal/refs The enum ref resolution was exported from the root package only so the completion engine could reach it, which committed a library API to an internal wiring need. It now lives beside the matrix ref helper it shares, and asAnySlice joins slicesext. The moved test also covers a ref resolving to a []string, the case the engine's former copy rejected. --- internal/complete/engine.go | 3 +- internal/refs/refs.go | 72 ++++++++++++++++++ .../refs/refs_test.go | 24 ++++-- internal/slicesext/slicesext.go | 5 ++ requires.go | 18 +---- variables.go | 73 +++---------------- 6 files changed, 110 insertions(+), 85 deletions(-) create mode 100644 internal/refs/refs.go rename requires_internal_test.go => internal/refs/refs_test.go (55%) diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 5e550091bf..12593cea60 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/pflag" "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/refs" "github.com/go-task/task/v3/internal/slicesext" "github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/taskfile/ast" @@ -192,7 +193,7 @@ func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directiv // enumValues returns the allowed values of a required var, resolving an // `enum.ref` against vars. func enumValues(v *ast.VarsWithValidation, vars *ast.Vars) []string { - resolved := task.ResolveEnumRef(v, vars) + resolved := refs.ResolveEnum(v, vars) if resolved.Enum == nil { return nil } diff --git a/internal/refs/refs.go b/internal/refs/refs.go new file mode 100644 index 0000000000..a7898a82ae --- /dev/null +++ b/internal/refs/refs.go @@ -0,0 +1,72 @@ +// Package refs resolves the `ref` fields of a Taskfile into concrete values. +// A ref is a template expression evaluated against a var set, which callers +// then expect as a list: `for: matrix` rows and `requires` enums. +package refs + +import ( + "fmt" + + "github.com/go-task/task/v3/internal/slicesext" + "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile/ast" +) + +// AsList converts a resolved ref into a []any. A ref does not always resolve to +// a []any: lists declared in a Taskfile do, but template functions such as +// `keys` and `splitList` return a []string. The accepted types mirror the list +// types itemsFromFor supports. +func AsList(v any) ([]any, bool) { + switch value := v.(type) { + case []any: + return value, true + case []string: + return slicesext.AsAny(value), true + case []int: + return slicesext.AsAny(value), true + } + return nil, false +} + +// ResolveEnums fills in the values of every `enum.ref` in requires. +func ResolveEnums(requires *ast.Requires, cache *templater.Cache) error { + if requires == nil || len(requires.Vars) == 0 { + return nil + } + for _, v := range requires.Vars { + if v.Enum == nil || v.Enum.Ref == "" { + continue + } + resolved := templater.ResolveRef(v.Enum.Ref, cache) + if cache.Err() != nil { + return cache.Err() + } + arr, ok := AsList(resolved) + if !ok { + return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref) + } + strValues := make([]string, 0, len(arr)) + for _, item := range arr { + s, ok := item.(string) + if !ok { + return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref) + } + strValues = append(strValues, s) + } + v.Enum.Value = strValues + } + return nil +} + +// ResolveEnum returns a copy of v with its enum ref resolved into concrete +// values, so a caller can offer them as a list. Refs that depend on dynamic +// vars may not resolve here: v is then returned with its enum values empty, +// which the interactive prompter treats as free-form input. +func ResolveEnum(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation { + if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 { + return v + } + vCopy := v.DeepCopy() + cache := &templater.Cache{Vars: vars} + _ = ResolveEnums(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, cache) + return vCopy +} diff --git a/requires_internal_test.go b/internal/refs/refs_test.go similarity index 55% rename from requires_internal_test.go rename to internal/refs/refs_test.go index f90b129479..202a52f544 100644 --- a/requires_internal_test.go +++ b/internal/refs/refs_test.go @@ -1,14 +1,15 @@ -package task +package refs_test import ( "testing" "github.com/stretchr/testify/require" + "github.com/go-task/task/v3/internal/refs" "github.com/go-task/task/v3/taskfile/ast" ) -func TestResolveEnumRef(t *testing.T) { +func TestResolveEnum(t *testing.T) { t.Parallel() vars := ast.NewVars() @@ -19,9 +20,9 @@ func TestResolveEnumRef(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".ALLOWED_ENVS"}} - resolved := ResolveEnumRef(v, vars) + resolved := refs.ResolveEnum(v, vars) - require.Equal(t, []string{"dev", "staging", "prod"}, getEnumValues(resolved.Enum)) + require.Equal(t, []string{"dev", "staging", "prod"}, resolved.Enum.Value) require.Empty(t, v.Enum.Value, "input var must not be mutated") require.Equal(t, ".ALLOWED_ENVS", v.Enum.Ref) }) @@ -31,7 +32,7 @@ func TestResolveEnumRef(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".NONEXISTENT"}} - require.Empty(t, getEnumValues(ResolveEnumRef(v, vars).Enum)) + require.Empty(t, refs.ResolveEnum(v, vars).Enum.Value) }) t.Run("passes through a static enum unchanged", func(t *testing.T) { @@ -39,6 +40,17 @@ func TestResolveEnumRef(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Value: []string{"a", "b"}}} - require.Same(t, v, ResolveEnumRef(v, vars)) + require.Same(t, v, refs.ResolveEnum(v, vars)) + }) + + t.Run("accepts the list types template functions return", func(t *testing.T) { + t.Parallel() + + vars := ast.NewVars() + vars.Set("MAP", ast.Var{Value: map[string]any{"dev": 1, "prod": 2}}) + + v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: "keys .MAP | sortAlpha"}} + + require.Equal(t, []string{"dev", "prod"}, refs.ResolveEnum(v, vars).Enum.Value) }) } diff --git a/internal/slicesext/slicesext.go b/internal/slicesext/slicesext.go index 2aba5beb15..9376d19962 100644 --- a/internal/slicesext/slicesext.go +++ b/internal/slicesext/slicesext.go @@ -30,3 +30,8 @@ func Convert[T, U any](s []T, f func(T) U) []U { return result } + +// AsAny converts a typed slice into a []any. +func AsAny[T any](s []T) []any { + return Convert(s, func(v T) any { return v }) +} diff --git a/requires.go b/requires.go index 7a5526abb0..2903d25eef 100644 --- a/requires.go +++ b/requires.go @@ -7,7 +7,7 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/input" - "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/internal/refs" "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) @@ -46,7 +46,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { for _, v := range getMissingRequiredVars(compiledTask) { if !varsMap.Has(v.Name) { - varsMap.Set(v.Name, ResolveEnumRef(v, compiledTask.Vars)) + varsMap.Set(v.Name, refs.ResolveEnum(v, compiledTask.Vars)) } } @@ -217,17 +217,3 @@ func getEnumValues(e *ast.Enum) []string { } return e.Value } - -// ResolveEnumRef returns a copy of v with its enum ref resolved into concrete -// values, so a caller can offer them as a list. Refs that depend on dynamic -// vars may not resolve here: v is then returned with its enum values empty, -// which the interactive prompter treats as free-form input. -func ResolveEnumRef(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation { - if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 { - return v - } - vCopy := v.DeepCopy() - cache := &templater.Cache{Vars: vars} - _ = resolveEnumRefs(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, cache) - return vCopy -} diff --git a/variables.go b/variables.go index c2085bd1ea..2742b26312 100644 --- a/variables.go +++ b/variables.go @@ -15,6 +15,8 @@ import ( "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/fingerprint" + "github.com/go-task/task/v3/internal/refs" + "github.com/go-task/task/v3/internal/slicesext" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile/ast" ) @@ -118,7 +120,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err requires := origTask.Requires if evaluateShVars { requires = origTask.Requires.DeepCopy() - if err := resolveEnumRefs(requires, cache); err != nil { + if err := refs.ResolveEnums(requires, cache); err != nil { return nil, err } } @@ -347,30 +349,6 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err return &new, nil } -func asAnySlice[T any](slice []T) []any { - ret := make([]any, len(slice)) - for i, v := range slice { - ret[i] = v - } - return ret -} - -// resolvedAsAnySlice converts a value resolved from a reference into a []any. -// A reference does not always resolve to a []any: lists declared in a Taskfile -// do, but template functions such as `keys` and `splitList` return a []string. -// The accepted types mirror the list types itemsFromFor already supports. -func resolvedAsAnySlice(v any) ([]any, bool) { - switch value := v.(type) { - case []any: - return value, true - case []string: - return asAnySlice(value), true - case []int: - return asAnySlice(value), true - } - return nil, false -} - func itemsFromFor( f *ast.For, dir string, @@ -392,7 +370,7 @@ func itemsFromFor( Err: err, } } - return asAnySlice(product(resolvedMatrix)), nil, nil + return slicesext.AsAny(product(resolvedMatrix)), nil, nil } // Get the list from the explicit for list if len(f.List) > 0 { @@ -410,7 +388,7 @@ func itemsFromFor( return nil, nil, err } } - values = asAnySlice(glist) + values = slicesext.AsAny(glist) } // Get the list from the task generates if f.From == "generates" { @@ -424,7 +402,7 @@ func itemsFromFor( return nil, nil, err } } - values = asAnySlice(glist) + values = slicesext.AsAny(glist) } // Get the list from a variable and split it up if f.Var != "" { @@ -437,14 +415,14 @@ func itemsFromFor( switch value := v.Value.(type) { case string: if f.Split != "" { - values = asAnySlice(strings.Split(value, f.Split)) + values = slicesext.AsAny(strings.Split(value, f.Split)) } else { - values = asAnySlice(strings.Fields(value)) + values = slicesext.AsAny(strings.Fields(value)) } case []string: - values = asAnySlice(value) + values = slicesext.AsAny(value) case []int: - values = asAnySlice(value) + values = slicesext.AsAny(value) case []any: values = value case map[string]any: @@ -492,7 +470,7 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix, if cache.Err() != nil { return nil, cache.Err() } - value, ok := resolvedAsAnySlice(v) + value, ok := refs.AsList(v) if !ok { return nil, fmt.Errorf("matrix reference %q must resolve to a list", row.Ref) } @@ -502,35 +480,6 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix, return resolved, nil } -func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error { - if requires == nil || len(requires.Vars) == 0 { - return nil - } - for _, v := range requires.Vars { - if v.Enum == nil || v.Enum.Ref == "" { - continue - } - resolved := templater.ResolveRef(v.Enum.Ref, cache) - if cache.Err() != nil { - return cache.Err() - } - arr, ok := resolvedAsAnySlice(resolved) - if !ok { - return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref) - } - strValues := make([]string, 0, len(arr)) - for _, item := range arr { - s, ok := item.(string) - if !ok { - return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref) - } - strValues = append(strValues, s) - } - v.Enum.Value = strValues - } - return nil -} - // product generates the cartesian product of the input map of slices. func product(matrix *ast.Matrix) []map[string]any { if matrix.Len() == 0 { From 9536fd61d5eec15819348a75ba86441040235a44 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:26:47 +0200 Subject: [PATCH 39/45] refactor(complete): keep the shell list out of the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompletionShells was exported from the root package for the engine alone. The list is private to the engine again, and a test asserts every shell it offers is one the root package can serve — the drift the shared symbol was meant to prevent. --- completion.go | 5 +---- completion/protocol_test.go | 20 ++++++++++++++++++++ internal/complete/flags.go | 11 +++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/completion.go b/completion.go index 2416c04a41..b5e9d29460 100644 --- a/completion.go +++ b/completion.go @@ -39,10 +39,7 @@ var completionPowershellNext string //go:embed completion/next/zsh/_task var completionZshNext string -// CompletionShells lists the shells `--completion` accepts, in the order they -// are offered as candidates. The maps below also accept `nushell` for `nu`. -var CompletionShells = []string{"bash", "zsh", "fish", "powershell", "nu"} - +// The maps accept `nushell` as an alias of `nu`. var completionScripts = map[string]string{ "bash": completionBash, "fish": completionFish, diff --git a/completion/protocol_test.go b/completion/protocol_test.go index 7e967ee39c..850c216d2d 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/go-task/task/v3" "github.com/go-task/task/v3/internal/complete" ) @@ -257,3 +258,22 @@ func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]st return parseProtocol(t, out) } + +// TestCompletionShells keeps the shells the engine offers for --completion and +// --new-completion in step with the scripts the root package can actually serve. +func TestCompletionShells(t *testing.T) { + t.Parallel() + + for _, flag := range []string{"--completion", "--new-completion"} { + shells, directive := completeArgs(t, flag, "") + require.Equal(t, complete.DirectiveNoFileComp, directive) + require.NotEmpty(t, shells) + + for _, shell := range shells { + _, err := task.Completion(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + _, err = task.CompletionNext(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + } + } +} diff --git a/internal/complete/flags.go b/internal/complete/flags.go index fa8704f8ca..b9604aa370 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -5,17 +5,20 @@ import ( "strings" "github.com/spf13/pflag" - - "github.com/go-task/task/v3" ) +// completionShells are the values --completion accepts. The scripts themselves +// are served by the root package, which embeds them; TestCompletionShells keeps +// the two in step. +var completionShells = []string{"bash", "zsh", "fish", "powershell", "nu"} + // flagEnums lists allowed values for enum-style flags. Keep in sync with the // help strings in internal/flags/flags.go. var flagEnums = map[string][]string{ "output": {"interleaved", "group", "prefixed"}, "sort": {"default", "alphanumeric", "none"}, - "completion": task.CompletionShells, - "new-completion": task.CompletionShells, + "completion": completionShells, + "new-completion": completionShells, } // flagDirective maps value-taking flags to a file-completion directive. Any From 0fdd529f884b3aa85f509dcb08f85d3ca72fd617 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 22:27:03 +0200 Subject: [PATCH 40/45] docs(complete): say why DirectiveError is never emitted --- internal/complete/complete.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/complete/complete.go b/internal/complete/complete.go index ead5389e47..ddeac16f72 100644 --- a/internal/complete/complete.go +++ b/internal/complete/complete.go @@ -24,6 +24,8 @@ const ( // DirectiveDefault leaves the shell to perform its default file completion. DirectiveDefault Directive = 0 // DirectiveError signals an error; the shell should not offer completion. + // Reserved by the protocol: the engine never emits it, since a failure to + // load the Taskfile still leaves flags worth completing. 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). From 41eeb51b6f5cfcaeb6e27cda2964748d983c0bac Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 11 Aug 2026 23:16:14 +0200 Subject: [PATCH 41/45] fix(completion): pass zsh KeepOrder to _describe, not to compadd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-V` was appended to the options forwarded to compadd, where it takes the next argument as a group name: it swallowed the `-d` that _describe appends, and zsh offered _describe's own _tmpd and _tmpm variables as candidates. Completing a task with `requires: vars` — the only context emitting KeepOrder — was therefore unusable. The test covered that context already, but its _describe stub ignored where an option landed. It now mirrors the real signature and asserts each option in its own zone. --- completion/next/zsh/_task | 12 ++++++++---- completion/tests/wrapper.zsh | 25 ++++++++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/completion/next/zsh/_task b/completion/next/zsh/_task index a4d5d92e99..b3b26a0aca 100755 --- a/completion/next/zsh/_task +++ b/completion/next/zsh/_task @@ -6,7 +6,7 @@ TASK_CMD="${TASK_EXE:-task}" _task() { - local -a args lines completions opts ctl + local -a args lines completions describe_opts compadd_opts ctl local output directive line # Completion directives, mirroring internal/complete/complete.go. @@ -65,11 +65,15 @@ _task() { fi done - (( directive & NO_SPACE )) && opts+=(-S '') - (( directive & KEEP_ORDER )) && opts+=(-V) + # -S is a compadd option, passed after the array; -V is an option of + # _describe itself. In the compadd zone it would take the next argument as a + # group name, swallowing the `-d` _describe appends and offering its internal + # variables as candidates. + (( directive & NO_SPACE )) && compadd_opts+=(-S '') + (( directive & KEEP_ORDER )) && describe_opts+=(-V) if (( ${#completions} > 0 )); then - _describe -t tasks 'task' completions "${opts[@]}" + _describe "${describe_opts[@]}" -t tasks 'task' completions "${compadd_opts[@]}" fi (( directive & NO_FILE_COMP )) && return diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh index ddbfa11ae3..42a0766bf3 100755 --- a/completion/tests/wrapper.zsh +++ b/completion/tests/wrapper.zsh @@ -10,9 +10,21 @@ integer fails=0 local CAP compdef() { } # no-op: we call _task directly, not through compinit +# Mirrors the real signature — `_describe [-12JVoOx] [-t tag] descr array +# [compadd-opt ...]` — so that an option landing in the wrong zone is visible. +# zsh's own _describe forwards the trailing zone to compadd, where -J and -V +# require a group name and would swallow the next argument. _describe() { - local arr=$4 - CAP+="describe_opts:${@[5,-1]}"$'\n' + local -a flags + while [[ $1 == -* ]]; do + case $1 in + (-t) flags+=($1 $2); shift 2 ;; + (*) flags+=($1); shift ;; + esac + done + local arr=$2 # $1 is descr + CAP+="describe_flags:[${flags[*]}]"$'\n' + CAP+="compadd_opts:[${@[3,-1]}]"$'\n' local c; for c in ${(P)arr}; do CAP+="cand:$c"$'\n'; done } _files() { CAP+="files:$*"$'\n' } @@ -52,10 +64,13 @@ run task '' has "candidate forwarded" "cand:build" hasnot "no file fallback" "files:" -echo "zsh: :2|:32 (NoSpace|KeepOrder) map to -S and -V" +# -V belongs to _describe itself. In the compadd zone it would take the next +# argument as a group name, swallowing _describe's own `-d`, which offers its +# internal _tmpd/_tmpm variables as candidates. +echo "zsh: :2|:32 (NoSpace|KeepOrder) reach the right option zones" run task deploy '' -has "NoSpace -> -S" "describe_opts:-S" -has "KeepOrder -> -V" "-V" +has "KeepOrder -> _describe -V" "describe_flags:[-V" +has "NoSpace -> compadd -S" "compadd_opts:[-S ]" echo "zsh: :8 (FilterFileExt) routes to extension-filtered files" run task --taskfile '' From 94261a136c78c27748745f46c9d25a43de0af126 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 12 Aug 2026 20:33:11 +0200 Subject: [PATCH 42/45] fix(completion): suggest the prefix of wildcard task names A wildcard task name is a pattern, not a runnable name: inserting `release:*` on the command line only gets the shell to escape the glob, and running it leaves `.MATCH` empty. Suggest the literal prefix up to the first `*` instead, with NoSpace so the cursor stays against it. The directive is only raised when such a candidate is in the response, so Taskfiles without wildcard tasks keep the trailing space. --- completion/protocol_test.go | 14 +++++++ internal/complete/complete_test.go | 51 ++++++++++++++++++++++++- internal/complete/engine.go | 60 ++++++++++++++++++++++-------- 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/completion/protocol_test.go b/completion/protocol_test.go index 850c216d2d..e6a152a5a5 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -244,6 +244,20 @@ func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) { require.Equal(t, complete.DirectiveNoFileComp, directive) } +// TestProtocol_WildcardTaskNames checks that a pattern reaches the shell as the +// prefix to type onto, with the trailing space suppressed. +func TestProtocol_WildcardTaskNames(t *testing.T) { + t.Parallel() + + values, directive := completeInDir(t, filepath.Join("..", "testdata", "wildcards"), nil, "") + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, directive) + require.Subset(t, values, []string{"start-", "s-", "wildcard-", "matches-exactly-"}) + for _, v := range values { + require.NotEmpty(t, v) + require.NotContains(t, v, "*") + } +} + // completeInDir runs `task __complete ` in dir, with extra environment // variables appended to the current environment. func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) { diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index 0a8a5300e9..b3d9520dcc 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -73,10 +73,42 @@ tasks: - 'echo serving' ` +const wildcardTaskfile = `version: '3' + +tasks: + wildcard-*: + cmds: + - 'echo {{index .MATCH 0}}' + + wildcard-*-*: + cmds: + - 'echo {{index .MATCH 0}}' + + '*-wildcard-*': + cmds: + - 'echo {{index .MATCH 0}}' + + start-*: + desc: Start a service + aliases: [s-*] + cmds: + - 'echo {{index .MATCH 0}}' + + build: + desc: Build it + cmds: + - 'echo build' +` + func setupExecutor(t *testing.T) *task.Executor { + t.Helper() + return setupExecutorWith(t, testTaskfile) +} + +func setupExecutorWith(t *testing.T, taskfile string) *task.Executor { t.Helper() dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(testTaskfile), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) e := task.NewExecutor( task.WithDir(dir), @@ -102,6 +134,23 @@ func TestComplete_TaskNames(t *testing.T) { require.Contains(t, descriptions(suggs), "Deploy the application") } +func TestComplete_WildcardTaskNames(t *testing.T) { + t.Parallel() + + e := setupExecutorWith(t, wildcardTaskfile) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{}) + + // Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*` + // collapse into one candidate, and `*-wildcard-*` leaves nothing to insert. + require.Equal(t, []string{"build", "start-", "s-", "wildcard-"}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) + // Without a desc, the pattern says what the prefix stands for. + require.Contains(t, descriptions(suggs), "wildcard-*") + + suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true}) + require.Equal(t, []string{"", "", "", ""}, descriptions(suggs)) +} + func TestComplete_AliasResolvesToTaskVars(t *testing.T) { t.Parallel() diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 12593cea60..804a74c8e4 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -45,7 +45,7 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) } } - return completeTaskNames(e, opts), DirectiveNoFileComp + return completeTaskNames(e, opts) } // NeedsTaskfile reports whether completing args requires a loaded Taskfile. @@ -64,17 +64,19 @@ func taskNames(e *task.Executor) []string { if t.Internal { continue } - out = append(out, suggestedName(t.Task)) + name, _ := suggestedName(t.Task) + out = append(out, name) for _, alias := range t.Aliases { - out = append(out, suggestedName(alias)) + name, _ := suggestedName(alias) + out = append(out, name) } } return out } -func completeTaskNames(e *task.Executor, opts Options) []Suggestion { +func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) { if e == nil || e.Taskfile == nil { - return nil + return nil, DirectiveNoFileComp } tasks := listTasks(e, opts) desc := func(t *ast.Task) string { @@ -83,23 +85,43 @@ func completeTaskNames(e *task.Executor, opts Options) []Suggestion { } return t.Desc } + out := make([]Suggestion, 0, len(tasks)) + seen := make(map[string]bool, len(tasks)) + anyPartial := false + add := func(name, desc string) { + value, partial := suggestedName(name) + // `*-wildcard-*` has no prefix to insert, and two patterns can share + // one (`wildcard-*` and `wildcard-*-*`). + if value == "" || seen[value] { + return + } + seen[value] = true + if partial { + anyPartial = true + if desc == "" && !opts.NoDescriptions { + desc = name + } + } + out = append(out, Suggestion{Value: value, Description: desc}) + } + for _, t := range tasks { - out = append(out, Suggestion{ - Value: suggestedName(t.Task), - Description: desc(t), - }) + add(t.Task, desc(t)) if opts.NoAliases { continue } for _, alias := range t.Aliases { - out = append(out, Suggestion{ - Value: suggestedName(alias), - Description: desc(t), - }) + add(alias, desc(t)) } } - return out + + // A truncated pattern is only half a name: the shell must leave the cursor + // against it so the rest can be typed. + if anyPartial { + return out, DirectiveNoSpace | DirectiveNoFileComp + } + return out, DirectiveNoFileComp } // listTasks returns the tasks to suggest. Descriptions are the only compiled @@ -131,8 +153,14 @@ func listTasks(e *task.Executor, opts Options) []*ast.Task { return out } -func suggestedName(name string) string { - return strings.TrimRight(name, ":") +// suggestedName returns the text to insert for a task name, and whether that +// text is partial: a wildcard pattern is truncated at its first `*`, since the +// pattern itself is not a runnable name — running it leaves `.MATCH` empty. +func suggestedName(name string) (string, bool) { + if prefix, _, ok := strings.Cut(name, "*"); ok { + return prefix, true + } + return strings.TrimRight(name, ":"), false } // completeFlagValue completes the value of a value-taking flag. prefix is empty From b389d27293dc355a5e003c5505c15e1c04e1bba3 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 12 Aug 2026 22:43:28 +0200 Subject: [PATCH 43/45] chore(completion): silence gosec on the stdin-entrypoint test command --- completion/protocol_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/completion/protocol_test.go b/completion/protocol_test.go index e6a152a5a5..9db1489e9b 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -234,7 +234,8 @@ func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") + // taskBin is the test-built binary and the arguments are literals. + cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") //nolint:gosec cmd.Dir = t.TempDir() cmd.Stdin = r out, err := cmd.Output() From 94ef621b3cc6d2d216b3ec2967417703c9f7a9ea Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 15:51:42 +0200 Subject: [PATCH 44/45] fix(completion): keep the legacy wrappers in step with the official remote flags The remote Taskfile flags left the experiment in #2906, but the legacy Bash, Fish, Zsh and PowerShell wrappers this branch restores still gated them behind `__task_is_experiment_enabled REMOTE_TASKFILES`. --- completion/fish/task.fish | 22 +++++++++------------- completion/ps/task.ps1 | 25 ++++++++++--------------- completion/zsh/_task | 31 +++++++++---------------------- 3 files changed, 28 insertions(+), 50 deletions(-) diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 6b3c4e6c75..5fd9382c6b 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -101,20 +101,16 @@ complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose outp complete -c $GO_TASK_PROGNAME -l version -d 'show version' complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes' complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts' +complete -c $GO_TASK_PROGNAME -l offline -d 'use only local or cached Taskfiles' +complete -c $GO_TASK_PROGNAME -l timeout -d 'timeout for remote Taskfile downloads' +complete -c $GO_TASK_PROGNAME -l expiry -d 'cache expiry duration' +complete -c $GO_TASK_PROGNAME -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)" +complete -c $GO_TASK_PROGNAME -l cacert -d 'custom CA certificate for TLS' -r +complete -c $GO_TASK_PROGNAME -l cert -d 'client certificate for mTLS' -r +complete -c $GO_TASK_PROGNAME -l cert-key -d 'client certificate private key' -r +complete -c $GO_TASK_PROGNAME -l download -d 'download remote Taskfile' +complete -c $GO_TASK_PROGNAME -l clear-cache -d 'clear remote Taskfile cache' # Experimental flags (dynamically checked at completion time via -n condition) # GentleForce experiment complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies' - -# RemoteTaskfiles experiment - Options -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l offline -d 'use only local or cached Taskfiles' -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads' -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration' -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)" -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cacert -d 'custom CA certificate for TLS' -r -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert -d 'client certificate for mTLS' -r -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key -d 'client certificate private key' -r - -# RemoteTaskfiles experiment - Operations -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile' -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l clear-cache -d 'clear remote Taskfile cache' diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 71b58b88f1..dd5ed32c23 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -63,7 +63,16 @@ Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock { [CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'), [CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'), [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'), - [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes'), + [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles'), + [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout'), + [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry'), + [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory'), + [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate'), + [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate'), + [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key'), + [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile'), + [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') ) # Experimental flags (dynamically added based on enabled experiments) @@ -73,20 +82,6 @@ Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock { $completions += [CompletionResult]::new('--force-all', '--force-all', [CompletionResultType]::ParameterName, 'force all dependencies') } - if ($experiments -match '\* REMOTE_TASKFILES:.*on') { - # Options - $completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles') - $completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout') - $completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry') - $completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory') - $completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate') - $completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate') - $completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key') - # Operations - $completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile') - $completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') - } - return $completions.Where{ $_.CompletionText.StartsWith($commandName) } } diff --git a/completion/zsh/_task b/completion/zsh/_task index 7e3082e7ce..cd3e43a90d 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -120,6 +120,14 @@ _task() { '(-v --verbose)'{-v,--verbose}'[verbose mode]' '(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]' '(-y --yes)'{-y,--yes}'[assume yes to all prompts]' + '(--offline --clear-cache)--download[download remote Taskfile]' + '(--offline --download)--offline[use only local or cached Taskfiles]' + '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' + '(--expiry)--expiry[cache expiry duration]:duration: ' + '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' + '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' + '(--cert)--cert[client certificate for mTLS]:file:_files' + '(--cert-key)--cert-key[client certificate private key]:file:_files' ) # Experimental flags (dynamically added based on enabled experiments) @@ -128,18 +136,6 @@ _task() { standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]') fi - if __task_is_experiment_enabled "REMOTE_TASKFILES"; then - standard_args+=( - '(--offline --download)--offline[use only local or cached Taskfiles]' - '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' - '(--expiry)--expiry[cache expiry duration]:duration: ' - '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' - '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' - '(--cert)--cert[client certificate for mTLS]:file:_files' - '(--cert-key)--cert-key[client certificate private key]:file:_files' - ) - fi - operation_args=( # Task names completion (can be specified multiple times) '(operation)*: :__task_list' @@ -150,17 +146,8 @@ _task() { '(*)'{-i,--init}'[create new Taskfile.yml]' '(- *)'{-h,--help}'[show help]' '(- *)--version[show version and exit]' - ) - - # Experimental operations (dynamically added based on enabled experiments) - if __task_is_experiment_enabled "REMOTE_TASKFILES"; then - standard_args+=( - '(--offline --clear-cache)--download[download remote Taskfile]' - ) - operation_args+=( '(* --download)--clear-cache[clear remote Taskfile cache]' - ) - fi + ) _arguments -S $standard_args $operation_args } From d0a53f4ad54a12f2a5dd37fcfacc2f155a7c2550 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 15:51:49 +0200 Subject: [PATCH 45/45] test(completion): guard the offline keystroke path now that remote is official Remote includes are resolved for everyone since #2906, so a keystroke on a Taskfile including an uncached remote one is now a real freeze: without the offline override it waits the full --timeout and hits the network. The experiment-gated flag test moved to --force-all, the only flag still behind an experiment. --- completion/protocol_test.go | 52 ++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/completion/protocol_test.go b/completion/protocol_test.go index 9db1489e9b..6c3776e389 100644 --- a/completion/protocol_test.go +++ b/completion/protocol_test.go @@ -7,12 +7,15 @@ package completion_test import ( "context" "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -213,11 +216,58 @@ func TestProtocol_ExperimentGatedFlag(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) - values, directive := completeInDir(t, dir, []string{"TASK_X_REMOTE_TASKFILES=1"}, "--offline", "") + values, directive := completeInDir(t, dir, []string{"TASK_X_GENTLE_FORCE=1"}, "--force-all", "") require.Equal(t, complete.DirectiveNoFileComp, directive) require.Subset(t, values, []string{"build", "deploy"}) } +// TestProtocol_RemoteIncludeStaysOffline guards the keystroke path against a +// remote include with no cache: downloading it would freeze the shell for up to +// --timeout and prompt for trust. +func TestProtocol_RemoteIncludeStaysOffline(t *testing.T) { + t.Parallel() + + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + <-r.Context().Done() + })) + defer srv.Close() + + taskfile := fmt.Sprintf(`version: '3' + +includes: + remote: %s/Taskfile.yml + +tasks: + build: + desc: Build it +`, srv.URL) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + // A fresh cache dir: nothing can be served from disk, so a download is the + // only way the include could be resolved. The insecure opt-in keeps the + // plain-HTTP test server from being rejected before the download. + // taskBin is the test-built binary and the arguments are literals. + cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "") //nolint:gosec + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "TASK_REMOTE_DIR="+t.TempDir(), + "TASK_REMOTE_INSECURE=1", + ) + out, err := cmd.Output() + require.NoError(t, err, "completion must not hang on a remote include") + + _, directive := parseProtocol(t, out) + require.Equal(t, complete.DirectiveNoFileComp, directive) + require.Zero(t, hits.Load(), "completion must not reach the network") +} + // TestProtocol_StdinEntrypointDoesNotHang guards the keystroke path against // `--taskfile -`, which would otherwise read the Taskfile from the terminal. func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) {