From f2fa581c04d4e7f49eb5e5207f39020f9fd09cd1 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 01/18] 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 | 144 ++++----------- completion/ps/task.ps1 | 131 +++++--------- completion/zsh/_task | 200 +++++---------------- 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, 924 insertions(+), 398 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 d4629000f8..db10d2aa61 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -1,120 +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" -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 - -# 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' +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 71b58b88f1..595287cd0b 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -1,94 +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') - ) +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 + } - 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') - } + $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 ba163f45e7..4e2c2930c1 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -1,171 +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 +# +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. - 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 +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)' - '(-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 + (( directive & 2 )) && opts+=(-S '') + (( directive & 32 )) && opts+=(-V) - 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' - ) + 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]' - ) - - # 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 + (( 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 f1bf6c767f..de747bbbf6 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" @@ -177,6 +178,13 @@ func init() { pflag.StringVar(&Cert, "cert", getConfig(config, "REMOTE_CERT", func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.") pflag.StringVar(&CertKey, "cert-key", getConfig(config, "REMOTE_CERT_KEY", func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.") } + // 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 6c9d3dfdfd4ec1ec857a075719c1bc4c24ec526d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 02/18] chore: changelog for completion engine --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc921400c5..c245cabb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,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 5240f0fa21826f731af183949fed48adb266e98b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 16:03:33 +0200 Subject: [PATCH 03/18] 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 c245cabb8b..4ca5f6efa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,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 4c64a6a47c8aa2ced0070a6af232bcef4e581f4c Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 22:29:42 +0200 Subject: [PATCH 04/18] 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/test.yml | 33 +++++++++++++ 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, 579 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/test.yml b/.github/workflows/test.yml index ceaab866d5..186f342d9d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,3 +31,36 @@ jobs: - name: Test run: go run ./cmd/task test + + completion: + name: Completion + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest] + runs-on: ${{matrix.platform}} + steps: + - name: Check out code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: 1.26.x + + # 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: go run ./cmd/task test:completion 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 b0d11f716d4d7160711b4dd66c0d31e711d0e921 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 22:33:27 +0200 Subject: [PATCH 05/18] 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 91dab16062a0e0fd58cd8fa501c6252a8f965151 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 23:00:50 +0200 Subject: [PATCH 06/18] 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 7f57a9fb84a6efc2cfd93277a585954ebf5ef226 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 23:29:43 +0200 Subject: [PATCH 07/18] 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 772600faa54890ae7a6e7f5ebffe9b0831d45c54 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 17:37:34 +0200 Subject: [PATCH 08/18] 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..d4629000f8 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" +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..ba163f45e7 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)' + '(-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 d8a7b055b887af6de2f9f32c63ac10f70b30b693 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 17:37:40 +0200 Subject: [PATCH 09/18] 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 4ca5f6efa6..f9c1b5002e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ (#2184 by @jubr). - Fixed `joinUrl` collapsing the `//` in a URL scheme (e.g. producing `http:/localhost` instead of `http://localhost`) (#2915 by @vsaraikin). +- 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 @@ -52,11 +60,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 1ab08e76f6..685c08b9c0 100644 --- a/completion.go +++ b/completion.go @@ -17,9 +17,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": @@ -29,6 +45,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 de747bbbf6..41e57d7b97 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 32cfaec1202260e1cf2bd62fe5bcf00973346f49 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 17:42:33 +0200 Subject: [PATCH 10/18] 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 a983dc0e0f..1e9d9dd318 100644 --- a/website/src/docs/installation.md +++ b/website/src/docs/installation.md @@ -446,3 +446,41 @@ canonical task names, add the `show-aliases` zstyle: ```shell zstyle ':completion:*:*:task:*' show-aliases false ``` + +### 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 c0b77b7e76fdda1666e9f37fde986de7e8c65032 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:08:42 +0200 Subject: [PATCH 11/18] 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 4d714425e66d7797d4b91ff4d9665d02e5104bf7 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:08:48 +0200 Subject: [PATCH 12/18] 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 467e9cc6ac25e2d012f5971b058f88921b2b6697 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:41:54 +0200 Subject: [PATCH 13/18] 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 12e42d38c04efd2ef06a0cf2c9aca294f6488f4f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:41:54 +0200 Subject: [PATCH 14/18] 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 bc6f4ff8b116cd5f938af882463ab4dfbe3e4190 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:42:04 +0200 Subject: [PATCH 15/18] 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 050914a8576d577b560c05a0f655f2d2bc19b5f0 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 18:42:04 +0200 Subject: [PATCH 16/18] 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 8726b8139942bae3634ca179bbc6aa36e6aebb13 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 19:43:33 +0200 Subject: [PATCH 17/18] 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 0b986fe55d22d1fcffad706f653b46b134e09c30 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 19 Jul 2026 20:08:48 +0200 Subject: [PATCH 18/18] 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