mirror of
https://github.com/go-task/task.git
synced 2026-09-01 19:50:16 +02:00
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:
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -2,6 +2,17 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Added a new completion engine that unifies Bash, Fish, Zsh, Nushell and
|
||||
PowerShell behind a single `task __complete` command, so every shell offers
|
||||
the same suggestions: task names, aliases, flags, flag values and per-task CLI
|
||||
variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now
|
||||
backed by the `--no-aliases` and `--no-descriptions` completion flags. It is
|
||||
opt-in for now via `task --new-completion <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
|
||||
|
||||
@@ -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
41
cmd/task/complete_cmd.go
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
89
completion/next/bash/task.bash
Normal file
89
completion/next/bash/task.bash
Normal 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"
|
||||
98
completion/next/fish/task.fish
Normal file
98
completion/next/fish/task.fish
Normal 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)"
|
||||
86
completion/next/nu/task-completions.nu
Normal file
86
completion/next/nu/task-completions.nu
Normal 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
109
completion/next/ps/task.ps1
Normal 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
76
completion/next/zsh/_task
Executable 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
328
completion/protocol_test.go
Normal 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
94
completion/tests/run.sh
Executable 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
80
completion/tests/wrapper.bash
Executable 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
55
completion/tests/wrapper.fish
Executable 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"
|
||||
94
completion/tests/wrapper.nu
Normal file
94
completion/tests/wrapper.nu
Normal 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"
|
||||
67
completion/tests/wrapper.ps1
Normal file
67
completion/tests/wrapper.ps1
Normal 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
88
completion/tests/wrapper.zsh
Executable 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"
|
||||
59
internal/complete/complete.go
Normal file
59
internal/complete/complete.go
Normal 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
|
||||
}
|
||||
415
internal/complete/complete_test.go
Normal file
415
internal/complete/complete_test.go
Normal 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
|
||||
}
|
||||
79
internal/complete/context.go
Normal file
79
internal/complete/context.go
Normal 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
209
internal/complete/engine.go
Normal 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
|
||||
}
|
||||
70
internal/complete/flags.go
Normal file
70
internal/complete/flags.go
Normal 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
|
||||
}
|
||||
28
internal/complete/output.go
Normal file
28
internal/complete/output.go
Normal 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)
|
||||
}
|
||||
@@ -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
64
internal/refs/refs.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
17
requires.go
17
requires.go
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/internal/input"
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
"github.com/go-task/task/v3/internal/refs"
|
||||
"github.com/go-task/task/v3/internal/term"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
@@ -46,7 +46,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error {
|
||||
|
||||
for _, v := range getMissingRequiredVars(compiledTask) {
|
||||
if !varsMap.Has(v.Name) {
|
||||
varsMap.Set(v.Name, resolveEnumRefForPrompt(v, compiledTask.Vars))
|
||||
varsMap.Set(v.Name, refs.ResolveEnum(v, compiledTask.Vars))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,16 +217,3 @@ func getEnumValues(e *ast.Enum) []string {
|
||||
}
|
||||
return e.Value
|
||||
}
|
||||
|
||||
// resolveEnumRefForPrompt returns a copy of v with its enum ref resolved into
|
||||
// concrete values, so the interactive prompter can show a Select. Refs that
|
||||
// depend on dynamic vars may not resolve here and fall back to free-form input.
|
||||
func resolveEnumRefForPrompt(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation {
|
||||
if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 {
|
||||
return v
|
||||
}
|
||||
vCopy := v.DeepCopy()
|
||||
cache := &templater.Cache{Vars: vars}
|
||||
_ = resolveEnumRefs(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, cache)
|
||||
return vCopy
|
||||
}
|
||||
|
||||
73
variables.go
73
variables.go
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/go-task/task/v3/internal/execext"
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/fingerprint"
|
||||
"github.com/go-task/task/v3/internal/refs"
|
||||
"github.com/go-task/task/v3/internal/slicesext"
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
@@ -118,7 +120,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
requires := origTask.Requires
|
||||
if evaluateShVars {
|
||||
requires = origTask.Requires.DeepCopy()
|
||||
if err := resolveEnumRefs(requires, cache); err != nil {
|
||||
if err := refs.ResolveEnums(requires, cache); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -347,30 +349,6 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
return &new, nil
|
||||
}
|
||||
|
||||
func asAnySlice[T any](slice []T) []any {
|
||||
ret := make([]any, len(slice))
|
||||
for i, v := range slice {
|
||||
ret[i] = v
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// resolvedAsAnySlice converts a value resolved from a reference into a []any.
|
||||
// A reference does not always resolve to a []any: lists declared in a Taskfile
|
||||
// do, but template functions such as `keys` and `splitList` return a []string.
|
||||
// The accepted types mirror the list types itemsFromFor already supports.
|
||||
func resolvedAsAnySlice(v any) ([]any, bool) {
|
||||
switch value := v.(type) {
|
||||
case []any:
|
||||
return value, true
|
||||
case []string:
|
||||
return asAnySlice(value), true
|
||||
case []int:
|
||||
return asAnySlice(value), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func itemsFromFor(
|
||||
f *ast.For,
|
||||
dir string,
|
||||
@@ -392,7 +370,7 @@ func itemsFromFor(
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return asAnySlice(product(resolvedMatrix)), nil, nil
|
||||
return slicesext.AsAny(product(resolvedMatrix)), nil, nil
|
||||
}
|
||||
// Get the list from the explicit for list
|
||||
if len(f.List) > 0 {
|
||||
@@ -410,7 +388,7 @@ func itemsFromFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
values = asAnySlice(glist)
|
||||
values = slicesext.AsAny(glist)
|
||||
}
|
||||
// Get the list from the task generates
|
||||
if f.From == "generates" {
|
||||
@@ -424,7 +402,7 @@ func itemsFromFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
values = asAnySlice(glist)
|
||||
values = slicesext.AsAny(glist)
|
||||
}
|
||||
// Get the list from a variable and split it up
|
||||
if f.Var != "" {
|
||||
@@ -437,14 +415,14 @@ func itemsFromFor(
|
||||
switch value := v.Value.(type) {
|
||||
case string:
|
||||
if f.Split != "" {
|
||||
values = asAnySlice(strings.Split(value, f.Split))
|
||||
values = slicesext.AsAny(strings.Split(value, f.Split))
|
||||
} else {
|
||||
values = asAnySlice(strings.Fields(value))
|
||||
values = slicesext.AsAny(strings.Fields(value))
|
||||
}
|
||||
case []string:
|
||||
values = asAnySlice(value)
|
||||
values = slicesext.AsAny(value)
|
||||
case []int:
|
||||
values = asAnySlice(value)
|
||||
values = slicesext.AsAny(value)
|
||||
case []any:
|
||||
values = value
|
||||
case map[string]any:
|
||||
@@ -492,7 +470,7 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix,
|
||||
if cache.Err() != nil {
|
||||
return nil, cache.Err()
|
||||
}
|
||||
value, ok := resolvedAsAnySlice(v)
|
||||
value, ok := refs.AsList(v)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("matrix reference %q must resolve to a list", row.Ref)
|
||||
}
|
||||
@@ -502,35 +480,6 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix,
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error {
|
||||
if requires == nil || len(requires.Vars) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, v := range requires.Vars {
|
||||
if v.Enum == nil || v.Enum.Ref == "" {
|
||||
continue
|
||||
}
|
||||
resolved := templater.ResolveRef(v.Enum.Ref, cache)
|
||||
if cache.Err() != nil {
|
||||
return cache.Err()
|
||||
}
|
||||
arr, ok := resolvedAsAnySlice(resolved)
|
||||
if !ok {
|
||||
return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref)
|
||||
}
|
||||
strValues := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref)
|
||||
}
|
||||
strValues = append(strValues, s)
|
||||
}
|
||||
v.Enum.Value = strValues
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// product generates the cartesian product of the input map of slices.
|
||||
func product(matrix *ast.Matrix) []map[string]any {
|
||||
if matrix.Len() == 0 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user