diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62009de150..a41d7fab94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,49 @@ jobs: - name: ๐Ÿงช Test run: task test --output group --output-group-begin '::group::{{.TASK}}' --output-group-end '::endgroup::' + completion: + name: ๐Ÿš Completion (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: ๐Ÿ“ฅ Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: โฌ‡๏ธ Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.26.x + + - name: โฌ‡๏ธ Setup Task + uses: go-task/setup-task@v2 + + # zsh and pwsh are preinstalled on the runners; only fish is missing + # (plus zsh on the Linux image). + - name: โฌ‡๏ธ Install shells (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh fish + + - name: โฌ‡๏ธ Install shells (macOS) + if: runner.os == 'macOS' + run: brew install fish + + # Nushell ships in no runner image and is not packaged by apt, so it comes + # from its own release archives. + - name: โฌ‡๏ธ Install Nushell + uses: hustcer/setup-nu@f3fd65374ffc4d60974c0dd2f7263c6c5c285f81 # v3.26 + with: + version: "*" + + - name: ๐Ÿงช Test completion + # Strict mode fails the run if any shell is missing, so we never get a + # false pass when a runner image stops shipping one (e.g. pwsh). + env: + TASK_COMPLETION_STRICT: "1" + run: task test:completion + lint: name: ๐Ÿ” Lint (${{ matrix.go-version }}) strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 413c8f7f12..0fb7dc06fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### ๐Ÿš€ Features + +- `task --completion ` now serves a new completion engine that unifies + Bash, Fish, Zsh, Nushell and PowerShell behind a single `task __complete` + command, so every shell offers the same suggestions: task names, aliases, + flags, flag values and per-task CLI variables. The Zsh `show-aliases` and + `verbose` zstyles keep working, now backed by the `--no-aliases` and + `--no-descriptions` completion flags. The previous hand-written scripts remain + available as `task --legacy-completion `; they are deprecated and will + be removed in a future release (#2897 by @vmaerten). + ### ๐Ÿ“ฆ Package API - Bumped the minimum Go version to 1.26. Task follows Go's two-latest support diff --git a/Taskfile.yml b/Taskfile.yml index da8b2b44f8..32dd1512d2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -158,6 +158,16 @@ tasks: cmds: - go test -bench=. -benchmem -tags=fsbench -run=^$ ./... + test:completion: + desc: Tests the shell completion engine and wrappers (bash, zsh, fish, nu, powershell) + sources: + - internal/complete/**/*.go + - cmd/task/**/*.go + - completion/**/* + - testdata/completion/* + cmds: + - bash testdata/completion/run.sh + goreleaser:test: desc: Tests release process without publishing cmds: diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go new file mode 100644 index 0000000000..c074aef3cf --- /dev/null +++ b/cmd/task/complete_cmd.go @@ -0,0 +1,42 @@ +package main + +import ( + "bufio" + "io" + "os" + "strings" + + "github.com/spf13/pflag" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/complete" + "github.com/go-task/task/v3/internal/flags" +) + +func runComplete(args []string) error { + opts, args := complete.ParseOptions(args) + + // Overridden after WithFlags: a keystroke stays silent and never touches the + // network, whatever the user typed. + e := task.NewExecutor( + flags.WithFlags(), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithStdin(strings.NewReader("")), + task.WithVersionCheck(false), + task.WithDisableFuzzy(true), + task.WithOffline(true), + task.WithDownload(false), + ) + + // Best-effort: a missing or broken Taskfile must not break completion. + if complete.NeedsTaskfile(args, pflag.CommandLine) { + _ = e.Setup() + } + + suggs, dirv := complete.Complete(e, pflag.CommandLine, args, opts) + + out := bufio.NewWriter(os.Stdout) + complete.Write(out, suggs, dirv) + return out.Flush() +} diff --git a/cmd/task/task.go b/cmd/task/task.go index b81e23dd5f..12630b922d 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(complete.Words()) + } + log := &logger.Logger{ Stdout: os.Stdout, Stderr: os.Stderr, @@ -126,6 +133,15 @@ func run() error { return nil } + if flags.LegacyCompletion != "" { + script, err := task.LegacyCompletion(flags.LegacyCompletion) + if err != nil { + return err + } + fmt.Println(script) + return nil + } + e := task.NewExecutor( flags.WithFlags(), task.WithVersionCheck(true), diff --git a/completion.go b/completion.go index ab333b7ad4..e26738f975 100644 --- a/completion.go +++ b/completion.go @@ -5,6 +5,8 @@ import ( "fmt" ) +// Thin wrappers around the `task __complete` engine, served by `--completion`. + //go:embed completion/bash/task.bash var completionBash string @@ -20,20 +22,55 @@ var completionPowershell string //go:embed completion/zsh/_task var completionZsh string -func Completion(completion string) (string, error) { - // Get the file extension for the selected shell - switch completion { - case "bash": - return completionBash, nil - case "fish": - return completionFish, nil - case "nu", "nushell": - return completionNu, nil - case "powershell": - return completionPowershell, nil - case "zsh": - return completionZsh, nil - default: - return "", fmt.Errorf("unknown shell: %s", completion) +// The self-contained scripts that predate the engine, kept behind +// `--legacy-completion` as an escape hatch for a couple of releases. + +//go:embed completion/legacy/bash/task.bash +var completionBashLegacy string + +//go:embed completion/legacy/fish/task.fish +var completionFishLegacy string + +//go:embed completion/legacy/nu/task-completions.nu +var completionNuLegacy string + +//go:embed completion/legacy/ps/task.ps1 +var completionPowershellLegacy string + +//go:embed completion/legacy/zsh/_task +var completionZshLegacy string + +// The maps accept `nushell` as an alias of `nu`. +var completionScripts = map[string]string{ + "bash": completionBash, + "fish": completionFish, + "nu": completionNu, + "nushell": completionNu, + "powershell": completionPowershell, + "zsh": completionZsh, +} + +var completionScriptsLegacy = map[string]string{ + "bash": completionBashLegacy, + "fish": completionFishLegacy, + "nu": completionNuLegacy, + "nushell": completionNuLegacy, + "powershell": completionPowershellLegacy, + "zsh": completionZshLegacy, +} + +func Completion(shell string) (string, error) { + return completionScript(completionScripts, shell) +} + +func LegacyCompletion(shell string) (string, error) { + return completionScript(completionScriptsLegacy, shell) +} + +func completionScript(scripts map[string]string, shell string) (string, error) { + script, ok := scripts[shell] + if !ok { + return "", fmt.Errorf("unknown shell: %s", shell) } + return script, nil } diff --git a/completion/bash/task.bash b/completion/bash/task.bash index 60e807aa43..e1e2a3b88f 100644 --- a/completion/bash/task.bash +++ b/completion/bash/task.bash @@ -1,60 +1,94 @@ # vim: set tabstop=2 shiftwidth=2 expandtab: +# +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. -_GO_TASK_COMPLETION_LIST_OPTION='--list-all' TASK_CMD="${TASK_EXE:-task}" -function _task() -{ +# `=` stays inside the current word (see `_init_completion -n =:`), so an inline +# `--flag=` prefix must be stripped before _filedir and re-applied after. +_task_filedir() { + local fpfx="" savecur="$cur" + if [[ "$cur" == -*=* ]]; then + fpfx="${cur%%=*}=" + cur="${cur#*=}" + fi + _filedir ${1:+"$1"} + cur="$savecur" + if [[ -n "$fpfx" ]]; then + COMPREPLY=( ${COMPREPLY[@]+"${COMPREPLY[@]/#/$fpfx}"} ) + fi +} + +_task() { local cur prev words cword - _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 + + # 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 + + # `=` and `:` out of the word breaks: `--output=`, `docs:serve` stay one token. + _init_completion -n =: || return + + local -a args=( "${words[@]:1:cword}" ) + if (( ${#args[@]} == 0 )); then + args=( "" ) + fi + + local output + output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _task_filedir + return + fi + + local -a lines=() + local line + while IFS= read -r line; do + lines+=( "$line" ) + done <<< "$output" + + local last_idx=$(( ${#lines[@]} - 1 )) + local directive="${lines[$last_idx]#:}" + unset 'lines[$last_idx]' + + if (( directive & FILTER_FILE_EXT )); then + local exts="" + # ${arr[@]+โ€ฆ} guards an empty array under `set -u` in bash 3.2 (macOS). + for line in ${lines[@]+"${lines[@]}"}; do + exts+="${exts:+|}$line" + done + _task_filedir "@($exts)" + return + fi + + if (( directive & FILTER_DIRS )); then + _task_filedir -d + return + fi + + # Not `compgen -W`: it splits the word list on IFS, mangling values with spaces. + local value + COMPREPLY=() + for line in ${lines[@]+"${lines[@]}"}; do + value="${line%%$'\t'*}" + if [[ -z "$cur" || "$value" == "$cur"* ]]; then + COMPREPLY+=( "$value" ) fi 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. + if (( directive & NO_SPACE )); then + compopt -o nospace 2>/dev/null + fi + + # nosort needs bash 4.4; the 3.2 shipped by macOS ignores it and stays sorted. + if (( directive & KEEP_ORDER )); then + compopt -o nosort 2>/dev/null + fi + __ltrim_colon_completions "$cur" + + if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then + _task_filedir + fi } complete -F _task "$TASK_CMD" diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 5fd9382c6b..908f089783 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -1,116 +1,98 @@ +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. + 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 +# Completion directives, mirroring internal/complete/complete.go. `math` has no +# bitwise operators, hence __task_test_bit. NoSpace (2) and KeepOrder (32) need +# none: fish appends no space and keeps the order. +set -g __task_directive_no_file_comp 4 +set -g __task_directive_filter_file_ext 8 +set -g __task_directive_filter_dirs 16 -# 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_test_bit --argument-names value bit + test (math "floor($value / $bit) % 2") -eq 1 +end - # Return cached value if still valid - if test (math "$now - $__task_experiments_cache_time") -lt $ttl - printf '%s\n' $__task_experiments_cache - return - 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 - # Refresh cache - set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null) - set -g __task_experiments_cache_time $now - printf '%s\n' $__task_experiments_cache -end + set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null) + set -l count (count $output) + if test $count -eq 0 + return + end -# Helper function to check if an experiment is enabled -function __task_is_experiment_enabled - set -l experiment $argv[1] - __task_get_experiments | string match -qr "^\* $experiment:.*on" -end + set -l last $output[$count] + if not string match -q ':*' -- $last + # Protocol violation: emit raw lines as a fallback. + printf '%s\n' $output + return + 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 - end + set -l directive (string replace -r '^:' '' -- $last) + set -l data + if test $count -gt 1 + set data $output[1..(math $count - 1)] 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 + # The registration below passes `--no-files`, so every file-completion + # directive must be served here or nothing is offered at all. - # Return on non-zero exit code (for cases when there is no Taskfile found or etc.) - if test $status -ne 0 + # fish replaces the whole token, so an inline `--flag=` must be kept on every + # candidate. + set -l flagpfx "" + set -l pathcur $current + if string match -qr '^--?[^=]+=' -- $current + set flagpfx (string replace -r '=.*$' '=' -- $current) + set pathcur (string replace -r '^--?[^=]+=' '' -- $current) + end + + # __fish_complete_suffix prioritizes the extension instead of filtering. + if __task_test_bit $directive $__task_directive_filter_file_ext + for entry in (__fish_complete_path $pathcur) + set -l name (string split -f1 \t -- $entry) + if string match -qr '/$' -- $name + printf '%s%s\n' $flagpfx $entry + continue + end + for ext in $data + if string match -qr "\.$ext\$" -- $name + printf '%s%s\n' $flagpfx $entry + break + end + end + end 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 + if __task_test_bit $directive $__task_directive_filter_dirs + for entry in (__fish_complete_directories $pathcur) + printf '%s%s\n' $flagpfx $entry + end + return 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 --" + for line in $data + printf '%s\n' $line + end + + # NoFileComp unset โ†’ offer files too (DirectiveDefault). + if not __task_test_bit $directive $__task_directive_no_file_comp + for entry in (__fish_complete_path $pathcur) + printf '%s%s\n' $flagpfx $entry + end + end +end -# Standard flags -complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks' -complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)' -complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks' -complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell nu" -complete -c $GO_TASK_PROGNAME -s d -l dir -d 'set directory of execution' -complete -c $GO_TASK_PROGNAME -l disable-fuzzy -d 'disable fuzzy matching for task names' -complete -c $GO_TASK_PROGNAME -s n -l dry -d 'compile and print tasks without executing' -complete -c $GO_TASK_PROGNAME -s x -l exit-code -d 'pass-through exit code of task command' -complete -c $GO_TASK_PROGNAME -l experiments -d 'list available experiments' -complete -c $GO_TASK_PROGNAME -s F -l failfast -d 'when running tasks in parallel, stop all tasks if one fails' -complete -c $GO_TASK_PROGNAME -s f -l force -d 'force execution even when up-to-date' -complete -c $GO_TASK_PROGNAME -s g -l global -d 'run global Taskfile from home directory' -complete -c $GO_TASK_PROGNAME -s h -l help -d 'show help' -complete -c $GO_TASK_PROGNAME -s i -l init -d 'create new Taskfile' -complete -c $GO_TASK_PROGNAME -l insecure -d 'allow insecure Taskfile downloads' -complete -c $GO_TASK_PROGNAME -s I -l interval -d 'interval to watch for changes' -complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task list as JSON' -complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' -complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' -complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' -complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables' -complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" -complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' -complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' -complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks' -complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel' -complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing' -complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none" -complete -c $GO_TASK_PROGNAME -l status -d 'exit non-zero if tasks not up-to-date' -complete -c $GO_TASK_PROGNAME -l summary -d 'show task summary' -complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose Taskfile to run' -complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose output' -complete -c $GO_TASK_PROGNAME -l version -d 'show version' -complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes' -complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts' -complete -c $GO_TASK_PROGNAME -l offline -d 'use only local or cached Taskfiles' -complete -c $GO_TASK_PROGNAME -l timeout -d 'timeout for remote Taskfile downloads' -complete -c $GO_TASK_PROGNAME -l expiry -d 'cache expiry duration' -complete -c $GO_TASK_PROGNAME -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)" -complete -c $GO_TASK_PROGNAME -l cacert -d 'custom CA certificate for TLS' -r -complete -c $GO_TASK_PROGNAME -l cert -d 'client certificate for mTLS' -r -complete -c $GO_TASK_PROGNAME -l cert-key -d 'client certificate private key' -r -complete -c $GO_TASK_PROGNAME -l download -d 'download remote Taskfile' -complete -c $GO_TASK_PROGNAME -l clear-cache -d 'clear remote Taskfile cache' +# fish accumulates `complete` entries instead of replacing them, so an older +# completion would keep contributing alongside the engine. +complete -c $GO_TASK_PROGNAME -e -# 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' +# `--no-files` keeps fish from mixing in files against the engine's directive. +complete -c $GO_TASK_PROGNAME --no-files -a "(__task_complete)" diff --git a/completion/legacy/bash/task.bash b/completion/legacy/bash/task.bash new file mode 100644 index 0000000000..60e807aa43 --- /dev/null +++ b/completion/legacy/bash/task.bash @@ -0,0 +1,60 @@ +# vim: set tabstop=2 shiftwidth=2 expandtab: + +_GO_TASK_COMPLETION_LIST_OPTION='--list-all' +TASK_CMD="${TASK_EXE:-task}" + +function _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 + 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. + __ltrim_colon_completions "$cur" +} + +complete -F _task "$TASK_CMD" diff --git a/completion/legacy/fish/task.fish b/completion/legacy/fish/task.fish new file mode 100644 index 0000000000..5fd9382c6b --- /dev/null +++ b/completion/legacy/fish/task.fish @@ -0,0 +1,116 @@ +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 + +# 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 + 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 + 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 +end + +complete -c $GO_TASK_PROGNAME \ + -d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was specified.' \ + -xa "(__task_get_tasks)" \ + -n "not __fish_seen_subcommand_from --" + +# Standard flags +complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks' +complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)' +complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks' +complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell nu" +complete -c $GO_TASK_PROGNAME -s d -l dir -d 'set directory of execution' +complete -c $GO_TASK_PROGNAME -l disable-fuzzy -d 'disable fuzzy matching for task names' +complete -c $GO_TASK_PROGNAME -s n -l dry -d 'compile and print tasks without executing' +complete -c $GO_TASK_PROGNAME -s x -l exit-code -d 'pass-through exit code of task command' +complete -c $GO_TASK_PROGNAME -l experiments -d 'list available experiments' +complete -c $GO_TASK_PROGNAME -s F -l failfast -d 'when running tasks in parallel, stop all tasks if one fails' +complete -c $GO_TASK_PROGNAME -s f -l force -d 'force execution even when up-to-date' +complete -c $GO_TASK_PROGNAME -s g -l global -d 'run global Taskfile from home directory' +complete -c $GO_TASK_PROGNAME -s h -l help -d 'show help' +complete -c $GO_TASK_PROGNAME -s i -l init -d 'create new Taskfile' +complete -c $GO_TASK_PROGNAME -l insecure -d 'allow insecure Taskfile downloads' +complete -c $GO_TASK_PROGNAME -s I -l interval -d 'interval to watch for changes' +complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task list as JSON' +complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' +complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' +complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' +complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables' +complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" +complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' +complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' +complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks' +complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel' +complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing' +complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none" +complete -c $GO_TASK_PROGNAME -l status -d 'exit non-zero if tasks not up-to-date' +complete -c $GO_TASK_PROGNAME -l summary -d 'show task summary' +complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose Taskfile to run' +complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose output' +complete -c $GO_TASK_PROGNAME -l version -d 'show version' +complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes' +complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts' +complete -c $GO_TASK_PROGNAME -l offline -d 'use only local or cached Taskfiles' +complete -c $GO_TASK_PROGNAME -l timeout -d 'timeout for remote Taskfile downloads' +complete -c $GO_TASK_PROGNAME -l expiry -d 'cache expiry duration' +complete -c $GO_TASK_PROGNAME -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)" +complete -c $GO_TASK_PROGNAME -l cacert -d 'custom CA certificate for TLS' -r +complete -c $GO_TASK_PROGNAME -l cert -d 'client certificate for mTLS' -r +complete -c $GO_TASK_PROGNAME -l cert-key -d 'client certificate private key' -r +complete -c $GO_TASK_PROGNAME -l download -d 'download remote Taskfile' +complete -c $GO_TASK_PROGNAME -l clear-cache -d 'clear remote Taskfile cache' + +# Experimental flags (dynamically checked at completion time via -n condition) +# GentleForce experiment +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies' diff --git a/completion/legacy/nu/task-completions.nu b/completion/legacy/nu/task-completions.nu new file mode 100644 index 0000000000..63148f1965 --- /dev/null +++ b/completion/legacy/nu/task-completions.nu @@ -0,0 +1,180 @@ +# Nushell completions for Task (https://taskfile.dev). +# +# Nushell cannot source a script from stdin, so save this file where Nushell +# picks it up automatically: +# mkdir ($nu.data-dir | path join "vendor/autoload") +# task --completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") +# +# The file must not be named task.nu: Nushell refuses to export a known external +# named like its module, which would break `use task-completions.nu *`. + +# Name or path of the Task executable, like the other completion scripts. +# The *completed* command is always `task`: an `extern` declaration requires a +# literal name. For a renamed executable, alias it instead: `alias go-task = task`. +def "nu-complete task-exe" [] { + $env.TASK_EXE? | default "task" +} + +def "nu-complete task-words" [context: string] { + $context | split row --regex '\s+' | where {|word| $word != "" } +} + +# Strips the quotes the user may have typed around a value and expands `~`, +# which Nushell does not do for a value coming from a variable. +def "nu-complete task-value" [value: string] { + let unquoted = ($value | str trim --char '"' | str trim --char "'") + if ($unquoted | str starts-with "~") { + $unquoted | path expand --no-symlink + } else { + $unquoted + } +} + +# Rebuilds the flags deciding *which* Taskfile is read, so the task list follows +# the `-t/--taskfile`, `-d/--dir` and `-g/--global` already on the command line. +def "nu-complete task-scope" [words: list] { + mut scope: list = [] + mut pending = "" + + for word in ($words | skip 1) { + if $pending != "" { + $scope = ($scope | append [$pending, (nu-complete task-value $word)]) + $pending = "" + continue + } + + let parts = ($word | split row "=") + let name = ($parts | first) + let inline = if ($parts | length) > 1 { $parts | skip 1 | str join "=" } else { null } + + if $name in ["-g", "--global"] { + $scope = ($scope | append "--global") + } else if $name in ["-t", "--taskfile", "-d", "--dir"] { + let long = if $name in ["-t", "--taskfile"] { "--taskfile" } else { "--dir" } + if $inline != null { + $scope = ($scope | append [$long, (nu-complete task-value $inline)]) + } else { + $pending = $long + } + } + } + + $scope +} + +# Lists the tasks of the targeted Taskfile. `--no-status` keeps completion fast: +# without it Task fingerprints every task's sources on each keystroke. Returns an +# empty list when Task exits non-zero (no Taskfile, invalid Taskfile). +def "nu-complete task-list" [words: list] { + let exe = (nu-complete task-exe) + let args = [...(nu-complete task-scope $words) "--list-all" "--json" "--no-status"] + let result = (try { do { ^$exe ...$args } | complete } catch { null }) + + if ($result | is-empty) or $result.exit_code != 0 { + return [] + } + + try { $result.stdout | from json | get tasks } catch { [] } +} + +def "nu-complete task" [context: string] { + let words = (nu-complete task-words $context) + + # Words after `--` are forwarded to the task as CLI_ARGS: stop offering task + # names and let Nushell fall back to its own file completion. + if "--" in $words { + return null + } + + let completions = ( + nu-complete task-list $words + | each {|item| + # `task` is the invocable name; `name` may be a display-only label. + let name = ($item.task | str trim --right --char ':') + let desc = ($item.desc? | default "") + let aliases = ( + $item.aliases? + | default [] + | each {|alias| { + value: ($alias | str trim --right --char ':') + description: (if ($desc | is-empty) { $"alias of ($name)" } else { $"($desc) \(alias of ($name)\)" }) + } } + ) + [{ value: $name, description: $desc }] | append $aliases + } + | flatten + ) + + # `sort: false` keeps the order Task chose, which honours --sort and .taskrc. + { options: { sort: false }, completions: $completions } +} + +def "nu-complete task-shells" [] { + ["bash", "zsh", "fish", "powershell", "nu"] +} + +def "nu-complete task-output" [] { + ["interleaved", "group", "prefixed"] +} + +def "nu-complete task-sort" [] { + ["default", "alphanumeric", "none"] +} + +# 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. +# +# An `extern` signature is static, so the experimental flag at the bottom is +# always offered; Task rejects it when the experiment is off. Run +# `task --experiments` to see which experiments are enabled. +export extern "task" [ + ...tasks: string@"nu-complete task" # task(s) to run + --list(-l) # list tasks with a description + --list-all(-a) # list all tasks, with or without a description + --json(-j) # format the task list as JSON + --no-status # ignore status when listing tasks as JSON + --nested # nest namespaces when listing tasks as JSON + --sort: string@"nu-complete task-sort" # change the order of the tasks when listed + --init(-i) # create a new Taskfile.yml in the current folder + --completion: string@"nu-complete task-shells" # generate a shell completion script + --taskfile(-t): glob # choose which Taskfile to run + --dir(-d): directory # set the directory in which Task will execute + --global(-g) # run the global Taskfile from $HOME + --temp-dir: directory # directory used to store Task temporary files + --force(-f) # force execution even when the task is up-to-date + --status # exit with a non-zero code if tasks are not up-to-date + --dry(-n) # compile and print the tasks without executing them + --summary # show the summary of a task instead of running it + --watch(-w) # watch the given tasks and re-run them on changes + --interval(-I): string # interval to watch for changes, e.g. 500ms + --parallel(-p) # run the tasks given on the command line in parallel + --concurrency(-C): int # limit the number of tasks run concurrently + --failfast(-F) # when running in parallel, stop everything if one task fails + --exit-code(-x) # pass through the exit code of the task command + --interactive # prompt for missing required variables + --yes(-y) # assume "yes" as the answer to all prompts + --output(-o): string@"nu-complete task-output" # set the output style + --output-group-begin: string # message template printed before a task's grouped output + --output-group-end: string # message template printed after a task's grouped output + --output-group-error-only # swallow the output of successful tasks + --color(-c) # colored output, enabled by default + --silent(-s) # disable echoing + --verbose(-v) # enable verbose mode + --disable-fuzzy # disable fuzzy matching for task names + --download # download a cached version of a remote Taskfile + --offline # only use local or cached Taskfiles + --clear-cache # clear the remote Taskfile cache + --trusted-hosts: string # trusted hosts for remote Taskfiles (comma-separated) + --timeout: string # timeout for downloading remote Taskfiles + --expiry: string # expiry duration for cached remote Taskfiles + --remote-cache-dir: directory # directory used to cache remote Taskfiles + --cacert: path # custom CA certificate for HTTPS connections + --cert: path # client certificate for HTTPS connections + --cert-key: path # client certificate key for HTTPS connections + --insecure # allow Taskfiles to be downloaded over insecure connections + --experiments # list the available experiments and whether they are enabled + --version # show the Task version + --help(-h) # show Task usage + + --force-all # [GENTLE_FORCE] force the called task and all its dependencies +] diff --git a/completion/legacy/ps/task.ps1 b/completion/legacy/ps/task.ps1 new file mode 100644 index 0000000000..dd5ed32c23 --- /dev/null +++ b/completion/legacy/ps/task.ps1 @@ -0,0 +1,89 @@ +using namespace System.Management.Automation + +$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) + + if ($commandName.StartsWith('-')) { + $completions = @( + # Standard flags (alphabetical order) + [CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'list all tasks'), + [CompletionResult]::new('--list-all', '--list-all', [CompletionResultType]::ParameterName, 'list all tasks'), + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'colored output'), + [CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'colored output'), + [CompletionResult]::new('-C', '-C', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), + [CompletionResult]::new('--concurrency', '--concurrency', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), + [CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'generate shell completion'), + [CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'set directory'), + [CompletionResult]::new('--dir', '--dir', [CompletionResultType]::ParameterName, 'set directory'), + [CompletionResult]::new('--disable-fuzzy', '--disable-fuzzy', [CompletionResultType]::ParameterName, 'disable fuzzy matching'), + [CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'dry run'), + [CompletionResult]::new('--dry', '--dry', [CompletionResultType]::ParameterName, 'dry run'), + [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'pass-through exit code'), + [CompletionResult]::new('--exit-code', '--exit-code', [CompletionResultType]::ParameterName, 'pass-through exit code'), + [CompletionResult]::new('--experiments', '--experiments', [CompletionResultType]::ParameterName, 'list experiments'), + [CompletionResult]::new('-F', '-F', [CompletionResultType]::ParameterName, 'fail fast on pallalel tasks'), + [CompletionResult]::new('--failfast', '--failfast', [CompletionResultType]::ParameterName, 'force execution'), + [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'force execution'), + [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'force execution'), + [CompletionResult]::new('-g', '-g', [CompletionResultType]::ParameterName, 'run global Taskfile'), + [CompletionResult]::new('--global', '--global', [CompletionResultType]::ParameterName, 'run global Taskfile'), + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'show help'), + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'show help'), + [CompletionResult]::new('-i', '-i', [CompletionResultType]::ParameterName, 'create new Taskfile'), + [CompletionResult]::new('--init', '--init', [CompletionResultType]::ParameterName, 'create new Taskfile'), + [CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'allow insecure downloads'), + [CompletionResult]::new('-I', '-I', [CompletionResultType]::ParameterName, 'watch interval'), + [CompletionResult]::new('--interval', '--interval', [CompletionResultType]::ParameterName, 'watch interval'), + [CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'format as JSON'), + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'format as JSON'), + [CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'list tasks'), + [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), + [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), + [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), + [CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'), + [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), + [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), + [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), + [CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'), + [CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'), + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'), + [CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'), + [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'), + [CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'silent mode'), + [CompletionResult]::new('--sort', '--sort', [CompletionResultType]::ParameterName, 'task sorting order'), + [CompletionResult]::new('--status', '--status', [CompletionResultType]::ParameterName, 'check task status'), + [CompletionResult]::new('--summary', '--summary', [CompletionResultType]::ParameterName, 'show task summary'), + [CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'choose Taskfile'), + [CompletionResult]::new('--taskfile', '--taskfile', [CompletionResultType]::ParameterName, 'choose Taskfile'), + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'verbose output'), + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'verbose output'), + [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'show version'), + [CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'), + [CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'), + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'), + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes'), + [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles'), + [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout'), + [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry'), + [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory'), + [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate'), + [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate'), + [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key'), + [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile'), + [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') + ) + + # 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') + } + + return $completions.Where{ $_.CompletionText.StartsWith($commandName) } + } + + return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " } +} diff --git a/completion/legacy/zsh/_task b/completion/legacy/zsh/_task new file mode 100755 index 0000000000..cd3e43a90d --- /dev/null +++ b/completion/legacy/zsh/_task @@ -0,0 +1,158 @@ +#compdef task +typeset -A opt_args +TASK_CMD="${TASK_EXE:-task}" +compdef _task "$TASK_CMD" + +_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" + +# Check if an experiment is enabled +function __task_is_experiment_enabled() { + local experiment=$1 + task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on" +} + +# Listing commands from Taskfile.yml +function __task_list() { + local -a scripts cmd task_aliases match mbegin mend + local -i enabled=0 + local taskfile item task desc task_alias + + cmd=($TASK_CMD) + taskfile=${(Qv)opt_args[(i)-t|--taskfile]} + taskfile=${taskfile//\~/$HOME} + + 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 + + standard_args=( + '(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' + '(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' + '(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]' + '(-f --force)'{-f,--force}'[run even if task is up-to-date]' + '(-c --color)'{-c,--color}'[colored output]' + '(--completion)--completion[generate shell completion script]:shell:(bash zsh fish powershell nu)' + '(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs' + '(--disable-fuzzy)--disable-fuzzy[disable fuzzy matching for task names]' + '(-n --dry)'{-n,--dry}'[compiles and prints tasks without executing]' + '(--dry)--dry[dry-run mode, compile and print tasks only]' + '(-x --exit-code)'{-x,--exit-code}'[pass-through exit code of task command]' + '(--experiments)--experiments[list available experiments]' + '(-g --global)'{-g,--global}'[run global Taskfile from home directory]' + '(--insecure)--insecure[allow insecure Taskfile downloads]' + '(-I --interval)'{-I,--interval}'[interval to watch for changes]:duration: ' + '(-j --json)'{-j,--json}'[format task list as JSON]' + '(--nested)--nested[nest namespaces when listing as JSON]' + '(--no-status)--no-status[ignore status when listing as JSON]' + '(--interactive)--interactive[prompt for missing required variables]' + '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' + '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' + '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' + '(--output-group-error-only)--output-group-error-only[hide output from successful tasks]' + '(-s --silent)'{-s,--silent}'[disable echoing]' + '(--sort)--sort[set task sorting order]:order:(default alphanumeric none)' + '(--status)--status[exit non-zero if supplied tasks not up-to-date]' + '(--summary)--summary[show summary\: field from tasks instead of running them]' + '(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files' + '(-v --verbose)'{-v,--verbose}'[verbose mode]' + '(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]' + '(-y --yes)'{-y,--yes}'[assume yes to all prompts]' + '(--offline --clear-cache)--download[download remote Taskfile]' + '(--offline --download)--offline[use only local or cached Taskfiles]' + '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' + '(--expiry)--expiry[cache expiry duration]:duration: ' + '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' + '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' + '(--cert)--cert[client certificate for mTLS]:file:_files' + '(--cert-key)--cert-key[client certificate private key]:file:_files' + ) + + # 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 + + operation_args=( + # Task names completion (can be specified multiple times) + '(operation)*: :__task_list' + # Operational args completion (mutually exclusive) + + '(operation)' + '(*)'{-l,--list}'[list describable tasks]' + '(*)'{-a,--list-all}'[list all tasks]' + '(*)'{-i,--init}'[create new Taskfile.yml]' + '(- *)'{-h,--help}'[show help]' + '(- *)--version[show version and exit]' + '(* --download)--clear-cache[clear remote Taskfile cache]' + ) + + _arguments -S $standard_args $operation_args +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_task" ]; then + _task "$@" +fi diff --git a/completion/nu/task-completions.nu b/completion/nu/task-completions.nu index 63148f1965..25fd56abd0 100644 --- a/completion/nu/task-completions.nu +++ b/completion/nu/task-completions.nu @@ -1,180 +1,86 @@ -# Nushell completions for Task (https://taskfile.dev). -# -# Nushell cannot source a script from stdin, so save this file where Nushell -# picks it up automatically: -# mkdir ($nu.data-dir | path join "vendor/autoload") -# task --completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") -# -# The file must not be named task.nu: Nushell refuses to export a known external -# named like its module, which would break `use task-completions.nu *`. +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. -# Name or path of the Task executable, like the other completion scripts. -# The *completed* command is always `task`: an `extern` declaration requires a -# literal name. For a renamed executable, alias it instead: `alias go-task = task`. -def "nu-complete task-exe" [] { - $env.TASK_EXE? | default "task" -} - -def "nu-complete task-words" [context: string] { - $context | split row --regex '\s+' | where {|word| $word != "" } -} - -# Strips the quotes the user may have typed around a value and expands `~`, -# which Nushell does not do for a value coming from a variable. -def "nu-complete task-value" [value: string] { - let unquoted = ($value | str trim --char '"' | str trim --char "'") - if ($unquoted | str starts-with "~") { - $unquoted | path expand --no-symlink - } else { - $unquoted - } -} - -# Rebuilds the flags deciding *which* Taskfile is read, so the task list follows -# the `-t/--taskfile`, `-d/--dir` and `-g/--global` already on the command line. -def "nu-complete task-scope" [words: list] { - mut scope: list = [] - mut pending = "" - - for word in ($words | skip 1) { - if $pending != "" { - $scope = ($scope | append [$pending, (nu-complete task-value $word)]) - $pending = "" - continue - } - - let parts = ($word | split row "=") - let name = ($parts | first) - let inline = if ($parts | length) > 1 { $parts | skip 1 | str join "=" } else { null } - - if $name in ["-g", "--global"] { - $scope = ($scope | append "--global") - } else if $name in ["-t", "--taskfile", "-d", "--dir"] { - let long = if $name in ["-t", "--taskfile"] { "--taskfile" } else { "--dir" } - if $inline != null { - $scope = ($scope | append [$long, (nu-complete task-value $inline)]) - } else { - $pending = $long - } - } - } - - $scope -} +# The `{completions, options}` record documented for `def` completers is +# rejected for an external one: return records or null, nothing else. +def task-external-completer [spans: list] { + let exe = ($env.TASK_EXE? | default "task") -# Lists the tasks of the targeted Taskfile. `--no-status` keeps completion fast: -# without it Task fingerprints every task's sources on each keystroke. Returns an -# empty list when Task exits non-zero (no Taskfile, invalid Taskfile). -def "nu-complete task-list" [words: list] { - let exe = (nu-complete task-exe) - let args = [...(nu-complete task-scope $words) "--list-all" "--json" "--no-status"] - let result = (try { do { ^$exe ...$args } | complete } catch { null }) + # The trailing empty word tells the engine the cursor is on a fresh word. + let words = ($spans | skip 1) + let args = (if ($words | is-empty) { [""] } else { $words }) + let current = ($args | last) + # `complete` keeps stderr off the prompt; a missing binary raises, hence `try`. + let result = (try { do { ^$exe "__complete" ...$args } | complete } catch { null }) if ($result | is-empty) or $result.exit_code != 0 { - return [] + return null } - try { $result.stdout | from json | get tasks } catch { [] } -} - -def "nu-complete task" [context: string] { - let words = (nu-complete task-words $context) - - # Words after `--` are forwarded to the task as CLI_ARGS: stop offering task - # names and let Nushell fall back to its own file completion. - if "--" in $words { + let lines = ($result.stdout | lines) + let last = ($lines | last) + # Protocol violation: offer nothing rather than garbage. + if ($last | is-empty) or (not ($last | str starts-with ":")) { return null } + let directive = (try { $last | str substring 1.. | into int } catch { 0 }) + let data = ($lines | drop 1) + + # Completion directives, mirroring internal/complete/complete.go. NoSpace (2) + # and KeepOrder (32) need none: no space is appended, order is kept. + let no_file_comp = (($directive | bits and 4) != 0) + let filter_file_ext = (($directive | bits and 8) != 0) + let filter_dirs = (($directive | bits and 16) != 0) + + # Nushell replaces the whole token, so an inline `--flag=` must be re-applied. + let inline = ($current | parse --regex '^(?--?[^=]+=)(?.*)$') + let flag_prefix = (if ($inline | is-empty) { "" } else { $inline.0.flag }) + let path_arg = (if ($inline | is-empty) { $current } else { $inline.0.path }) + + if $filter_file_ext or $filter_dirs { + # `into glob` turns the literal path into a pattern; matching nothing raises. + let entries = (try { ls ($"($path_arg)*" | into glob) } catch { [] }) + let matched = (if $filter_file_ext { + $entries | where {|entry| $entry.type == "dir" or ($entry.name | path parse | get extension) in $data } + } else { + $entries | where type == "dir" + }) + return ($matched | each {|entry| + # Without a trailing separator a second matches the dir again. + let name = (if $entry.type == "dir" { $"($entry.name)(char path_sep)" } else { $entry.name }) + { value: $"($flag_prefix)($name)" } + }) + } - let completions = ( - nu-complete task-list $words - | each {|item| - # `task` is the invocable name; `name` may be a display-only label. - let name = ($item.task | str trim --right --char ':') - let desc = ($item.desc? | default "") - let aliases = ( - $item.aliases? - | default [] - | each {|alias| { - value: ($alias | str trim --right --char ':') - description: (if ($desc | is-empty) { $"alias of ($name)" } else { $"($desc) \(alias of ($name)\)" }) - } } - ) - [{ value: $name, description: $desc }] | append $aliases + # Nushell does not filter an external completer's results. + let candidates = ($data + | each {|line| + let parts = ($line | split row --number 2 "\t") + let value = ($parts | first) + if ($parts | length) > 1 { { value: $value, description: ($parts | last) } } else { { value: $value } } } - | flatten - ) - - # `sort: false` keeps the order Task chose, which honours --sort and .taskrc. - { options: { sort: false }, completions: $completions } -} + | where {|candidate| $candidate.value | str starts-with --ignore-case $current }) -def "nu-complete task-shells" [] { - ["bash", "zsh", "fish", "powershell", "nu"] -} + if ($candidates | is-empty) and (not $no_file_comp) { + return null + } -def "nu-complete task-output" [] { - ["interleaved", "group", "prefixed"] + $candidates } -def "nu-complete task-sort" [] { - ["default", "alphanumeric", "none"] +# Nushell shares one external completer between every command, so chain to the +# installed one instead of breaking every other tool. +let task_previous_completer = ($env.config.completions.external.completer? | default null) + +$env.config.completions.external.completer = {|spans| + let exe = ($env.TASK_EXE? | default "task") + # Compare basenames so `./task`, `/usr/local/bin/task` and `task.exe` match. + let head = ($spans | first | path basename | str replace --regex '(?i)\.exe$' '') + let name = ($exe | path basename | str replace --regex '(?i)\.exe$' '') + if $head == $name { + task-external-completer $spans + } else if $task_previous_completer != null { + do $task_previous_completer $spans + } else { + null + } } - -# 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. -# -# An `extern` signature is static, so the experimental flag at the bottom is -# always offered; Task rejects it when the experiment is off. Run -# `task --experiments` to see which experiments are enabled. -export extern "task" [ - ...tasks: string@"nu-complete task" # task(s) to run - --list(-l) # list tasks with a description - --list-all(-a) # list all tasks, with or without a description - --json(-j) # format the task list as JSON - --no-status # ignore status when listing tasks as JSON - --nested # nest namespaces when listing tasks as JSON - --sort: string@"nu-complete task-sort" # change the order of the tasks when listed - --init(-i) # create a new Taskfile.yml in the current folder - --completion: string@"nu-complete task-shells" # generate a shell completion script - --taskfile(-t): glob # choose which Taskfile to run - --dir(-d): directory # set the directory in which Task will execute - --global(-g) # run the global Taskfile from $HOME - --temp-dir: directory # directory used to store Task temporary files - --force(-f) # force execution even when the task is up-to-date - --status # exit with a non-zero code if tasks are not up-to-date - --dry(-n) # compile and print the tasks without executing them - --summary # show the summary of a task instead of running it - --watch(-w) # watch the given tasks and re-run them on changes - --interval(-I): string # interval to watch for changes, e.g. 500ms - --parallel(-p) # run the tasks given on the command line in parallel - --concurrency(-C): int # limit the number of tasks run concurrently - --failfast(-F) # when running in parallel, stop everything if one task fails - --exit-code(-x) # pass through the exit code of the task command - --interactive # prompt for missing required variables - --yes(-y) # assume "yes" as the answer to all prompts - --output(-o): string@"nu-complete task-output" # set the output style - --output-group-begin: string # message template printed before a task's grouped output - --output-group-end: string # message template printed after a task's grouped output - --output-group-error-only # swallow the output of successful tasks - --color(-c) # colored output, enabled by default - --silent(-s) # disable echoing - --verbose(-v) # enable verbose mode - --disable-fuzzy # disable fuzzy matching for task names - --download # download a cached version of a remote Taskfile - --offline # only use local or cached Taskfiles - --clear-cache # clear the remote Taskfile cache - --trusted-hosts: string # trusted hosts for remote Taskfiles (comma-separated) - --timeout: string # timeout for downloading remote Taskfiles - --expiry: string # expiry duration for cached remote Taskfiles - --remote-cache-dir: directory # directory used to cache remote Taskfiles - --cacert: path # custom CA certificate for HTTPS connections - --cert: path # client certificate for HTTPS connections - --cert-key: path # client certificate key for HTTPS connections - --insecure # allow Taskfiles to be downloaded over insecure connections - --experiments # list the available experiments and whether they are enabled - --version # show the Task version - --help(-h) # show Task usage - - --force-all # [GENTLE_FORCE] force the called task and all its dependencies -] diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index dd5ed32c23..6f19e87c51 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -1,89 +1,109 @@ using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. $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) - - if ($commandName.StartsWith('-')) { - $completions = @( - # Standard flags (alphabetical order) - [CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'list all tasks'), - [CompletionResult]::new('--list-all', '--list-all', [CompletionResultType]::ParameterName, 'list all tasks'), - [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'colored output'), - [CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'colored output'), - [CompletionResult]::new('-C', '-C', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), - [CompletionResult]::new('--concurrency', '--concurrency', [CompletionResultType]::ParameterName, 'limit concurrent tasks'), - [CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'generate shell completion'), - [CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'set directory'), - [CompletionResult]::new('--dir', '--dir', [CompletionResultType]::ParameterName, 'set directory'), - [CompletionResult]::new('--disable-fuzzy', '--disable-fuzzy', [CompletionResultType]::ParameterName, 'disable fuzzy matching'), - [CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'dry run'), - [CompletionResult]::new('--dry', '--dry', [CompletionResultType]::ParameterName, 'dry run'), - [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'pass-through exit code'), - [CompletionResult]::new('--exit-code', '--exit-code', [CompletionResultType]::ParameterName, 'pass-through exit code'), - [CompletionResult]::new('--experiments', '--experiments', [CompletionResultType]::ParameterName, 'list experiments'), - [CompletionResult]::new('-F', '-F', [CompletionResultType]::ParameterName, 'fail fast on pallalel tasks'), - [CompletionResult]::new('--failfast', '--failfast', [CompletionResultType]::ParameterName, 'force execution'), - [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'force execution'), - [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'force execution'), - [CompletionResult]::new('-g', '-g', [CompletionResultType]::ParameterName, 'run global Taskfile'), - [CompletionResult]::new('--global', '--global', [CompletionResultType]::ParameterName, 'run global Taskfile'), - [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'show help'), - [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'show help'), - [CompletionResult]::new('-i', '-i', [CompletionResultType]::ParameterName, 'create new Taskfile'), - [CompletionResult]::new('--init', '--init', [CompletionResultType]::ParameterName, 'create new Taskfile'), - [CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'allow insecure downloads'), - [CompletionResult]::new('-I', '-I', [CompletionResultType]::ParameterName, 'watch interval'), - [CompletionResult]::new('--interval', '--interval', [CompletionResultType]::ParameterName, 'watch interval'), - [CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'format as JSON'), - [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'format as JSON'), - [CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'list tasks'), - [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), - [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), - [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), - [CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'), - [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), - [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), - [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), - [CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'), - [CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'), - [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'), - [CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'), - [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'), - [CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'silent mode'), - [CompletionResult]::new('--sort', '--sort', [CompletionResultType]::ParameterName, 'task sorting order'), - [CompletionResult]::new('--status', '--status', [CompletionResultType]::ParameterName, 'check task status'), - [CompletionResult]::new('--summary', '--summary', [CompletionResultType]::ParameterName, 'show task summary'), - [CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'choose Taskfile'), - [CompletionResult]::new('--taskfile', '--taskfile', [CompletionResultType]::ParameterName, 'choose Taskfile'), - [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'verbose output'), - [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'verbose output'), - [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'show version'), - [CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'), - [CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'), - [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'), - [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes'), - [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles'), - [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout'), - [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry'), - [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory'), - [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate'), - [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate'), - [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key'), - [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile'), - [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') - ) - - # 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') +Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' } + + # The current word arrives with the quote the user opened. + $current = $wordToComplete + if ($current.Length -ge 1 -and ($current[0] -eq '"' -or $current[0] -eq "'")) { + $quoteChar = $current[0] + $current = $current.Substring(1) + if ($current.EndsWith($quoteChar)) { + $current = $current.Substring(0, $current.Length - 1) + } + } + + # A string element yields its Value, so `--dir "a b"` arrives unquoted. + $argsToPass = @() + $elements = $commandAst.CommandElements + for ($i = 1; $i -lt $elements.Count; $i++) { + $el = $elements[$i] + if ($el.Extent.StartOffset -ge $cursorPosition) { break } + $argsToPass += if ($el -is [StringConstantExpressionAst] -or $el -is [ExpandableStringExpressionAst]) { + $el.Value + } else { + $el.ToString() } + } + # The trailing word tells the engine the cursor is on a fresh word. + if ($argsToPass.Count -eq 0 -or $argsToPass[-1] -ne $current) { + $argsToPass += $current + } + + $output = & $TaskExe __complete @argsToPass 2>$null + if (-not $output) { return } + + $lines = @($output) + $last = $lines[-1] + if (-not $last.StartsWith(':')) { return } + + $directive = [int]($last.Substring(1)) + $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + + # Completion directives, mirroring internal/complete/complete.go. + $NoFileComp = 4 + $FilterFileExt = 8 + $FilterDirs = 16 + + # PowerShell replaces the whole token, so the flag and directory prefix must + # be prepended back to every candidate. + $flagPrefix = '' + $pathArg = $current + if ($current -match '^(--?[^=]+=)(.*)$') { + $flagPrefix = $Matches[1] + $pathArg = $Matches[2] + } + $pathPrefix = $flagPrefix + ($pathArg -replace '[^\\/]*$', '') + + # DirectiveNoSpace cannot be honored: CompletionResult has no per-item "no + # trailing space" option, so `VAR=` gets one anyway. + + # The text replaces the token as-is, so a value holding a space must be quoted. + $asCompletionText = { + param($text) + if ($text -match '[\s'']') { "'" + $text.Replace("'", "''") + "'" } else { $text } + } + + $asPathResult = { + param($item) + $type = if ($item.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } + [CompletionResult]::new((& $asCompletionText "$pathPrefix$($item.Name)"), $item.Name, $type, $item.Name) + } + + # Directories are kept so the user can descend. `-Include` needs `-Recurse`. + if ($directive -band $FilterFileExt) { + $exts = $data | ForEach-Object { ".$_" } + return Get-ChildItem -Path "$pathArg*" -ErrorAction SilentlyContinue | + Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | + ForEach-Object { & $asPathResult $_ } + } + + if ($directive -band $FilterDirs) { + return Get-ChildItem -Path "$pathArg*" -Directory -ErrorAction SilentlyContinue | + ForEach-Object { & $asPathResult $_ } + } + + # PowerShell does not filter native argument-completer results itself. + $results = @($data | ForEach-Object { + $parts = $_ -split "`t", 2 + $value = $parts[0] + if ($current -and -not $value.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase)) { return } + $desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value } + [CompletionResult]::new((& $asCompletionText $value), $value, [CompletionResultType]::ParameterValue, $desc) + }) - return $completions.Where{ $_.CompletionText.StartsWith($commandName) } + # NoFileComp unset and nothing matched โ†’ DirectiveDefault, so offer files. + if ($results.Count -eq 0 -and -not ($directive -band $NoFileComp)) { + return Get-ChildItem -Path "$pathArg*" -ErrorAction SilentlyContinue | + ForEach-Object { & $asPathResult $_ } } - return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " } + return $results } diff --git a/completion/zsh/_task b/completion/zsh/_task index cd3e43a90d..107dea8fb1 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -1,158 +1,76 @@ #compdef task -typeset -A opt_args -TASK_CMD="${TASK_EXE:-task}" -compdef _task "$TASK_CMD" - -_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" - -# Check if an experiment is enabled -function __task_is_experiment_enabled() { - local experiment=$1 - task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on" -} - -# Listing commands from Taskfile.yml -function __task_list() { - local -a scripts cmd task_aliases match mbegin mend - local -i enabled=0 - local taskfile item task desc task_alias - - cmd=($TASK_CMD) - taskfile=${(Qv)opt_args[(i)-t|--taskfile]} - taskfile=${taskfile//\~/$HOME} - - 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=() +# +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. - # 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_CMD="${TASK_EXE:-task}" _task() { - local -a standard_args operation_args - - standard_args=( - '(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' - '(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' - '(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]' - '(-f --force)'{-f,--force}'[run even if task is up-to-date]' - '(-c --color)'{-c,--color}'[colored output]' - '(--completion)--completion[generate shell completion script]:shell:(bash zsh fish powershell nu)' - '(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs' - '(--disable-fuzzy)--disable-fuzzy[disable fuzzy matching for task names]' - '(-n --dry)'{-n,--dry}'[compiles and prints tasks without executing]' - '(--dry)--dry[dry-run mode, compile and print tasks only]' - '(-x --exit-code)'{-x,--exit-code}'[pass-through exit code of task command]' - '(--experiments)--experiments[list available experiments]' - '(-g --global)'{-g,--global}'[run global Taskfile from home directory]' - '(--insecure)--insecure[allow insecure Taskfile downloads]' - '(-I --interval)'{-I,--interval}'[interval to watch for changes]:duration: ' - '(-j --json)'{-j,--json}'[format task list as JSON]' - '(--nested)--nested[nest namespaces when listing as JSON]' - '(--no-status)--no-status[ignore status when listing as JSON]' - '(--interactive)--interactive[prompt for missing required variables]' - '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' - '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' - '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' - '(--output-group-error-only)--output-group-error-only[hide output from successful tasks]' - '(-s --silent)'{-s,--silent}'[disable echoing]' - '(--sort)--sort[set task sorting order]:order:(default alphanumeric none)' - '(--status)--status[exit non-zero if supplied tasks not up-to-date]' - '(--summary)--summary[show summary\: field from tasks instead of running them]' - '(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files' - '(-v --verbose)'{-v,--verbose}'[verbose mode]' - '(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]' - '(-y --yes)'{-y,--yes}'[assume yes to all prompts]' - '(--offline --clear-cache)--download[download remote Taskfile]' - '(--offline --download)--offline[use only local or cached Taskfiles]' - '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' - '(--expiry)--expiry[cache expiry duration]:duration: ' - '(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' - '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' - '(--cert)--cert[client certificate for mTLS]:file:_files' - '(--cert-key)--cert-key[client certificate private key]:file:_files' - ) - - # 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 - - operation_args=( - # Task names completion (can be specified multiple times) - '(operation)*: :__task_list' - # Operational args completion (mutually exclusive) - + '(operation)' - '(*)'{-l,--list}'[list describable tasks]' - '(*)'{-a,--list-all}'[list all tasks]' - '(*)'{-i,--init}'[create new Taskfile.yml]' - '(- *)'{-h,--help}'[show help]' - '(- *)--version[show version and exit]' - '(* --download)--clear-cache[clear remote Taskfile cache]' - ) - - _arguments -S $standard_args $operation_args + local -a args lines completions describe_opts compadd_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 + + # `-T` is true when the style is unset, so a flag goes out only when it is off. + zstyle -T ":completion:${curcontext}:" show-aliases || ctl+=(--no-aliases) + zstyle -T ":completion:${curcontext}:" verbose || ctl+=(--no-descriptions) + + # (@) preserves the trailing empty word the engine reads as a fresh cursor. + args=("${(@)words[2,CURRENT]}") + (( ${#args} == 0 )) && args=("") + + output=$("$TASK_CMD" __complete "${ctl[@]}" "${args[@]}" 2>/dev/null) + if [[ -z "$output" ]]; then + _files + return + fi + + lines=("${(f)output}") + directive="${lines[-1]#:}" + lines=("${(@)lines[1,-2]}") + + if (( directive & FILTER_FILE_EXT )); then + local -a globs + for line in "${lines[@]}"; do + globs+=("*.${line}") + done + # Inline `--flag=` into IPREFIX so file completion runs on the value. Only + # here: globally it would break `_describe` on inline enums. + compset -P '*=' + _files -g "(${(j:|:)globs})" + return + fi + + if (( directive & FILTER_DIRS )); then + compset -P '*=' + _path_files -/ + return + fi + + # _describe splits on the first unescaped colon: "docs:serve" โ†’ "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 + + # -S is a compadd option, passed after the array; -V belongs to _describe. + # In the compadd zone it would take the next argument as a group name. + (( directive & NO_SPACE )) && compadd_opts+=(-S '') + (( directive & KEEP_ORDER )) && describe_opts+=(-V) + + if (( ${#completions} > 0 )); then + _describe "${describe_opts[@]}" -t tasks 'task' completions "${compadd_opts[@]}" + fi + + (( directive & NO_FILE_COMP )) && return + compset -P '*=' + _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..29c8d5a538 --- /dev/null +++ b/internal/complete/complete.go @@ -0,0 +1,64 @@ +// Package complete implements the `task __complete` protocol consumed by the +// shell wrappers. It 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 +} + +// Words returns the command line being completed: the args after __complete. +func Words() []string { + return os.Args[2:] +} + +// Directive mirrors cobra's ShellCompDirective bitfield, emitted as `:`. +type Directive int + +const ( + DirectiveDefault Directive = 0 + // Never emitted: a failed Taskfile load still leaves flags worth completing. + 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 +} + +// Named after the control flags, so the zero value is the standard set. +type Options struct { + NoAliases bool + NoDescriptions bool +} + +// Control flags the shell wrappers prepend to the __complete invocation. +const ( + FlagNoAliases = "--no-aliases" + FlagNoDescriptions = "--no-descriptions" +) + +// Only leading flags are consumed; a `--no-aliases` typed later is left alone. +func ParseOptions(args []string) (Options, []string) { + var opts Options + for len(args) > 0 { + switch args[0] { + case FlagNoAliases: + opts.NoAliases = true + case FlagNoDescriptions: + opts.NoDescriptions = true + default: + return opts, args + } + args = args[1:] + } + return opts, args +} diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go new file mode 100644 index 0000000000..43d9657eab --- /dev/null +++ b/internal/complete/complete_test.go @@ -0,0 +1,523 @@ +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" + "github.com/go-task/task/v3/internal/slicesext" +) + +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.StringVar(&s, "temp-dir", "", "Temp dir") + fs.StringVarP(&s, "output", "o", "", "Output style") + fs.StringVar(&s, "sort", "", "Sort order") + fs.StringVar(&s, "cacert", "", "CA cert path") + fs.StringVar(&s, "completion", "", "Generate a completion script") + fs.StringVar(&s, "legacy-completion", "", "Generate a completion script") + 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 + requires: + vars: + - PORT + cmds: + - 'echo serving' + +` + +const wildcardTaskfile = `version: '3' + +tasks: + wildcard-*: + cmds: + - 'echo {{index .MATCH 0}}' + + wildcard-*-*: + cmds: + - 'echo {{index .MATCH 0}}' + + '*-wildcard-*': + cmds: + - 'echo {{index .MATCH 0}}' + + matches-exactly-*: + cmds: + - 'echo {{.MATCH}}' + + start-*: + desc: Start a service + aliases: [s-*] + cmds: + - 'echo {{index .MATCH 0}}' + + release-*: + desc: Release a component + requires: + vars: + - name: CHANNEL + enum: + - beta + - stable + cmds: + - 'echo {{index .MATCH 0}}' + + build: + desc: Build it + cmds: + - 'echo build' +` + +func setupExecutor(t *testing.T) *task.Executor { + t.Helper() + return setupExecutorWith(t, testTaskfile) +} + +func setupExecutorWith(t *testing.T, taskfile string) *task.Executor { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 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{""}, complete.Options{}) + + 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_WildcardTaskNames(t *testing.T) { + t.Parallel() + + e := setupExecutorWith(t, wildcardTaskfile) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{}) + + // Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*` + // collapse into one candidate, and `*-wildcard-*` leaves nothing to insert. + require.Equal(t, []string{"build", "matches-exactly-", "release-", "start-", "s-", "wildcard-"}, values(suggs)) + // A truncated prefix costs the whole response its trailing space, so task + // names keep theirs and the wildcard prefix gets one it does not want. + require.Equal(t, complete.DirectiveNoFileComp, dir) + // Without a desc, the pattern says what the prefix stands for. + require.Contains(t, descriptions(suggs), "wildcard-*") + + suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true}) + require.Equal(t, []string{"", "", "", "", "", ""}, descriptions(suggs)) +} + +func TestComplete_AliasResolvesToTaskVars(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}, complete.Options{}) + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) +} + +func TestComplete_StaticEnum(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""}, complete.Options{}) + + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) +} + +func TestComplete_EnumRef(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""}, complete.Options{}) + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs)) +} + +func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}, complete.Options{}) + 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", ""}, complete.Options{}) + require.Equal(t, []string{"PORT="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) +} + +func TestComplete_FlagValueInlineEquals(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, complete.Options{}) + // The inline form returns full `--output=value` tokens. + require.Equal(t, []string{"--output=interleaved", "--output=group", "--output=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", "--", ""}, complete.Options{}) + 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{"-"}, complete.Options{}) + 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", ""}, complete.Options{}) + 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", ""}, complete.Options{}) + 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", ""}, complete.Options{}) + 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", ""}, complete.Options{}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveFilterDirs, dir) +} + +func TestComplete_PathFlag_TempDir(t *testing.T) { + t.Parallel() + + suggs, dir := complete.Complete(setupExecutor(t), newTestFlagSet(), []string{"--temp-dir", ""}, complete.Options{}) + 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", ""}, complete.Options{}) + 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{"-"}, complete.Options{}) + require.NotEmpty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_NoAliases(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + opts := complete.Options{NoAliases: 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{NoDescriptions: true} + 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.Options{}, opts) + require.Equal(t, []string{"deploy", ""}, rest) + }) + + t.Run("both flags", func(t *testing.T) { + t.Parallel() + opts, rest := complete.ParseOptions([]string{"--no-aliases", "--no-descriptions", "deploy", ""}) + require.True(t, opts.NoAliases) + require.True(t, opts.NoDescriptions) + 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.False(t, opts.NoAliases) + 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())) + }) + } +} + +// Reading the Taskfile from standard input would block until EOF, freezing the +// shell on a keystroke. +func TestNeedsTaskfile_StdinEntrypoint(t *testing.T) { + t.Parallel() + + fs := newTestFlagSet() + require.NoError(t, fs.Set("taskfile", "-")) + require.False(t, complete.NeedsTaskfile([]string{""}, fs)) + require.False(t, complete.NeedsTaskfile([]string{"deploy", ""}, fs)) +} + +// Keeps the shells the engine offers in step with the scripts the root package +// can actually serve. +func TestCompletionShells(t *testing.T) { + t.Parallel() + + for _, flag := range []string{"--completion", "--legacy-completion"} { + suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{flag, ""}, complete.Options{}) + require.Equal(t, complete.DirectiveNoFileComp, dir) + require.NotEmpty(t, suggs) + + for _, shell := range values(suggs) { + _, err := task.Completion(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + _, err = task.LegacyCompletion(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + } + } +} + +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()) +} + +// CLI variables are global to the invocation, so once every requirement on the +// line is met the engine goes back to offering task names. +func TestComplete_TaskNamesAfterTaskWithoutRequires(t *testing.T) { + t.Parallel() + + suggs, dir := complete.Complete(setupExecutor(t), newTestFlagSet(), []string{"build", ""}, complete.Options{}) + + require.Subset(t, values(suggs), []string{"build", "deploy", "docs:serve"}) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_RequiredVarsThenTaskNames(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + fs := newTestFlagSet() + + suggs, dir := complete.Complete(e, fs, []string{"deploy", ""}, complete.Options{}) + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) + + suggs, dir = complete.Complete(e, fs, []string{"deploy", "ENV=dev", ""}, complete.Options{}) + require.Equal(t, []string{"REGION="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) + + suggs, dir = complete.Complete(e, fs, []string{"deploy", "ENV=dev", "REGION=eu", ""}, complete.Options{}) + require.Subset(t, values(suggs), []string{"build", "deploy"}) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_RequiredVarsUnionAcrossTasks(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", "deploy", ""}, complete.Options{}) + + // ENV is required by both and appears once, in the order the line names them. + require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs)) +} + +func TestComplete_WildcardTaskRequiredVars(t *testing.T) { + t.Parallel() + + e := setupExecutorWith(t, wildcardTaskfile) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"release-cli", ""}, complete.Options{}) + + require.Equal(t, []string{"CHANNEL=beta", "CHANNEL=stable"}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) +} + +// flags.WithFlags() applies WithTaskSorter after NewExecutor set its default, so +// the engine must not assume a sorter is present. +func TestComplete_DefaultSorterFallback(t *testing.T) { + t.Parallel() + + e := setupExecutorWith(t, testTaskfile) + e.Options(task.WithTaskSorter(nil)) + + suggs, _ := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{}) + require.Equal(t, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, values(suggs)) +} + +func values(suggs []complete.Suggestion) []string { + return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Value }) +} + +func descriptions(suggs []complete.Suggestion) []string { + return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Description }) +} diff --git a/internal/complete/context.go b/internal/complete/context.go new file mode 100644 index 0000000000..1502ecd63e --- /dev/null +++ b/internal/complete/context.go @@ -0,0 +1,74 @@ +package complete + +import ( + "slices" + "strings" + + "github.com/spf13/pflag" +) + +type completionContext struct { + toComplete string + prev string + afterDash bool +} + +// Infers the cursor position from args alone, so flag completion never loads +// the task list. +func parseContext(args []string) 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] + } + + ctx.afterDash = slices.Contains(args[:len(args)-1], "--") + + return ctx +} + +func (ctx completionContext) flagValue(fs *pflag.FlagSet) *pflag.Flag { + if f := matchFlagName(fs, ctx.prev); f != nil && flagTakesValue(f) { + return f + } + return nil +} + +func (ctx completionContext) inTaskContext(fs *pflag.FlagSet) bool { + return !ctx.afterDash && ctx.flagValue(fs) == nil && !strings.HasPrefix(ctx.toComplete, "-") +} + +// parsePriorWords splits the words before the cursor into task candidates and +// the names of the variables already set. fs is needed to skip the word after a +// value-taking flag: `task --dir deploy` must not read "deploy" as a task name. +func parsePriorWords(prior []string, fs *pflag.FlagSet) ([]string, map[string]bool) { + var tasks []string + setVars := make(map[string]bool, len(prior)) + + skipNext := false + for _, w := range prior { + if skipNext { + skipNext = false + continue + } + if strings.HasPrefix(w, "-") { + if !strings.Contains(w, "=") { + if f := matchFlagName(fs, w); f != nil && flagTakesValue(f) { + skipNext = true + } + } + continue + } + if name, _, ok := strings.Cut(w, "="); ok { + setVars[name] = true + continue + } + tasks = append(tasks, w) + } + + return tasks, setVars +} diff --git a/internal/complete/engine.go b/internal/complete/engine.go new file mode 100644 index 0000000000..eda451b5b6 --- /dev/null +++ b/internal/complete/engine.go @@ -0,0 +1,206 @@ +package complete + +import ( + "strings" + + "github.com/spf13/pflag" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/refs" + "github.com/go-task/task/v3/internal/slicesext" + "github.com/go-task/task/v3/internal/sort" + "github.com/go-task/task/v3/taskfile/ast" +) + +// e may be nil when the Taskfile failed to load; flag completion still works. +func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) ([]Suggestion, Directive) { + ctx := parseContext(args) + + if ctx.afterDash { + return nil, DirectiveDefault + } + + if flag := ctx.flagValue(fs); flag != nil { + return completeFlagValue(flag.Name, "") + } + + if strings.HasPrefix(ctx.toComplete, "-") { + if flagWord, _, ok := strings.Cut(ctx.toComplete, "="); ok { + if f := matchFlagName(fs, flagWord); f != nil && flagTakesValue(f) { + // Shells match against the whole token, so a bare value never would. + return completeFlagValue(f.Name, flagWord+"=") + } + } + return listFlags(fs), DirectiveNoFileComp + } + + // No prior arg means nothing can require a variable yet. + if e != nil && e.Taskfile != nil && len(args) > 1 { + if suggs, dir, ok := completeRequiredVars(e, args[:len(args)-1], fs); ok { + return suggs, dir + } + } + + return completeTaskNames(e, opts) +} + +func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool { + if !parseContext(args).inTaskContext(fs) { + return false + } + // Reading the Taskfile from standard input would hang the shell on a keystroke. + f := fs.Lookup("taskfile") + return f == nil || f.Value.String() != "-" +} + +func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) { + if e == nil || e.Taskfile == nil { + return nil, DirectiveNoFileComp + } + tasks := listTasks(e, opts) + desc := func(t *ast.Task) string { + if opts.NoDescriptions { + return "" + } + return t.Desc + } + + out := make([]Suggestion, 0, len(tasks)) + seen := make(map[string]bool, len(tasks)) + add := func(name, desc string) { + value, partial := suggestedName(name) + // `*-wildcard-*` has no prefix, and `wildcard-*` / `wildcard-*-*` share one. + if value == "" || seen[value] { + return + } + seen[value] = true + // Without a desc, a truncated pattern says what its prefix stands for. + if partial && desc == "" && !opts.NoDescriptions { + desc = name + } + out = append(out, Suggestion{Value: value, Description: desc}) + } + + for _, t := range tasks { + add(t.Task, desc(t)) + if opts.NoAliases { + continue + } + for _, alias := range t.Aliases { + add(alias, desc(t)) + } + } + + // A single truncated wildcard prefix would otherwise cost every complete + // name its trailing space: the directive covers the whole response. + return out, DirectiveNoFileComp +} + +// GetTaskList compiles every task, on every keystroke, and a description is the +// only compiled field read: worth its cost only when one holds a template. +func listTasks(e *task.Executor, opts Options) []*ast.Task { + // Not dead defence: flags.WithFlags() clobbers the sorter NewExecutor set. + sorter := e.TaskSorter + if sorter == nil { + sorter = sort.AlphaNumericWithRootTasksFirst + } + + out := make([]*ast.Task, 0, e.Taskfile.Tasks.Len()) + templated := false + for t := range e.Taskfile.Tasks.Values(sorter) { + if t.Internal { + continue + } + templated = templated || (!opts.NoDescriptions && strings.Contains(t.Desc, "{{")) + out = append(out, t) + } + + if templated { + // The uncompiled tasks keep one broken task from emptying the list. + if compiled, err := e.GetTaskList(task.FilterOutInternal); err == nil { + return compiled + } + } + return out +} + +// A pattern is truncated at its `*`: it is not runnable, `.MATCH` would be empty. +func suggestedName(name string) (string, bool) { + if prefix, _, ok := strings.Cut(name, "*"); ok { + return prefix, true + } + return strings.TrimRight(name, ":"), false +} + +// prefix is `=` for the inline form, so a candidate matches the whole token. +func completeFlagValue(flagName, prefix string) ([]Suggestion, Directive) { + // An absent key yields DirectiveDefault, falling through to the enums. + switch flagDirective[flagName] { + case DirectiveFilterFileExt: + return suggest("", taskfileExtensions), DirectiveFilterFileExt + case DirectiveFilterDirs: + return nil, DirectiveFilterDirs + } + + if values, ok := flagEnums[flagName]; ok { + return suggest(prefix, values), DirectiveNoFileComp + } + + return nil, DirectiveDefault +} + +func suggest(prefix string, values []string) []Suggestion { + return slicesext.Convert(values, func(v string) Suggestion { + return Suggestion{Value: prefix + v} + }) +} + +// CLI variables are global to the invocation, not scoped to a task, so this +// unions the still-unset requirements of every task named on the line. Reporting +// none lets the caller offer task names instead, which is how the line resolves +// itself: fill in what blocks execution, then add another task. +func completeRequiredVars(e *task.Executor, prior []string, fs *pflag.FlagSet) ([]Suggestion, Directive, bool) { + taskWords, setVars := parsePriorWords(prior, fs) + + out := make([]Suggestion, 0, 8) + seen := make(map[string]bool, 8) + for _, w := range taskWords { + // FindMatchingTasks resolves aliases and wildcards, and unlike GetTask it + // does not build the fuzzy model to spell-check a word that is not a task. + if matches, err := e.FindMatchingTasks(&task.Call{Task: w}); err != nil || len(matches) == 0 { + continue + } + compiled, err := e.FastCompiledTask(&task.Call{Task: w}) + if err != nil || compiled == nil || compiled.Requires == nil { + continue + } + for _, v := range compiled.Requires.Vars { + if v == nil || v.Name == "" || setVars[v.Name] || seen[v.Name] { + continue + } + seen[v.Name] = true + values := enumValues(v, compiled.Vars) + if len(values) == 0 { + out = append(out, Suggestion{Value: v.Name + "="}) + continue + } + for _, val := range values { + out = append(out, Suggestion{Value: v.Name + "=" + val}) + } + } + } + + if len(out) == 0 { + return nil, 0, false + } + // KeepOrder preserves the declaration order of the `requires` block. + return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder, true +} + +func enumValues(v *ast.VarsWithValidation, vars *ast.Vars) []string { + resolved := refs.ResolveEnum(v, vars) + if resolved.Enum == nil { + return nil + } + return resolved.Enum.Value +} diff --git a/internal/complete/flags.go b/internal/complete/flags.go new file mode 100644 index 0000000000..42ba30b875 --- /dev/null +++ b/internal/complete/flags.go @@ -0,0 +1,71 @@ +package complete + +import ( + "slices" + "strings" + + "github.com/spf13/pflag" +) + +// TestCompletionShells keeps this in step with the scripts the root package serves. +var completionShells = []string{"bash", "zsh", "fish", "powershell", "nu"} + +// 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": completionShells, + "legacy-completion": completionShells, +} + +// A flag absent here falls back to the shell's default file completion. +var flagDirective = map[string]Directive{ + "taskfile": DirectiveFilterFileExt, + "dir": DirectiveFilterDirs, + "remote-cache-dir": DirectiveFilterDirs, + "temp-dir": DirectiveFilterDirs, +} + +var taskfileExtensions = []string{"yml", "yaml"} + +func flagTakesValue(f *pflag.Flag) bool { + return f.NoOptDefVal == "" +} + +// Walks fs at call time so experiment-gated flags follow 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, + }) + } + }) + slices.SortFunc(out, func(a, b Suggestion) int { return strings.Compare(a.Value, b.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..69e08da735 --- /dev/null +++ b/internal/complete/output.go @@ -0,0 +1,28 @@ +package complete + +import ( + "fmt" + "io" + "strings" +) + +// The trailing `:` line is emitted even with 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) +} + +// A value's tab or newline would be read as a field or record separator. +var completionSanitizer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + +func sanitize(s string) string { + return completionSanitizer.Replace(s) +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 9e43d4a943..d09df3ad3f 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" @@ -48,6 +49,7 @@ var ( Help bool Init bool Completion string + LegacyCompletion string List bool ListAll bool ListJson bool @@ -124,6 +126,7 @@ func init() { pflag.BoolVarP(&Help, "help", "h", false, "Shows Task usage.") pflag.BoolVarP(&Init, "init", "i", false, "Creates a new Taskfile.yml in the current folder.") pflag.StringVar(&Completion, "completion", "", "Generates shell completion script.") + pflag.StringVar(&LegacyCompletion, "legacy-completion", "", "Generates the pre-engine shell completion script. Deprecated: use --completion.") pflag.BoolVarP(&List, "list", "l", false, "Lists tasks with description of current Taskfile.") pflag.BoolVarP(&ListAll, "list-all", "a", false, "Lists tasks with or without a description.") pflag.BoolVarP(&ListJson, "json", "j", false, "Formats task list as JSON.") @@ -174,6 +177,17 @@ func init() { pflag.BoolVarP(&ForceAll, "force", "f", false, "Forces execution even when the task is up-to-date.") } + // The words being completed hold partially typed and unknown flags, yet the + // flags deciding which Taskfile is loaded must still reach the engine. + // ContinueOnError keeps what was parsed and prints nothing. + if complete.IsActive() { + _, words := complete.ParseOptions(complete.Words()) + pflag.CommandLine.Init(pflag.CommandLine.Name(), pflag.ContinueOnError) + pflag.CommandLine.ParseErrorsAllowlist.UnknownFlags = true + _ = pflag.CommandLine.Parse(words) + return + } + pflag.Parse() // Auto-detect color based on environment when not explicitly configured @@ -265,6 +279,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { sorter = sort.NoSort case "alphanumeric": sorter = sort.AlphaNumeric + default: + // Not nil: this overwrites the sorter NewExecutor already set. + sorter = sort.AlphaNumericWithRootTasksFirst } // Change the directory to the user's home directory if the global flag is set diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 0000000000..1fa6347ad3 --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,23 @@ +package flags_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/flags" +) + +// WithFlags applies WithTaskSorter after NewExecutor set its default, so an +// unset --sort must still resolve to a sorter instead of clearing it. +func TestWithFlags_DefaultSorterIsNotCleared(t *testing.T) { //nolint:paralleltest // mutates package state + original := flags.TaskSort + t.Cleanup(func() { flags.TaskSort = original }) + + for _, sort := range []string{"", "default"} { + flags.TaskSort = sort + e := task.NewExecutor(flags.WithFlags()) + require.NotNilf(t, e.TaskSorter, "--sort %q left the executor without a sorter", sort) + } +} diff --git a/internal/refs/refs.go b/internal/refs/refs.go new file mode 100644 index 0000000000..30d532eb61 --- /dev/null +++ b/internal/refs/refs.go @@ -0,0 +1,64 @@ +// Package refs resolves the `ref` fields of a Taskfile into concrete values. +package refs + +import ( + "fmt" + + "github.com/go-task/task/v3/internal/slicesext" + "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile/ast" +) + +// Declared lists resolve to a []any, but `keys` and `splitList` return a []string. +func AsList(v any) ([]any, bool) { + switch value := v.(type) { + case []any: + return value, true + case []string: + return slicesext.AsAny(value), true + case []int: + return slicesext.AsAny(value), true + } + return nil, false +} + +func ResolveEnums(requires *ast.Requires, cache *templater.Cache) error { + if requires == nil || len(requires.Vars) == 0 { + return nil + } + for _, v := range requires.Vars { + if v.Enum == nil || v.Enum.Ref == "" { + continue + } + resolved := templater.ResolveRef(v.Enum.Ref, cache) + if cache.Err() != nil { + return cache.Err() + } + arr, ok := AsList(resolved) + if !ok { + return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref) + } + strValues := make([]string, 0, len(arr)) + for _, item := range arr { + s, ok := item.(string) + if !ok { + return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref) + } + strValues = append(strValues, s) + } + v.Enum.Value = strValues + } + return nil +} + +// A ref depending on dynamic vars may not resolve: the copy then has no enum +// value, which the interactive prompter treats as free-form input. +func ResolveEnum(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation { + if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 { + return v + } + vCopy := v.DeepCopy() + cache := &templater.Cache{Vars: vars} + _ = ResolveEnums(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, cache) + return vCopy +} diff --git a/requires_internal_test.go b/internal/refs/refs_test.go similarity index 55% rename from requires_internal_test.go rename to internal/refs/refs_test.go index fcfd6a1af9..202a52f544 100644 --- a/requires_internal_test.go +++ b/internal/refs/refs_test.go @@ -1,14 +1,15 @@ -package task +package refs_test import ( "testing" "github.com/stretchr/testify/require" + "github.com/go-task/task/v3/internal/refs" "github.com/go-task/task/v3/taskfile/ast" ) -func TestResolveEnumRefForPrompt(t *testing.T) { +func TestResolveEnum(t *testing.T) { t.Parallel() vars := ast.NewVars() @@ -19,9 +20,9 @@ func TestResolveEnumRefForPrompt(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".ALLOWED_ENVS"}} - resolved := resolveEnumRefForPrompt(v, vars) + resolved := refs.ResolveEnum(v, vars) - require.Equal(t, []string{"dev", "staging", "prod"}, getEnumValues(resolved.Enum)) + require.Equal(t, []string{"dev", "staging", "prod"}, resolved.Enum.Value) require.Empty(t, v.Enum.Value, "input var must not be mutated") require.Equal(t, ".ALLOWED_ENVS", v.Enum.Ref) }) @@ -31,7 +32,7 @@ func TestResolveEnumRefForPrompt(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".NONEXISTENT"}} - require.Empty(t, getEnumValues(resolveEnumRefForPrompt(v, vars).Enum)) + require.Empty(t, refs.ResolveEnum(v, vars).Enum.Value) }) t.Run("passes through a static enum unchanged", func(t *testing.T) { @@ -39,6 +40,17 @@ func TestResolveEnumRefForPrompt(t *testing.T) { v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Value: []string{"a", "b"}}} - require.Same(t, v, resolveEnumRefForPrompt(v, vars)) + require.Same(t, v, refs.ResolveEnum(v, vars)) + }) + + t.Run("accepts the list types template functions return", func(t *testing.T) { + t.Parallel() + + vars := ast.NewVars() + vars.Set("MAP", ast.Var{Value: map[string]any{"dev": 1, "prod": 2}}) + + v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: "keys .MAP | sortAlpha"}} + + require.Equal(t, []string{"dev", "prod"}, refs.ResolveEnum(v, vars).Enum.Value) }) } diff --git a/internal/slicesext/slicesext.go b/internal/slicesext/slicesext.go index 2aba5beb15..fb6a3d7477 100644 --- a/internal/slicesext/slicesext.go +++ b/internal/slicesext/slicesext.go @@ -30,3 +30,7 @@ func Convert[T, U any](s []T, f func(T) U) []U { return result } + +func AsAny[T any](s []T) []any { + return Convert(s, func(v T) any { return v }) +} diff --git a/requires.go b/requires.go index e425f83ce3..2903d25eef 100644 --- a/requires.go +++ b/requires.go @@ -7,7 +7,7 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/input" - "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/internal/refs" "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) @@ -46,7 +46,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { for _, v := range getMissingRequiredVars(compiledTask) { if !varsMap.Has(v.Name) { - varsMap.Set(v.Name, resolveEnumRefForPrompt(v, compiledTask.Vars)) + varsMap.Set(v.Name, refs.ResolveEnum(v, compiledTask.Vars)) } } @@ -217,16 +217,3 @@ func getEnumValues(e *ast.Enum) []string { } return e.Value } - -// resolveEnumRefForPrompt returns a copy of v with its enum ref resolved into -// concrete values, so the interactive prompter can show a Select. Refs that -// depend on dynamic vars may not resolve here and fall back to free-form input. -func resolveEnumRefForPrompt(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation { - if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 { - return v - } - vCopy := v.DeepCopy() - cache := &templater.Cache{Vars: vars} - _ = resolveEnumRefs(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, cache) - return vCopy -} diff --git a/testdata/completion/run.sh b/testdata/completion/run.sh new file mode 100755 index 0000000000..478de99db2 --- /dev/null +++ b/testdata/completion/run.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Builds the task binary and a fixture Taskfile, then runs every installed shell +# wrapper against them. The engine itself is covered by the Go tests. +set -u + +# fish, Nushell and PowerShell resolve the binary through these; an ambient value +# would silently test something other than the binary built below. +unset TASK_EXE GO_TASK_PROGNAME + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +root=$(cd "$here/../.." && pwd) + +bindir=$(mktemp -d) +fixture=$(mktemp -d) +trap 'rm -rf "$bindir" "$fixture"' EXIT + +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`. +export PATH="$bindir:$PATH" + +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" +# Nested path completion must keep the directory prefix. +touch "$fixture/sub/nested.yml" +# Shells must pass a quoted `--dir` value to the engine unquoted, and quote it +# back on insert. +mkdir -p "$fixture/with space" +cat > "$fixture/with space/Taskfile.yml" <<'YML' +version: '3' + +tasks: + spaced: + desc: Task from the spaced dir +YML +export TASK_FIXTURE="$fixture" + +# Strict mode (CI) turns a missing shell into a failure instead of a skip, so an +# absent pwsh never reads as a pass. +strict=${TASK_COMPLETION_STRICT:-} + +fails=0 +run() { # LABEL COMMAND... + echo "== $1 ==" + "${@:2}" || fails=$((fails + 1)) + echo +} +run_if() { # BIN LABEL COMMAND... + if command -v "$1" >/dev/null 2>&1; then run "${@:2}"; else skip "$2"; fi +} +skip() { # LABEL + if [[ -n "$strict" ]]; then + echo "== $1 == (MISSING โ€” required under TASK_COMPLETION_STRICT)" + fails=$((fails + 1)) + else + echo "== $1 == (skipped: not installed)" + fi + echo +} + +run "bash wrapper" bash "$here/wrapper.bash" +run_if zsh "zsh wrapper" zsh "$here/wrapper.zsh" +run_if fish "fish wrapper" fish "$here/wrapper.fish" +# --no-config-file: the user's own external completer must not interfere. +run_if nu "nu wrapper" nu --no-config-file "$here/wrapper.nu" + +pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true) +if [[ -n "$pwsh_bin" ]]; then + 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/testdata/completion/wrapper.bash b/testdata/completion/wrapper.bash new file mode 100755 index 0000000000..63cfcbd41b --- /dev/null +++ b/testdata/completion/wrapper.bash @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Smoke-tests how the bash wrapper routes each directive, by stubbing the +# bash-completion helpers. Requires TASK_BIN and TASK_FIXTURE. +set -u + +: "${TASK_BIN:?}"; : "${TASK_FIXTURE:?}" +export TASK_EXE="$TASK_BIN" +cd "$TASK_FIXTURE" || exit 1 + +fails=0 +CAP="" + +_init_completion() { + words=("${TEST_WORDS[@]}") + cword=$TEST_CWORD + cur="${TEST_WORDS[$TEST_CWORD]}" + prev="${TEST_WORDS[$((TEST_CWORD - 1))]}" + return 0 +} +# Records $cur so a test can assert the inline `--flag=` prefix was stripped. +_filedir() { CAP+="filedir:$* cur=$cur"$'\n'; } +compopt() { CAP+="compopt:$*"$'\n'; } +__ltrim_colon_completions() { :; } + +source "$(dirname "${BASH_SOURCE[0]}")/../../completion/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: :4 (NoFileComp) forwards candidates, no file fallback" +run task '' +reply_has "candidate forwarded" build +cap_hasnot "no file fallback" "filedir:" + +echo "bash: :2|:32 (NoSpace|KeepOrder) disable the trailing space and the sort" +run task deploy '' +cap_has "nospace applied" "compopt:-o nospace" +cap_has "keeporder applied" "compopt:-o nosort" + +echo "bash: :8 (FilterFileExt) routes to extension-filtered files" +run task --taskfile '' +cap_has "filedir ext glob" "filedir:@(yml|yaml)" + +echo "bash: :16 (FilterDirs) routes to directory completion" +run task --dir '' +cap_has "filedir -d" "filedir:-d" + +echo "bash: :0 (Default) falls back to files" +run task build -- '' +cap_has "filedir default" "filedir:" + +echo "bash: inline --flag= strips the prefix before file completion" +run task --taskfile=sub/x +cap_has "inline cur stripped" "cur=sub/x" + +if ((fails)); then + echo "bash: $fails failure(s)" + exit 1 +fi +echo "bash: all passed" diff --git a/testdata/completion/wrapper.fish b/testdata/completion/wrapper.fish new file mode 100755 index 0000000000..aa5833417a --- /dev/null +++ b/testdata/completion/wrapper.fish @@ -0,0 +1,55 @@ +#!/usr/bin/env fish +# Smoke-tests how the fish wrapper routes each directive, via `complete -C`. +# Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test. + +cd $TASK_FIXTURE +source (dirname (status -f))/../../completion/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: :4 (NoFileComp) forwards candidates, offers no files" +has "candidate forwarded" 'task ' build +hasnot "no file fallback" 'task ' notes.txt + +echo "fish: :16 (FilterDirs) offers directories only" +has "dir offered" 'task --dir ' sub/ +hasnot "no plain file" 'task --dir ' notes.txt + +echo "fish: :8 (FilterFileExt) filters by extension" +has "matching file" 'task --taskfile ' Taskfile.yml +hasnot "non-matching file" 'task --taskfile ' notes.txt + +echo "fish: :0 (Default) falls back to files" +has "file offered" 'task build -- ' notes.txt + +echo "fish: inline --flag=path keeps the --flag= prefix" +has "inline nested" 'task --taskfile=sub/' --taskfile=sub/nested.yml +hasnot "inline non-matching" 'task --taskfile=' --taskfile=notes.txt + +if test $fails -ne 0 + echo "fish: $fails failure(s)" + exit 1 +end +echo "fish: all passed" diff --git a/testdata/completion/wrapper.nu b/testdata/completion/wrapper.nu new file mode 100644 index 0000000000..e3339fa4fd --- /dev/null +++ b/testdata/completion/wrapper.nu @@ -0,0 +1,94 @@ +#!/usr/bin/env nu +# Smoke-tests how the Nushell wrapper routes each directive. External completers +# only run in the interactive REPL, so the closure is called directly. +# Set up by run.sh: $env.TASK_FIXTURE, and `task` on PATH = the binary under test. + +# `source` needs a parse-time constant path. +const TASK_NU = (path self "../../completion/nu/task-completions.nu") + +# Installed before the wrapper is sourced, to assert the delegation path. +$env.config.completions.external.completer = {|spans| [{ value: $"prev:($spans | first)" }] } + +source $TASK_NU + +cd $env.TASK_FIXTURE + +let completer = $env.config.completions.external.completer + +def cands [spans: list] { + let out = (do $completer $spans) + if $out == null { [] } else { $out | get value } +} + +def has [label: string, spans: list, value: string] { + let values = (cands $spans) + if $value in $values { + print $" ok ($label)" + 0 + } else { + print $" FAIL ($label) โ€” '($value)' missing from: ($values | str join ' ')" + 1 + } +} + +def hasnot [label: string, spans: list, value: string] { + if $value in (cands $spans) { + print $" FAIL ($label) โ€” '($value)' should be absent" + 1 + } else { + print $" ok ($label)" + 0 + } +} + +def check [label: string, ok: bool] { + if $ok { + print $" ok ($label)" + 0 + } else { + print $" FAIL ($label)" + 1 + } +} + +mut fails = 0 + +print "nu: :4 (NoFileComp) forwards candidates, offers no files" +$fails += (has "candidate forwarded" [task ""] "build") +$fails += (hasnot "no file fallback" [task ""] "notes.txt") + +print "nu: filters candidates by the current word" +$fails += (has "prefix keeps match" [task b] "build") +$fails += (hasnot "prefix drops others" [task b] "deploy") + +print "nu: :16 (FilterDirs) offers directories only" +$fails += (has "dir offered" [task --dir ""] $"sub(char path_sep)") +$fails += (hasnot "no plain file" [task --dir ""] "notes.txt") + +print "nu: :8 (FilterFileExt) filters by extension" +$fails += (has "matching file" [task --taskfile ""] "Taskfile.yml") +$fails += (hasnot "non-matching file" [task --taskfile ""] "notes.txt") + +print "nu: nested path completion keeps the directory prefix" +$fails += (has "prefix kept" [task --taskfile $"sub(char path_sep)"] $"sub(char path_sep)nested.yml") + +print "nu: inline --flag=path keeps the --flag= prefix" +$fails += (has "inline nested" [task $"--taskfile=sub(char path_sep)"] $"--taskfile=sub(char path_sep)nested.yml") +$fails += (hasnot "inline non-matching" [task "--taskfile="] "--taskfile=notes.txt") + +print "nu: :2|:32 (NoSpace|KeepOrder) keep the order the engine emitted" +let vars = (cands [task deploy ""]) +$fails += (has "required var offered" [task deploy ""] "ENV=dev") +$fails += (check "declaration order kept" (($vars | enumerate | where item == "ENV=dev" | get 0.index) < ($vars | enumerate | where item == "REGION=" | get 0.index))) + +print "nu: :0 (Default) returns null so Nushell completes files itself" +$fails += (check "null returned" ((do $completer [task build "--" ""]) == null)) + +print "nu: other commands go to the previously installed completer" +$fails += (has "delegated" [git status ""] "prev:git") + +if $fails != 0 { + print $"nu: ($fails) failure\(s\)" + exit 1 +} +print "nu: all passed" diff --git a/testdata/completion/wrapper.ps1 b/testdata/completion/wrapper.ps1 new file mode 100644 index 0000000000..798b0423aa --- /dev/null +++ b/testdata/completion/wrapper.ps1 @@ -0,0 +1,67 @@ +# Smoke-tests how the PowerShell wrapper routes each directive, via the +# completion API. Set up by run.sh: $env:TASK_FIXTURE, and `task` on PATH = +# the binary under test. + +Set-Location $env:TASK_FIXTURE +. "$PSScriptRoot/../../completion/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: :4 (NoFileComp) forwards candidates, offers no files" +Has "candidate forwarded" 'task ' 'build' +HasNot "no file fallback" 'task ' 'notes.txt' + +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: :16 (FilterDirs) offers directories only" +Has "dir offered" 'task --dir ' 'sub' +HasNot "no plain file" 'task --dir ' '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' + +Write-Output "powershell: nested path completion keeps the directory prefix" +Has "prefix kept" 'task --taskfile sub/' 'sub/nested.yml' + +Write-Output "powershell: inline --flag=path keeps the --flag= prefix" +Has "inline nested" 'task --taskfile=sub/' '--taskfile=sub/nested.yml' +HasNot "inline non-matching" 'task --taskfile=' '--taskfile=notes.txt' + +Write-Output "powershell: a quoted argument reaches the engine unquoted" +Has "single-quoted dir" "task --dir 'with space' " 'spaced' +Has "double-quoted dir" 'task --dir "with space" ' 'spaced' + +Write-Output "powershell: a candidate holding a space is quoted for insertion" +Has "dir quoted" 'task --dir w' "'with space'" + +if ($fails -ne 0) { + Write-Output "powershell: $fails failure(s)" + exit 1 +} +Write-Output "powershell: all passed" diff --git a/testdata/completion/wrapper.zsh b/testdata/completion/wrapper.zsh new file mode 100755 index 0000000000..c42f9ef2c1 --- /dev/null +++ b/testdata/completion/wrapper.zsh @@ -0,0 +1,88 @@ +#!/usr/bin/env zsh +# Smoke-tests how the zsh wrapper routes each directive, by stubbing _describe, +# _files and _path_files. Requires TASK_BIN and TASK_FIXTURE. + +export TASK_EXE=$TASK_BIN +cd $TASK_FIXTURE + +integer fails=0 +local CAP +compdef() { } # no-op: we call _task directly, not through compinit + +# Mirrors the real signature โ€” `_describe [-12JVoOx] [-t tag] descr array +# [compadd-opt ...]` โ€” so an option landing in the wrong zone is visible: the +# trailing zone goes to compadd, where -J and -V swallow the next argument. +_describe() { + local -a flags + while [[ $1 == -* ]]; do + case $1 in + (-t) flags+=($1 $2); shift 2 ;; + (*) flags+=($1); shift ;; + esac + done + local arr=$2 # $1 is descr + CAP+="describe_flags:[${flags[*]}]"$'\n' + CAP+="compadd_opts:[${@[3,-1]}]"$'\n' + local c; for c in ${(P)arr}; do CAP+="cand:$c"$'\n'; done +} +_files() { CAP+="files:$*"$'\n' } +_path_files() { CAP+="path_files:$*"$'\n' } + +# Sourcing avoids the autoload first-call quirk; `compdef` is stubbed above. +source ${0:A:h}/../../completion/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: :4 (NoFileComp) forwards candidates, no file fallback" +run task '' +has "candidate forwarded" "cand:build" +hasnot "no file fallback" "files:" + +# In the compadd zone, -V would take the next argument as a group name and +# swallow _describe's own `-d`, offering its internal variables as candidates. +echo "zsh: :2|:32 (NoSpace|KeepOrder) reach the right option zones" +run task deploy '' +has "KeepOrder -> _describe -V" "describe_flags:[-V" +has "NoSpace -> compadd -S" "compadd_opts:[-S ]" + +echo "zsh: :8 (FilterFileExt) routes to extension-filtered files" +run task --taskfile '' +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: :0 (Default) falls back to files" +run task build -- '' +has "files default" "files:" + +if (( fails )); then + echo "zsh: $fails failure(s)" + exit 1 +fi +echo "zsh: all passed" diff --git a/variables.go b/variables.go index c2085bd1ea..2742b26312 100644 --- a/variables.go +++ b/variables.go @@ -15,6 +15,8 @@ import ( "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/fingerprint" + "github.com/go-task/task/v3/internal/refs" + "github.com/go-task/task/v3/internal/slicesext" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile/ast" ) @@ -118,7 +120,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err requires := origTask.Requires if evaluateShVars { requires = origTask.Requires.DeepCopy() - if err := resolveEnumRefs(requires, cache); err != nil { + if err := refs.ResolveEnums(requires, cache); err != nil { return nil, err } } @@ -347,30 +349,6 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err return &new, nil } -func asAnySlice[T any](slice []T) []any { - ret := make([]any, len(slice)) - for i, v := range slice { - ret[i] = v - } - return ret -} - -// resolvedAsAnySlice converts a value resolved from a reference into a []any. -// A reference does not always resolve to a []any: lists declared in a Taskfile -// do, but template functions such as `keys` and `splitList` return a []string. -// The accepted types mirror the list types itemsFromFor already supports. -func resolvedAsAnySlice(v any) ([]any, bool) { - switch value := v.(type) { - case []any: - return value, true - case []string: - return asAnySlice(value), true - case []int: - return asAnySlice(value), true - } - return nil, false -} - func itemsFromFor( f *ast.For, dir string, @@ -392,7 +370,7 @@ func itemsFromFor( Err: err, } } - return asAnySlice(product(resolvedMatrix)), nil, nil + return slicesext.AsAny(product(resolvedMatrix)), nil, nil } // Get the list from the explicit for list if len(f.List) > 0 { @@ -410,7 +388,7 @@ func itemsFromFor( return nil, nil, err } } - values = asAnySlice(glist) + values = slicesext.AsAny(glist) } // Get the list from the task generates if f.From == "generates" { @@ -424,7 +402,7 @@ func itemsFromFor( return nil, nil, err } } - values = asAnySlice(glist) + values = slicesext.AsAny(glist) } // Get the list from a variable and split it up if f.Var != "" { @@ -437,14 +415,14 @@ func itemsFromFor( switch value := v.Value.(type) { case string: if f.Split != "" { - values = asAnySlice(strings.Split(value, f.Split)) + values = slicesext.AsAny(strings.Split(value, f.Split)) } else { - values = asAnySlice(strings.Fields(value)) + values = slicesext.AsAny(strings.Fields(value)) } case []string: - values = asAnySlice(value) + values = slicesext.AsAny(value) case []int: - values = asAnySlice(value) + values = slicesext.AsAny(value) case []any: values = value case map[string]any: @@ -492,7 +470,7 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix, if cache.Err() != nil { return nil, cache.Err() } - value, ok := resolvedAsAnySlice(v) + value, ok := refs.AsList(v) if !ok { return nil, fmt.Errorf("matrix reference %q must resolve to a list", row.Ref) } @@ -502,35 +480,6 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix, return resolved, nil } -func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error { - if requires == nil || len(requires.Vars) == 0 { - return nil - } - for _, v := range requires.Vars { - if v.Enum == nil || v.Enum.Ref == "" { - continue - } - resolved := templater.ResolveRef(v.Enum.Ref, cache) - if cache.Err() != nil { - return cache.Err() - } - arr, ok := resolvedAsAnySlice(resolved) - if !ok { - return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref) - } - strValues := make([]string, 0, len(arr)) - for _, item := range arr { - s, ok := item.(string) - if !ok { - return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref) - } - strValues = append(strValues, s) - } - v.Enum.Value = strValues - } - return nil -} - // product generates the cartesian product of the input map of slices. func product(matrix *ast.Matrix) []map[string]any { if matrix.Len() == 0 { diff --git a/website/src/next/docs/installation.md b/website/src/next/docs/installation.md index 05658d04c7..989f6435d5 100644 --- a/website/src/next/docs/installation.md +++ b/website/src/next/docs/installation.md @@ -369,8 +369,14 @@ go tool task {arguments...} Some installation methods will automatically install completions too, but if this isn't working for you or your chosen method doesn't include them, you can run `task --completion ` to output a completion script for any supported -shell. There are a couple of ways these completions can be added to your shell -config: +shell. + +Every shell shares a single source of truth: the script is a thin wrapper that +asks the `task` binary itself what to suggest, so Bash, Zsh, Fish, Nushell and +PowerShell all offer the same task names, aliases, flags, flag values and +`requires` vars. + +There are a couple of ways these completions can be added to your shell config: ### Option 1. Load the completions in your shell's startup config (Recommended) @@ -468,21 +474,29 @@ to an autoload directory. Option 1 rewrites it at every startup, which keeps it in sync with the installed version of Task โ€” the refreshed completions are picked up by the next shell. With option 2, re-run the command after upgrading Task. -The completions are attached to an `extern "task"` declaration, which Nushell -requires to be static. Three consequences are worth knowing: - -- The experimental flags (`--force-all`, `--download`, `--offline`, โ€ฆ) are always - offered, even when the corresponding experiment is disabled. Their description - is prefixed with the experiment name, and `task --experiments` lists the ones - that are enabled. -- Passing a value to a boolean flag with `=` does not work: Nushell forwards - `--color=false` as two arguments, so Task reads `false` as a task name. Use - `NO_COLOR=1`, or bypass the declaration with `^task --color=false`. -- `TASK_EXE` selects the executable that is run, but not the command name the - completions are attached to, which is always `task`. For a renamed executable, - alias it instead: +Nushell shares a single external completer between every command, so the script +chains to the one already configured โ€” carapace and friends keep working. Load it +from an autoload directory as shown above rather than from `config.nu`, so that +your own completer is the one being chained to. If you would rather wire it +yourself, the script also exposes a `task-external-completer` command: ```nu -use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") * -alias go-task = task +$env.config.completions.external.completer = {|spans| + match ($spans | first) { + task => (task-external-completer $spans) + _ => (do $my_other_completer $spans) + } +} ``` + +Two engine directives behave differently under Nushell by design: it never +appends a space after an external completion (so `NoSpace` is a no-op) and never +re-sorts the results (so `KeepOrder` is always honoured). + +### Legacy completion scripts + +Before the engine, every shell carried its own hand-written completion script, +each with its own idea of what to suggest. Those scripts are still shipped and +available through `task --legacy-completion `, as an escape hatch should +the engine misbehave in your setup. They are deprecated, will not receive further +fixes, and will be removed in a future release.