feat(completion): unify shell completions behind an opt-in task __complete engine

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 <shell>`, 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.
This commit is contained in:
Valentin Maerten
2026-08-20 13:56:01 +02:00
parent dd4463ec60
commit ed7a206e19
31 changed files with 2471 additions and 99 deletions

View File

@@ -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:

View File

@@ -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 <shell>`, 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

View File

@@ -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:

41
cmd/task/complete_cmd.go Normal file
View File

@@ -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()
}

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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)"

View File

@@ -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<string>] {
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 '^(?<flag>--?[^=]+=)(?<path>.*)$')
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 <tab> 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
}
}

109
completion/next/ps/task.ps1 Normal file
View File

@@ -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
}

76
completion/next/zsh/_task Executable file
View File

@@ -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"

328
completion/protocol_test.go Normal file
View File

@@ -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 <args>` 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 :<directive> 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 <args>` 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)
}
}
}

94
completion/tests/run.sh Executable file
View File

@@ -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"

80
completion/tests/wrapper.bash Executable file
View File

@@ -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"

55
completion/tests/wrapper.fish Executable file
View File

@@ -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"

View File

@@ -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<string>] {
let out = (do $completer $spans)
if $out == null { [] } else { $out | get value }
}
def has [label: string, spans: list<string>, 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<string>, 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"

View File

@@ -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"

88
completion/tests/wrapper.zsh Executable file
View File

@@ -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"

View File

@@ -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 `:<n>`.
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
}

View File

@@ -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
}

View File

@@ -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
}

209
internal/complete/engine.go Normal file
View File

@@ -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 <tab>` 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 `<flag>=` 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,28 @@
package complete
import (
"fmt"
"io"
"strings"
)
// The trailing `:<directive>` 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)
}

View File

@@ -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

64
internal/refs/refs.go Normal file
View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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 })
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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).