mirror of
https://github.com/go-task/task.git
synced 2026-10-23 11:48:55 +03:00
feat(completion): unify shell completions behind task __complete (#2897)
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:
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
instead of retrying every default Taskfile name and concluding that no
|
||||
Taskfile exists (#2329 by @vmaerten).
|
||||
|
||||
- `task --completion <shell>` now serves a new completion engine that unifies
|
||||
Bash, Fish, Zsh, Nushell and PowerShell behind a single `task __complete`
|
||||
command, so every shell offers the same suggestions: task names, aliases,
|
||||
flags, flag values and per-task CLI variables. The Zsh `show-aliases` and
|
||||
`verbose` zstyles keep working, now backed by the `--no-aliases` and
|
||||
`--no-descriptions` completion flags (#2897 by @vmaerten).
|
||||
|
||||
### 📦 Package API
|
||||
|
||||
- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support
|
||||
|
||||
10
Taskfile.yml
10
Taskfile.yml
@@ -158,6 +158,16 @@ tasks:
|
||||
cmds:
|
||||
- go test -bench=. -benchmem -tags=fsbench -run=^$ ./...
|
||||
|
||||
test:completion:
|
||||
desc: Tests the shell completion engine and wrappers (bash, zsh, fish, nu, powershell)
|
||||
sources:
|
||||
- internal/complete/**/*.go
|
||||
- cmd/task/**/*.go
|
||||
- completion/**/*
|
||||
- testdata/completion/*
|
||||
cmds:
|
||||
- bash testdata/completion/run.sh
|
||||
|
||||
goreleaser:test:
|
||||
desc: Tests release process without publishing
|
||||
cmds:
|
||||
|
||||
44
cmd/task/complete_cmd.go
Normal file
44
cmd/task/complete_cmd.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
"github.com/go-task/task/v3/internal/flags"
|
||||
)
|
||||
|
||||
func runComplete(args []string) error {
|
||||
opts, args := complete.ParseOptions(args)
|
||||
|
||||
// Overridden after WithFlags: a keystroke stays silent and never touches the
|
||||
// network, whatever the user typed.
|
||||
e := task.NewExecutor(
|
||||
flags.WithFlags(),
|
||||
task.WithStdout(io.Discard),
|
||||
task.WithStderr(io.Discard),
|
||||
task.WithStdin(strings.NewReader("")),
|
||||
task.WithVersionCheck(false),
|
||||
task.WithDisableFuzzy(true),
|
||||
task.WithOffline(true),
|
||||
task.WithDownload(false),
|
||||
)
|
||||
|
||||
// Best-effort: a missing or broken Taskfile must not break completion.
|
||||
if complete.NeedsTaskfile(args, pflag.CommandLine) {
|
||||
if err := e.Setup(); err != nil {
|
||||
e = nil
|
||||
}
|
||||
}
|
||||
|
||||
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(complete.Words())
|
||||
}
|
||||
|
||||
log := &logger.Logger{
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Thin wrappers around the `task __complete` engine, served by `--completion`.
|
||||
|
||||
//go:embed completion/bash/task.bash
|
||||
var completionBash string
|
||||
|
||||
@@ -20,20 +22,20 @@ 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)
|
||||
}
|
||||
// Accept `nushell` as an alias of `nu`.
|
||||
var completionScripts = map[string]string{
|
||||
"bash": completionBash,
|
||||
"fish": completionFish,
|
||||
"nu": completionNu,
|
||||
"nushell": completionNu,
|
||||
"powershell": completionPowershell,
|
||||
"zsh": completionZsh,
|
||||
}
|
||||
|
||||
func Completion(shell string) (string, error) {
|
||||
script, ok := completionScripts[shell]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown shell: %s", shell)
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
@@ -1,60 +1,94 @@
|
||||
# vim: set tabstop=2 shiftwidth=2 expandtab:
|
||||
#
|
||||
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.
|
||||
|
||||
_GO_TASK_COMPLETION_LIST_OPTION='--list-all'
|
||||
TASK_CMD="${TASK_EXE:-task}"
|
||||
|
||||
function _task()
|
||||
{
|
||||
local cur prev words cword
|
||||
_init_completion -n : || return
|
||||
# `=` 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
|
||||
}
|
||||
|
||||
# Check for `--` within command-line and quit or strip suffix.
|
||||
local i
|
||||
for i in "${!words[@]}"; do
|
||||
if [ "${words[$i]}" == "--" ]; then
|
||||
# Do not complete words following `--` passed to CLI_ARGS.
|
||||
[ $cword -gt $i ] && return
|
||||
# Remove the words following `--` to not put --list in CLI_ARGS.
|
||||
words=( "${words[@]:0:$i}" )
|
||||
break
|
||||
_task() {
|
||||
local cur prev words cword
|
||||
|
||||
# Completion directives, mirroring internal/complete/complete.go.
|
||||
local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16 KEEP_ORDER=32
|
||||
|
||||
# `=` and `:` out of the word breaks: `--output=`, `docs:serve` stay one token.
|
||||
_init_completion -n =: || return
|
||||
|
||||
local -a args=( "${words[@]:1:cword}" )
|
||||
if (( ${#args[@]} == 0 )); then
|
||||
args=( "" )
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
|
||||
if [[ -z "$output" ]]; then
|
||||
_task_filedir
|
||||
return
|
||||
fi
|
||||
|
||||
local -a lines=()
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
lines+=( "$line" )
|
||||
done <<< "$output"
|
||||
|
||||
local last_idx=$(( ${#lines[@]} - 1 ))
|
||||
local directive="${lines[$last_idx]#:}"
|
||||
unset 'lines[$last_idx]'
|
||||
|
||||
if (( directive & FILTER_FILE_EXT )); then
|
||||
local exts=""
|
||||
# ${arr[@]+…} guards an empty array under `set -u` in bash 3.2 (macOS).
|
||||
for line in ${lines[@]+"${lines[@]}"}; do
|
||||
exts+="${exts:+|}$line"
|
||||
done
|
||||
_task_filedir "@($exts)"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( directive & FILTER_DIRS )); then
|
||||
_task_filedir -d
|
||||
return
|
||||
fi
|
||||
|
||||
# Not `compgen -W`: it splits the word list on IFS, mangling values with spaces.
|
||||
local value
|
||||
COMPREPLY=()
|
||||
for line in ${lines[@]+"${lines[@]}"}; do
|
||||
value="${line%%$'\t'*}"
|
||||
if [[ -z "$cur" || "$value" == "$cur"* ]]; then
|
||||
COMPREPLY+=( "$value" )
|
||||
fi
|
||||
done
|
||||
|
||||
# Handle special arguments of options.
|
||||
case "$prev" in
|
||||
-d|--dir|--remote-cache-dir)
|
||||
_filedir -d
|
||||
return $?
|
||||
;;
|
||||
--cacert|--cert|--cert-key)
|
||||
_filedir
|
||||
return $?
|
||||
;;
|
||||
-t|--taskfile)
|
||||
_filedir yaml || return $?
|
||||
_filedir yml
|
||||
return $?
|
||||
;;
|
||||
-o|--output)
|
||||
COMPREPLY=( $( compgen -W "interleaved group prefixed" -- $cur ) )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
if (( directive & NO_SPACE )); then
|
||||
compopt -o nospace 2>/dev/null
|
||||
fi
|
||||
|
||||
# Handle normal options.
|
||||
case "$cur" in
|
||||
-*)
|
||||
COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
# nosort needs bash 4.4; the 3.2 shipped by macOS ignores it and stays sorted.
|
||||
if (( directive & KEEP_ORDER )); then
|
||||
compopt -o nosort 2>/dev/null
|
||||
fi
|
||||
|
||||
# Prepare task name completions.
|
||||
local tasks=( $( "${words[@]}" --silent $_GO_TASK_COMPLETION_LIST_OPTION 2> /dev/null ) )
|
||||
COMPREPLY=( $( compgen -W "${tasks[*]}" -- "$cur" ) )
|
||||
|
||||
# Post-process because task names might contain colons.
|
||||
__ltrim_colon_completions "$cur"
|
||||
|
||||
if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then
|
||||
_task_filedir
|
||||
fi
|
||||
}
|
||||
|
||||
complete -F _task "$TASK_CMD"
|
||||
|
||||
@@ -1,116 +1,98 @@
|
||||
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.
|
||||
|
||||
set -l GO_TASK_PROGNAME (if set -q GO_TASK_PROGNAME; echo $GO_TASK_PROGNAME; else if set -q TASK_EXE; echo $TASK_EXE; else; echo task; end)
|
||||
|
||||
# Cache variables for experiments (global)
|
||||
set -g __task_experiments_cache ""
|
||||
set -g __task_experiments_cache_time 0
|
||||
# Completion directives, mirroring internal/complete/complete.go. `math` has no
|
||||
# bitwise operators, hence __task_test_bit. NoSpace (2) and KeepOrder (32) need
|
||||
# none: fish handles spacing and the registration below preserves order.
|
||||
set -g __task_directive_no_file_comp 4
|
||||
set -g __task_directive_filter_file_ext 8
|
||||
set -g __task_directive_filter_dirs 16
|
||||
|
||||
# Helper function to get experiments with 1-second cache
|
||||
function __task_get_experiments --inherit-variable GO_TASK_PROGNAME
|
||||
set -l now (date +%s)
|
||||
set -l ttl 1 # Cache for 1 second only
|
||||
|
||||
# Return cached value if still valid
|
||||
if test (math "$now - $__task_experiments_cache_time") -lt $ttl
|
||||
printf '%s\n' $__task_experiments_cache
|
||||
return
|
||||
end
|
||||
|
||||
# Refresh cache
|
||||
set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null)
|
||||
set -g __task_experiments_cache_time $now
|
||||
printf '%s\n' $__task_experiments_cache
|
||||
function __task_test_bit --argument-names value bit
|
||||
test (math "floor($value / $bit) % 2") -eq 1
|
||||
end
|
||||
|
||||
# Helper function to check if an experiment is enabled
|
||||
function __task_is_experiment_enabled
|
||||
set -l experiment $argv[1]
|
||||
__task_get_experiments | string match -qr "^\* $experiment:.*on"
|
||||
end
|
||||
|
||||
function __task_get_tasks --description "Prints all available tasks with their description" --inherit-variable GO_TASK_PROGNAME
|
||||
# Check if the global task is requested
|
||||
set -l global_task false
|
||||
commandline --current-process | read --tokenize --list --local cmd_args
|
||||
for arg in $cmd_args
|
||||
if test "_$arg" = "_--"
|
||||
break # ignore arguments to be passed to the task
|
||||
end
|
||||
if test "_$arg" = "_--global" -o "_$arg" = "_-g"
|
||||
set global_task true
|
||||
break
|
||||
end
|
||||
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
|
||||
|
||||
# Read the list of tasks (and potential errors)
|
||||
if $global_task
|
||||
$GO_TASK_PROGNAME --global --list-all
|
||||
else
|
||||
$GO_TASK_PROGNAME --list-all
|
||||
end 2>&1 | read -lz rawOutput
|
||||
|
||||
# Return on non-zero exit code (for cases when there is no Taskfile found or etc.)
|
||||
if test $status -ne 0
|
||||
set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
|
||||
set -l count (count $output)
|
||||
if test $count -eq 0
|
||||
return
|
||||
end
|
||||
|
||||
# Grab names and descriptions (if any) of the tasks
|
||||
set -l output (echo $rawOutput | sed -e '1d; s/\* \(.*\):[[:space:]]\{2,\}\(.*\)[[:space:]]\{2,\}(\(aliases.*\))/\1\t\2\t\3/' -e 's/\* \(.*\):[[:space:]]\{2,\}\(.*\)/\1\t\2/'| string split0)
|
||||
if test $output
|
||||
echo $output
|
||||
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
|
||||
|
||||
complete -c $GO_TASK_PROGNAME \
|
||||
-d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was specified.' \
|
||||
-xa "(__task_get_tasks)" \
|
||||
-n "not __fish_seen_subcommand_from --"
|
||||
# fish accumulates `complete` entries instead of replacing them, so an older
|
||||
# completion would keep contributing alongside the engine.
|
||||
complete -c $GO_TASK_PROGNAME -e
|
||||
|
||||
# Standard flags
|
||||
complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks'
|
||||
complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)'
|
||||
complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks'
|
||||
complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell nu"
|
||||
complete -c $GO_TASK_PROGNAME -s d -l dir -d 'set directory of execution'
|
||||
complete -c $GO_TASK_PROGNAME -l disable-fuzzy -d 'disable fuzzy matching for task names'
|
||||
complete -c $GO_TASK_PROGNAME -s n -l dry -d 'compile and print tasks without executing'
|
||||
complete -c $GO_TASK_PROGNAME -s x -l exit-code -d 'pass-through exit code of task command'
|
||||
complete -c $GO_TASK_PROGNAME -l experiments -d 'list available experiments'
|
||||
complete -c $GO_TASK_PROGNAME -s F -l failfast -d 'when running tasks in parallel, stop all tasks if one fails'
|
||||
complete -c $GO_TASK_PROGNAME -s f -l force -d 'force execution even when up-to-date'
|
||||
complete -c $GO_TASK_PROGNAME -s g -l global -d 'run global Taskfile from home directory'
|
||||
complete -c $GO_TASK_PROGNAME -s h -l help -d 'show help'
|
||||
complete -c $GO_TASK_PROGNAME -s i -l init -d 'create new Taskfile'
|
||||
complete -c $GO_TASK_PROGNAME -l insecure -d 'allow insecure Taskfile downloads'
|
||||
complete -c $GO_TASK_PROGNAME -s I -l interval -d 'interval to watch for changes'
|
||||
complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task list as JSON'
|
||||
complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions'
|
||||
complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON'
|
||||
complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON'
|
||||
complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables'
|
||||
complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed"
|
||||
complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output'
|
||||
complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output'
|
||||
complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks'
|
||||
complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel'
|
||||
complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing'
|
||||
complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none"
|
||||
complete -c $GO_TASK_PROGNAME -l status -d 'exit non-zero if tasks not up-to-date'
|
||||
complete -c $GO_TASK_PROGNAME -l summary -d 'show task summary'
|
||||
complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose Taskfile to run'
|
||||
complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose output'
|
||||
complete -c $GO_TASK_PROGNAME -l version -d 'show version'
|
||||
complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes'
|
||||
complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts'
|
||||
complete -c $GO_TASK_PROGNAME -l offline -d 'use only local or cached Taskfiles'
|
||||
complete -c $GO_TASK_PROGNAME -l timeout -d 'timeout for remote Taskfile downloads'
|
||||
complete -c $GO_TASK_PROGNAME -l expiry -d 'cache expiry duration'
|
||||
complete -c $GO_TASK_PROGNAME -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)"
|
||||
complete -c $GO_TASK_PROGNAME -l cacert -d 'custom CA certificate for TLS' -r
|
||||
complete -c $GO_TASK_PROGNAME -l cert -d 'client certificate for mTLS' -r
|
||||
complete -c $GO_TASK_PROGNAME -l cert-key -d 'client certificate private key' -r
|
||||
complete -c $GO_TASK_PROGNAME -l download -d 'download remote Taskfile'
|
||||
complete -c $GO_TASK_PROGNAME -l clear-cache -d 'clear remote Taskfile cache'
|
||||
|
||||
# Experimental flags (dynamically checked at completion time via -n condition)
|
||||
# GentleForce experiment
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies'
|
||||
# `--no-files` keeps fish from mixing in files against the engine's directive.
|
||||
complete -c $GO_TASK_PROGNAME --no-files --keep-order -a "(__task_complete)"
|
||||
|
||||
@@ -1,180 +1,86 @@
|
||||
# Nushell completions for Task (https://taskfile.dev).
|
||||
#
|
||||
# Nushell cannot source a script from stdin, so save this file where Nushell
|
||||
# picks it up automatically:
|
||||
# mkdir ($nu.data-dir | path join "vendor/autoload")
|
||||
# task --completion nu | save --force ($nu.data-dir | path join "vendor/autoload/task-completions.nu")
|
||||
#
|
||||
# The file must not be named task.nu: Nushell refuses to export a known external
|
||||
# named like its module, which would break `use task-completions.nu *`.
|
||||
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.
|
||||
|
||||
# Name or path of the Task executable, like the other completion scripts.
|
||||
# The *completed* command is always `task`: an `extern` declaration requires a
|
||||
# literal name. For a renamed executable, alias it instead: `alias go-task = task`.
|
||||
def "nu-complete task-exe" [] {
|
||||
$env.TASK_EXE? | default "task"
|
||||
}
|
||||
# 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")
|
||||
|
||||
def "nu-complete task-words" [context: string] {
|
||||
$context | split row --regex '\s+' | where {|word| $word != "" }
|
||||
}
|
||||
|
||||
# Strips the quotes the user may have typed around a value and expands `~`,
|
||||
# which Nushell does not do for a value coming from a variable.
|
||||
def "nu-complete task-value" [value: string] {
|
||||
let unquoted = ($value | str trim --char '"' | str trim --char "'")
|
||||
if ($unquoted | str starts-with "~") {
|
||||
$unquoted | path expand --no-symlink
|
||||
} else {
|
||||
$unquoted
|
||||
}
|
||||
}
|
||||
|
||||
# Rebuilds the flags deciding *which* Taskfile is read, so the task list follows
|
||||
# the `-t/--taskfile`, `-d/--dir` and `-g/--global` already on the command line.
|
||||
def "nu-complete task-scope" [words: list<string>] {
|
||||
mut scope: list<string> = []
|
||||
mut pending = ""
|
||||
|
||||
for word in ($words | skip 1) {
|
||||
if $pending != "" {
|
||||
$scope = ($scope | append [$pending, (nu-complete task-value $word)])
|
||||
$pending = ""
|
||||
continue
|
||||
}
|
||||
|
||||
let parts = ($word | split row "=")
|
||||
let name = ($parts | first)
|
||||
let inline = if ($parts | length) > 1 { $parts | skip 1 | str join "=" } else { null }
|
||||
|
||||
if $name in ["-g", "--global"] {
|
||||
$scope = ($scope | append "--global")
|
||||
} else if $name in ["-t", "--taskfile", "-d", "--dir"] {
|
||||
let long = if $name in ["-t", "--taskfile"] { "--taskfile" } else { "--dir" }
|
||||
if $inline != null {
|
||||
$scope = ($scope | append [$long, (nu-complete task-value $inline)])
|
||||
} else {
|
||||
$pending = $long
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$scope
|
||||
}
|
||||
|
||||
# Lists the tasks of the targeted Taskfile. `--no-status` keeps completion fast:
|
||||
# without it Task fingerprints every task's sources on each keystroke. Returns an
|
||||
# empty list when Task exits non-zero (no Taskfile, invalid Taskfile).
|
||||
def "nu-complete task-list" [words: list<string>] {
|
||||
let exe = (nu-complete task-exe)
|
||||
let args = [...(nu-complete task-scope $words) "--list-all" "--json" "--no-status"]
|
||||
let result = (try { do { ^$exe ...$args } | complete } catch { null })
|
||||
# The trailing empty word tells the engine the cursor is on a fresh word.
|
||||
let words = ($spans | skip 1)
|
||||
let args = (if ($words | is-empty) { [""] } else { $words })
|
||||
let current = ($args | last)
|
||||
|
||||
# `complete` keeps stderr off the prompt; a missing binary raises, hence `try`.
|
||||
let result = (try { do { ^$exe "__complete" ...$args } | complete } catch { null })
|
||||
if ($result | is-empty) or $result.exit_code != 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
try { $result.stdout | from json | get tasks } catch { [] }
|
||||
}
|
||||
|
||||
def "nu-complete task" [context: string] {
|
||||
let words = (nu-complete task-words $context)
|
||||
|
||||
# Words after `--` are forwarded to the task as CLI_ARGS: stop offering task
|
||||
# names and let Nushell fall back to its own file completion.
|
||||
if "--" in $words {
|
||||
return null
|
||||
}
|
||||
|
||||
let completions = (
|
||||
nu-complete task-list $words
|
||||
| each {|item|
|
||||
# `task` is the invocable name; `name` may be a display-only label.
|
||||
let name = ($item.task | str trim --right --char ':')
|
||||
let desc = ($item.desc? | default "")
|
||||
let aliases = (
|
||||
$item.aliases?
|
||||
| default []
|
||||
| each {|alias| {
|
||||
value: ($alias | str trim --right --char ':')
|
||||
description: (if ($desc | is-empty) { $"alias of ($name)" } else { $"($desc) \(alias of ($name)\)" })
|
||||
} }
|
||||
)
|
||||
[{ value: $name, description: $desc }] | append $aliases
|
||||
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 } }
|
||||
}
|
||||
| flatten
|
||||
)
|
||||
| where {|candidate| $candidate.value | str starts-with --ignore-case $current })
|
||||
|
||||
# `sort: false` keeps the order Task chose, which honours --sort and .taskrc.
|
||||
{ options: { sort: false }, completions: $completions }
|
||||
if ($candidates | is-empty) and (not $no_file_comp) {
|
||||
return null
|
||||
}
|
||||
|
||||
$candidates
|
||||
}
|
||||
|
||||
def "nu-complete task-shells" [] {
|
||||
["bash", "zsh", "fish", "powershell", "nu"]
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
||||
def "nu-complete task-output" [] {
|
||||
["interleaved", "group", "prefixed"]
|
||||
}
|
||||
|
||||
def "nu-complete task-sort" [] {
|
||||
["default", "alphanumeric", "none"]
|
||||
}
|
||||
|
||||
# Runs the specified task(s). Falls back to the "default" task if no task name
|
||||
# was specified, or lists all tasks if an unknown task name was specified.
|
||||
#
|
||||
# An `extern` signature is static, so the experimental flag at the bottom is
|
||||
# always offered; Task rejects it when the experiment is off. Run
|
||||
# `task --experiments` to see which experiments are enabled.
|
||||
export extern "task" [
|
||||
...tasks: string@"nu-complete task" # task(s) to run
|
||||
--list(-l) # list tasks with a description
|
||||
--list-all(-a) # list all tasks, with or without a description
|
||||
--json(-j) # format the task list as JSON
|
||||
--no-status # ignore status when listing tasks as JSON
|
||||
--nested # nest namespaces when listing tasks as JSON
|
||||
--sort: string@"nu-complete task-sort" # change the order of the tasks when listed
|
||||
--init(-i) # create a new Taskfile.yml in the current folder
|
||||
--completion: string@"nu-complete task-shells" # generate a shell completion script
|
||||
--taskfile(-t): glob # choose which Taskfile to run
|
||||
--dir(-d): directory # set the directory in which Task will execute
|
||||
--global(-g) # run the global Taskfile from $HOME
|
||||
--temp-dir: directory # directory used to store Task temporary files
|
||||
--force(-f) # force execution even when the task is up-to-date
|
||||
--status # exit with a non-zero code if tasks are not up-to-date
|
||||
--dry(-n) # compile and print the tasks without executing them
|
||||
--summary # show the summary of a task instead of running it
|
||||
--watch(-w) # watch the given tasks and re-run them on changes
|
||||
--interval(-I): string # interval to watch for changes, e.g. 500ms
|
||||
--parallel(-p) # run the tasks given on the command line in parallel
|
||||
--concurrency(-C): int # limit the number of tasks run concurrently
|
||||
--failfast(-F) # when running in parallel, stop everything if one task fails
|
||||
--exit-code(-x) # pass through the exit code of the task command
|
||||
--interactive # prompt for missing required variables
|
||||
--yes(-y) # assume "yes" as the answer to all prompts
|
||||
--output(-o): string@"nu-complete task-output" # set the output style
|
||||
--output-group-begin: string # message template printed before a task's grouped output
|
||||
--output-group-end: string # message template printed after a task's grouped output
|
||||
--output-group-error-only # swallow the output of successful tasks
|
||||
--color(-c) # colored output, enabled by default
|
||||
--silent(-s) # disable echoing
|
||||
--verbose(-v) # enable verbose mode
|
||||
--disable-fuzzy # disable fuzzy matching for task names
|
||||
--download # download a cached version of a remote Taskfile
|
||||
--offline # only use local or cached Taskfiles
|
||||
--clear-cache # clear the remote Taskfile cache
|
||||
--trusted-hosts: string # trusted hosts for remote Taskfiles (comma-separated)
|
||||
--timeout: string # timeout for downloading remote Taskfiles
|
||||
--expiry: string # expiry duration for cached remote Taskfiles
|
||||
--remote-cache-dir: directory # directory used to cache remote Taskfiles
|
||||
--cacert: path # custom CA certificate for HTTPS connections
|
||||
--cert: path # client certificate for HTTPS connections
|
||||
--cert-key: path # client certificate key for HTTPS connections
|
||||
--insecure # allow Taskfiles to be downloaded over insecure connections
|
||||
--experiments # list the available experiments and whether they are enabled
|
||||
--version # show the Task version
|
||||
--help(-h) # show Task usage
|
||||
|
||||
--force-all # [GENTLE_FORCE] force the called task and all its dependencies
|
||||
]
|
||||
|
||||
@@ -1,89 +1,110 @@
|
||||
using namespace System.Management.Automation
|
||||
using namespace System.Management.Automation.Language
|
||||
|
||||
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.
|
||||
|
||||
$cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique
|
||||
|
||||
Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock {
|
||||
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
|
||||
Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
|
||||
param($wordToComplete, $commandAst, $cursorPosition)
|
||||
|
||||
if ($commandName.StartsWith('-')) {
|
||||
$completions = @(
|
||||
# Standard flags (alphabetical order)
|
||||
[CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'list all tasks'),
|
||||
[CompletionResult]::new('--list-all', '--list-all', [CompletionResultType]::ParameterName, 'list all tasks'),
|
||||
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'colored output'),
|
||||
[CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'colored output'),
|
||||
[CompletionResult]::new('-C', '-C', [CompletionResultType]::ParameterName, 'limit concurrent tasks'),
|
||||
[CompletionResult]::new('--concurrency', '--concurrency', [CompletionResultType]::ParameterName, 'limit concurrent tasks'),
|
||||
[CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'generate shell completion'),
|
||||
[CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'set directory'),
|
||||
[CompletionResult]::new('--dir', '--dir', [CompletionResultType]::ParameterName, 'set directory'),
|
||||
[CompletionResult]::new('--disable-fuzzy', '--disable-fuzzy', [CompletionResultType]::ParameterName, 'disable fuzzy matching'),
|
||||
[CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'dry run'),
|
||||
[CompletionResult]::new('--dry', '--dry', [CompletionResultType]::ParameterName, 'dry run'),
|
||||
[CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'pass-through exit code'),
|
||||
[CompletionResult]::new('--exit-code', '--exit-code', [CompletionResultType]::ParameterName, 'pass-through exit code'),
|
||||
[CompletionResult]::new('--experiments', '--experiments', [CompletionResultType]::ParameterName, 'list experiments'),
|
||||
[CompletionResult]::new('-F', '-F', [CompletionResultType]::ParameterName, 'fail fast on pallalel tasks'),
|
||||
[CompletionResult]::new('--failfast', '--failfast', [CompletionResultType]::ParameterName, 'force execution'),
|
||||
[CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'force execution'),
|
||||
[CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'force execution'),
|
||||
[CompletionResult]::new('-g', '-g', [CompletionResultType]::ParameterName, 'run global Taskfile'),
|
||||
[CompletionResult]::new('--global', '--global', [CompletionResultType]::ParameterName, 'run global Taskfile'),
|
||||
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'show help'),
|
||||
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'show help'),
|
||||
[CompletionResult]::new('-i', '-i', [CompletionResultType]::ParameterName, 'create new Taskfile'),
|
||||
[CompletionResult]::new('--init', '--init', [CompletionResultType]::ParameterName, 'create new Taskfile'),
|
||||
[CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'allow insecure downloads'),
|
||||
[CompletionResult]::new('-I', '-I', [CompletionResultType]::ParameterName, 'watch interval'),
|
||||
[CompletionResult]::new('--interval', '--interval', [CompletionResultType]::ParameterName, 'watch interval'),
|
||||
[CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'format as JSON'),
|
||||
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'format as JSON'),
|
||||
[CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'list tasks'),
|
||||
[CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'),
|
||||
[CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'),
|
||||
[CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'),
|
||||
[CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'),
|
||||
[CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'),
|
||||
[CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'),
|
||||
[CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'),
|
||||
[CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'),
|
||||
[CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'),
|
||||
[CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'),
|
||||
[CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'),
|
||||
[CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'),
|
||||
[CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'silent mode'),
|
||||
[CompletionResult]::new('--sort', '--sort', [CompletionResultType]::ParameterName, 'task sorting order'),
|
||||
[CompletionResult]::new('--status', '--status', [CompletionResultType]::ParameterName, 'check task status'),
|
||||
[CompletionResult]::new('--summary', '--summary', [CompletionResultType]::ParameterName, 'show task summary'),
|
||||
[CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'choose Taskfile'),
|
||||
[CompletionResult]::new('--taskfile', '--taskfile', [CompletionResultType]::ParameterName, 'choose Taskfile'),
|
||||
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'verbose output'),
|
||||
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'verbose output'),
|
||||
[CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'show version'),
|
||||
[CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'),
|
||||
[CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'),
|
||||
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'),
|
||||
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes'),
|
||||
[CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles'),
|
||||
[CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout'),
|
||||
[CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry'),
|
||||
[CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory'),
|
||||
[CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate'),
|
||||
[CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate'),
|
||||
[CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key'),
|
||||
[CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile'),
|
||||
[CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
||||
)
|
||||
$TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' }
|
||||
|
||||
# Experimental flags (dynamically added based on enabled experiments)
|
||||
$experiments = & task --experiments 2>$null | Out-String
|
||||
|
||||
if ($experiments -match '\* GENTLE_FORCE:.*on') {
|
||||
$completions += [CompletionResult]::new('--force-all', '--force-all', [CompletionResultType]::ParameterName, 'force all dependencies')
|
||||
# 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)
|
||||
}
|
||||
|
||||
return $completions.Where{ $_.CompletionText.StartsWith($commandName) }
|
||||
}
|
||||
|
||||
return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " }
|
||||
# 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. Quote shell metacharacters as well as
|
||||
# spaces so paths and enum values are inserted as literals.
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -1,158 +1,82 @@
|
||||
#compdef task
|
||||
typeset -A opt_args
|
||||
#
|
||||
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.
|
||||
|
||||
TASK_CMD="${TASK_EXE:-task}"
|
||||
compdef _task "$TASK_CMD"
|
||||
|
||||
_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}"
|
||||
|
||||
# Check if an experiment is enabled
|
||||
function __task_is_experiment_enabled() {
|
||||
local experiment=$1
|
||||
task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on"
|
||||
}
|
||||
|
||||
# Listing commands from Taskfile.yml
|
||||
function __task_list() {
|
||||
local -a scripts cmd task_aliases match mbegin mend
|
||||
local -i enabled=0
|
||||
local taskfile item task desc task_alias
|
||||
|
||||
cmd=($TASK_CMD)
|
||||
taskfile=${(Qv)opt_args[(i)-t|--taskfile]}
|
||||
taskfile=${taskfile//\~/$HOME}
|
||||
|
||||
for arg in "${words[@]:0:$CURRENT}"; do
|
||||
if [[ "$arg" = "--" ]]; then
|
||||
# Use default completion for words after `--` as they are CLI_ARGS.
|
||||
_default
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$taskfile" && -f "$taskfile" ]]; then
|
||||
cmd+=(--taskfile "$taskfile")
|
||||
fi
|
||||
|
||||
# Check if global flag is set
|
||||
if (( ${+opt_args[-g]} || ${+opt_args[--global]} )); then
|
||||
cmd+=(--global)
|
||||
fi
|
||||
|
||||
if output=$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION 2>/dev/null); then
|
||||
enabled=1
|
||||
fi
|
||||
|
||||
(( enabled )) || return 0
|
||||
|
||||
scripts=()
|
||||
|
||||
# Read zstyle verbose option (default = true via -T)
|
||||
local show_desc
|
||||
zstyle -T ":completion:${curcontext}:" verbose && show_desc=true || show_desc=false
|
||||
|
||||
# Read zstyle show-aliases option (default = true via -T)
|
||||
local show_aliases
|
||||
zstyle -T ":completion:${curcontext}:" show-aliases && show_aliases=true || show_aliases=false
|
||||
|
||||
for item in "${(@)${(f)output}[2,-1]#\* }"; do
|
||||
task="${item%%:[[:space:]]*}"
|
||||
|
||||
# Extract the aliases listed in the trailing "(aliases: a, b)" column.
|
||||
# NB: `aliases` is a reserved zsh parameter, so use a different name.
|
||||
task_aliases=()
|
||||
if [[ "$show_aliases" == "true" && "$item" == (#b)*'(aliases: '(*)')' ]]; then
|
||||
task_aliases=( "${(@s:, :)match[1]}" )
|
||||
fi
|
||||
|
||||
if [[ "$show_desc" == "true" ]]; then
|
||||
local desc="${item##[^[:space:]]##[[:space:]]##}"
|
||||
scripts+=( "${task//:/\\:}:$desc" )
|
||||
for task_alias in $task_aliases; do
|
||||
scripts+=( "${task_alias//:/\\:}:$desc (alias of $task)" )
|
||||
done
|
||||
else
|
||||
scripts+=( "$task" )
|
||||
for task_alias in $task_aliases; do
|
||||
scripts+=( "$task_alias" )
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$show_desc" == "true" ]]; then
|
||||
_describe 'Task to run' scripts
|
||||
else
|
||||
compadd -Q -a scripts
|
||||
fi
|
||||
}
|
||||
|
||||
_task() {
|
||||
local -a standard_args operation_args
|
||||
local -a args lines completions describe_opts compadd_opts ctl
|
||||
local output directive line
|
||||
|
||||
standard_args=(
|
||||
'(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: '
|
||||
'(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]'
|
||||
'(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]'
|
||||
'(-f --force)'{-f,--force}'[run even if task is up-to-date]'
|
||||
'(-c --color)'{-c,--color}'[colored output]'
|
||||
'(--completion)--completion[generate shell completion script]:shell:(bash zsh fish powershell nu)'
|
||||
'(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs'
|
||||
'(--disable-fuzzy)--disable-fuzzy[disable fuzzy matching for task names]'
|
||||
'(-n --dry)'{-n,--dry}'[compiles and prints tasks without executing]'
|
||||
'(--dry)--dry[dry-run mode, compile and print tasks only]'
|
||||
'(-x --exit-code)'{-x,--exit-code}'[pass-through exit code of task command]'
|
||||
'(--experiments)--experiments[list available experiments]'
|
||||
'(-g --global)'{-g,--global}'[run global Taskfile from home directory]'
|
||||
'(--insecure)--insecure[allow insecure Taskfile downloads]'
|
||||
'(-I --interval)'{-I,--interval}'[interval to watch for changes]:duration: '
|
||||
'(-j --json)'{-j,--json}'[format task list as JSON]'
|
||||
'(--nested)--nested[nest namespaces when listing as JSON]'
|
||||
'(--no-status)--no-status[ignore status when listing as JSON]'
|
||||
'(--interactive)--interactive[prompt for missing required variables]'
|
||||
'(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)'
|
||||
'(--output-group-begin)--output-group-begin[message template before grouped output]:template text: '
|
||||
'(--output-group-end)--output-group-end[message template after grouped output]:template text: '
|
||||
'(--output-group-error-only)--output-group-error-only[hide output from successful tasks]'
|
||||
'(-s --silent)'{-s,--silent}'[disable echoing]'
|
||||
'(--sort)--sort[set task sorting order]:order:(default alphanumeric none)'
|
||||
'(--status)--status[exit non-zero if supplied tasks not up-to-date]'
|
||||
'(--summary)--summary[show summary\: field from tasks instead of running them]'
|
||||
'(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files'
|
||||
'(-v --verbose)'{-v,--verbose}'[verbose mode]'
|
||||
'(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]'
|
||||
'(-y --yes)'{-y,--yes}'[assume yes to all prompts]'
|
||||
'(--offline --clear-cache)--download[download remote Taskfile]'
|
||||
'(--offline --download)--offline[use only local or cached Taskfiles]'
|
||||
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
|
||||
'(--expiry)--expiry[cache expiry duration]:duration: '
|
||||
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs'
|
||||
'(--cacert)--cacert[custom CA certificate for TLS]:file:_files'
|
||||
'(--cert)--cert[client certificate for mTLS]:file:_files'
|
||||
'(--cert-key)--cert-key[client certificate private key]:file:_files'
|
||||
)
|
||||
# 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
|
||||
|
||||
# Experimental flags (dynamically added based on enabled experiments)
|
||||
# Options (modify behavior)
|
||||
if __task_is_experiment_enabled "GENTLE_FORCE"; then
|
||||
standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]')
|
||||
fi
|
||||
# `-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)
|
||||
|
||||
operation_args=(
|
||||
# Task names completion (can be specified multiple times)
|
||||
'(operation)*: :__task_list'
|
||||
# Operational args completion (mutually exclusive)
|
||||
+ '(operation)'
|
||||
'(*)'{-l,--list}'[list describable tasks]'
|
||||
'(*)'{-a,--list-all}'[list all tasks]'
|
||||
'(*)'{-i,--init}'[create new Taskfile.yml]'
|
||||
'(- *)'{-h,--help}'[show help]'
|
||||
'(- *)--version[show version and exit]'
|
||||
'(* --download)--clear-cache[clear remote Taskfile cache]'
|
||||
)
|
||||
# Q removes shell quoting; @ preserves the trailing empty cursor word.
|
||||
args=("${(@Q)words[2,CURRENT]}")
|
||||
(( ${#args} == 0 )) && args=("")
|
||||
|
||||
_arguments -S $standard_args $operation_args
|
||||
output=$("$TASK_CMD" __complete "${ctl[@]}" "${args[@]}" 2>/dev/null)
|
||||
if [[ -z "$output" ]]; then
|
||||
_files
|
||||
return
|
||||
fi
|
||||
|
||||
lines=("${(f)output}")
|
||||
directive="${lines[-1]#:}"
|
||||
lines=("${(@)lines[1,-2]}")
|
||||
|
||||
if (( directive & FILTER_FILE_EXT )); then
|
||||
local -a globs
|
||||
for line in "${lines[@]}"; do
|
||||
globs+=("*.${line}")
|
||||
done
|
||||
# Inline `--flag=` into IPREFIX so file completion runs on the value. Only
|
||||
# here: globally it would break `_describe` on inline enums.
|
||||
compset -P '*='
|
||||
_files -g "(${(j:|:)globs})"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( directive & FILTER_DIRS )); then
|
||||
compset -P '*='
|
||||
_path_files -/
|
||||
return
|
||||
fi
|
||||
|
||||
# _describe splits on the first unescaped colon: "docs:serve" → "docs".
|
||||
local value desc
|
||||
for line in "${lines[@]}"; do
|
||||
if [[ "$line" == *$'\t'* ]]; then
|
||||
value="${line%%$'\t'*}"
|
||||
desc="${line#*$'\t'}"
|
||||
completions+=("${value//:/\\:}:$desc")
|
||||
else
|
||||
completions+=("${line//:/\\:}")
|
||||
fi
|
||||
done
|
||||
|
||||
# -S is a compadd option, passed after the array; -V belongs to _describe.
|
||||
# In the compadd zone it would take the next argument as a group name.
|
||||
(( directive & NO_SPACE )) && compadd_opts+=(-S '')
|
||||
(( directive & KEEP_ORDER )) && describe_opts+=(-V)
|
||||
|
||||
if (( ${#completions} > 0 )); then
|
||||
_describe "${describe_opts[@]}" -t tasks 'task' completions "${compadd_opts[@]}"
|
||||
fi
|
||||
|
||||
(( directive & NO_FILE_COMP )) && return
|
||||
compset -P '*='
|
||||
_files
|
||||
}
|
||||
|
||||
# don't run the completion function when being source-ed or eval-ed
|
||||
if [ "$funcstack[1]" = "_task" ]; then
|
||||
_task "$@"
|
||||
compdef _task "$TASK_CMD"
|
||||
|
||||
# An autoload call must also complete on its first invocation. Sourcing the
|
||||
# script only registers the function.
|
||||
if [[ "$funcstack[1]" == "_task" ]]; then
|
||||
_task "$@"
|
||||
fi
|
||||
|
||||
64
internal/complete/complete.go
Normal file
64
internal/complete/complete.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Package complete implements the `task __complete` protocol consumed by the
|
||||
// shell wrappers. It mirrors cobra v2 so a future migration stays cheap.
|
||||
package complete
|
||||
|
||||
import "os"
|
||||
|
||||
const CommandName = "__complete"
|
||||
|
||||
func IsActive() bool {
|
||||
return len(os.Args) >= 2 && os.Args[1] == CommandName
|
||||
}
|
||||
|
||||
// Words returns the command line being completed: the args after __complete.
|
||||
func Words() []string {
|
||||
return os.Args[2:]
|
||||
}
|
||||
|
||||
// Directive mirrors cobra's ShellCompDirective bitfield, emitted as `:<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
|
||||
}
|
||||
45
internal/complete/complete_benchmark_test.go
Normal file
45
internal/complete/complete_benchmark_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package complete_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
)
|
||||
|
||||
func BenchmarkComplete(b *testing.B) {
|
||||
var tasks strings.Builder
|
||||
tasks.WriteString("version: '3'\nvars:\n TITLE: example\ntasks:\n")
|
||||
for i := range 1000 {
|
||||
fmt.Fprintf(&tasks, " task%d:\n desc: Static description\n", i)
|
||||
}
|
||||
const loop = `version: '3'
|
||||
tasks:
|
||||
build:
|
||||
vars:
|
||||
ITEMS: '{{until 10000}}'
|
||||
cmds:
|
||||
- for: {var: ITEMS}
|
||||
cmd: 'echo {{.ITEM}}'
|
||||
`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
taskfile string
|
||||
words []string
|
||||
}{
|
||||
{"Static1000", tasks.String(), []string{""}},
|
||||
{"OneTemplatedDescription1000", strings.Replace(tasks.String(), "Static description", "'{{.TITLE}}'", 1), []string{""}},
|
||||
{"AfterLargeLoop", loop, []string{"build", ""}},
|
||||
{"StaticEnumWithLargeLoop", loop + " requires:\n vars:\n - name: ENV\n enum: [dev, prod]\n", []string{"build", ""}},
|
||||
} {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
e := setupExecutorWith(b, tc.taskfile)
|
||||
fs := newTestFlagSet()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
complete.Complete(e, fs, tc.words, complete.Options{})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
657
internal/complete/complete_test.go
Normal file
657
internal/complete/complete_test.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package complete_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
"github.com/go-task/task/v3/internal/slicesext"
|
||||
)
|
||||
|
||||
func newTestFlagSet() *pflag.FlagSet {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
var b bool
|
||||
var s string
|
||||
fs.BoolVarP(&b, "list-all", "a", false, "Lists all tasks")
|
||||
fs.BoolVarP(&b, "list", "l", false, "Lists tasks with descriptions")
|
||||
fs.BoolVarP(&b, "verbose", "v", false, "Verbose mode")
|
||||
fs.StringVarP(&s, "taskfile", "t", "", "Taskfile path")
|
||||
fs.StringVarP(&s, "dir", "d", "", "Run dir")
|
||||
fs.StringVar(&s, "temp-dir", "", "Temp dir")
|
||||
fs.StringVarP(&s, "output", "o", "", "Output style")
|
||||
fs.StringVar(&s, "sort", "", "Sort order")
|
||||
fs.StringVar(&s, "cacert", "", "CA cert path")
|
||||
fs.StringVar(&s, "completion", "", "Generate a completion script")
|
||||
return fs
|
||||
}
|
||||
|
||||
const testTaskfile = `version: '3'
|
||||
|
||||
vars:
|
||||
ALLOWED_ENVS:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
|
||||
tasks:
|
||||
deploy:
|
||||
desc: Deploy the application
|
||||
aliases: [dep, ship]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
- REGION
|
||||
cmds:
|
||||
- 'echo {{.ENV}} {{.REGION}}'
|
||||
|
||||
build:
|
||||
desc: Build it
|
||||
cmds:
|
||||
- 'echo build'
|
||||
|
||||
dynenum:
|
||||
desc: Dynamic enum
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum:
|
||||
ref: .ALLOWED_ENVS
|
||||
cmds:
|
||||
- 'echo {{.ENV}}'
|
||||
|
||||
docs:serve:
|
||||
desc: Serve docs locally
|
||||
requires:
|
||||
vars:
|
||||
- PORT
|
||||
cmds:
|
||||
- 'echo serving'
|
||||
|
||||
`
|
||||
|
||||
const wildcardTaskfile = `version: '3'
|
||||
|
||||
tasks:
|
||||
wildcard-*:
|
||||
cmds:
|
||||
- 'echo {{index .MATCH 0}}'
|
||||
|
||||
wildcard-*-*:
|
||||
cmds:
|
||||
- 'echo {{index .MATCH 0}}'
|
||||
|
||||
'*-wildcard-*':
|
||||
cmds:
|
||||
- 'echo {{index .MATCH 0}}'
|
||||
|
||||
matches-exactly-*:
|
||||
cmds:
|
||||
- 'echo {{.MATCH}}'
|
||||
|
||||
start-*:
|
||||
desc: Start a service
|
||||
aliases: [s-*]
|
||||
cmds:
|
||||
- 'echo {{index .MATCH 0}}'
|
||||
|
||||
release-*:
|
||||
desc: Release a component
|
||||
requires:
|
||||
vars:
|
||||
- name: CHANNEL
|
||||
enum:
|
||||
- beta
|
||||
- stable
|
||||
cmds:
|
||||
- 'echo {{index .MATCH 0}}'
|
||||
|
||||
build:
|
||||
desc: Build it
|
||||
cmds:
|
||||
- 'echo build'
|
||||
`
|
||||
|
||||
func setupExecutor(t *testing.T) *task.Executor {
|
||||
t.Helper()
|
||||
return setupExecutorWith(t, testTaskfile)
|
||||
}
|
||||
|
||||
func setupExecutorWith(tb testing.TB, taskfile string) *task.Executor {
|
||||
tb.Helper()
|
||||
dir := tb.TempDir()
|
||||
require.NoError(tb, 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(tb, e.Setup())
|
||||
return e
|
||||
}
|
||||
|
||||
func TestComplete_TaskNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{})
|
||||
|
||||
require.ElementsMatch(t,
|
||||
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
|
||||
values(suggs),
|
||||
)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
require.Contains(t, descriptions(suggs), "Deploy the application")
|
||||
}
|
||||
|
||||
func TestComplete_WildcardTaskNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutorWith(t, wildcardTaskfile)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{})
|
||||
|
||||
// Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*`
|
||||
// collapse into one candidate, and `*-wildcard-*` leaves nothing to insert.
|
||||
require.Equal(t, []string{"build", "matches-exactly-", "release-", "start-", "s-", "wildcard-"}, values(suggs))
|
||||
// A truncated prefix costs the whole response its trailing space, so task
|
||||
// names keep theirs and the wildcard prefix gets one it does not want.
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
// Without a desc, the pattern says what the prefix stands for.
|
||||
require.Contains(t, descriptions(suggs), "wildcard-*")
|
||||
|
||||
suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true})
|
||||
require.Equal(t, []string{"", "", "", "", "", ""}, descriptions(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_AliasResolvesToTaskVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
|
||||
}
|
||||
|
||||
func TestComplete_StaticEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""}, complete.Options{})
|
||||
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
|
||||
}
|
||||
|
||||
func TestComplete_EnumRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}, complete.Options{})
|
||||
require.ElementsMatch(t,
|
||||
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
|
||||
values(suggs),
|
||||
)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NamespacedTaskName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"PORT="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagValueInlineEquals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, complete.Options{})
|
||||
// The inline form returns full `--output=value` tokens.
|
||||
require.Equal(t, []string{"--output=interleaved", "--output=group", "--output=prefixed"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_AfterDash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""}, complete.Options{})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveDefault, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"}, complete.Options{})
|
||||
require.NotEmpty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
|
||||
vals := values(suggs)
|
||||
require.Contains(t, vals, "--list-all")
|
||||
require.Contains(t, vals, "--taskfile")
|
||||
require.Contains(t, vals, "-a")
|
||||
}
|
||||
|
||||
func TestComplete_EnumFlagValue_Output(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"interleaved", "group", "prefixed"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_EnumFlagValue_Sort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"default", "alphanumeric", "none"}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_Taskfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"yml", "yaml"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveFilterFileExt, dir)
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_Dir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""}, complete.Options{})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveFilterDirs, dir)
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_TempDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggs, dir := complete.Complete(setupExecutor(t), newTestFlagSet(), []string{"--temp-dir", ""}, complete.Options{})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveFilterDirs, dir)
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_Cacert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""}, complete.Options{})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveDefault, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NilExecutor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"}, complete.Options{})
|
||||
require.NotEmpty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NoAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
opts := complete.Options{NoAliases: true}
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, opts)
|
||||
|
||||
require.ElementsMatch(t,
|
||||
[]string{"build", "deploy", "dynenum", "docs:serve"},
|
||||
values(suggs),
|
||||
)
|
||||
require.NotContains(t, values(suggs), "dep")
|
||||
require.NotContains(t, values(suggs), "ship")
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NoDescriptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
opts := complete.Options{NoDescriptions: true}
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{""}, opts)
|
||||
|
||||
require.ElementsMatch(t,
|
||||
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
|
||||
values(suggs),
|
||||
)
|
||||
for _, d := range descriptions(suggs) {
|
||||
require.Empty(t, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("defaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts, rest := complete.ParseOptions([]string{"deploy", ""})
|
||||
require.Equal(t, complete.Options{}, opts)
|
||||
require.Equal(t, []string{"deploy", ""}, rest)
|
||||
})
|
||||
|
||||
t.Run("both flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts, rest := complete.ParseOptions([]string{"--no-aliases", "--no-descriptions", "deploy", ""})
|
||||
require.True(t, opts.NoAliases)
|
||||
require.True(t, opts.NoDescriptions)
|
||||
require.Equal(t, []string{"deploy", ""}, rest)
|
||||
})
|
||||
|
||||
t.Run("only leading flags consumed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A flag appearing after the user's words is left in the command line.
|
||||
opts, rest := complete.ParseOptions([]string{"deploy", "--no-aliases"})
|
||||
require.False(t, opts.NoAliases)
|
||||
require.Equal(t, []string{"deploy", "--no-aliases"}, rest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNeedsTaskfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]struct {
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
"task name": {[]string{""}, true},
|
||||
"partial task name": {[]string{"bui"}, true},
|
||||
"task var": {[]string{"deploy", ""}, true},
|
||||
"value flag then name": {[]string{"--dir", "/tmp", ""}, true},
|
||||
"flag name": {[]string{"-"}, false},
|
||||
"long flag name": {[]string{"--li"}, false},
|
||||
"inline flag value": {[]string{"--output="}, false},
|
||||
"flag value": {[]string{"--output", ""}, false},
|
||||
"path flag value": {[]string{"--taskfile", ""}, false},
|
||||
"after dash": {[]string{"deploy", "--", ""}, false},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, complete.NeedsTaskfile(tt.args, newTestFlagSet()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reading the Taskfile from standard input would block until EOF, freezing the
|
||||
// shell on a keystroke.
|
||||
func TestNeedsTaskfile_StdinEntrypoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTestFlagSet()
|
||||
require.NoError(t, fs.Set("taskfile", "-"))
|
||||
require.False(t, complete.NeedsTaskfile([]string{""}, fs))
|
||||
require.False(t, complete.NeedsTaskfile([]string{"deploy", ""}, fs))
|
||||
}
|
||||
|
||||
// Keeps the shells the engine offers in step with the scripts the root package
|
||||
// can actually serve.
|
||||
func TestCompletionShells(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"--completion", ""}, complete.Options{})
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
require.NotEmpty(t, suggs)
|
||||
|
||||
for _, shell := range values(suggs) {
|
||||
_, err := task.Completion(shell)
|
||||
require.NoErrorf(t, err, "--completion offers %q", shell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite_Format(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
complete.Write(&buf, []complete.Suggestion{
|
||||
{Value: "deploy", Description: "Deploy the app"},
|
||||
{Value: "build"},
|
||||
}, complete.DirectiveNoSpace|complete.DirectiveNoFileComp)
|
||||
require.Equal(t, "deploy\tDeploy the app\nbuild\n:6\n", buf.String())
|
||||
}
|
||||
|
||||
func TestWrite_EmptyWithDirective(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
complete.Write(&buf, nil, complete.DirectiveFilterDirs)
|
||||
require.Equal(t, ":16\n", buf.String())
|
||||
}
|
||||
|
||||
// CLI variables are global to the invocation, so once every requirement on the
|
||||
// line is met the engine goes back to offering task names.
|
||||
func TestComplete_TaskNamesAfterTaskWithoutRequires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggs, dir := complete.Complete(setupExecutor(t), newTestFlagSet(), []string{"build", ""}, complete.Options{})
|
||||
|
||||
require.Subset(t, values(suggs), []string{"build", "deploy", "docs:serve"})
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_RequiredVarsThenTaskNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
fs := newTestFlagSet()
|
||||
|
||||
suggs, dir := complete.Complete(e, fs, []string{"deploy", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
|
||||
|
||||
suggs, dir = complete.Complete(e, fs, []string{"deploy", "ENV=dev", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"REGION="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
|
||||
|
||||
suggs, dir = complete.Complete(e, fs, []string{"deploy", "ENV=dev", "REGION=eu", ""}, complete.Options{})
|
||||
require.Subset(t, values(suggs), []string{"build", "deploy"})
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_RequiredVarsUnionAcrossTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", "deploy", ""}, complete.Options{})
|
||||
|
||||
// ENV is required by both and appears once, in the order the line names them.
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_WildcardTaskRequiredVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutorWith(t, wildcardTaskfile)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"release-cli", ""}, complete.Options{})
|
||||
|
||||
require.Equal(t, []string{"CHANNEL=beta", "CHANNEL=stable"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
|
||||
}
|
||||
|
||||
// flags.WithFlags() applies WithTaskSorter after NewExecutor set its default, so
|
||||
// the engine must not assume a sorter is present.
|
||||
func TestComplete_DefaultSorterFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutorWith(t, testTaskfile)
|
||||
e.Options(task.WithTaskSorter(nil))
|
||||
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{})
|
||||
require.Equal(t, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, values(suggs))
|
||||
}
|
||||
|
||||
func values(suggs []complete.Suggestion) []string {
|
||||
return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Value })
|
||||
}
|
||||
|
||||
func descriptions(suggs []complete.Suggestion) []string {
|
||||
return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Description })
|
||||
}
|
||||
|
||||
func TestComplete_StaticRequirementsDoNotCompileTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutorWith(t, `version: '3'
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- for:
|
||||
matrix:
|
||||
item: {ref: .MISSING}
|
||||
cmd: 'echo {{.ITEM}}'
|
||||
deploy:
|
||||
aliases: [dep]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: [dev, prod]
|
||||
cmds:
|
||||
- for:
|
||||
matrix:
|
||||
env: {ref: 'splitList "," .ENV'}
|
||||
cmd: 'echo {{.ITEM.env}}'
|
||||
`)
|
||||
// Static suggestions must not even need a compiler, regardless of the
|
||||
// commands, loops, variables or dotenv files attached to the named task.
|
||||
e.Compiler = nil
|
||||
fs := newTestFlagSet()
|
||||
suggs, _ := complete.Complete(e, fs, []string{"build", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"build", "deploy", "dep"}, values(suggs))
|
||||
suggs, _ = complete.Complete(e, fs, []string{"dep", ""}, complete.Options{})
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=prod"}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_EnumRefUsesCLIGlobals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutorWith(t, `version: '3'
|
||||
vars:
|
||||
TARGETS: default
|
||||
ENVS: {ref: 'splitList "," .TARGETS'}
|
||||
tasks:
|
||||
deploy:
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: {ref: .ENVS}
|
||||
local:
|
||||
vars:
|
||||
TARGETS: local
|
||||
ENVS: {ref: 'splitList "," .TARGETS'}
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: {ref: .ENVS}
|
||||
'release-*':
|
||||
vars:
|
||||
ENVS: {ref: 'splitList "," (index .MATCH 0)'}
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: {ref: .ENVS}
|
||||
`)
|
||||
fs := newTestFlagSet()
|
||||
for _, tt := range []struct {
|
||||
words []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{"TARGETS=ignored", "deploy", "TARGETS=dev,prod", ""}, []string{"ENV=dev", "ENV=prod"}},
|
||||
{[]string{"deploy", ""}, []string{"ENV=default"}}, // No mutation between requests.
|
||||
{[]string{"TARGETS=dev,prod", "local", ""}, []string{"ENV=local"}},
|
||||
{[]string{"release-beta,stable", ""}, []string{"ENV=beta", "ENV=stable"}},
|
||||
} {
|
||||
suggs, _ := complete.Complete(e, fs, tt.words, complete.Options{})
|
||||
require.Equal(t, tt.want, values(suggs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplete_DescriptionsResolveIndependently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutorWith(t, `version: '3'
|
||||
vars:
|
||||
TITLE: default
|
||||
DYNAMIC:
|
||||
sh: 'exit 1'
|
||||
tasks:
|
||||
build:
|
||||
desc: 'Build {{.TITLE}}'
|
||||
aliases: [b]
|
||||
cmds:
|
||||
- for:
|
||||
matrix:
|
||||
item: {ref: .MISSING}
|
||||
cmd: 'echo {{.ITEM}}'
|
||||
broken:
|
||||
desc: '{{index .MISSING 0}}'
|
||||
static:
|
||||
desc: Static
|
||||
dir: '{{index .MISSING 0}}'
|
||||
`)
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"TITLE=custom", ""}, complete.Options{})
|
||||
descs := make(map[string]string, len(suggs))
|
||||
for _, s := range suggs {
|
||||
descs[s.Value] = s.Description
|
||||
}
|
||||
require.Equal(t, "Build custom", descs["build"])
|
||||
require.Equal(t, "Build custom", descs["b"])
|
||||
require.Equal(t, "{{index .MISSING 0}}", descs["broken"])
|
||||
require.Equal(t, "Static", descs["static"])
|
||||
}
|
||||
|
||||
func TestComplete_FlagContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
fs := newTestFlagSet()
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
words []string
|
||||
want []string
|
||||
load bool
|
||||
}{
|
||||
{"grouped value flag", []string{"-vo", ""}, []string{"interleaved", "group", "prefixed"}, false},
|
||||
{"grouped inline value", []string{"-vo=g"}, []string{"-vo=interleaved", "-vo=group", "-vo=prefixed"}, false},
|
||||
{"flag-like value", []string{"--dir", "--output", ""}, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, true},
|
||||
{"dash value", []string{"--dir", "--", ""}, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, true},
|
||||
{"grouped prior value", []string{"-vd", "deploy", "build", ""}, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, true},
|
||||
{"attached prior value", []string{"-vddeploy", "build", ""}, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, true},
|
||||
{"boolean assignment", []string{"-v=false", "deploy", ""}, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.load, complete.NeedsTaskfile(tt.words, fs))
|
||||
suggs, _ := complete.Complete(e, fs, tt.words, complete.Options{})
|
||||
require.Equal(t, tt.want, values(suggs))
|
||||
})
|
||||
}
|
||||
}
|
||||
57
internal/complete/context.go
Normal file
57
internal/complete/context.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
type completionContext struct {
|
||||
toComplete string
|
||||
valueFlag *pflag.Flag
|
||||
afterDash bool
|
||||
tasks []string
|
||||
vars *ast.Vars
|
||||
}
|
||||
|
||||
// Walk only the completed words. A pending value consumes the next word even
|
||||
// when it looks like a flag or "--", just as pflag does.
|
||||
func parseContext(args []string, fs *pflag.FlagSet) completionContext {
|
||||
ctx := completionContext{}
|
||||
if len(args) == 0 {
|
||||
return ctx
|
||||
}
|
||||
ctx.toComplete = args[len(args)-1]
|
||||
for _, word := range args[:len(args)-1] {
|
||||
if ctx.valueFlag != nil {
|
||||
ctx.valueFlag = nil
|
||||
continue
|
||||
}
|
||||
if word == "--" {
|
||||
ctx.afterDash = true
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(word, "-") && word != "-" {
|
||||
flag, prefix := valueFlag(fs, word)
|
||||
if prefix == "" {
|
||||
ctx.valueFlag = flag
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name, value, ok := strings.Cut(word, "="); ok {
|
||||
if ctx.vars == nil {
|
||||
ctx.vars = ast.NewVars()
|
||||
}
|
||||
ctx.vars.Set(name, ast.Var{Value: value})
|
||||
continue
|
||||
}
|
||||
ctx.tasks = append(ctx.tasks, word)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx completionContext) inTaskContext() bool {
|
||||
return !ctx.afterDash && ctx.valueFlag == nil && !strings.HasPrefix(ctx.toComplete, "-")
|
||||
}
|
||||
235
internal/complete/engine.go
Normal file
235
internal/complete/engine.go
Normal file
@@ -0,0 +1,235 @@
|
||||
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/internal/templater"
|
||||
"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, fs)
|
||||
|
||||
if ctx.afterDash {
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
|
||||
if flag := ctx.valueFlag; flag != nil {
|
||||
return completeFlagValue(flag.Name, "")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ctx.toComplete, "-") {
|
||||
if strings.Contains(ctx.toComplete, "=") {
|
||||
if f, prefix := valueFlag(fs, ctx.toComplete); f != nil {
|
||||
// Shells match against the whole token, so a bare value never would.
|
||||
return completeFlagValue(f.Name, prefix)
|
||||
}
|
||||
}
|
||||
return listFlags(fs), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
if e == nil || e.Taskfile == nil {
|
||||
return nil, DirectiveNoFileComp
|
||||
}
|
||||
c := compilerWithGlobals(e.Compiler, ctx.vars)
|
||||
if suggs := completeRequiredVars(e, c, ctx); len(suggs) > 0 {
|
||||
return suggs, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder
|
||||
}
|
||||
|
||||
return completeTaskNames(e, c, opts), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool {
|
||||
if !parseContext(args, fs).inTaskContext() {
|
||||
return false
|
||||
}
|
||||
// Reading the Taskfile from standard input would hang the shell on a keystroke.
|
||||
if fs == nil {
|
||||
return true
|
||||
}
|
||||
f := fs.Lookup("taskfile")
|
||||
return f == nil || f.Value.String() != "-"
|
||||
}
|
||||
|
||||
func completeTaskNames(e *task.Executor, c *task.Compiler, opts Options) []Suggestion {
|
||||
sorter := e.TaskSorter
|
||||
if sorter == nil {
|
||||
sorter = sort.AlphaNumericWithRootTasksFirst
|
||||
}
|
||||
|
||||
out := make([]Suggestion, 0, e.Taskfile.Tasks.Len())
|
||||
seen := make(map[string]bool, e.Taskfile.Tasks.Len())
|
||||
add := func(name, desc string) {
|
||||
value, partial := suggestedName(name)
|
||||
// `*-wildcard-*` has no prefix, and `wildcard-*` / `wildcard-*-*` share one.
|
||||
if value == "" || seen[value] {
|
||||
return
|
||||
}
|
||||
seen[value] = true
|
||||
// Without a desc, a truncated pattern says what its prefix stands for.
|
||||
if partial && desc == "" && !opts.NoDescriptions {
|
||||
desc = name
|
||||
}
|
||||
out = append(out, Suggestion{Value: value, Description: desc})
|
||||
}
|
||||
|
||||
for t := range e.Taskfile.Tasks.Values(sorter) {
|
||||
if t.Internal {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if !opts.NoDescriptions {
|
||||
desc = taskDescription(c, t)
|
||||
}
|
||||
add(t.Task, desc)
|
||||
if opts.NoAliases {
|
||||
continue
|
||||
}
|
||||
for _, alias := range t.Aliases {
|
||||
add(alias, desc)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Only a templated description needs variables. A failure leaves this task's
|
||||
// raw description intact without affecting any other task.
|
||||
func taskDescription(c *task.Compiler, t *ast.Task) string {
|
||||
if c == nil || !strings.Contains(t.Desc, "{{") {
|
||||
return t.Desc
|
||||
}
|
||||
vars, err := taskVariables(c, t, t.Task, nil)
|
||||
if err != nil {
|
||||
return t.Desc
|
||||
}
|
||||
cache := &templater.Cache{Vars: vars}
|
||||
desc := templater.Replace(t.Desc, cache)
|
||||
if cache.Err() != nil {
|
||||
return t.Desc
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
// 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:
|
||||
return suggest("", taskfileExtensions), DirectiveFilterFileExt
|
||||
case DirectiveFilterDirs:
|
||||
return nil, DirectiveFilterDirs
|
||||
}
|
||||
|
||||
if values, ok := flagEnums[flagName]; ok {
|
||||
return suggest(prefix, values), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
|
||||
func suggest(prefix string, values []string) []Suggestion {
|
||||
return slicesext.Convert(values, func(v string) Suggestion {
|
||||
return Suggestion{Value: prefix + v}
|
||||
})
|
||||
}
|
||||
|
||||
// CLI variables are global to the invocation, not scoped to a task, so this
|
||||
// unions the still-unset requirements of every task named on the line. Reporting
|
||||
// none lets the caller offer task names instead, which is how the line resolves
|
||||
// itself: fill in what blocks execution, then add another task.
|
||||
func completeRequiredVars(e *task.Executor, c *task.Compiler, ctx completionContext) []Suggestion {
|
||||
if len(ctx.tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []Suggestion
|
||||
seen := make(map[string]bool, 8)
|
||||
for _, w := range ctx.tasks {
|
||||
matches, err := e.FindMatchingTasks(&task.Call{Task: w})
|
||||
if err != nil || len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
t := matches[0].Task
|
||||
if t.Requires == nil {
|
||||
continue
|
||||
}
|
||||
// Static requirements need no compilation. Resolve task variables at
|
||||
// most once, and only if an unset requirement has an enum reference.
|
||||
var vars *ast.Vars
|
||||
resolvedVars := false
|
||||
for _, v := range t.Requires.Vars {
|
||||
if v == nil || v.Name == "" || seen[v.Name] {
|
||||
continue
|
||||
}
|
||||
if _, set := ctx.vars.Get(v.Name); set {
|
||||
continue
|
||||
}
|
||||
seen[v.Name] = true
|
||||
if v.Enum != nil && v.Enum.Ref != "" && len(v.Enum.Value) == 0 && !resolvedVars {
|
||||
resolvedVars = true
|
||||
if c != nil {
|
||||
vars, _ = taskVariables(c, t, w, matches[0].Wildcards)
|
||||
}
|
||||
}
|
||||
values := enumValues(v, 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func taskVariables(c *task.Compiler, t *ast.Task, name string, wildcards []string) (*ast.Vars, error) {
|
||||
vars := ast.NewVars()
|
||||
vars.Set("MATCH", ast.Var{Value: wildcards})
|
||||
return c.FastGetVariables(t, &task.Call{Task: name, Vars: vars})
|
||||
}
|
||||
|
||||
// Execution merges CLI globals into Taskfile vars before resolving templates.
|
||||
// Use a separate compiler so completion never mutates the loaded Taskfile or
|
||||
// carries an assignment into a later request.
|
||||
func compilerWithGlobals(c *task.Compiler, globals *ast.Vars) *task.Compiler {
|
||||
if c == nil || globals.Len() == 0 {
|
||||
return c
|
||||
}
|
||||
vars := ast.NewVars()
|
||||
vars.Merge(c.TaskfileVars, nil)
|
||||
vars.Merge(globals, nil)
|
||||
return &task.Compiler{
|
||||
Dir: c.Dir,
|
||||
Entrypoint: c.Entrypoint,
|
||||
UserWorkingDir: c.UserWorkingDir,
|
||||
TaskfileEnv: c.TaskfileEnv,
|
||||
TaskfileVars: vars,
|
||||
Logger: c.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
func enumValues(v *ast.VarsWithValidation, vars *ast.Vars) []string {
|
||||
resolved := refs.ResolveEnum(v, vars)
|
||||
if resolved.Enum == nil {
|
||||
return nil
|
||||
}
|
||||
return resolved.Enum.Value
|
||||
}
|
||||
99
internal/complete/flags.go
Normal file
99
internal/complete/flags.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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,
|
||||
}
|
||||
|
||||
// A flag absent here falls back to the shell's default file completion.
|
||||
var flagDirective = map[string]Directive{
|
||||
"taskfile": DirectiveFilterFileExt,
|
||||
"dir": DirectiveFilterDirs,
|
||||
"remote-cache-dir": DirectiveFilterDirs,
|
||||
"temp-dir": DirectiveFilterDirs,
|
||||
}
|
||||
|
||||
var taskfileExtensions = []string{"yml", "yaml"}
|
||||
|
||||
func flagTakesValue(f *pflag.Flag) bool {
|
||||
return f.NoOptDefVal == ""
|
||||
}
|
||||
|
||||
// Walks fs at call time so experiment-gated flags follow the active experiments.
|
||||
func listFlags(fs *pflag.FlagSet) []Suggestion {
|
||||
if fs == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Suggestion, 0, 64)
|
||||
fs.VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || f.Deprecated != "" {
|
||||
return
|
||||
}
|
||||
out = append(out, Suggestion{
|
||||
Value: "--" + f.Name,
|
||||
Description: f.Usage,
|
||||
})
|
||||
if f.Shorthand != "" {
|
||||
out = append(out, Suggestion{
|
||||
Value: "-" + f.Shorthand,
|
||||
Description: f.Usage,
|
||||
})
|
||||
}
|
||||
})
|
||||
slices.SortFunc(out, func(a, b Suggestion) int { return strings.Compare(a.Value, b.Value) })
|
||||
return out
|
||||
}
|
||||
|
||||
// valueFlag identifies the value-taking option in a long flag or shorthand
|
||||
// group. A nonempty prefix means its value is attached to the same word.
|
||||
func valueFlag(fs *pflag.FlagSet, word string) (*pflag.Flag, string) {
|
||||
if fs == nil {
|
||||
return nil, ""
|
||||
}
|
||||
if strings.HasPrefix(word, "--") {
|
||||
name, _, attached := strings.Cut(word[2:], "=")
|
||||
if f := fs.Lookup(name); f != nil && flagTakesValue(f) {
|
||||
if attached {
|
||||
return f, "--" + name + "="
|
||||
}
|
||||
return f, ""
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return nil, ""
|
||||
}
|
||||
for i := 1; i < len(word); i++ {
|
||||
f := fs.ShorthandLookup(word[i : i+1])
|
||||
if f == nil {
|
||||
break
|
||||
}
|
||||
attached := i+1 < len(word)
|
||||
if flagTakesValue(f) {
|
||||
if !attached {
|
||||
return f, ""
|
||||
}
|
||||
end := i + 1
|
||||
if word[end] == '=' {
|
||||
end++
|
||||
}
|
||||
return f, word[:end]
|
||||
}
|
||||
if attached && word[i+1] == '=' {
|
||||
break // The rest is an explicit boolean value, not more flags.
|
||||
}
|
||||
}
|
||||
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"
|
||||
@@ -93,6 +94,11 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
cliArgs := os.Args[1:]
|
||||
if complete.IsActive() {
|
||||
_, cliArgs = complete.ParseOptions(complete.Words())
|
||||
}
|
||||
|
||||
// Config files can enable experiments which alter the availability and/or
|
||||
// behavior of some flags, so we need to parse the experiments before the
|
||||
// flags. However, we need the --taskfile and --dir flags before we can
|
||||
@@ -106,7 +112,7 @@ func init() {
|
||||
fs.StringVarP(&dir, "dir", "d", "", "")
|
||||
fs.StringVarP(&entrypoint, "taskfile", "t", "", "")
|
||||
fs.Usage = func() {}
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
_ = fs.Parse(cliArgs)
|
||||
|
||||
// Parse the experiments
|
||||
dir = cmp.Or(dir, filepath.Dir(entrypoint))
|
||||
@@ -178,6 +184,16 @@ 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() {
|
||||
pflag.CommandLine.Init(pflag.CommandLine.Name(), pflag.ContinueOnError)
|
||||
pflag.CommandLine.ParseErrorsAllowlist.UnknownFlags = true
|
||||
_ = pflag.CommandLine.Parse(cliArgs)
|
||||
return
|
||||
}
|
||||
|
||||
pflag.Parse()
|
||||
|
||||
// Auto-detect color based on environment when not explicitly configured
|
||||
@@ -269,6 +285,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
||||
sorter = sort.NoSort
|
||||
case "alphanumeric":
|
||||
sorter = sort.AlphaNumeric
|
||||
default:
|
||||
// Not nil: this overwrites the sorter NewExecutor already set.
|
||||
sorter = sort.AlphaNumericWithRootTasksFirst
|
||||
}
|
||||
|
||||
// Change the directory to the user's home directory if the global flag is set
|
||||
|
||||
23
internal/flags/flags_test.go
Normal file
23
internal/flags/flags_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package flags_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/flags"
|
||||
)
|
||||
|
||||
// WithFlags applies WithTaskSorter after NewExecutor set its default, so an
|
||||
// unset --sort must still resolve to a sorter instead of clearing it.
|
||||
func TestWithFlags_DefaultSorterIsNotCleared(t *testing.T) { //nolint:paralleltest // mutates package state
|
||||
original := flags.TaskSort
|
||||
t.Cleanup(func() { flags.TaskSort = original })
|
||||
|
||||
for _, sort := range []string{"", "default"} {
|
||||
flags.TaskSort = sort
|
||||
e := task.NewExecutor(flags.WithFlags())
|
||||
require.NotNilf(t, e.TaskSorter, "--sort %q left the executor without a sorter", sort)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
123
testdata/completion/run.sh
vendored
Executable file
123
testdata/completion/run.sh
vendored
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the task binary and a fixture Taskfile, then runs every installed shell
|
||||
# wrapper against them. The engine itself is covered by the Go tests.
|
||||
set -u
|
||||
|
||||
# fish, Nushell and PowerShell resolve the binary through these; an ambient value
|
||||
# would silently test something other than the binary built below.
|
||||
unset TASK_EXE GO_TASK_PROGNAME
|
||||
|
||||
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
root=$(cd "$here/../.." && pwd)
|
||||
|
||||
bindir=$(mktemp -d)
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf "$bindir" "$fixture"' EXIT
|
||||
|
||||
if ! go build -o "$bindir/task" "$root/cmd/task"; then
|
||||
echo "failed to build task binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
export TASK_BIN="$bindir/task"
|
||||
# fish and PowerShell register completion for the command name `task`.
|
||||
export PATH="$bindir:$PATH"
|
||||
|
||||
cat > "$fixture/Taskfile.yml" <<'YML'
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
desc: Build it
|
||||
deploy:
|
||||
desc: Deploy it
|
||||
aliases: [dep]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: [prod, dev]
|
||||
- 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
|
||||
|
||||
# Control options must be removed before both flag passes, including the one
|
||||
# that locates configuration and enables experimental flags.
|
||||
cat > "$fixture/other/.taskrc.yml" <<'YML'
|
||||
experiments:
|
||||
GENTLE_FORCE: 1
|
||||
YML
|
||||
for option in --no-aliases --no-descriptions; do
|
||||
if output=$("$TASK_BIN" __complete "$option" --dir "$fixture/other" --f) && [[ "$output" == *--force-all* ]]; then
|
||||
echo "engine: $option preserves project configuration"
|
||||
else
|
||||
echo "engine: $option lost project configuration"
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Setup can fail after parsing the Taskfile but before creating its compiler.
|
||||
# The protocol must still finish cleanly instead of panicking on a nil compiler.
|
||||
if output=$("$TASK_BIN" __complete --dir "$fixture" --output=g deploy "") && [[ "$output" == ':4' ]]; then
|
||||
echo "engine: failed setup returns an empty completion response"
|
||||
else
|
||||
echo "engine: failed setup broke the completion protocol"
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
|
||||
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"
|
||||
81
testdata/completion/wrapper.bash
vendored
Executable file
81
testdata/completion/wrapper.bash
vendored
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-tests how the bash wrapper routes each directive, by stubbing the
|
||||
# bash-completion helpers. Requires TASK_BIN and TASK_FIXTURE.
|
||||
set -u
|
||||
|
||||
: "${TASK_BIN:?}"; : "${TASK_FIXTURE:?}"
|
||||
export TASK_EXE="$TASK_BIN"
|
||||
cd "$TASK_FIXTURE" || exit 1
|
||||
|
||||
fails=0
|
||||
CAP=""
|
||||
|
||||
_init_completion() {
|
||||
words=("${TEST_WORDS[@]}")
|
||||
cword=$TEST_CWORD
|
||||
cur="${TEST_WORDS[$TEST_CWORD]}"
|
||||
prev="${TEST_WORDS[$((TEST_CWORD - 1))]}"
|
||||
return 0
|
||||
}
|
||||
# Records $cur so a test can assert the inline `--flag=` prefix was stripped.
|
||||
_filedir() { CAP+="filedir:$* cur=$cur"$'\n'; }
|
||||
compopt() { CAP+="compopt:$*"$'\n'; }
|
||||
__ltrim_colon_completions() { :; }
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../../completion/bash/task.bash"
|
||||
|
||||
run() {
|
||||
CAP=""
|
||||
TEST_WORDS=("$@")
|
||||
TEST_CWORD=$((${#TEST_WORDS[@]} - 1))
|
||||
COMPREPLY=()
|
||||
_task
|
||||
}
|
||||
|
||||
reply_has() { # LABEL VALUE
|
||||
local v
|
||||
for v in "${COMPREPLY[@]}"; do [[ "$v" == "$2" ]] && { echo " ok $1"; return; }; done
|
||||
echo " FAIL $1 — '$2' missing from COMPREPLY: ${COMPREPLY[*]}"
|
||||
fails=$((fails + 1))
|
||||
}
|
||||
cap_has() { # LABEL PATTERN
|
||||
if [[ "$CAP" == *"$2"* ]]; then echo " ok $1"; else
|
||||
echo " FAIL $1 — expected '$2' in: $CAP"; fails=$((fails + 1)); fi
|
||||
}
|
||||
cap_hasnot() { # LABEL PATTERN
|
||||
if [[ "$CAP" == *"$2"* ]]; then
|
||||
echo " FAIL $1 — '$2' should be absent in: $CAP"; fails=$((fails + 1)); else
|
||||
echo " ok $1"; fi
|
||||
}
|
||||
|
||||
echo "bash: :4 (NoFileComp) forwards candidates, no file fallback"
|
||||
run task ''
|
||||
reply_has "candidate forwarded" build
|
||||
cap_hasnot "no file fallback" "filedir:"
|
||||
|
||||
echo "bash: :2|:32 (NoSpace|KeepOrder) disable the trailing space and the sort"
|
||||
run task deploy ''
|
||||
cap_has "nospace applied" "compopt:-o nospace"
|
||||
cap_has "keeporder applied" "compopt:-o nosort"
|
||||
|
||||
echo "bash: :8 (FilterFileExt) routes to extension-filtered files"
|
||||
run task --taskfile ''
|
||||
cap_has "filedir ext glob" "filedir:@(yml|yaml)"
|
||||
|
||||
echo "bash: :16 (FilterDirs) routes to directory completion"
|
||||
run task --dir ''
|
||||
cap_has "filedir -d" "filedir:-d"
|
||||
|
||||
echo "bash: :0 (Default) falls back to files"
|
||||
run task build -- ''
|
||||
cap_has "filedir default" "filedir:"
|
||||
|
||||
echo "bash: inline --flag= strips the prefix before file completion"
|
||||
run task --taskfile=sub/x
|
||||
cap_has "inline cur stripped" "cur=sub/x"
|
||||
|
||||
if ((fails)); then
|
||||
echo "bash: $fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "bash: all passed"
|
||||
64
testdata/completion/wrapper.fish
vendored
Executable file
64
testdata/completion/wrapper.fish
vendored
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env fish
|
||||
# Smoke-tests how the fish wrapper routes each directive, via `complete -C`.
|
||||
# Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test.
|
||||
|
||||
cd $TASK_FIXTURE
|
||||
source (dirname (status -f))/../../completion/fish/task.fish
|
||||
|
||||
set -g fails 0
|
||||
|
||||
function cands
|
||||
complete -C $argv[1] | string split -f1 \t
|
||||
end
|
||||
|
||||
function has # LABEL LINE VALUE
|
||||
if contains -- $argv[3] (cands $argv[2])
|
||||
echo " ok $argv[1]"
|
||||
else
|
||||
echo " FAIL $argv[1] — '$argv[3]' missing from: "(cands $argv[2])
|
||||
set fails (math $fails + 1)
|
||||
end
|
||||
end
|
||||
|
||||
function hasnot # LABEL LINE VALUE
|
||||
if contains -- $argv[3] (cands $argv[2])
|
||||
echo " FAIL $argv[1] — '$argv[3]' should be absent"
|
||||
set fails (math $fails + 1)
|
||||
else
|
||||
echo " ok $argv[1]"
|
||||
end
|
||||
end
|
||||
|
||||
echo "fish: :4 (NoFileComp) forwards candidates, offers no files"
|
||||
has "candidate forwarded" 'task ' build
|
||||
hasnot "no file fallback" 'task ' notes.txt
|
||||
|
||||
echo "fish: KeepOrder preserves non-alphabetical enum declarations"
|
||||
set -l enum_values (cands 'task deploy ENV=')
|
||||
if test (string join ',' -- $enum_values) = 'ENV=prod,ENV=dev'
|
||||
echo " ok enum declaration order"
|
||||
else
|
||||
echo " FAIL enum declaration order: $enum_values"
|
||||
set fails (math $fails + 1)
|
||||
end
|
||||
|
||||
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
testdata/completion/wrapper.nu
vendored
Normal file
94
testdata/completion/wrapper.nu
vendored
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 "../../completion/nu/task-completions.nu")
|
||||
|
||||
# Installed before the wrapper is sourced, to assert the delegation path.
|
||||
$env.config.completions.external.completer = {|spans| [{ value: $"prev:($spans | first)" }] }
|
||||
|
||||
source $TASK_NU
|
||||
|
||||
cd $env.TASK_FIXTURE
|
||||
|
||||
let completer = $env.config.completions.external.completer
|
||||
|
||||
def cands [spans: list<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"
|
||||
76
testdata/completion/wrapper.ps1
vendored
Normal file
76
testdata/completion/wrapper.ps1
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# Smoke-tests how the PowerShell wrapper routes each directive, via the
|
||||
# completion API. Set up by run.sh: $env:TASK_FIXTURE, and `task` on PATH =
|
||||
# the binary under test.
|
||||
|
||||
Set-Location $env:TASK_FIXTURE
|
||||
. "$PSScriptRoot/../../completion/ps/task.ps1"
|
||||
|
||||
$fails = 0
|
||||
|
||||
function Cands($line) {
|
||||
([System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null)).CompletionMatches |
|
||||
ForEach-Object { $_.CompletionText }
|
||||
}
|
||||
|
||||
function Has($label, $line, $value) {
|
||||
if ((Cands $line) -contains $value) {
|
||||
Write-Output " ok $label"
|
||||
} else {
|
||||
Write-Output " FAIL $label — '$value' missing from: $((Cands $line) -join ' ')"
|
||||
$script:fails++
|
||||
}
|
||||
}
|
||||
|
||||
function HasNot($label, $line, $value) {
|
||||
if ((Cands $line) -contains $value) {
|
||||
Write-Output " FAIL $label — '$value' should be absent"
|
||||
$script:fails++
|
||||
} else {
|
||||
Write-Output " ok $label"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "powershell: :4 (NoFileComp) forwards candidates, offers no files"
|
||||
Has "candidate forwarded" 'task ' 'build'
|
||||
HasNot "no file fallback" 'task ' 'notes.txt'
|
||||
|
||||
Write-Output "powershell: filters candidates by the current word"
|
||||
Has "prefix keeps match" 'task b' 'build'
|
||||
HasNot "prefix drops others" 'task b' 'deploy'
|
||||
|
||||
Write-Output "powershell: :16 (FilterDirs) offers directories only"
|
||||
Has "dir offered" 'task --dir ' 'sub'
|
||||
HasNot "no plain file" 'task --dir ' 'notes.txt'
|
||||
|
||||
Write-Output "powershell: :8 (FilterFileExt) filters by extension"
|
||||
Has "matching file" 'task --taskfile ' 'Taskfile.yml'
|
||||
HasNot "non-matching file" 'task --taskfile ' 'notes.txt'
|
||||
|
||||
Write-Output "powershell: nested path completion keeps the directory prefix"
|
||||
Has "prefix kept" 'task --taskfile sub/' 'sub/nested.yml'
|
||||
|
||||
Write-Output "powershell: inline --flag=path keeps the --flag= prefix"
|
||||
Has "inline nested" 'task --taskfile=sub/' '--taskfile=sub/nested.yml'
|
||||
HasNot "inline non-matching" 'task --taskfile=' '--taskfile=notes.txt'
|
||||
|
||||
Write-Output "powershell: a quoted argument reaches the engine unquoted"
|
||||
Has "single-quoted dir" "task --dir 'with space' " 'spaced'
|
||||
Has "double-quoted dir" 'task --dir "with space" ' 'spaced'
|
||||
|
||||
Write-Output "powershell: a candidate holding a space is quoted for insertion"
|
||||
Has "dir quoted" 'task --dir w' "'with space'"
|
||||
|
||||
Write-Output "powershell: shell metacharacters are inserted literally"
|
||||
foreach ($name in @('cost$usd', 'semi;colon', 'paren(dir)', "quote'name", 'back`tick')) {
|
||||
[System.IO.Directory]::CreateDirectory((Join-Path $env:TASK_FIXTURE $name)) | Out-Null
|
||||
$prefix = $name.Substring(0, 3)
|
||||
$quoted = "'" + $name.Replace("'", "''") + "'"
|
||||
Has "literal $name" "task --dir $prefix" $quoted
|
||||
Has "inline literal $name" "task --dir=$prefix" ("'--dir=" + $name.Replace("'", "''") + "'")
|
||||
}
|
||||
|
||||
if ($fails -ne 0) {
|
||||
Write-Output "powershell: $fails failure(s)"
|
||||
exit 1
|
||||
}
|
||||
Write-Output "powershell: all passed"
|
||||
105
testdata/completion/wrapper.zsh
vendored
Executable file
105
testdata/completion/wrapper.zsh
vendored
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/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' }
|
||||
|
||||
completion_dir=${0:A:h}/../../completion/zsh
|
||||
source $completion_dir/_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:"
|
||||
|
||||
echo "zsh: arguments are passed without shell quoting"
|
||||
run task --dir "'with space'" ''
|
||||
has "single-quoted dir" "cand:spaced"
|
||||
run task --dir '"with space"' ''
|
||||
has "double-quoted dir" "cand:spaced"
|
||||
run task --dir 'with\ space' ''
|
||||
has "escaped dir" "cand:spaced"
|
||||
run task --taskfile "'with space/Taskfile.yml'" ''
|
||||
has "quoted taskfile" "cand:spaced"
|
||||
|
||||
echo "zsh: autoload completes on the first invocation"
|
||||
unfunction _task
|
||||
fpath=($completion_dir $fpath)
|
||||
autoload -Uz _task
|
||||
run task ''
|
||||
has "first autoload call" "cand:build"
|
||||
|
||||
# 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"
|
||||
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 {
|
||||
|
||||
@@ -595,8 +595,14 @@ enable shell completions below.
|
||||
Some installation methods will automatically install completions too, but if
|
||||
this isn't working for you or your chosen method doesn't include them, you can
|
||||
run `task --completion <shell>` to output a completion script for any supported
|
||||
shell. There are a couple of ways these completions can be added to your shell
|
||||
config:
|
||||
shell.
|
||||
|
||||
Every shell shares a single source of truth: the script is a thin wrapper that
|
||||
asks the `task` binary itself what to suggest, so Bash, Zsh, Fish, Nushell and
|
||||
PowerShell all offer the same task names, aliases, flags, flag values and
|
||||
`requires` vars.
|
||||
|
||||
There are a couple of ways these completions can be added to your shell config:
|
||||
|
||||
### Option 1. Load the completions in your shell's startup config (Recommended)
|
||||
|
||||
@@ -696,21 +702,21 @@ in sync with the installed version of Task, and the refreshed completions are
|
||||
picked up by the next shell. With option 2, re-run the command after upgrading
|
||||
Task.
|
||||
|
||||
The completions are attached to an `extern "task"` declaration, which Nushell
|
||||
requires to be static. Three consequences are worth knowing:
|
||||
|
||||
- The experimental flags (`--force-all`, `--download`, `--offline`, …) are
|
||||
always offered, even when the corresponding experiment is disabled. Their
|
||||
description is prefixed with the experiment name, and `task --experiments`
|
||||
lists the ones that are enabled.
|
||||
- Passing a value to a boolean flag with `=` does not work: Nushell forwards
|
||||
`--color=false` as two arguments, so Task reads `false` as a task name. Use
|
||||
`NO_COLOR=1`, or bypass the declaration with `^task --color=false`.
|
||||
- `TASK_EXE` selects the executable that is run, but not the command name the
|
||||
completions are attached to, which is always `task`. For a renamed executable,
|
||||
alias it instead:
|
||||
Nushell shares a single external completer between every command, so the script
|
||||
chains to the one already configured — carapace and friends keep working. Load it
|
||||
from an autoload directory as shown above rather than from `config.nu`, so that
|
||||
your own completer is the one being chained to. If you would rather wire it
|
||||
yourself, the script also exposes a `task-external-completer` command:
|
||||
|
||||
```nu
|
||||
use ($nu.data-dir | path join "vendor/autoload/task-completions.nu") *
|
||||
alias go-task = task
|
||||
$env.config.completions.external.completer = {|spans|
|
||||
match ($spans | first) {
|
||||
task => (task-external-completer $spans)
|
||||
_ => (do $my_other_completer $spans)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two engine directives behave differently under Nushell by design: it never
|
||||
appends a space after an external completion (so `NoSpace` is a no-op) and never
|
||||
re-sorts the results (so `KeepOrder` is always honoured).
|
||||
|
||||
Reference in New Issue
Block a user