From 46201bcac97009fe52d36c28155cd19ecdebd5e8 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH] feat(completion): unify shell wrappers behind `task __complete` --- cmd/task/complete_cmd.go | 47 +++++ cmd/task/task.go | 7 + completion/bash/task.bash | 99 +++++----- completion/fish/task.fish | 144 ++++----------- completion/ps/task.ps1 | 135 ++++++-------- completion/zsh/_task | 208 ++++++--------------- 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, 928 insertions(+), 402 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 00000000..98fbc7be --- /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 b81e23dd..f35cf536 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 60e807aa..98f9ef78 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 + COMPREPLY=( $( compgen -W "${values[*]}" -- "$cur" ) ) - # Handle normal options. - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) ) - return 0 - ;; - esac + if (( directive & 2 )); then + compopt -o nospace 2>/dev/null + fi - # Prepare task name completions. - local tasks=( $( "${words[@]}" --silent $_GO_TASK_COMPLETION_LIST_OPTION 2> /dev/null ) ) - COMPREPLY=( $( compgen -W "${tasks[*]}" -- "$cur" ) ) - - # Post-process because task names might contain colons. __ltrim_colon_completions "$cur" + + if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then + _filedir + fi } complete -F _task "$TASK_CMD" diff --git a/completion/fish/task.fish b/completion/fish/task.fish index d4629000..db10d2aa 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -1,120 +1,46 @@ +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + set -l GO_TASK_PROGNAME (if set -q GO_TASK_PROGNAME; echo $GO_TASK_PROGNAME; else if set -q TASK_EXE; echo $TASK_EXE; else; echo task; end) -# Cache variables for experiments (global) -set -g __task_experiments_cache "" -set -g __task_experiments_cache_time 0 +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 -# 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 - - # 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 + 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 - if test "_$arg" = "_--global" -o "_$arg" = "_-g" - set global_task true - break + + 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 - 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 - - # 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 71b58b88..595287cd 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -1,94 +1,61 @@ using namespace System.Management.Automation +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + $cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique -Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock { - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) +Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) - 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') - ) + $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' } - # Experimental flags (dynamically added based on enabled experiments) - $experiments = & task --experiments 2>$null | Out-String - - 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() } - - if ($experiments -match '\* REMOTE_TASKFILES:.*on') { - # Options - $completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles') - $completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout') - $completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry') - $completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory') - $completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate') - $completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate') - $completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key') - # Operations - $completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile') - $completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') - } - - return $completions.Where{ $_.CompletionText.StartsWith($commandName) } + } + # 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 } - return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " } + $output = & $TaskExe __complete @argsToPass 2>$null + if (-not $output) { return } + + $lines = @($output) + if ($lines.Count -eq 0) { return } + $last = $lines[-1] + if (-not $last.StartsWith(':')) { return } + + $directive = [int]($last.Substring(1)) + $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + + # 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) } + } + + # 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 ba163f45..4e2c2930 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -1,171 +1,65 @@ #compdef task -typeset -A opt_args +# +# Thin wrapper around `task __complete`. All suggestion logic lives in the +# Go engine — do not add completion logic here. + TASK_CMD="${TASK_EXE:-task}" -compdef _task "$TASK_CMD" - -_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" - -# Check if an experiment is enabled -function __task_is_experiment_enabled() { - local experiment=$1 - task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on" -} - -# Listing commands from Taskfile.yml -function __task_list() { - local -a scripts cmd task_aliases match mbegin mend - local -i enabled=0 - local taskfile item task desc task_alias - - cmd=($TASK_CMD) - taskfile=${(Qv)opt_args[(i)-t|--taskfile]} - taskfile=${taskfile//\~/$HOME} - - for arg in "${words[@]:0:$CURRENT}"; do - if [[ "$arg" = "--" ]]; then - # Use default completion for words after `--` as they are CLI_ARGS. - _default - return 0 - fi - done - - if [[ -n "$taskfile" && -f "$taskfile" ]]; then - cmd+=(--taskfile "$taskfile") - fi - - # Check if global flag is set - if (( ${+opt_args[-g]} || ${+opt_args[--global]} )); then - cmd+=(--global) - fi - - if output=$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION 2>/dev/null); then - enabled=1 - fi - - (( enabled )) || return 0 - - scripts=() - - # Read zstyle verbose option (default = true via -T) - local show_desc - zstyle -T ":completion:${curcontext}:" verbose && show_desc=true || show_desc=false - - # Read zstyle show-aliases option (default = true via -T) - local show_aliases - zstyle -T ":completion:${curcontext}:" show-aliases && show_aliases=true || show_aliases=false - - for item in "${(@)${(f)output}[2,-1]#\* }"; do - task="${item%%:[[:space:]]*}" - - # Extract the aliases listed in the trailing "(aliases: a, b)" column. - # NB: `aliases` is a reserved zsh parameter, so use a different name. - task_aliases=() - if [[ "$show_aliases" == "true" && "$item" == (#b)*'(aliases: '(*)')' ]]; then - task_aliases=( "${(@s:, :)match[1]}" ) - fi - - if [[ "$show_desc" == "true" ]]; then - local desc="${item##[^[:space:]]##[[:space:]]##}" - scripts+=( "${task//:/\\:}:$desc" ) - for task_alias in $task_aliases; do - scripts+=( "${task_alias//:/\\:}:$desc (alias of $task)" ) - done - else - scripts+=( "$task" ) - for task_alias in $task_aliases; do - scripts+=( "$task_alias" ) - done - fi - done - - if [[ "$show_desc" == "true" ]]; then - _describe 'Task to run' scripts - else - compadd -Q -a scripts - fi -} _task() { - local -a standard_args operation_args + local -a args lines completions opts + local output directive line - 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]' - ) + # (@) 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=("") - # 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]') + output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _files + return fi - if __task_is_experiment_enabled "REMOTE_TASKFILES"; then - standard_args+=( - '(--offline --download)--offline[use only local or cached Taskfiles]' - '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' - '(--expiry)--expiry[cache expiry duration]:duration: ' - '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' - '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' - '(--cert)--cert[client certificate for mTLS]:file:_files' - '(--cert-key)--cert-key[client certificate private key]:file:_files' - ) + 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 - 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]' - ) + if (( directive & 16 )); then + _path_files -/ + return fi - _arguments -S $standard_args $operation_args + # `:` inside the value must be escaped: _describe splits on the first + # unescaped colon (e.g. "docs:serve" would otherwise become value "docs"). + local value desc + for line in "${lines[@]}"; do + if [[ "$line" == *$'\t'* ]]; then + value="${line%%$'\t'*}" + desc="${line#*$'\t'}" + completions+=("${value//:/\\:}:$desc") + else + completions+=("${line//:/\\:}") + fi + done + + (( directive & 2 )) && opts+=(-S '') + (( directive & 32 )) && opts+=(-V) + + if (( ${#completions} > 0 )); then + _describe -t tasks 'task' completions "${opts[@]}" + fi + + (( 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 00000000..9870c401 --- /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 00000000..15b33f7d --- /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 00000000..f1954c34 --- /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 00000000..6e1c78ff --- /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 00000000..45411cce --- /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 00000000..59e07cf5 --- /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 eff0a0cb..9d8639ee 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,9 +50,28 @@ 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) { if len(namespacePath) == 0 { return diff --git a/internal/flags/flags.go b/internal/flags/flags.go index f1bf6c76..de747bbb 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