From ed7a206e191f3b8bac6b21d11b26742eb9f2fecf Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 13:56:01 +0200 Subject: [PATCH 1/8] feat(completion): unify shell completions behind an opt-in `task __complete` engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash, Fish, Zsh, Nushell and PowerShell now share a single backend: `task __complete` returns the suggestions plus a directive, and every wrapper is a thin shim around it. All five shells offer 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 engine is opt-in via `task --new-completion `, leaving `--completion` and the legacy scripts untouched; it will become the default in a future release. The new wrappers live under `completion/next/`. Completing a keystroke never reaches the network, never blocks on a stdin entrypoint, and honors every flag that decides how the Taskfile is loaded. Ref resolution shared by `requires` and enum completion moved to `internal/refs`. A cross-shell test suite exercises the protocol in Go with thin shell smoke tests, and runs in CI. --- .github/workflows/ci.yml | 43 ++ CHANGELOG.md | 11 + Taskfile.yml | 9 + cmd/task/complete_cmd.go | 41 ++ cmd/task/task.go | 16 + completion.go | 65 ++- completion/next/bash/task.bash | 89 ++++ completion/next/fish/task.fish | 98 +++++ completion/next/nu/task-completions.nu | 86 ++++ completion/next/ps/task.ps1 | 109 +++++ completion/next/zsh/_task | 76 ++++ completion/protocol_test.go | 328 ++++++++++++++ completion/tests/run.sh | 94 ++++ completion/tests/wrapper.bash | 80 ++++ completion/tests/wrapper.fish | 55 +++ completion/tests/wrapper.nu | 94 ++++ completion/tests/wrapper.ps1 | 67 +++ completion/tests/wrapper.zsh | 88 ++++ internal/complete/complete.go | 59 +++ internal/complete/complete_test.go | 415 ++++++++++++++++++ internal/complete/context.go | 79 ++++ internal/complete/engine.go | 209 +++++++++ internal/complete/flags.go | 70 +++ internal/complete/output.go | 28 ++ internal/flags/flags.go | 14 + internal/refs/refs.go | 64 +++ .../refs/refs_test.go | 24 +- internal/slicesext/slicesext.go | 4 + requires.go | 17 +- variables.go | 73 +-- website/src/latest/docs/installation.md | 63 +++ 31 files changed, 2470 insertions(+), 98 deletions(-) create mode 100644 cmd/task/complete_cmd.go create mode 100644 completion/next/bash/task.bash create mode 100644 completion/next/fish/task.fish create mode 100644 completion/next/nu/task-completions.nu create mode 100644 completion/next/ps/task.ps1 create mode 100755 completion/next/zsh/_task create mode 100644 completion/protocol_test.go create mode 100755 completion/tests/run.sh create mode 100755 completion/tests/wrapper.bash create mode 100755 completion/tests/wrapper.fish create mode 100644 completion/tests/wrapper.nu create mode 100644 completion/tests/wrapper.ps1 create mode 100755 completion/tests/wrapper.zsh create mode 100644 internal/complete/complete.go create mode 100644 internal/complete/complete_test.go create mode 100644 internal/complete/context.go create mode 100644 internal/complete/engine.go create mode 100644 internal/complete/flags.go create mode 100644 internal/complete/output.go create mode 100644 internal/refs/refs.go rename requires_internal_test.go => internal/refs/refs_test.go (55%) 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..ee68fd02d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### ๐Ÿš€ Features + +- Added a new completion engine that unifies Bash, Fish, Zsh, Nushell and + PowerShell behind a single `task __complete` command, so every shell offers + the same suggestions: task names, aliases, flags, flag values and per-task CLI + variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now + backed by the `--no-aliases` and `--no-descriptions` completion flags. It is + opt-in for now via `task --new-completion `, leaving `--completion` + unchanged, and will become the default 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..df62a0a4b5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -158,6 +158,15 @@ tasks: cmds: - go test -bench=. -benchmem -tags=fsbench -run=^$ ./... + test:completion: + desc: Tests the shell completion engine and wrappers (bash, zsh, fish, nu, powershell) + sources: + - internal/complete/**/*.go + - cmd/task/**/*.go + - completion/**/* + cmds: + - bash completion/tests/run.sh + goreleaser:test: desc: Tests release process without publishing cmds: diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go new file mode 100644 index 0000000000..bc06d02b50 --- /dev/null +++ b/cmd/task/complete_cmd.go @@ -0,0 +1,41 @@ +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.WithOffline(true), + task.WithDownload(false), + ) + + // Best-effort, and never from stdin: that would hang the shell. + if complete.NeedsTaskfile(args, pflag.CommandLine) && flags.Entrypoint != "-" { + _ = 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..2332845199 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -13,6 +13,7 @@ import ( "github.com/go-task/task/v3/args" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/experiments" + "github.com/go-task/task/v3/internal/complete" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/logger" @@ -58,6 +59,12 @@ func emitCIErrorAnnotation(err error) { } func run() error { + // Dispatched before flag validation: the args after __complete are the + // user's command line, not Task's own flags. + if complete.IsActive() { + return runComplete(os.Args[2:]) + } + log := &logger.Logger{ Stdout: os.Stdout, Stderr: os.Stderr, @@ -126,6 +133,15 @@ func run() error { return nil } + if flags.NewCompletion != "" { + script, err := task.CompletionNext(flags.NewCompletion) + if err != nil { + return err + } + fmt.Println(script) + return nil + } + e := task.NewExecutor( flags.WithFlags(), task.WithVersionCheck(true), diff --git a/completion.go b/completion.go index ab333b7ad4..15e6896124 100644 --- a/completion.go +++ b/completion.go @@ -20,20 +20,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) +// Thin wrappers around the `task __complete` engine, served via +// `--new-completion` until the engine becomes the default. + +//go:embed completion/next/bash/task.bash +var completionBashNext string + +//go:embed completion/next/fish/task.fish +var completionFishNext string + +//go:embed completion/next/nu/task-completions.nu +var completionNuNext string + +//go:embed completion/next/ps/task.ps1 +var completionPowershellNext string + +//go:embed completion/next/zsh/_task +var completionZshNext 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 completionScriptsNext = map[string]string{ + "bash": completionBashNext, + "fish": completionFishNext, + "nu": completionNuNext, + "nushell": completionNuNext, + "powershell": completionPowershellNext, + "zsh": completionZshNext, +} + +func Completion(shell string) (string, error) { + return completionScript(completionScripts, shell) +} + +func CompletionNext(shell string) (string, error) { + return completionScript(completionScriptsNext, shell) +} + +func completionScript(scripts map[string]string, shell string) (string, error) { + script, ok := scripts[shell] + if !ok { + return "", fmt.Errorf("unknown shell: %s", shell) } + return script, nil } diff --git a/completion/next/bash/task.bash b/completion/next/bash/task.bash new file mode 100644 index 0000000000..90205f1c71 --- /dev/null +++ b/completion/next/bash/task.bash @@ -0,0 +1,89 @@ +# vim: set tabstop=2 shiftwidth=2 expandtab: +# +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. + +TASK_CMD="${TASK_EXE:-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 + + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 + + # `=` 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 + + if (( directive & NO_SPACE )); then + compopt -o nospace 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/next/fish/task.fish b/completion/next/fish/task.fish new file mode 100644 index 0000000000..908f089783 --- /dev/null +++ b/completion/next/fish/task.fish @@ -0,0 +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) + +# 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 + +function __task_test_bit --argument-names value bit + test (math "floor($value / $bit) % 2") -eq 1 +end + +function __task_complete --inherit-variable GO_TASK_PROGNAME + set -l tokens (commandline -opc) + set -l current (commandline -ct) + set -l args + if test (count $tokens) -gt 1 + set args $tokens[2..-1] + end + set args $args $current + + set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null) + set -l count (count $output) + if test $count -eq 0 + return + end + + set -l last $output[$count] + if not string match -q ':*' -- $last + # Protocol violation: emit raw lines as a fallback. + printf '%s\n' $output + return + end + + set -l directive (string replace -r '^:' '' -- $last) + set -l data + if test $count -gt 1 + set data $output[1..(math $count - 1)] + end + + # The registration below passes `--no-files`, so every file-completion + # directive must be served here or nothing is offered at all. + + # 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 + + 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 + + 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 + +# fish accumulates `complete` entries instead of replacing them, so an older +# completion would keep contributing alongside the engine. +complete -c $GO_TASK_PROGNAME -e + +# `--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/next/nu/task-completions.nu b/completion/next/nu/task-completions.nu new file mode 100644 index 0000000000..25fd56abd0 --- /dev/null +++ b/completion/next/nu/task-completions.nu @@ -0,0 +1,86 @@ +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. + +# 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") + + # 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 null + } + + let lines = ($result.stdout | lines) + let last = ($lines | last) + # Protocol violation: offer nothing rather than garbage. + if ($last | is-empty) or (not ($last | str starts-with ":")) { + return null + } + let directive = (try { $last | str substring 1.. | into int } catch { 0 }) + let data = ($lines | drop 1) + + # Completion directives, mirroring internal/complete/complete.go. 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)" } + }) + } + + # 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 } } + } + | where {|candidate| $candidate.value | str starts-with --ignore-case $current }) + + if ($candidates | is-empty) and (not $no_file_comp) { + return null + } + + $candidates +} + +# 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 + } +} diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 new file mode 100644 index 0000000000..6f19e87c51 --- /dev/null +++ b/completion/next/ps/task.ps1 @@ -0,0 +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 -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) + }) + + # 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 $results +} diff --git a/completion/next/zsh/_task b/completion/next/zsh/_task new file mode 100755 index 0000000000..107dea8fb1 --- /dev/null +++ b/completion/next/zsh/_task @@ -0,0 +1,76 @@ +#compdef task +# +# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. + +TASK_CMD="${TASK_EXE:-task}" + +_task() { + 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 +} + +compdef _task "$TASK_CMD" diff --git a/completion/protocol_test.go b/completion/protocol_test.go new file mode 100644 index 0000000000..6a7e142061 --- /dev/null +++ b/completion/protocol_test.go @@ -0,0 +1,328 @@ +// Black-box tests of the `task __complete` wire protocol. How each shell +// wrapper interprets the directive is smoke-tested in completion/tests/. +package completion_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/complete" +) + +var taskBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "task-completion-test") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + taskBin = filepath.Join(dir, "task") + if runtime.GOOS == "windows" { + taskBin += ".exe" + } + if out, err := exec.CommandContext(context.Background(), "go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out) + os.RemoveAll(dir) + os.Exit(1) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +const fixtureTaskfile = `version: '3' + +tasks: + build: + desc: Build it + deploy: + desc: Deploy the application + aliases: [dep, ship] + requires: + vars: + - name: ENV + enum: [dev, staging, prod] + - REGION + docs:serve: + desc: Serve docs locally +` + +// completeArgs runs `task __complete ` in a fresh fixture directory. +func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) { + t.Helper() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) + + cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec + cmd.Dir = dir + out, err := cmd.Output() + require.NoError(t, err) + + return parseProtocol(t, out) +} + +func parseProtocol(t *testing.T, out []byte) ([]string, complete.Directive) { + t.Helper() + + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + require.NotEmpty(t, lines, "protocol output must end with a directive line") + + last := lines[len(lines)-1] + require.True(t, strings.HasPrefix(last, ":"), "last line must be the : line, got %q", last) + n, err := strconv.Atoi(strings.TrimPrefix(last, ":")) + require.NoError(t, err) + + values := make([]string, 0, len(lines)-1) + for _, line := range lines[:len(lines)-1] { + values = append(values, strings.SplitN(line, "\t", 2)[0]) + } + return values, complete.Directive(n) +} + +func TestProtocol(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []string // candidate values that must be offered + absent []string // candidate values that must NOT be offered + directive complete.Directive + }{ + { + name: "task names and aliases", + args: []string{""}, + want: []string{"build", "deploy", "dep", "ship", "docs:serve"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "no-aliases drops aliases", + args: []string{"--no-aliases", ""}, + want: []string{"build", "deploy"}, + absent: []string{"dep", "ship"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "flag names", + args: []string{"-"}, + want: []string{"--taskfile", "--dir", "--output"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "separate flag value is bare", + args: []string{"--output", ""}, + want: []string{"interleaved", "group", "prefixed"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "inline flag value is full form", + args: []string{"--output="}, + want: []string{"--output=interleaved", "--output=group", "--output=prefixed"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "sort enum values", + args: []string{"--sort", ""}, + want: []string{"default", "alphanumeric", "none"}, + directive: complete.DirectiveNoFileComp, + }, + { + name: "taskfile filters by extension", + args: []string{"--taskfile", ""}, + want: []string{"yml", "yaml"}, + directive: complete.DirectiveFilterFileExt, + }, + { + name: "dir filters to directories", + args: []string{"--dir", ""}, + directive: complete.DirectiveFilterDirs, + }, + { + name: "task variables keep order and suppress the space", + args: []string{"deploy", ""}, + want: []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, + directive: complete.DirectiveNoSpace | complete.DirectiveNoFileComp | complete.DirectiveKeepOrder, + }, + { + name: "after -- yields default file completion", + args: []string{"deploy", "--", ""}, + directive: complete.DirectiveDefault, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + values, directive := completeArgs(t, tt.args...) + require.Equal(t, tt.directive, directive) + require.Subset(t, values, tt.want) + for _, a := range tt.absent { + require.NotContains(t, values, a) + } + }) + } +} + +// --sort is the flag deciding how the Taskfile is read with a visible order. +func TestProtocol_SortFlagIsApplied(t *testing.T) { + t.Parallel() + + const taskfile = `version: '3' + +tasks: + zebra: + desc: Declared first, last alphabetically + alpha: + desc: Declared last, first alphabetically +` + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) + + sorted, _ := completeInDir(t, dir, nil, "") + require.Equal(t, []string{"alpha", "zebra"}, sorted) + + declared, _ := completeInDir(t, dir, nil, "--sort", "none", "") + require.Equal(t, []string{"zebra", "alpha"}, declared) +} + +func TestProtocol_ExperimentGatedFlag(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) + + values, directive := completeInDir(t, dir, []string{"TASK_X_GENTLE_FORCE=1"}, "--force-all", "") + require.Equal(t, complete.DirectiveNoFileComp, directive) + require.Subset(t, values, []string{"build", "deploy"}) +} + +// Downloading an uncached remote include would freeze the shell for up to +// --timeout and prompt for trust. +func TestProtocol_RemoteIncludeStaysOffline(t *testing.T) { + t.Parallel() + + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + <-r.Context().Done() + })) + defer srv.Close() + + taskfile := fmt.Sprintf(`version: '3' + +includes: + remote: %s/Taskfile.yml + +tasks: + build: + desc: Build it +`, srv.URL) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + // A fresh cache dir leaves a download as the only way to resolve the + // include; the insecure opt-in keeps the plain-HTTP server from being + // rejected before it. + cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "") //nolint:gosec + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "TASK_REMOTE_DIR="+t.TempDir(), + "TASK_REMOTE_INSECURE=1", + ) + out, err := cmd.Output() + require.NoError(t, err, "completion must not hang on a remote include") + + _, directive := parseProtocol(t, out) + require.Equal(t, complete.DirectiveNoFileComp, directive) + require.Zero(t, hits.Load(), "completion must not reach the network") +} + +// `--taskfile -` would otherwise read the Taskfile from the terminal. +func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) { + t.Parallel() + + // An unwritten pipe: reading it would block until the context expires. + r, w, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { + r.Close() + w.Close() + }) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") //nolint:gosec + cmd.Dir = t.TempDir() + cmd.Stdin = r + out, err := cmd.Output() + require.NoError(t, err, "completion must not read the Taskfile from stdin") + + _, directive := parseProtocol(t, out) + require.Equal(t, complete.DirectiveNoFileComp, directive) +} + +func TestProtocol_WildcardTaskNames(t *testing.T) { + t.Parallel() + + values, directive := completeInDir(t, filepath.Join("..", "testdata", "wildcards"), nil, "") + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, directive) + require.Subset(t, values, []string{"start-", "s-", "wildcard-", "matches-exactly-"}) + for _, v := range values { + require.NotEmpty(t, v) + require.NotContains(t, v, "*") + } +} + +// completeInDir runs `task __complete ` in dir, with env appended to the +// current environment. +func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) { + t.Helper() + + cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + out, err := cmd.Output() + require.NoError(t, err) + + return parseProtocol(t, out) +} + +// 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", "--new-completion"} { + shells, directive := completeArgs(t, flag, "") + require.Equal(t, complete.DirectiveNoFileComp, directive) + require.NotEmpty(t, shells) + + for _, shell := range shells { + _, err := task.Completion(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + _, err = task.CompletionNext(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + } + } +} diff --git a/completion/tests/run.sh b/completion/tests/run.sh new file mode 100755 index 0000000000..891884f86a --- /dev/null +++ b/completion/tests/run.sh @@ -0,0 +1,94 @@ +#!/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 + +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/completion/tests/wrapper.bash b/completion/tests/wrapper.bash new file mode 100755 index 0000000000..a234e2156c --- /dev/null +++ b/completion/tests/wrapper.bash @@ -0,0 +1,80 @@ +#!/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]}")/../next/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 (NoSpace) disables the trailing space" +run task deploy '' +cap_has "nospace applied" "compopt:-o nospace" + +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/completion/tests/wrapper.fish b/completion/tests/wrapper.fish new file mode 100755 index 0000000000..e1a24e55c0 --- /dev/null +++ b/completion/tests/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))/../next/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/completion/tests/wrapper.nu b/completion/tests/wrapper.nu new file mode 100644 index 0000000000..ed47a4c0b2 --- /dev/null +++ b/completion/tests/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 "../next/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/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 new file mode 100644 index 0000000000..7d896e0066 --- /dev/null +++ b/completion/tests/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/../next/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/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh new file mode 100755 index 0000000000..820261e4fc --- /dev/null +++ b/completion/tests/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}/../next/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/internal/complete/complete.go b/internal/complete/complete.go new file mode 100644 index 0000000000..5acf14f427 --- /dev/null +++ b/internal/complete/complete.go @@ -0,0 +1,59 @@ +// 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 +} + +// 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..a5fc438f1a --- /dev/null +++ b/internal/complete/complete_test.go @@ -0,0 +1,415 @@ +package complete_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/complete" +) + +func newTestFlagSet() *pflag.FlagSet { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + var b bool + var s string + fs.BoolVarP(&b, "list-all", "a", false, "Lists all tasks") + fs.BoolVarP(&b, "list", "l", false, "Lists tasks with descriptions") + fs.BoolVarP(&b, "verbose", "v", false, "Verbose mode") + fs.StringVarP(&s, "taskfile", "t", "", "Taskfile path") + fs.StringVarP(&s, "dir", "d", "", "Run dir") + fs.StringVarP(&s, "output", "o", "", "Output style") + fs.StringVar(&s, "sort", "", "Sort order") + fs.StringVar(&s, "cacert", "", "CA cert path") + return fs +} + +const testTaskfile = `version: '3' + +vars: + ALLOWED_ENVS: + - dev + - staging + - prod + +tasks: + deploy: + desc: Deploy the application + aliases: [dep, ship] + requires: + vars: + - name: ENV + enum: + - dev + - staging + - prod + - REGION + cmds: + - 'echo {{.ENV}} {{.REGION}}' + + build: + desc: Build it + cmds: + - 'echo build' + + dynenum: + desc: Dynamic enum + requires: + vars: + - name: ENV + enum: + ref: .ALLOWED_ENVS + cmds: + - 'echo {{.ENV}}' + + docs:serve: + desc: Serve docs locally + cmds: + - 'echo serving' +` + +const wildcardTaskfile = `version: '3' + +tasks: + wildcard-*: + cmds: + - 'echo {{index .MATCH 0}}' + + wildcard-*-*: + cmds: + - 'echo {{index .MATCH 0}}' + + '*-wildcard-*': + cmds: + - 'echo {{index .MATCH 0}}' + + start-*: + desc: Start a service + aliases: [s-*] + cmds: + - 'echo {{index .MATCH 0}}' + + build: + desc: Build it + cmds: + - 'echo build' +` + +func setupExecutor(t *testing.T) *task.Executor { + t.Helper() + return setupExecutorWith(t, testTaskfile) +} + +func setupExecutorWith(t *testing.T, taskfile string) *task.Executor { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(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", "start-", "s-", "wildcard-"}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) + // Without a desc, the pattern says what the prefix stands for. + require.Contains(t, descriptions(suggs), "wildcard-*") + + suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true}) + require.Equal(t, []string{"", "", "", ""}, descriptions(suggs)) +} + +func TestComplete_AliasResolvesToTaskVars(t *testing.T) { + t.Parallel() + + 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_NoRequires(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}, complete.Options{}) + require.Empty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}, 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.Empty(t, suggs) + require.Equal(t, complete.DirectiveNoFileComp, dir) +} + +func TestComplete_FlagValueInlineEquals(t *testing.T) { + t.Parallel() + + e := setupExecutor(t) + suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, 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_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())) + }) + } +} + +func TestWrite_Format(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + complete.Write(&buf, []complete.Suggestion{ + {Value: "deploy", Description: "Deploy the app"}, + {Value: "build"}, + }, complete.DirectiveNoSpace|complete.DirectiveNoFileComp) + require.Equal(t, "deploy\tDeploy the app\nbuild\n:6\n", buf.String()) +} + +func TestWrite_EmptyWithDirective(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + complete.Write(&buf, nil, complete.DirectiveFilterDirs) + require.Equal(t, ":16\n", buf.String()) +} + +func values(suggs []complete.Suggestion) []string { + out := make([]string, 0, len(suggs)) + for _, s := range suggs { + out = append(out, s.Value) + } + return out +} + +func descriptions(suggs []complete.Suggestion) []string { + out := make([]string, 0, len(suggs)) + for _, s := range suggs { + out = append(out, s.Description) + } + return out +} diff --git a/internal/complete/context.go b/internal/complete/context.go new file mode 100644 index 0000000000..b6738f7119 --- /dev/null +++ b/internal/complete/context.go @@ -0,0 +1,79 @@ +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, "-") +} + +// fs is needed to skip the word after a value-taking flag: `task --dir deploy` +// must not read "deploy" as a task name. +func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) string { + if len(args) <= 1 { + return "" + } + + taskName := "" + skipNext := false + for _, w := range args[:len(args)-1] { + if skipNext { + skipNext = false + continue + } + if w == "--" { + return taskName + } + if strings.HasPrefix(w, "-") { + if !strings.Contains(w, "=") { + if f := matchFlagName(fs, w); f != nil && flagTakesValue(f) { + skipNext = true + } + } + continue + } + if strings.Contains(w, "=") { + continue + } + if slices.Contains(knownTasks, w) { + taskName = w + } + } + + return taskName +} diff --git a/internal/complete/engine.go b/internal/complete/engine.go new file mode 100644 index 0000000000..4e0b853ce9 --- /dev/null +++ b/internal/complete/engine.go @@ -0,0 +1,209 @@ +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 no task word, so `task ` never builds the list. + if e != nil && e.Taskfile != nil && len(args) > 1 { + if taskName := detectTaskName(args, taskNames(e), fs); taskName != "" { + return completeTaskVars(e, taskName) + } + } + + return completeTaskNames(e, opts) +} + +func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool { + return parseContext(args).inTaskContext(fs) +} + +func taskNames(e *task.Executor) []string { + if e == nil || e.Taskfile == nil { + return nil + } + var out []string + for t := range e.Taskfile.Tasks.Values(nil) { + if t.Internal { + continue + } + name, _ := suggestedName(t.Task) + out = append(out, name) + for _, alias := range t.Aliases { + name, _ := suggestedName(alias) + out = append(out, name) + } + } + return out +} + +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)) + anyPartial := false + 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 + if partial { + anyPartial = true + if 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 truncated pattern is half a name: the cursor must stay against it. + if anyPartial { + return out, DirectiveNoSpace | DirectiveNoFileComp + } + 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 { + sorter := e.TaskSorter + if sorter == nil { + sorter = sort.AlphaNumericWithRootTasksFirst + } + + out := make([]*ast.Task, 0, e.Taskfile.Tasks.Len()) + templated := false + for t := range e.Taskfile.Tasks.Values(sorter) { + if t.Internal { + continue + } + templated = templated || strings.Contains(t.Desc, "{{") + out = append(out, t) + } + + if !opts.NoDescriptions && templated { + // 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: + exts := slicesext.Convert(taskfileExtensions, func(ext string) Suggestion { + return Suggestion{Value: ext} + }) + return exts, DirectiveFilterFileExt + case DirectiveFilterDirs: + return nil, DirectiveFilterDirs + } + + if values, ok := flagEnums[flagName]; ok { + out := slicesext.Convert(values, func(v string) Suggestion { + return Suggestion{Value: prefix + v} + }) + return out, DirectiveNoFileComp + } + + return nil, DirectiveDefault +} + +func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directive) { + compiled, err := e.FastCompiledTask(&task.Call{Task: taskName}) + if err != nil || compiled == nil || compiled.Requires == nil { + return nil, DirectiveNoFileComp + } + + out := make([]Suggestion, 0, 8) + for _, v := range compiled.Requires.Vars { + if v == nil || v.Name == "" { + continue + } + 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, DirectiveNoFileComp + } + // KeepOrder preserves the declaration order of the `requires` block. + return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder +} + +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..888eeab555 --- /dev/null +++ b/internal/complete/flags.go @@ -0,0 +1,70 @@ +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, + "new-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, +} + +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..7f87048081 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 + NewCompletion 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(&NewCompletion, "new-completion", "", "Generates the new (experimental) shell completion script, powered by the `task __complete` engine.") pflag.BoolVarP(&List, "list", "l", false, "Lists tasks with description of current Taskfile.") pflag.BoolVarP(&ListAll, "list-all", "a", false, "Lists tasks with or without a description.") pflag.BoolVarP(&ListJson, "json", "j", false, "Formats task list as JSON.") @@ -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(os.Args[2:]) + 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 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/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/latest/docs/installation.md b/website/src/latest/docs/installation.md index 05658d04c7..f7422f876e 100644 --- a/website/src/latest/docs/installation.md +++ b/website/src/latest/docs/installation.md @@ -486,3 +486,66 @@ requires to be static. Three consequences are worth knowing: use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") * alias go-task = task ``` + +### Trying the new completion engine (experimental) + +Task is migrating to a new completion engine, where every shell shares a single +source of truth: the `task __complete` command. This gives Bash, Zsh, Fish, +Nushell and PowerShell the exact same suggestions (task names, aliases, flags, +flag values and `requires` vars, including their enums). It is currently +**opt-in** and will become the default of `--completion` in a future release. + +To try it, swap `--completion` for `--new-completion` in any of the snippets +above, for example: + +::: code-group + +```shell [bash] +# ~/.bashrc +eval "$(task --new-completion bash)" +``` + +```shell [zsh] +# ~/.zshrc +eval "$(task --new-completion zsh)" +``` + +```shell [fish] +# ~/.config/fish/config.fish +task --new-completion fish | source +``` + +```powershell [powershell] +# $PROFILE\Microsoft.PowerShell_profile.ps1 +Invoke-Expression (&task --new-completion powershell | Out-String) +``` + +```nu [nushell] +# ~/.config/nushell/config.nu +mkdir ($nu.data-dir | path join "vendor/autoload") +task --new-completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") +``` + +::: + +The `verbose` and `show-aliases` zstyles documented above work with the new Zsh +completion too. + +Nushell shares a single external completer between every command, so the script +chains to the one already configured โ€” carapace and friends keep working. Load +it from an autoload directory as shown above rather than from `config.nu`, so +that your own completer is the one being chained to. If you would rather wire it +yourself, the script also exposes a `task-external-completer` command: + +```nu +$env.config.completions.external.completer = {|spans| + match ($spans | first) { + task => (task-external-completer $spans) + _ => (do $my_other_completer $spans) + } +} +``` + +Two engine directives behave differently under Nushell by design: it never +appends a space after an external completion (so `NoSpace` is a no-op) and never +re-sorts the results (so `KeepOrder` is always honoured). From 960fdb7bc5aa3ae61843e7e9c080a9372c399a40 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 14:28:16 +0200 Subject: [PATCH 2/8] docs(completion): document the new engine in next/, not latest/ The `--new-completion` section landed in website/src/latest/, which only release commits write to. Moved verbatim to website/src/next/, where the two files were byte-identical up to that point. --- website/src/latest/docs/installation.md | 63 ------------------------- website/src/next/docs/installation.md | 63 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/website/src/latest/docs/installation.md b/website/src/latest/docs/installation.md index f7422f876e..05658d04c7 100644 --- a/website/src/latest/docs/installation.md +++ b/website/src/latest/docs/installation.md @@ -486,66 +486,3 @@ requires to be static. Three consequences are worth knowing: use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") * alias go-task = task ``` - -### Trying the new completion engine (experimental) - -Task is migrating to a new completion engine, where every shell shares a single -source of truth: the `task __complete` command. This gives Bash, Zsh, Fish, -Nushell and PowerShell the exact same suggestions (task names, aliases, flags, -flag values and `requires` vars, including their enums). It is currently -**opt-in** and will become the default of `--completion` in a future release. - -To try it, swap `--completion` for `--new-completion` in any of the snippets -above, for example: - -::: code-group - -```shell [bash] -# ~/.bashrc -eval "$(task --new-completion bash)" -``` - -```shell [zsh] -# ~/.zshrc -eval "$(task --new-completion zsh)" -``` - -```shell [fish] -# ~/.config/fish/config.fish -task --new-completion fish | source -``` - -```powershell [powershell] -# $PROFILE\Microsoft.PowerShell_profile.ps1 -Invoke-Expression (&task --new-completion powershell | Out-String) -``` - -```nu [nushell] -# ~/.config/nushell/config.nu -mkdir ($nu.data-dir | path join "vendor/autoload") -task --new-completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") -``` - -::: - -The `verbose` and `show-aliases` zstyles documented above work with the new Zsh -completion too. - -Nushell shares a single external completer between every command, so the script -chains to the one already configured โ€” carapace and friends keep working. Load -it from an autoload directory as shown above rather than from `config.nu`, so -that your own completer is the one being chained to. If you would rather wire it -yourself, the script also exposes a `task-external-completer` command: - -```nu -$env.config.completions.external.completer = {|spans| - match ($spans | first) { - task => (task-external-completer $spans) - _ => (do $my_other_completer $spans) - } -} -``` - -Two engine directives behave differently under Nushell by design: it never -appends a space after an external completion (so `NoSpace` is a no-op) and never -re-sorts the results (so `KeepOrder` is always honoured). diff --git a/website/src/next/docs/installation.md b/website/src/next/docs/installation.md index 05658d04c7..f7422f876e 100644 --- a/website/src/next/docs/installation.md +++ b/website/src/next/docs/installation.md @@ -486,3 +486,66 @@ requires to be static. Three consequences are worth knowing: use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") * alias go-task = task ``` + +### Trying the new completion engine (experimental) + +Task is migrating to a new completion engine, where every shell shares a single +source of truth: the `task __complete` command. This gives Bash, Zsh, Fish, +Nushell and PowerShell the exact same suggestions (task names, aliases, flags, +flag values and `requires` vars, including their enums). It is currently +**opt-in** and will become the default of `--completion` in a future release. + +To try it, swap `--completion` for `--new-completion` in any of the snippets +above, for example: + +::: code-group + +```shell [bash] +# ~/.bashrc +eval "$(task --new-completion bash)" +``` + +```shell [zsh] +# ~/.zshrc +eval "$(task --new-completion zsh)" +``` + +```shell [fish] +# ~/.config/fish/config.fish +task --new-completion fish | source +``` + +```powershell [powershell] +# $PROFILE\Microsoft.PowerShell_profile.ps1 +Invoke-Expression (&task --new-completion powershell | Out-String) +``` + +```nu [nushell] +# ~/.config/nushell/config.nu +mkdir ($nu.data-dir | path join "vendor/autoload") +task --new-completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") +``` + +::: + +The `verbose` and `show-aliases` zstyles documented above work with the new Zsh +completion too. + +Nushell shares a single external completer between every command, so the script +chains to the one already configured โ€” carapace and friends keep working. Load +it from an autoload directory as shown above rather than from `config.nu`, so +that your own completer is the one being chained to. If you would rather wire it +yourself, the script also exposes a `task-external-completer` command: + +```nu +$env.config.completions.external.completer = {|spans| + match ($spans | first) { + task => (task-external-completer $spans) + _ => (do $my_other_completer $spans) + } +} +``` + +Two engine directives behave differently under Nushell by design: it never +appends a space after an external completion (so `NoSpace` is a no-op) and never +re-sorts the results (so `KeepOrder` is always honoured). From b00d2bafb62f12b57aad02e8fadbccf3169cc554 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 14:50:45 +0200 Subject: [PATCH 3/8] test(completion): drop the binary-building protocol tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit completion/protocol_test.go built a `task` binary in TestMain and drove it through 21 subprocess spawns. Every one of its table cases already had a one-for-one in-process equivalent in internal/complete, and the wire format is asserted by TestWrite_Format. The one guarantee worth keeping was the `-t -` guard, which stops a keystroke from blocking on a Taskfile read from the terminal. It moves from cmd/task into complete.NeedsTaskfile, where it reads the flagset instead of the flags package global โ€” testable in-process, and one less global read in cmd/task. TestCompletionShells moves to internal/complete unchanged, and the in-process wildcard fixture gains `matches-exactly-*`, the only pattern of testdata/wildcards it was missing. The binary path stays covered end to end by completion/tests/run.sh, which the CI completion job runs against five real shells on Linux and macOS. --- cmd/task/complete_cmd.go | 4 +- completion/protocol_test.go | 328 ----------------------------- internal/complete/complete_test.go | 40 +++- internal/complete/engine.go | 7 +- 4 files changed, 46 insertions(+), 333 deletions(-) delete mode 100644 completion/protocol_test.go diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index bc06d02b50..0d5f64e33e 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -28,8 +28,8 @@ func runComplete(args []string) error { task.WithDownload(false), ) - // Best-effort, and never from stdin: that would hang the shell. - if complete.NeedsTaskfile(args, pflag.CommandLine) && flags.Entrypoint != "-" { + // Best-effort: a missing or broken Taskfile must not break completion. + if complete.NeedsTaskfile(args, pflag.CommandLine) { _ = e.Setup() } diff --git a/completion/protocol_test.go b/completion/protocol_test.go deleted file mode 100644 index 6a7e142061..0000000000 --- a/completion/protocol_test.go +++ /dev/null @@ -1,328 +0,0 @@ -// Black-box tests of the `task __complete` wire protocol. How each shell -// wrapper interprets the directive is smoke-tested in completion/tests/. -package completion_test - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/go-task/task/v3" - "github.com/go-task/task/v3/internal/complete" -) - -var taskBin string - -func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "task-completion-test") - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - taskBin = filepath.Join(dir, "task") - if runtime.GOOS == "windows" { - taskBin += ".exe" - } - if out, err := exec.CommandContext(context.Background(), "go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil { - fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out) - os.RemoveAll(dir) - os.Exit(1) - } - code := m.Run() - os.RemoveAll(dir) - os.Exit(code) -} - -const fixtureTaskfile = `version: '3' - -tasks: - build: - desc: Build it - deploy: - desc: Deploy the application - aliases: [dep, ship] - requires: - vars: - - name: ENV - enum: [dev, staging, prod] - - REGION - docs:serve: - desc: Serve docs locally -` - -// completeArgs runs `task __complete ` in a fresh fixture directory. -func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) { - t.Helper() - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) - - cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec - cmd.Dir = dir - out, err := cmd.Output() - require.NoError(t, err) - - return parseProtocol(t, out) -} - -func parseProtocol(t *testing.T, out []byte) ([]string, complete.Directive) { - t.Helper() - - lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") - require.NotEmpty(t, lines, "protocol output must end with a directive line") - - last := lines[len(lines)-1] - require.True(t, strings.HasPrefix(last, ":"), "last line must be the : line, got %q", last) - n, err := strconv.Atoi(strings.TrimPrefix(last, ":")) - require.NoError(t, err) - - values := make([]string, 0, len(lines)-1) - for _, line := range lines[:len(lines)-1] { - values = append(values, strings.SplitN(line, "\t", 2)[0]) - } - return values, complete.Directive(n) -} - -func TestProtocol(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - args []string - want []string // candidate values that must be offered - absent []string // candidate values that must NOT be offered - directive complete.Directive - }{ - { - name: "task names and aliases", - args: []string{""}, - want: []string{"build", "deploy", "dep", "ship", "docs:serve"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "no-aliases drops aliases", - args: []string{"--no-aliases", ""}, - want: []string{"build", "deploy"}, - absent: []string{"dep", "ship"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "flag names", - args: []string{"-"}, - want: []string{"--taskfile", "--dir", "--output"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "separate flag value is bare", - args: []string{"--output", ""}, - want: []string{"interleaved", "group", "prefixed"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "inline flag value is full form", - args: []string{"--output="}, - want: []string{"--output=interleaved", "--output=group", "--output=prefixed"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "sort enum values", - args: []string{"--sort", ""}, - want: []string{"default", "alphanumeric", "none"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "taskfile filters by extension", - args: []string{"--taskfile", ""}, - want: []string{"yml", "yaml"}, - directive: complete.DirectiveFilterFileExt, - }, - { - name: "dir filters to directories", - args: []string{"--dir", ""}, - directive: complete.DirectiveFilterDirs, - }, - { - name: "task variables keep order and suppress the space", - args: []string{"deploy", ""}, - want: []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, - directive: complete.DirectiveNoSpace | complete.DirectiveNoFileComp | complete.DirectiveKeepOrder, - }, - { - name: "after -- yields default file completion", - args: []string{"deploy", "--", ""}, - directive: complete.DirectiveDefault, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - values, directive := completeArgs(t, tt.args...) - require.Equal(t, tt.directive, directive) - require.Subset(t, values, tt.want) - for _, a := range tt.absent { - require.NotContains(t, values, a) - } - }) - } -} - -// --sort is the flag deciding how the Taskfile is read with a visible order. -func TestProtocol_SortFlagIsApplied(t *testing.T) { - t.Parallel() - - const taskfile = `version: '3' - -tasks: - zebra: - desc: Declared first, last alphabetically - alpha: - desc: Declared last, first alphabetically -` - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) - - sorted, _ := completeInDir(t, dir, nil, "") - require.Equal(t, []string{"alpha", "zebra"}, sorted) - - declared, _ := completeInDir(t, dir, nil, "--sort", "none", "") - require.Equal(t, []string{"zebra", "alpha"}, declared) -} - -func TestProtocol_ExperimentGatedFlag(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) - - values, directive := completeInDir(t, dir, []string{"TASK_X_GENTLE_FORCE=1"}, "--force-all", "") - require.Equal(t, complete.DirectiveNoFileComp, directive) - require.Subset(t, values, []string{"build", "deploy"}) -} - -// Downloading an uncached remote include would freeze the shell for up to -// --timeout and prompt for trust. -func TestProtocol_RemoteIncludeStaysOffline(t *testing.T) { - t.Parallel() - - var hits atomic.Int64 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits.Add(1) - <-r.Context().Done() - })) - defer srv.Close() - - taskfile := fmt.Sprintf(`version: '3' - -includes: - remote: %s/Taskfile.yml - -tasks: - build: - desc: Build it -`, srv.URL) - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) - - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - - // A fresh cache dir leaves a download as the only way to resolve the - // include; the insecure opt-in keeps the plain-HTTP server from being - // rejected before it. - cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "") //nolint:gosec - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "TASK_REMOTE_DIR="+t.TempDir(), - "TASK_REMOTE_INSECURE=1", - ) - out, err := cmd.Output() - require.NoError(t, err, "completion must not hang on a remote include") - - _, directive := parseProtocol(t, out) - require.Equal(t, complete.DirectiveNoFileComp, directive) - require.Zero(t, hits.Load(), "completion must not reach the network") -} - -// `--taskfile -` would otherwise read the Taskfile from the terminal. -func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) { - t.Parallel() - - // An unwritten pipe: reading it would block until the context expires. - r, w, err := os.Pipe() - require.NoError(t, err) - t.Cleanup(func() { - r.Close() - w.Close() - }) - - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") //nolint:gosec - cmd.Dir = t.TempDir() - cmd.Stdin = r - out, err := cmd.Output() - require.NoError(t, err, "completion must not read the Taskfile from stdin") - - _, directive := parseProtocol(t, out) - require.Equal(t, complete.DirectiveNoFileComp, directive) -} - -func TestProtocol_WildcardTaskNames(t *testing.T) { - t.Parallel() - - values, directive := completeInDir(t, filepath.Join("..", "testdata", "wildcards"), nil, "") - require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, directive) - require.Subset(t, values, []string{"start-", "s-", "wildcard-", "matches-exactly-"}) - for _, v := range values { - require.NotEmpty(t, v) - require.NotContains(t, v, "*") - } -} - -// completeInDir runs `task __complete ` in dir, with env appended to the -// current environment. -func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) { - t.Helper() - - cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec - cmd.Dir = dir - cmd.Env = append(os.Environ(), env...) - out, err := cmd.Output() - require.NoError(t, err) - - return parseProtocol(t, out) -} - -// 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", "--new-completion"} { - shells, directive := completeArgs(t, flag, "") - require.Equal(t, complete.DirectiveNoFileComp, directive) - require.NotEmpty(t, shells) - - for _, shell := range shells { - _, err := task.Completion(shell) - require.NoErrorf(t, err, "%s offers %q", flag, shell) - _, err = task.CompletionNext(shell) - require.NoErrorf(t, err, "%s offers %q", flag, shell) - } - } -} diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index a5fc438f1a..e40b074815 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -26,6 +26,8 @@ func newTestFlagSet() *pflag.FlagSet { 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, "new-completion", "", "Generate a completion script") return fs } @@ -88,6 +90,10 @@ tasks: cmds: - 'echo {{index .MATCH 0}}' + matches-exactly-*: + cmds: + - 'echo {{.MATCH}}' + start-*: desc: Start a service aliases: [s-*] @@ -142,13 +148,13 @@ func TestComplete_WildcardTaskNames(t *testing.T) { // Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*` // collapse into one candidate, and `*-wildcard-*` leaves nothing to insert. - require.Equal(t, []string{"build", "start-", "s-", "wildcard-"}, values(suggs)) + require.Equal(t, []string{"build", "matches-exactly-", "start-", "s-", "wildcard-"}, values(suggs)) require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) // Without a desc, the pattern says what the prefix stands for. require.Contains(t, descriptions(suggs), "wildcard-*") suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true}) - require.Equal(t, []string{"", "", "", ""}, descriptions(suggs)) + require.Equal(t, []string{"", "", "", "", ""}, descriptions(suggs)) } func TestComplete_AliasResolvesToTaskVars(t *testing.T) { @@ -379,6 +385,36 @@ func TestNeedsTaskfile(t *testing.T) { } } +// 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", "--new-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.CompletionNext(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + } + } +} + func TestWrite_Format(t *testing.T) { t.Parallel() diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 4e0b853ce9..6c0b9b27e1 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -45,7 +45,12 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) } func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool { - return parseContext(args).inTaskContext(fs) + 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 taskNames(e *task.Executor) []string { From 5fe752d48a2f50b45a6e7d09544fafcb36ffc57b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 15:12:47 +0200 Subject: [PATCH 4/8] refactor(completion): tighten the engine after a cleanup pass `--temp-dir` was missing from the flag-to-directive map, so it fell back to plain file completion while `--dir` and `--remote-cache-dir` offered directories. The rest is dead weight: `listTasks` re-defaulted a sorter `NewExecutor` already sets and scanned descriptions for templates even with `--no-descriptions`; `detectTaskName` had a `--` branch `Complete` returns before reaching; the two flag-value branches built the same suggestion slice twice. `os.Args[2:]` is now sliced in one place, `complete.Words()`, instead of three, and the test helpers reuse `slicesext.Convert`. --- cmd/task/task.go | 2 +- internal/complete/complete.go | 5 +++++ internal/complete/complete_test.go | 22 ++++++++++++---------- internal/complete/context.go | 3 --- internal/complete/engine.go | 28 +++++++++++----------------- internal/complete/flags.go | 1 + internal/flags/flags.go | 2 +- 7 files changed, 31 insertions(+), 32 deletions(-) diff --git a/cmd/task/task.go b/cmd/task/task.go index 2332845199..7b1c19f6a1 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -62,7 +62,7 @@ func run() error { // Dispatched before flag validation: the args after __complete are the // user's command line, not Task's own flags. if complete.IsActive() { - return runComplete(os.Args[2:]) + return runComplete(complete.Words()) } log := &logger.Logger{ diff --git a/internal/complete/complete.go b/internal/complete/complete.go index 5acf14f427..29c8d5a538 100644 --- a/internal/complete/complete.go +++ b/internal/complete/complete.go @@ -10,6 +10,11 @@ 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 diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index e40b074815..222f059be4 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -12,6 +12,7 @@ import ( "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 { @@ -23,6 +24,7 @@ func newTestFlagSet() *pflag.FlagSet { 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") @@ -282,6 +284,14 @@ func TestComplete_PathFlag_Dir(t *testing.T) { 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() @@ -435,17 +445,9 @@ func TestWrite_EmptyWithDirective(t *testing.T) { } func values(suggs []complete.Suggestion) []string { - out := make([]string, 0, len(suggs)) - for _, s := range suggs { - out = append(out, s.Value) - } - return out + return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Value }) } func descriptions(suggs []complete.Suggestion) []string { - out := make([]string, 0, len(suggs)) - for _, s := range suggs { - out = append(out, s.Description) - } - return out + return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Description }) } diff --git a/internal/complete/context.go b/internal/complete/context.go index b6738f7119..5616fd1dcc 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -56,9 +56,6 @@ func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) strin skipNext = false continue } - if w == "--" { - return taskName - } if strings.HasPrefix(w, "-") { if !strings.Contains(w, "=") { if f := matchFlagName(fs, w); f != nil && flagTakesValue(f) { diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 6c0b9b27e1..1994f8bc04 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -8,7 +8,6 @@ import ( "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" ) @@ -123,22 +122,17 @@ func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) // 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 { - 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) { + for t := range e.Taskfile.Tasks.Values(e.TaskSorter) { if t.Internal { continue } - templated = templated || strings.Contains(t.Desc, "{{") + templated = templated || (!opts.NoDescriptions && strings.Contains(t.Desc, "{{")) out = append(out, t) } - if !opts.NoDescriptions && templated { + if templated { // The uncompiled tasks keep one broken task from emptying the list. if compiled, err := e.GetTaskList(task.FilterOutInternal); err == nil { return compiled @@ -160,24 +154,24 @@ func completeFlagValue(flagName, prefix string) ([]Suggestion, Directive) { // An absent key yields DirectiveDefault, falling through to the enums. switch flagDirective[flagName] { case DirectiveFilterFileExt: - exts := slicesext.Convert(taskfileExtensions, func(ext string) Suggestion { - return Suggestion{Value: ext} - }) - return exts, DirectiveFilterFileExt + return suggest("", taskfileExtensions), DirectiveFilterFileExt case DirectiveFilterDirs: return nil, DirectiveFilterDirs } if values, ok := flagEnums[flagName]; ok { - out := slicesext.Convert(values, func(v string) Suggestion { - return Suggestion{Value: prefix + v} - }) - return out, DirectiveNoFileComp + 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} + }) +} + func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directive) { compiled, err := e.FastCompiledTask(&task.Call{Task: taskName}) if err != nil || compiled == nil || compiled.Requires == nil { diff --git a/internal/complete/flags.go b/internal/complete/flags.go index 888eeab555..ca70b3a9d2 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -23,6 +23,7 @@ var flagDirective = map[string]Directive{ "taskfile": DirectiveFilterFileExt, "dir": DirectiveFilterDirs, "remote-cache-dir": DirectiveFilterDirs, + "temp-dir": DirectiveFilterDirs, } var taskfileExtensions = []string{"yml", "yaml"} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 7f87048081..04342004df 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -181,7 +181,7 @@ func init() { // 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(os.Args[2:]) + _, words := complete.ParseOptions(complete.Words()) pflag.CommandLine.Init(pflag.CommandLine.Name(), pflag.ContinueOnError) pflag.CommandLine.ParseErrorsAllowlist.UnknownFlags = true _ = pflag.CommandLine.Parse(words) From 978277273eb541644f53fedcd92fba0a24cda637 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 16:11:49 +0200 Subject: [PATCH 5/8] fix(completion): complete task names and required vars together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI variables are global to the invocation, not scoped to a task: `args.Parse` turns every word holding `=` into a global and every other word into a call, so `task build ENV=dev deploy` and `task build deploy ENV=dev` are the same command. The engine assumed the opposite and, as soon as a word matched a task, served only that task's variables โ€” nothing at all when it had none. `task build ` and even `task build de` went silent, where all five legacy wrappers offered task names at every position. The engine now unions the still-unset requirements of every task named on the line, and falls through to task names once they are all set. The line resolves itself: fill in what blocks execution, then add another task. Keeping the two families exclusive means each keeps a coherent directive, so nothing loses its trailing space. Task words are matched with FindMatchingTasks instead of a hand-built list of names truncated at their first `*`, which is why `task wildcard-foo ` used to offer task names rather than the variables of `wildcard-*`. Completion also disables fuzzy matching: a suggestion list has no "did you mean". Three fixes ride along. `--sort default` left the sorter nil and cleared the one NewExecutor had set, so completion listed tasks in Taskfile order while `--list` sorted them โ€” and a single templated description silently restored the sort through GetTaskList. The bash wrapper never defined KeepOrder, losing the declaration order of `requires`; it now passes `compopt -o nosort`, which bash 3.2 ignores as it already ignores nospace. And the shell suite unsets TASK_EXE and GO_TASK_PROGNAME: fish, Nushell and PowerShell resolve the binary through them, so an ambient value silently tested something other than the binary just built. --- cmd/task/complete_cmd.go | 1 + completion/next/bash/task.bash | 7 ++- completion/tests/run.sh | 4 ++ completion/tests/wrapper.bash | 3 +- internal/complete/complete_test.go | 94 +++++++++++++++++++++++++----- internal/complete/context.go | 24 ++++---- internal/complete/engine.go | 77 ++++++++++++------------ internal/flags/flags.go | 3 + internal/flags/flags_test.go | 23 ++++++++ 9 files changed, 171 insertions(+), 65 deletions(-) create mode 100644 internal/flags/flags_test.go diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 0d5f64e33e..c074aef3cf 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -24,6 +24,7 @@ func runComplete(args []string) error { task.WithStderr(io.Discard), task.WithStdin(strings.NewReader("")), task.WithVersionCheck(false), + task.WithDisableFuzzy(true), task.WithOffline(true), task.WithDownload(false), ) diff --git a/completion/next/bash/task.bash b/completion/next/bash/task.bash index 90205f1c71..e1e2a3b88f 100644 --- a/completion/next/bash/task.bash +++ b/completion/next/bash/task.bash @@ -23,7 +23,7 @@ _task() { local cur prev words cword # Completion directives, mirroring internal/complete/complete.go. - local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 + 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 @@ -79,6 +79,11 @@ _task() { 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 diff --git a/completion/tests/run.sh b/completion/tests/run.sh index 891884f86a..478de99db2 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -3,6 +3,10 @@ # 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) diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash index a234e2156c..5ea17454b6 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -53,9 +53,10 @@ run task '' reply_has "candidate forwarded" build cap_hasnot "no file fallback" "filedir:" -echo "bash: :2 (NoSpace) disables the trailing space" +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 '' diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index 222f059be4..d047c8f0c1 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -73,8 +73,12 @@ tasks: docs:serve: desc: Serve docs locally + requires: + vars: + - PORT cmds: - 'echo serving' + ` const wildcardTaskfile = `version: '3' @@ -102,6 +106,17 @@ tasks: 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: @@ -150,13 +165,13 @@ func TestComplete_WildcardTaskNames(t *testing.T) { // 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-", "start-", "s-", "wildcard-"}, values(suggs)) + require.Equal(t, []string{"build", "matches-exactly-", "release-", "start-", "s-", "wildcard-"}, values(suggs)) require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) // Without a desc, the pattern says what the prefix stands for. require.Contains(t, descriptions(suggs), "wildcard-*") suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true}) - require.Equal(t, []string{"", "", "", "", ""}, descriptions(suggs)) + require.Equal(t, []string{"", "", "", "", "", ""}, descriptions(suggs)) } func TestComplete_AliasResolvesToTaskVars(t *testing.T) { @@ -186,15 +201,6 @@ func TestComplete_EnumRef(t *testing.T) { require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs)) } -func TestComplete_NoRequires(t *testing.T) { - t.Parallel() - - e := setupExecutor(t) - suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}, complete.Options{}) - require.Empty(t, suggs) - require.Equal(t, complete.DirectiveNoFileComp, dir) -} - func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) { t.Parallel() @@ -212,8 +218,8 @@ func TestComplete_NamespacedTaskName(t *testing.T) { e := setupExecutor(t) suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""}, complete.Options{}) - require.Empty(t, suggs) - require.Equal(t, complete.DirectiveNoFileComp, dir) + require.Equal(t, []string{"PORT="}, values(suggs)) + require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir) } func TestComplete_FlagValueInlineEquals(t *testing.T) { @@ -444,6 +450,68 @@ func TestWrite_EmptyWithDirective(t *testing.T) { 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 }) } diff --git a/internal/complete/context.go b/internal/complete/context.go index 5616fd1dcc..1502ecd63e 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -42,16 +42,15 @@ func (ctx completionContext) inTaskContext(fs *pflag.FlagSet) bool { return !ctx.afterDash && ctx.flagValue(fs) == nil && !strings.HasPrefix(ctx.toComplete, "-") } -// fs is needed to skip the word after a value-taking flag: `task --dir deploy` -// must not read "deploy" as a task name. -func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) string { - if len(args) <= 1 { - return "" - } +// 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)) - taskName := "" skipNext := false - for _, w := range args[:len(args)-1] { + for _, w := range prior { if skipNext { skipNext = false continue @@ -64,13 +63,12 @@ func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) strin } continue } - if strings.Contains(w, "=") { + if name, _, ok := strings.Cut(w, "="); ok { + setVars[name] = true continue } - if slices.Contains(knownTasks, w) { - taskName = w - } + tasks = append(tasks, w) } - return taskName + return tasks, setVars } diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 1994f8bc04..99afea88e5 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -33,10 +34,10 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) return listFlags(fs), DirectiveNoFileComp } - // No prior arg means no task word, so `task ` never builds the list. + // No prior arg means nothing can require a variable yet. if e != nil && e.Taskfile != nil && len(args) > 1 { - if taskName := detectTaskName(args, taskNames(e), fs); taskName != "" { - return completeTaskVars(e, taskName) + if suggs, dir, ok := completeRequiredVars(e, args[:len(args)-1], fs); ok { + return suggs, dir } } @@ -52,25 +53,6 @@ func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool { return f == nil || f.Value.String() != "-" } -func taskNames(e *task.Executor) []string { - if e == nil || e.Taskfile == nil { - return nil - } - var out []string - for t := range e.Taskfile.Tasks.Values(nil) { - if t.Internal { - continue - } - name, _ := suggestedName(t.Task) - out = append(out, name) - for _, alias := range t.Aliases { - name, _ := suggestedName(alias) - out = append(out, name) - } - } - return out -} - func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) { if e == nil || e.Taskfile == nil { return nil, DirectiveNoFileComp @@ -122,9 +104,15 @@ func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) // 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(e.TaskSorter) { + for t := range e.Taskfile.Tasks.Values(sorter) { if t.Internal { continue } @@ -172,31 +160,46 @@ func suggest(prefix string, values []string) []Suggestion { }) } -func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directive) { - compiled, err := e.FastCompiledTask(&task.Call{Task: taskName}) - if err != nil || compiled == nil || compiled.Requires == nil { - return nil, DirectiveNoFileComp - } +// 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) - for _, v := range compiled.Requires.Vars { - if v == nil || v.Name == "" { + 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 } - values := enumValues(v, compiled.Vars) - if len(values) == 0 { - out = append(out, Suggestion{Value: v.Name + "="}) + compiled, err := e.FastCompiledTask(&task.Call{Task: w}) + if err != nil || compiled == nil || compiled.Requires == nil { continue } - for _, val := range values { - out = append(out, Suggestion{Value: v.Name + "=" + val}) + 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, DirectiveNoFileComp + return nil, 0, false } // KeepOrder preserves the declaration order of the `requires` block. - return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder + return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder, true } func enumValues(v *ast.VarsWithValidation, vars *ast.Vars) []string { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 04342004df..ccefcc5264 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -279,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) + } +} From 13f72a0b56b8a0b70c98c6940ecf2e94d7cb8801 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 16:51:59 +0200 Subject: [PATCH 6/8] fix(completion): always leave a trailing space after a task name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol carries one directive per response, so a single truncated wildcard prefix cost every complete name its trailing space: a Taskfile holding `deploy-*` anywhere made `task buil` insert `build` with the cursor stuck against it. Completing a task name is the common case and the wildcard prefix the rare one, so the polarity was backwards โ€” and the legacy wrappers, which had no notion of an incomplete candidate, always left the space. A wildcard prefix now gets a space it does not want, which is the accepted trade until suggestions can carry a directive of their own. Required variables keep NoSpace: there a free-form `VAR=` is the common case, not the exception. --- internal/complete/complete_test.go | 4 +++- internal/complete/engine.go | 15 +++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index d047c8f0c1..390978bc15 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -166,7 +166,9 @@ func TestComplete_WildcardTaskNames(t *testing.T) { // 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)) - require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir) + // 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-*") diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 99afea88e5..eda451b5b6 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -67,7 +67,6 @@ func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) out := make([]Suggestion, 0, len(tasks)) seen := make(map[string]bool, len(tasks)) - anyPartial := false add := func(name, desc string) { value, partial := suggestedName(name) // `*-wildcard-*` has no prefix, and `wildcard-*` / `wildcard-*-*` share one. @@ -75,11 +74,9 @@ func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) return } seen[value] = true - if partial { - anyPartial = true - if desc == "" && !opts.NoDescriptions { - desc = name - } + // 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}) } @@ -94,10 +91,8 @@ func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) } } - // A truncated pattern is half a name: the cursor must stay against it. - if anyPartial { - return out, DirectiveNoSpace | DirectiveNoFileComp - } + // A single truncated wildcard prefix would otherwise cost every complete + // name its trailing space: the directive covers the whole response. return out, DirectiveNoFileComp } From b782d18a4551790b6cee9854efc23c2bc11a4edf Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Aug 2026 10:36:54 +0200 Subject: [PATCH 7/8] feat(completion): make the engine the default behind --completion The engine shipped opt-in behind --new-completion, with the old scripts still on --completion. That split was never released, so flip it now rather than carry a third flag through a deprecation later. --completion serves the engine wrappers; the hand-written scripts move to completion/legacy/ and stay reachable via --legacy-completion for a release or two. .goreleaser.yml needed to follow: it packages the static files from completion/ into the deb/rpm/apk contents and the Homebrew cask, so leaving it untouched would have shipped the engine to anyone running `eval "$(task --completion zsh)"` and the old scripts to everyone installing from a package. The paths it references are unchanged and now resolve to the wrappers. Its archive glob is narrowed at the same time, so the test harness under completion/tests/ stops being shipped in the release archives. --- .goreleaser.yml | 7 +- CHANGELOG.md | 16 +- cmd/task/task.go | 4 +- completion.go | 44 +++-- completion/bash/task.bash | 130 ++++++++----- completion/fish/task.fish | 180 ++++++++--------- completion/legacy/bash/task.bash | 60 ++++++ completion/legacy/fish/task.fish | 116 +++++++++++ completion/legacy/nu/task-completions.nu | 180 +++++++++++++++++ completion/legacy/ps/task.ps1 | 89 +++++++++ completion/legacy/zsh/_task | 158 +++++++++++++++ completion/next/bash/task.bash | 94 --------- completion/next/fish/task.fish | 98 ---------- completion/next/nu/task-completions.nu | 86 --------- completion/next/ps/task.ps1 | 109 ----------- completion/next/zsh/_task | 76 -------- completion/nu/task-completions.nu | 236 +++++++---------------- completion/ps/task.ps1 | 180 +++++++++-------- completion/tests/wrapper.bash | 2 +- completion/tests/wrapper.fish | 2 +- completion/tests/wrapper.nu | 2 +- completion/tests/wrapper.ps1 | 2 +- completion/tests/wrapper.zsh | 2 +- completion/zsh/_task | 222 +++++++-------------- internal/complete/complete_test.go | 6 +- internal/complete/flags.go | 8 +- internal/flags/flags.go | 4 +- website/src/next/docs/installation.md | 87 ++------- 28 files changed, 1079 insertions(+), 1121 deletions(-) create mode 100644 completion/legacy/bash/task.bash create mode 100644 completion/legacy/fish/task.fish create mode 100644 completion/legacy/nu/task-completions.nu create mode 100644 completion/legacy/ps/task.ps1 create mode 100755 completion/legacy/zsh/_task delete mode 100644 completion/next/bash/task.bash delete mode 100644 completion/next/fish/task.fish delete mode 100644 completion/next/nu/task-completions.nu delete mode 100644 completion/next/ps/task.ps1 delete mode 100755 completion/next/zsh/_task diff --git a/.goreleaser.yml b/.goreleaser.yml index 245ac343a9..cff7c73e7c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -43,7 +43,12 @@ archives: files: - README.md - LICENSE - - completion/**/* + - completion/bash/* + - completion/fish/* + - completion/nu/* + - completion/ps/* + - completion/zsh/* + - completion/legacy/**/* format_overrides: - goos: windows formats: [zip] diff --git a/CHANGELOG.md b/CHANGELOG.md index ee68fd02d3..0fb7dc06fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,14 @@ ### ๐Ÿš€ Features -- Added a new completion engine that unifies Bash, Fish, Zsh, Nushell and - PowerShell behind a single `task __complete` command, so every shell offers - the same suggestions: task names, aliases, flags, flag values and per-task CLI - variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now - backed by the `--no-aliases` and `--no-descriptions` completion flags. It is - opt-in for now via `task --new-completion `, leaving `--completion` - unchanged, and will become the default in a future release (#2897 by - @vmaerten). +- `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 diff --git a/cmd/task/task.go b/cmd/task/task.go index 7b1c19f6a1..12630b922d 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -133,8 +133,8 @@ func run() error { return nil } - if flags.NewCompletion != "" { - script, err := task.CompletionNext(flags.NewCompletion) + if flags.LegacyCompletion != "" { + script, err := task.LegacyCompletion(flags.LegacyCompletion) if err != nil { return err } diff --git a/completion.go b/completion.go index 15e6896124..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,23 +22,23 @@ var completionPowershell string //go:embed completion/zsh/_task var completionZsh string -// Thin wrappers around the `task __complete` engine, served via -// `--new-completion` until the engine becomes the default. +// The self-contained scripts that predate the engine, kept behind +// `--legacy-completion` as an escape hatch for a couple of releases. -//go:embed completion/next/bash/task.bash -var completionBashNext string +//go:embed completion/legacy/bash/task.bash +var completionBashLegacy string -//go:embed completion/next/fish/task.fish -var completionFishNext string +//go:embed completion/legacy/fish/task.fish +var completionFishLegacy string -//go:embed completion/next/nu/task-completions.nu -var completionNuNext string +//go:embed completion/legacy/nu/task-completions.nu +var completionNuLegacy string -//go:embed completion/next/ps/task.ps1 -var completionPowershellNext string +//go:embed completion/legacy/ps/task.ps1 +var completionPowershellLegacy string -//go:embed completion/next/zsh/_task -var completionZshNext string +//go:embed completion/legacy/zsh/_task +var completionZshLegacy string // The maps accept `nushell` as an alias of `nu`. var completionScripts = map[string]string{ @@ -48,21 +50,21 @@ var completionScripts = map[string]string{ "zsh": completionZsh, } -var completionScriptsNext = map[string]string{ - "bash": completionBashNext, - "fish": completionFishNext, - "nu": completionNuNext, - "nushell": completionNuNext, - "powershell": completionPowershellNext, - "zsh": completionZshNext, +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 CompletionNext(shell string) (string, error) { - return completionScript(completionScriptsNext, shell) +func LegacyCompletion(shell string) (string, error) { + return completionScript(completionScriptsLegacy, shell) } func completionScript(scripts map[string]string, shell string) (string, error) { 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/next/bash/task.bash b/completion/next/bash/task.bash deleted file mode 100644 index e1e2a3b88f..0000000000 --- a/completion/next/bash/task.bash +++ /dev/null @@ -1,94 +0,0 @@ -# vim: set tabstop=2 shiftwidth=2 expandtab: -# -# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. - -TASK_CMD="${TASK_EXE:-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 - - # 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 - - 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/next/fish/task.fish b/completion/next/fish/task.fish deleted file mode 100644 index 908f089783..0000000000 --- a/completion/next/fish/task.fish +++ /dev/null @@ -1,98 +0,0 @@ -# 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) - -# 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 - -function __task_test_bit --argument-names value bit - test (math "floor($value / $bit) % 2") -eq 1 -end - -function __task_complete --inherit-variable GO_TASK_PROGNAME - set -l tokens (commandline -opc) - set -l current (commandline -ct) - set -l args - if test (count $tokens) -gt 1 - set args $tokens[2..-1] - end - set args $args $current - - set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null) - set -l count (count $output) - if test $count -eq 0 - return - end - - set -l last $output[$count] - if not string match -q ':*' -- $last - # Protocol violation: emit raw lines as a fallback. - printf '%s\n' $output - return - end - - set -l directive (string replace -r '^:' '' -- $last) - set -l data - if test $count -gt 1 - set data $output[1..(math $count - 1)] - end - - # The registration below passes `--no-files`, so every file-completion - # directive must be served here or nothing is offered at all. - - # 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 - - 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 - - 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 - -# fish accumulates `complete` entries instead of replacing them, so an older -# completion would keep contributing alongside the engine. -complete -c $GO_TASK_PROGNAME -e - -# `--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/next/nu/task-completions.nu b/completion/next/nu/task-completions.nu deleted file mode 100644 index 25fd56abd0..0000000000 --- a/completion/next/nu/task-completions.nu +++ /dev/null @@ -1,86 +0,0 @@ -# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. - -# 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") - - # 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 null - } - - let lines = ($result.stdout | lines) - let last = ($lines | last) - # Protocol violation: offer nothing rather than garbage. - if ($last | is-empty) or (not ($last | str starts-with ":")) { - return null - } - let directive = (try { $last | str substring 1.. | into int } catch { 0 }) - let data = ($lines | drop 1) - - # Completion directives, mirroring internal/complete/complete.go. 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)" } - }) - } - - # 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 } } - } - | where {|candidate| $candidate.value | str starts-with --ignore-case $current }) - - if ($candidates | is-empty) and (not $no_file_comp) { - return null - } - - $candidates -} - -# 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 - } -} diff --git a/completion/next/ps/task.ps1 b/completion/next/ps/task.ps1 deleted file mode 100644 index 6f19e87c51..0000000000 --- a/completion/next/ps/task.ps1 +++ /dev/null @@ -1,109 +0,0 @@ -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 -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) - }) - - # 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 $results -} diff --git a/completion/next/zsh/_task b/completion/next/zsh/_task deleted file mode 100755 index 107dea8fb1..0000000000 --- a/completion/next/zsh/_task +++ /dev/null @@ -1,76 +0,0 @@ -#compdef task -# -# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine. - -TASK_CMD="${TASK_EXE:-task}" - -_task() { - 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 -} - -compdef _task "$TASK_CMD" 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/tests/wrapper.bash b/completion/tests/wrapper.bash index 5ea17454b6..8fc5c1726d 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -22,7 +22,7 @@ _filedir() { CAP+="filedir:$* cur=$cur"$'\n'; } compopt() { CAP+="compopt:$*"$'\n'; } __ltrim_colon_completions() { :; } -source "$(dirname "${BASH_SOURCE[0]}")/../next/bash/task.bash" +source "$(dirname "${BASH_SOURCE[0]}")/../bash/task.bash" run() { CAP="" diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish index e1a24e55c0..51eb3173f1 100755 --- a/completion/tests/wrapper.fish +++ b/completion/tests/wrapper.fish @@ -3,7 +3,7 @@ # Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test. cd $TASK_FIXTURE -source (dirname (status -f))/../next/fish/task.fish +source (dirname (status -f))/../fish/task.fish set -g fails 0 diff --git a/completion/tests/wrapper.nu b/completion/tests/wrapper.nu index ed47a4c0b2..019cfa5636 100644 --- a/completion/tests/wrapper.nu +++ b/completion/tests/wrapper.nu @@ -4,7 +4,7 @@ # Set up by run.sh: $env.TASK_FIXTURE, and `task` on PATH = the binary under test. # `source` needs a parse-time constant path. -const TASK_NU = (path self "../next/nu/task-completions.nu") +const TASK_NU = (path self "../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)" }] } diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 index 7d896e0066..62429e1dc1 100644 --- a/completion/tests/wrapper.ps1 +++ b/completion/tests/wrapper.ps1 @@ -3,7 +3,7 @@ # the binary under test. Set-Location $env:TASK_FIXTURE -. "$PSScriptRoot/../next/ps/task.ps1" +. "$PSScriptRoot/../ps/task.ps1" $fails = 0 diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh index 820261e4fc..d52f1a36f3 100755 --- a/completion/tests/wrapper.zsh +++ b/completion/tests/wrapper.zsh @@ -29,7 +29,7 @@ _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}/../next/zsh/_task +source ${0:A:h}/../zsh/_task run() { CAP="" 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_test.go b/internal/complete/complete_test.go index 390978bc15..43d9657eab 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -29,7 +29,7 @@ func newTestFlagSet() *pflag.FlagSet { fs.StringVar(&s, "sort", "", "Sort order") fs.StringVar(&s, "cacert", "", "CA cert path") fs.StringVar(&s, "completion", "", "Generate a completion script") - fs.StringVar(&s, "new-completion", "", "Generate a completion script") + fs.StringVar(&s, "legacy-completion", "", "Generate a completion script") return fs } @@ -419,7 +419,7 @@ func TestNeedsTaskfile_StdinEntrypoint(t *testing.T) { func TestCompletionShells(t *testing.T) { t.Parallel() - for _, flag := range []string{"--completion", "--new-completion"} { + 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) @@ -427,7 +427,7 @@ func TestCompletionShells(t *testing.T) { for _, shell := range values(suggs) { _, err := task.Completion(shell) require.NoErrorf(t, err, "%s offers %q", flag, shell) - _, err = task.CompletionNext(shell) + _, err = task.LegacyCompletion(shell) require.NoErrorf(t, err, "%s offers %q", flag, shell) } } diff --git a/internal/complete/flags.go b/internal/complete/flags.go index ca70b3a9d2..42ba30b875 100644 --- a/internal/complete/flags.go +++ b/internal/complete/flags.go @@ -12,10 +12,10 @@ 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, - "new-completion": completionShells, + "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. diff --git a/internal/flags/flags.go b/internal/flags/flags.go index ccefcc5264..d09df3ad3f 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -49,7 +49,7 @@ var ( Help bool Init bool Completion string - NewCompletion string + LegacyCompletion string List bool ListAll bool ListJson bool @@ -126,7 +126,7 @@ func init() { pflag.BoolVarP(&Help, "help", "h", false, "Shows Task usage.") pflag.BoolVarP(&Init, "init", "i", false, "Creates a new Taskfile.yml in the current folder.") pflag.StringVar(&Completion, "completion", "", "Generates shell completion script.") - pflag.StringVar(&NewCompletion, "new-completion", "", "Generates the new (experimental) shell completion script, powered by the `task __complete` engine.") + pflag.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.") diff --git a/website/src/next/docs/installation.md b/website/src/next/docs/installation.md index f7422f876e..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,73 +474,10 @@ 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: - -```nu -use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") * -alias go-task = task -``` - -### Trying the new completion engine (experimental) - -Task is migrating to a new completion engine, where every shell shares a single -source of truth: the `task __complete` command. This gives Bash, Zsh, Fish, -Nushell and PowerShell the exact same suggestions (task names, aliases, flags, -flag values and `requires` vars, including their enums). It is currently -**opt-in** and will become the default of `--completion` in a future release. - -To try it, swap `--completion` for `--new-completion` in any of the snippets -above, for example: - -::: code-group - -```shell [bash] -# ~/.bashrc -eval "$(task --new-completion bash)" -``` - -```shell [zsh] -# ~/.zshrc -eval "$(task --new-completion zsh)" -``` - -```shell [fish] -# ~/.config/fish/config.fish -task --new-completion fish | source -``` - -```powershell [powershell] -# $PROFILE\Microsoft.PowerShell_profile.ps1 -Invoke-Expression (&task --new-completion powershell | Out-String) -``` - -```nu [nushell] -# ~/.config/nushell/config.nu -mkdir ($nu.data-dir | path join "vendor/autoload") -task --new-completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu") -``` - -::: - -The `verbose` and `show-aliases` zstyles documented above work with the new Zsh -completion too. - Nushell shares a single external completer between every command, so the script -chains to the one already configured โ€” carapace and friends keep working. Load -it from an autoload directory as shown above rather than from `config.nu`, so -that your own completer is the one being chained to. If you would rather wire it +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 @@ -549,3 +492,11 @@ $env.config.completions.external.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. From a9bf2c8020385b7fe90449a2c6147c1f4d2ab706 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Aug 2026 11:05:08 +0200 Subject: [PATCH 8/8] refactor(completion): move the shell test harness out of completion/ Packaging globs completion/ into the release archives, so the harness added alongside the engine was about to ship run.sh and five wrapper scripts to every user. The previous commit worked around it by listing the packaged directories one by one, which quietly stops packaging any shell added later. Moving the harness to testdata/completion/ leaves completion/ holding only what we ship, so the glob can go back to completion/**/* and needs no maintenance when a shell is added. --- .goreleaser.yml | 7 +------ Taskfile.yml | 3 ++- {completion/tests => testdata/completion}/run.sh | 0 {completion/tests => testdata/completion}/wrapper.bash | 2 +- {completion/tests => testdata/completion}/wrapper.fish | 2 +- {completion/tests => testdata/completion}/wrapper.nu | 2 +- {completion/tests => testdata/completion}/wrapper.ps1 | 2 +- {completion/tests => testdata/completion}/wrapper.zsh | 2 +- 8 files changed, 8 insertions(+), 12 deletions(-) rename {completion/tests => testdata/completion}/run.sh (100%) rename {completion/tests => testdata/completion}/wrapper.bash (96%) rename {completion/tests => testdata/completion}/wrapper.fish (96%) rename {completion/tests => testdata/completion}/wrapper.nu (97%) rename {completion/tests => testdata/completion}/wrapper.ps1 (98%) rename {completion/tests => testdata/completion}/wrapper.zsh (98%) diff --git a/.goreleaser.yml b/.goreleaser.yml index cff7c73e7c..245ac343a9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -43,12 +43,7 @@ archives: files: - README.md - LICENSE - - completion/bash/* - - completion/fish/* - - completion/nu/* - - completion/ps/* - - completion/zsh/* - - completion/legacy/**/* + - completion/**/* format_overrides: - goos: windows formats: [zip] diff --git a/Taskfile.yml b/Taskfile.yml index df62a0a4b5..32dd1512d2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -164,8 +164,9 @@ tasks: - internal/complete/**/*.go - cmd/task/**/*.go - completion/**/* + - testdata/completion/* cmds: - - bash completion/tests/run.sh + - bash testdata/completion/run.sh goreleaser:test: desc: Tests release process without publishing diff --git a/completion/tests/run.sh b/testdata/completion/run.sh similarity index 100% rename from completion/tests/run.sh rename to testdata/completion/run.sh diff --git a/completion/tests/wrapper.bash b/testdata/completion/wrapper.bash similarity index 96% rename from completion/tests/wrapper.bash rename to testdata/completion/wrapper.bash index 8fc5c1726d..63cfcbd41b 100755 --- a/completion/tests/wrapper.bash +++ b/testdata/completion/wrapper.bash @@ -22,7 +22,7 @@ _filedir() { CAP+="filedir:$* cur=$cur"$'\n'; } compopt() { CAP+="compopt:$*"$'\n'; } __ltrim_colon_completions() { :; } -source "$(dirname "${BASH_SOURCE[0]}")/../bash/task.bash" +source "$(dirname "${BASH_SOURCE[0]}")/../../completion/bash/task.bash" run() { CAP="" diff --git a/completion/tests/wrapper.fish b/testdata/completion/wrapper.fish similarity index 96% rename from completion/tests/wrapper.fish rename to testdata/completion/wrapper.fish index 51eb3173f1..aa5833417a 100755 --- a/completion/tests/wrapper.fish +++ b/testdata/completion/wrapper.fish @@ -3,7 +3,7 @@ # Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test. cd $TASK_FIXTURE -source (dirname (status -f))/../fish/task.fish +source (dirname (status -f))/../../completion/fish/task.fish set -g fails 0 diff --git a/completion/tests/wrapper.nu b/testdata/completion/wrapper.nu similarity index 97% rename from completion/tests/wrapper.nu rename to testdata/completion/wrapper.nu index 019cfa5636..e3339fa4fd 100644 --- a/completion/tests/wrapper.nu +++ b/testdata/completion/wrapper.nu @@ -4,7 +4,7 @@ # 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 "../nu/task-completions.nu") +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)" }] } diff --git a/completion/tests/wrapper.ps1 b/testdata/completion/wrapper.ps1 similarity index 98% rename from completion/tests/wrapper.ps1 rename to testdata/completion/wrapper.ps1 index 62429e1dc1..798b0423aa 100644 --- a/completion/tests/wrapper.ps1 +++ b/testdata/completion/wrapper.ps1 @@ -3,7 +3,7 @@ # the binary under test. Set-Location $env:TASK_FIXTURE -. "$PSScriptRoot/../ps/task.ps1" +. "$PSScriptRoot/../../completion/ps/task.ps1" $fails = 0 diff --git a/completion/tests/wrapper.zsh b/testdata/completion/wrapper.zsh similarity index 98% rename from completion/tests/wrapper.zsh rename to testdata/completion/wrapper.zsh index d52f1a36f3..c42f9ef2c1 100755 --- a/completion/tests/wrapper.zsh +++ b/testdata/completion/wrapper.zsh @@ -29,7 +29,7 @@ _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}/../zsh/_task +source ${0:A:h}/../../completion/zsh/_task run() { CAP=""