diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7b85c7a..c27e79ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,3 +31,36 @@ jobs: - name: Test run: go run ./cmd/task test + + completion: + name: Completion + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest] + runs-on: ${{matrix.platform}} + steps: + - name: Check out code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: 1.26.x + + # zsh and pwsh are preinstalled on the runners; only fish is missing + # (plus zsh on the Linux image). + - name: Install shells (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh fish + + - name: Install shells (macOS) + if: runner.os == 'macOS' + run: brew install fish + + - name: Test completion + # Strict mode fails the run if any shell is missing, so we never get a + # false pass when a runner image stops shipping one (e.g. pwsh). + env: + TASK_COMPLETION_STRICT: "1" + run: go run ./cmd/task test:completion diff --git a/Taskfile.yml b/Taskfile.yml index 57274715..a439b4ed 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -152,6 +152,15 @@ tasks: cmds: - gotestsum -f '{{.GOTESTSUM_FORMAT}}' -tags 'signals watch' ./... + test:completion: + desc: Tests the shell completion engine and wrappers (bash, zsh, fish, powershell) + sources: + - internal/complete/**/*.go + - cmd/task/**/*.go + - completion/**/* + cmds: + - bash completion/tests/run.sh + goreleaser:test: desc: Tests release process without publishing cmds: diff --git a/completion/bash/task.bash b/completion/bash/task.bash index 991346d0..4e7438f7 100644 --- a/completion/bash/task.bash +++ b/completion/bash/task.bash @@ -7,6 +7,10 @@ TASK_CMD="${TASK_EXE:-task}" _task() { local cur prev words cword + + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 + # Exclude both `=` and `:` from the word breaks so `--output=` and # `docs:serve` reach the engine as single tokens. _init_completion -n =: || return @@ -36,16 +40,18 @@ _task() { local directive="${lines[$last_idx]#:}" unset 'lines[$last_idx]' - if (( directive & 8 )); then + if (( directive & FILTER_FILE_EXT )); then local exts="" - for line in "${lines[@]}"; do + # ${arr[@]+…} guards against "unbound variable" on an empty array under + # `set -u` in bash 3.2 (macOS). + for line in ${lines[@]+"${lines[@]}"}; do exts+="${exts:+|}$line" done _filedir "@($exts)" return fi - if (( directive & 16 )); then + if (( directive & FILTER_DIRS )); then _filedir -d return fi @@ -54,20 +60,20 @@ _task() { # word list on IFS, which mangles any suggestion value containing a space. local value COMPREPLY=() - for line in "${lines[@]}"; do + for line in ${lines[@]+"${lines[@]}"}; do value="${line%%$'\t'*}" if [[ -z "$cur" || "$value" == "$cur"* ]]; then COMPREPLY+=( "$value" ) fi done - if (( directive & 2 )); then + if (( directive & NO_SPACE )); then compopt -o nospace 2>/dev/null fi __ltrim_colon_completions "$cur" - if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then + if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then _filedir fi } diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 30ad0150..9b643f41 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -3,6 +3,19 @@ set -l GO_TASK_PROGNAME (if set -q GO_TASK_PROGNAME; echo $GO_TASK_PROGNAME; else if set -q TASK_EXE; echo $TASK_EXE; else; echo task; end) +# Completion directives, mirroring internal/complete/complete.go. fish's `math` +# has no bitwise operators, so bits are stored as their power-of-two value and +# tested with integer division + modulo via __task_test_bit. +set -g __task_directive_no_space 2 +set -g __task_directive_no_file_comp 4 +set -g __task_directive_filter_file_ext 8 +set -g __task_directive_filter_dirs 16 +set -g __task_directive_keep_order 32 + +function __task_test_bit --argument-names value bit + test (math "floor($value / $bit) % 2") -eq 1 +end + function __task_complete --inherit-variable GO_TASK_PROGNAME set -l tokens (commandline -opc) set -l current (commandline -ct) @@ -36,15 +49,27 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME # served here, otherwise nothing is offered (e.g. `--cacert`, after `--`). # FilterFileExt: the engine emits the allowed extensions as the data lines. - if test (math "$directive & 8") -ne 0 - for ext in $data - __fish_complete_suffix ".$ext" + # __fish_complete_suffix only *prioritizes* the extension, so filter the file + # list ourselves — keeping directories so the user can still descend into them. + if __task_test_bit $directive $__task_directive_filter_file_ext + for entry in (__fish_complete_path $current) + set -l name (string split -f1 \t -- $entry) + if string match -qr '/$' -- $name + printf '%s\n' $entry + continue + end + for ext in $data + if string match -qr "\.$ext\$" -- $name + printf '%s\n' $entry + break + end + end end return end # FilterDirs: complete directories only. - if test (math "$directive & 16") -ne 0 + if __task_test_bit $directive $__task_directive_filter_dirs __fish_complete_directories $current return end @@ -55,9 +80,9 @@ function __task_complete --inherit-variable GO_TASK_PROGNAME printf '%s\n' $line end - # NoFileComp (bit 4) unset → also offer files, since `--no-files` suppressed - # the native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`). - if test (math "$directive & 4") -eq 0 + # NoFileComp unset → also offer files, since `--no-files` suppressed the + # native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`). + if not __task_test_bit $directive $__task_directive_no_file_comp __fish_complete_path $current end end diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 5cf74adf..7e189918 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -38,20 +38,31 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { $directive = [int]($last.Substring(1)) $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() } + # Completion directives, mirroring internal/complete/complete.go. + $NoFileComp = 4 + $FilterFileExt = 8 + $FilterDirs = 16 + # Note: DirectiveNoSpace (bit 2) cannot be honored here — PowerShell's # CompletionResult API has no per-item "no trailing space" option, so a # suggestion like `VAR=` gets a trailing space. This is a PowerShell limit. - # FilterFileExt - if ($directive -band 8) { - $patterns = $data | ForEach-Object { "*.$_" } - return Get-ChildItem -Path . -Include $patterns -File -ErrorAction SilentlyContinue | - ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } + # FilterFileExt: keep files whose extension matches, plus directories so the + # user can still descend into them. `-Include` is unreliable without + # `-Recurse`, so filter with Where-Object instead. + if ($directive -band $FilterFileExt) { + $exts = $data | ForEach-Object { ".$_" } + return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | + Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } | + ForEach-Object { + $type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem } + [CompletionResult]::new($_.Name, $_.Name, $type, $_.Name) + } } # FilterDirs - if ($directive -band 16) { - return Get-ChildItem -Path . -Directory -ErrorAction SilentlyContinue | + if ($directive -band $FilterDirs) { + return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue | ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) } } @@ -68,7 +79,7 @@ Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock { # NoFileComp (bit 4) unset and nothing matched → fall back to file completion, # since the engine returned DirectiveDefault (e.g. --cacert, after `--`). - if ($results.Count -eq 0 -and -not ($directive -band 4)) { + if ($results.Count -eq 0 -and -not ($directive -band $NoFileComp)) { return Get-ChildItem -Path . -ErrorAction SilentlyContinue | ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) } } diff --git a/completion/tests/engine.sh b/completion/tests/engine.sh new file mode 100755 index 00000000..f4937453 --- /dev/null +++ b/completion/tests/engine.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Tests the `task __complete` protocol directly (shell-agnostic). This is the +# backbone: it validates the candidates and directive the engine emits, which +# is what drives every shell wrapper. +# +# Requires: TASK_BIN (path to the task binary), TASK_FIXTURE (dir with a +# Taskfile.yml). Exits non-zero on the first failure. +set -u + +: "${TASK_BIN:?TASK_BIN must point to the task binary}" +: "${TASK_FIXTURE:?TASK_FIXTURE must point to the fixture directory}" +cd "$TASK_FIXTURE" || exit 1 + +fails=0 +out() { "$TASK_BIN" __complete "$@" 2>/dev/null; } +vals() { out "$@" | sed '$d' | cut -f1; } # candidate values, sans the :N line +dirv() { out "$@" | tail -1; } # the :N directive line + +has() { # LABEL VALUE ARGS... + local label=$1 value=$2; shift 2 + if vals "$@" | grep -qxF -- "$value"; then + echo " ok $label" + else + echo " FAIL $label — expected value '$value' among: $(vals "$@" | tr '\n' ' ')" + fails=$((fails + 1)) + fi +} +hasnot() { # LABEL VALUE ARGS... + local label=$1 value=$2; shift 2 + if vals "$@" | grep -qxF -- "$value"; then + echo " FAIL $label — value '$value' should be absent" + fails=$((fails + 1)) + else + echo " ok $label" + fi +} +directive() { # LABEL EXPECTED ARGS... + local label=$1 expected=$2; shift 2 + local got; got=$(dirv "$@") + if [[ "$got" == "$expected" ]]; then + echo " ok $label" + else + echo " FAIL $label — expected directive '$expected', got '$got'" + fails=$((fails + 1)) + fi +} + +echo "engine: task names" +has "lists tasks" build '' +has "lists aliases" dep '' +directive "NoFileComp" ':4' '' + +echo "engine: completion-control flags" +hasnot "--no-aliases drops aliases" dep --no-aliases '' +has "--no-aliases keeps tasks" deploy --no-aliases '' + +echo "engine: flags" +has "lists flags" --taskfile - +directive "flags NoFileComp" ':4' - + +echo "engine: flag values" +has "inline --output= is full form" --output=interleaved --output= +directive "inline NoFileComp" ':4' --output= +has "separate --output is bare" interleaved --output '' +has "--sort values" alphanumeric --sort '' + +echo "engine: file/dir directives" +has "--taskfile emits yml" yml --taskfile '' +has "--taskfile emits yaml" yaml --taskfile '' +directive "--taskfile FilterFileExt" ':8' --taskfile '' +directive "--dir FilterDirs" ':16' --dir '' + +echo "engine: task variables" +has "required var with enum" ENV=dev deploy '' +has "required var without enum" REGION= deploy '' +directive "vars NoSpace|NoFileComp|KeepOrder" ':38' deploy '' + +echo "engine: after --" +directive "after -- is default" ':0' deploy -- '' + +if (( fails )); then + echo "engine: $fails failure(s)" + exit 1 +fi +echo "engine: all passed" diff --git a/completion/tests/run.sh b/completion/tests/run.sh new file mode 100755 index 00000000..f70be538 --- /dev/null +++ b/completion/tests/run.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Runs the completion test suite: builds the task binary, creates a fixture +# Taskfile with sample files and directories, then exercises the engine and +# every installed shell wrapper. Skips shells that are not installed. +set -u + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +root=$(cd "$here/../.." && pwd) + +# Build the binary under test. +bindir=$(mktemp -d) +if ! go build -o "$bindir/task" "$root/cmd/task"; then + echo "failed to build task binary" >&2 + exit 1 +fi +export TASK_BIN="$bindir/task" +# fish and PowerShell register completion for the command name `task`, so make +# `task` on PATH resolve to the binary under test. +export PATH="$bindir:$PATH" + +# Fixture: a Taskfile plus files/dirs so file/dir completion has real entries. +fixture=$(mktemp -d) +cat > "$fixture/Taskfile.yml" <<'YML' +version: '3' + +tasks: + build: + desc: Build it + deploy: + desc: Deploy it + aliases: [dep] + requires: + vars: + - name: ENV + enum: [dev, prod] + - REGION + docs:serve: + desc: Serve docs +YML +touch "$fixture/extra.yaml" "$fixture/notes.txt" +mkdir -p "$fixture/sub" "$fixture/other" +export TASK_FIXTURE="$fixture" + +# In strict mode (set TASK_COMPLETION_STRICT=1, used in CI) a missing shell is +# a failure instead of a skip, so we never get a false pass when a shell the +# environment was expected to provide (e.g. pwsh on CI runners) is absent. +strict=${TASK_COMPLETION_STRICT:-} + +fails=0 +run() { # LABEL COMMAND... + echo "== $1 ==" + if "${@:2}"; then :; else fails=$((fails + 1)); fi + echo +} +skip() { # LABEL + if [[ -n "$strict" ]]; then + echo "== $1 == (MISSING — required under TASK_COMPLETION_STRICT)" + fails=$((fails + 1)) + else + echo "== $1 == (skipped: not installed)" + fi + echo +} + +run "engine" bash "$here/engine.sh" +run "bash wrapper" bash "$here/wrapper.bash" + +if command -v zsh >/dev/null 2>&1; then + run "zsh wrapper" zsh "$here/wrapper.zsh" +else + skip "zsh wrapper" +fi + +if command -v fish >/dev/null 2>&1; then + run "fish wrapper" fish "$here/wrapper.fish" +else + skip "fish wrapper" +fi + +pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true) +if [[ -n "$pwsh_bin" ]]; then + run "powershell wrapper" "$pwsh_bin" -NoProfile -File "$here/wrapper.ps1" +else + skip "powershell wrapper" +fi + +if ((fails)); then + echo "completion tests: $fails suite(s) failed" + exit 1 +fi +echo "completion tests: all suites passed" diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash new file mode 100755 index 00000000..6ea0de9e --- /dev/null +++ b/completion/tests/wrapper.bash @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Tests the bash wrapper by stubbing the bash-completion helpers it calls +# (_init_completion / _filedir / compopt / __ltrim_colon_completions) and +# asserting the resulting COMPREPLY and file routing. Deterministic, no TTY, +# and works without the bash-completion package installed. +# +# Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). +set -u + +: "${TASK_BIN:?}"; : "${TASK_FIXTURE:?}" +export TASK_EXE="$TASK_BIN" +cd "$TASK_FIXTURE" || exit 1 + +fails=0 +CAP="" + +# Stubs standing in for the bash-completion runtime. +_init_completion() { + words=("${TEST_WORDS[@]}") + cword=$TEST_CWORD + cur="${TEST_WORDS[$TEST_CWORD]}" + prev="${TEST_WORDS[$((TEST_CWORD - 1))]}" + return 0 +} +_filedir() { CAP+="filedir:$*"$'\n'; } +compopt() { CAP+="compopt:$*"$'\n'; } +__ltrim_colon_completions() { :; } + +source "$(dirname "${BASH_SOURCE[0]}")/../bash/task.bash" + +run() { + CAP="" + TEST_WORDS=("$@") + TEST_CWORD=$((${#TEST_WORDS[@]} - 1)) + COMPREPLY=() + _task +} + +reply_has() { # LABEL VALUE + local v + for v in "${COMPREPLY[@]}"; do [[ "$v" == "$2" ]] && { echo " ok $1"; return; }; done + echo " FAIL $1 — '$2' missing from COMPREPLY: ${COMPREPLY[*]}" + fails=$((fails + 1)) +} +cap_has() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then echo " ok $1"; else + echo " FAIL $1 — expected '$2' in: $CAP"; fails=$((fails + 1)); fi +} +cap_hasnot() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then + echo " FAIL $1 — '$2' should be absent in: $CAP"; fails=$((fails + 1)); else + echo " ok $1"; fi +} + +echo "bash: task names (no file fallback)" +run task '' +reply_has "lists tasks" build +reply_has "lists aliases" dep +cap_hasnot "no file fallback" "filedir:" + +echo "bash: task variables" +run task deploy '' +reply_has "required vars" "ENV=dev" +cap_has "NoSpace nospace" "compopt:-o nospace" + +echo "bash: inline --output= is full form" +run task '--output=' +reply_has "full-form value" "--output=interleaved" + +echo "bash: --dir routes to directory completion" +run task --dir '' +cap_has "filedir -d" "filedir:-d" + +echo "bash: --taskfile routes to extension-filtered files" +run task --taskfile '' +cap_has "filedir ext glob" "filedir:@(yml|yaml)" + +echo "bash: after -- falls back to files" +run task build -- '' +cap_has "filedir after --" "filedir:" + +if ((fails)); then + echo "bash: $fails failure(s)" + exit 1 +fi +echo "bash: all passed" diff --git a/completion/tests/wrapper.fish b/completion/tests/wrapper.fish new file mode 100755 index 00000000..78764171 --- /dev/null +++ b/completion/tests/wrapper.fish @@ -0,0 +1,61 @@ +#!/usr/bin/env fish +# Tests the fish wrapper end-to-end via `complete -C`, which asks fish for the +# real completions of a command line without a TTY. The `task` command must +# resolve to the binary under test (run.sh puts a symlink on PATH). +# +# Requires: TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). + +cd $TASK_FIXTURE +source (dirname (status -f))/../fish/task.fish + +set -g fails 0 + +function cands + complete -C $argv[1] | string split -f1 \t +end + +function has # LABEL LINE VALUE + if contains -- $argv[3] (cands $argv[2]) + echo " ok $argv[1]" + else + echo " FAIL $argv[1] — '$argv[3]' missing from: "(cands $argv[2]) + set fails (math $fails + 1) + end +end + +function hasnot # LABEL LINE VALUE + if contains -- $argv[3] (cands $argv[2]) + echo " FAIL $argv[1] — '$argv[3]' should be absent" + set fails (math $fails + 1) + else + echo " ok $argv[1]" + end +end + +echo "fish: task names (no files)" +has "lists tasks" 'task ' build +has "lists aliases" 'task ' dep +hasnot "no files for tasks" 'task ' notes.txt + +echo "fish: task variables" +has "required vars" 'task deploy ' ENV=dev + +echo "fish: flag values" +has "enum values" 'task --output ' interleaved + +echo "fish: --dir completes directories only" +has "dirs offered" 'task --dir ' sub/ +hasnot "no plain files" 'task --dir ' notes.txt + +echo "fish: --taskfile filters by extension" +has "yaml offered" 'task --taskfile ' Taskfile.yml +hasnot "txt filtered out" 'task --taskfile ' notes.txt + +echo "fish: after -- completes files" +has "files after --" 'task build -- ' notes.txt + +if test $fails -ne 0 + echo "fish: $fails failure(s)" + exit 1 +end +echo "fish: all passed" diff --git a/completion/tests/wrapper.ps1 b/completion/tests/wrapper.ps1 new file mode 100644 index 00000000..f98744b0 --- /dev/null +++ b/completion/tests/wrapper.ps1 @@ -0,0 +1,62 @@ +# Tests the PowerShell wrapper end-to-end via the completion API, which returns +# the real completions of a command line without a TTY. The `task` command must +# resolve to the binary under test (run.sh puts a symlink on PATH). +# +# Requires: $env:TASK_FIXTURE (dir with a Taskfile.yml and sample files/dirs). + +Set-Location $env:TASK_FIXTURE +. "$PSScriptRoot/../ps/task.ps1" + +$fails = 0 + +function Cands($line) { + ([System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null)).CompletionMatches | + ForEach-Object { $_.CompletionText } +} + +function Has($label, $line, $value) { + if ((Cands $line) -contains $value) { + Write-Output " ok $label" + } else { + Write-Output " FAIL $label — '$value' missing from: $((Cands $line) -join ' ')" + $script:fails++ + } +} + +function HasNot($label, $line, $value) { + if ((Cands $line) -contains $value) { + Write-Output " FAIL $label — '$value' should be absent" + $script:fails++ + } else { + Write-Output " ok $label" + } +} + +Write-Output "powershell: task names (no files)" +Has "lists tasks" 'task ' 'build' +Has "lists aliases" 'task ' 'dep' +HasNot "no files for tasks" 'task ' 'notes.txt' + +Write-Output "powershell: prefix filtering" +Has "filters by prefix" 'task b' 'build' +HasNot "prefix excludes" 'task b' 'deploy' + +Write-Output "powershell: task variables" +Has "required vars" 'task deploy ' 'ENV=dev' + +Write-Output "powershell: flag values" +Has "enum values" 'task --output ' 'interleaved' + +Write-Output "powershell: --dir completes directories only" +Has "dirs offered" 'task --dir ' 'sub' +HasNot "no plain files" 'task --dir ' 'notes.txt' + +Write-Output "powershell: --taskfile filters by extension" +Has "yaml offered" 'task --taskfile ' 'Taskfile.yml' +HasNot "txt filtered out" 'task --taskfile ' 'notes.txt' + +if ($fails -ne 0) { + Write-Output "powershell: $fails failure(s)" + exit 1 +} +Write-Output "powershell: all passed" diff --git a/completion/tests/wrapper.zsh b/completion/tests/wrapper.zsh new file mode 100755 index 00000000..18af968f --- /dev/null +++ b/completion/tests/wrapper.zsh @@ -0,0 +1,81 @@ +#!/usr/bin/env zsh +# Tests the zsh wrapper by stubbing the completion-system functions it calls +# (_describe / _files / _path_files) and asserting how it routes each directive. +# This is deterministic and needs no TTY. +# +# Requires: TASK_BIN (task binary), TASK_FIXTURE (dir with a Taskfile.yml). + +export TASK_EXE=$TASK_BIN +cd $TASK_FIXTURE + +integer fails=0 +local CAP +compdef() { } # no-op: we call _task directly, not through compinit + +_describe() { + local arr=$4 + CAP+="describe_opts:${@[5,-1]}"$'\n' + local c; for c in ${(P)arr}; do CAP+="cand:$c"$'\n'; done +} +_files() { CAP+="files:$*"$'\n' } +_path_files() { CAP+="path_files:$*"$'\n' } + +# Sourcing (not autoloading) defines _task and avoids the autoload first-call +# quirk; the trailing `compdef` call is stubbed above. +source ${0:A:h}/../zsh/_task + +run() { + CAP="" + local -a words=("$@") + integer CURRENT=$#words + local curcontext=":completion:complete:task:" + _task +} + +has() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then + echo " ok $1" + else + echo " FAIL $1 — expected '$2' in:"$'\n'"$CAP" + (( fails++ )) + fi +} +hasnot() { # LABEL PATTERN + if [[ "$CAP" == *"$2"* ]]; then + echo " FAIL $1 — '$2' should be absent in:"$'\n'"$CAP" + (( fails++ )) + else + echo " ok $1" + fi +} + +echo "zsh: task names (no file fallback)" +run task '' +has "lists tasks" "cand:build" +has "lists aliases" "cand:dep" +hasnot "no file fallback" "files:" + +echo "zsh: task variables" +run task deploy '' +has "required vars" "cand:ENV=dev" +has "NoSpace -> -S" "describe_opts:-S" +has "KeepOrder -> -V" "-V" + +echo "zsh: --dir routes to directory completion" +run task --dir '' +has "path_files -/" "path_files:-/" + +echo "zsh: --taskfile routes to extension-filtered files" +run task --taskfile '' +has "files glob" "files:" +has "yml in glob" "yml" + +echo "zsh: after -- falls back to files" +run task build -- '' +has "files after --" "files:" + +if (( fails )); then + echo "zsh: $fails failure(s)" + exit 1 +fi +echo "zsh: all passed" diff --git a/completion/zsh/_task b/completion/zsh/_task index edce79f8..130493ff 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -9,6 +9,9 @@ _task() { local -a args lines completions opts ctl local output directive line + # Completion directives, mirroring internal/complete/complete.go. + local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 KEEP_ORDER=32 + # Map the zsh completion zstyles to engine flags. `-T` is true when the # style is unset (its default) or explicitly true, so a flag is only passed # when the user turned the style off. @@ -30,7 +33,7 @@ _task() { directive="${lines[-1]#:}" lines=("${(@)lines[1,-2]}") - if (( directive & 8 )); then + if (( directive & FILTER_FILE_EXT )); then local -a globs for line in "${lines[@]}"; do globs+=("*.${line}") @@ -39,7 +42,7 @@ _task() { return fi - if (( directive & 16 )); then + if (( directive & FILTER_DIRS )); then _path_files -/ return fi @@ -57,14 +60,14 @@ _task() { fi done - (( directive & 2 )) && opts+=(-S '') - (( directive & 32 )) && opts+=(-V) + (( directive & NO_SPACE )) && opts+=(-S '') + (( directive & KEEP_ORDER )) && opts+=(-V) if (( ${#completions} > 0 )); then _describe -t tasks 'task' completions "${opts[@]}" fi - (( directive & 4 )) && return + (( directive & NO_FILE_COMP )) && return _files }