From 46201bcac97009fe52d36c28155cd19ecdebd5e8 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 1/7] 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 f9f2ecb8be897b5cf1a8fbab48507e10463ed92a Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 2/7] chore: changelog for completion engine --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f701269f13..5c63c3c721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ config) to customise the directory where Task stores temporary files such as checksums. Relative paths are resolved against the root Taskfile (#2891 by @kjasn). +- Unified Bash, Fish, Zsh and PowerShell completions behind a single `task + __complete` engine, so every shell offers the same suggestions: task names, + aliases, flags, flag values and per-task CLI variables (#2897 by @vmaerten). ## v3.51.1 - 2026-05-16 From 57bc6dd8cb153fa583b09b9c88d5c5bba97337db Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 16:03:33 +0200 Subject: [PATCH 3/7] 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 5c63c3c721..796ef18972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,9 @@ @kjasn). - Unified Bash, Fish, Zsh and PowerShell completions behind a single `task __complete` engine, so every shell offers the same suggestions: task names, - aliases, flags, flag values and per-task CLI variables (#2897 by @vmaerten). + aliases, flags, flag values and per-task CLI variables. The Zsh `show-aliases` + and `verbose` zstyles are preserved, now backed by the `--no-aliases` and + `--no-descriptions` completion flags (#2897 by @vmaerten). ## v3.51.1 - 2026-05-16 diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 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 7c90ee35f8e8761a0ab0158b59757226d9cf7332 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 22:29:42 +0200 Subject: [PATCH 4/7] 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 d7b85c7a1f..c27e79adf7 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 57274715bb..a439b4edd9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -152,6 +152,15 @@ tasks: cmds: - gotestsum -f '{{.GOTESTSUM_FORMAT}}' -tags 'signals watch' ./... + 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 e5ecd467500ceedf58a53e5626dad02d65f56722 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 22:33:27 +0200 Subject: [PATCH 5/7] 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 e3b6ed4a03c5f68c56f8d9eaa8ab176c1b64aca4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 23:00:50 +0200 Subject: [PATCH 6/7] 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 1521c9e50f0bbec8638ffd10902c3cfbbbfaef0b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 3 Jul 2026 23:29:43 +0200 Subject: [PATCH 7/7] 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