mirror of
https://github.com/go-task/task.git
synced 2026-07-04 01:48:44 +00:00
Compare commits
7 Commits
enable-rem
...
feat/compl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1521c9e50f | ||
|
|
e3b6ed4a03 | ||
|
|
e5ecd46750 | ||
|
|
7c90ee35f8 | ||
|
|
57bc6dd8cb | ||
|
|
f9f2ecb8be | ||
|
|
46201bcac9 |
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
@@ -31,3 +31,36 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: go run ./cmd/task test
|
||||
|
||||
completion:
|
||||
name: Completion
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{matrix.platform}}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.26.x
|
||||
|
||||
# zsh and pwsh are preinstalled on the runners; only fish is missing
|
||||
# (plus zsh on the Linux image).
|
||||
- name: Install shells (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh fish
|
||||
|
||||
- name: Install shells (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install fish
|
||||
|
||||
- name: Test completion
|
||||
# Strict mode fails the run if any shell is missing, so we never get a
|
||||
# false pass when a runner image stops shipping one (e.g. pwsh).
|
||||
env:
|
||||
TASK_COMPLETION_STRICT: "1"
|
||||
run: go run ./cmd/task test:completion
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
config) to customise the directory where Task stores temporary files such as
|
||||
checksums. Relative paths are resolved against the root Taskfile (#2891 by
|
||||
@kjasn).
|
||||
- Unified Bash, Fish, Zsh and PowerShell completions behind a single `task
|
||||
__complete` engine, 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 are preserved, now backed by the `--no-aliases` and
|
||||
`--no-descriptions` completion flags (#2897 by @vmaerten).
|
||||
|
||||
## v3.51.1 - 2026-05-16
|
||||
|
||||
|
||||
@@ -152,6 +152,15 @@ tasks:
|
||||
cmds:
|
||||
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' -tags 'signals watch' ./...
|
||||
|
||||
test:completion:
|
||||
desc: Tests the shell completion engine and wrappers (bash, zsh, fish, powershell)
|
||||
sources:
|
||||
- internal/complete/**/*.go
|
||||
- cmd/task/**/*.go
|
||||
- completion/**/*
|
||||
cmds:
|
||||
- bash completion/tests/run.sh
|
||||
|
||||
goreleaser:test:
|
||||
desc: Tests release process without publishing
|
||||
cmds:
|
||||
|
||||
55
cmd/task/complete_cmd.go
Normal file
55
cmd/task/complete_cmd.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
)
|
||||
|
||||
func runComplete(args []string) error {
|
||||
// Strip the completion-control flags the wrapper prepends; the rest is the
|
||||
// user's command line to complete.
|
||||
opts, args := complete.ParseOptions(args)
|
||||
|
||||
dir, entrypoint, global := extractTaskfileFlags(args)
|
||||
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithEntrypoint(entrypoint),
|
||||
task.WithStdout(io.Discard),
|
||||
task.WithStderr(io.Discard),
|
||||
task.WithVersionCheck(false),
|
||||
)
|
||||
if global {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
e.Options(task.WithDir(home))
|
||||
}
|
||||
}
|
||||
|
||||
// Loading the Taskfile parses YAML (and may hit the network for remote
|
||||
// Taskfiles), so skip it entirely when completing flags or their values.
|
||||
// Best-effort: a missing or broken Taskfile must not break completion.
|
||||
if complete.NeedsTaskfile(args, pflag.CommandLine) {
|
||||
_ = e.Setup()
|
||||
}
|
||||
|
||||
suggs, dirv := complete.Complete(e, pflag.CommandLine, args, opts)
|
||||
complete.Write(os.Stdout, suggs, dirv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTaskfileFlags(args []string) (dir, entrypoint string, global bool) {
|
||||
fs := pflag.NewFlagSet("complete", pflag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
fs.ParseErrorsAllowlist.UnknownFlags = true
|
||||
fs.Usage = func() {}
|
||||
fs.StringVarP(&dir, "dir", "d", "", "")
|
||||
fs.StringVarP(&entrypoint, "taskfile", "t", "", "")
|
||||
fs.BoolVarP(&global, "global", "g", false, "")
|
||||
_ = fs.Parse(args)
|
||||
return
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-task/task/v3/args"
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/experiments"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/flags"
|
||||
"github.com/go-task/task/v3/internal/logger"
|
||||
@@ -58,6 +59,12 @@ func emitCIErrorAnnotation(err error) {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Dispatched before flag validation: the args after __complete are the
|
||||
// user's command line, not Task's own flags.
|
||||
if complete.IsActive() {
|
||||
return runComplete(os.Args[2:])
|
||||
}
|
||||
|
||||
log := &logger.Logger{
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
|
||||
@@ -1,60 +1,81 @@
|
||||
# vim: set tabstop=2 shiftwidth=2 expandtab:
|
||||
#
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
_GO_TASK_COMPLETION_LIST_OPTION='--list-all'
|
||||
TASK_CMD="${TASK_EXE:-task}"
|
||||
|
||||
function _task()
|
||||
{
|
||||
_task() {
|
||||
local cur prev words cword
|
||||
_init_completion -n : || return
|
||||
|
||||
# Check for `--` within command-line and quit or strip suffix.
|
||||
local i
|
||||
for i in "${!words[@]}"; do
|
||||
if [ "${words[$i]}" == "--" ]; then
|
||||
# Do not complete words following `--` passed to CLI_ARGS.
|
||||
[ $cword -gt $i ] && return
|
||||
# Remove the words following `--` to not put --list in CLI_ARGS.
|
||||
words=( "${words[@]:0:$i}" )
|
||||
break
|
||||
# Completion directives, mirroring internal/complete/complete.go.
|
||||
local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16
|
||||
|
||||
# Exclude both `=` and `:` from the word breaks so `--output=` and
|
||||
# `docs:serve` reach the engine as single tokens.
|
||||
_init_completion -n =: || return
|
||||
|
||||
local -a args=()
|
||||
if (( cword > 0 )); then
|
||||
args=( "${words[@]:1:cword}" )
|
||||
fi
|
||||
if (( ${#args[@]} == 0 )); then
|
||||
args=( "" )
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
|
||||
if [[ -z "$output" ]]; then
|
||||
_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 against "unbound variable" on an empty array under
|
||||
# `set -u` in bash 3.2 (macOS).
|
||||
for line in ${lines[@]+"${lines[@]}"}; do
|
||||
exts+="${exts:+|}$line"
|
||||
done
|
||||
_filedir "@($exts)"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( directive & FILTER_DIRS )); then
|
||||
_filedir -d
|
||||
return
|
||||
fi
|
||||
|
||||
# Prefix-filter by hand instead of `compgen -W`: the latter joins/splits the
|
||||
# word list on IFS, which mangles any suggestion value containing a space.
|
||||
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
|
||||
|
||||
# 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
|
||||
_filedir
|
||||
fi
|
||||
}
|
||||
|
||||
complete -F _task "$TASK_CMD"
|
||||
|
||||
@@ -1,120 +1,91 @@
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
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. fish's `math`
|
||||
# has no bitwise operators, so bits are stored as their power-of-two value and
|
||||
# tested with integer division + modulo via __task_test_bit.
|
||||
set -g __task_directive_no_space 2
|
||||
set -g __task_directive_no_file_comp 4
|
||||
set -g __task_directive_filter_file_ext 8
|
||||
set -g __task_directive_filter_dirs 16
|
||||
set -g __task_directive_keep_order 32
|
||||
|
||||
# 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 main completion is registered with `--no-files`, which disables fish's
|
||||
# native file fallback. Every file-completion directive must therefore be
|
||||
# served here, otherwise nothing is offered (e.g. `--cacert`, after `--`).
|
||||
|
||||
# __fish_complete_suffix only *prioritizes* the extension rather than
|
||||
# filtering, so filter the file list ourselves (keeping dirs to descend into).
|
||||
if __task_test_bit $directive $__task_directive_filter_file_ext
|
||||
for entry in (__fish_complete_path $current)
|
||||
set -l name (string split -f1 \t -- $entry)
|
||||
if string match -qr '/$' -- $name
|
||||
printf '%s\n' $entry
|
||||
continue
|
||||
end
|
||||
for ext in $data
|
||||
if string match -qr "\.$ext\$" -- $name
|
||||
printf '%s\n' $entry
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if __task_test_bit $directive $__task_directive_filter_dirs
|
||||
__fish_complete_directories $current
|
||||
return
|
||||
end
|
||||
|
||||
# Emit the candidates verbatim; fish reads the tab as the value/description
|
||||
# separator.
|
||||
for line in $data
|
||||
printf '%s\n' $line
|
||||
end
|
||||
|
||||
# NoFileComp unset → also offer files, since `--no-files` suppressed the
|
||||
# native fallback. Covers DirectiveDefault (e.g. `--cacert`, after `--`).
|
||||
if not __task_test_bit $directive $__task_directive_no_file_comp
|
||||
__fish_complete_path $current
|
||||
end
|
||||
end
|
||||
|
||||
complete -c $GO_TASK_PROGNAME \
|
||||
-d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was specified.' \
|
||||
-xa "(__task_get_tasks)" \
|
||||
-n "not __fish_seen_subcommand_from --"
|
||||
|
||||
# Standard flags
|
||||
complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks'
|
||||
complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)'
|
||||
complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks'
|
||||
complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell"
|
||||
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'
|
||||
|
||||
# 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'
|
||||
|
||||
# RemoteTaskfiles experiment - Options
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l offline -d 'use only local or cached Taskfiles'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)"
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cacert -d 'custom CA certificate for TLS' -r
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert -d 'client certificate for mTLS' -r
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key -d 'client certificate private key' -r
|
||||
|
||||
# RemoteTaskfiles experiment - Operations
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l clear-cache -d 'clear remote Taskfile cache'
|
||||
# Single registration: all task names, flags, flag values and file completion
|
||||
# flow through the engine. `--no-files` prevents fish from mixing in files when
|
||||
# the engine says not to (NoFileComp); `__task_complete` re-adds them otherwise.
|
||||
complete -c $GO_TASK_PROGNAME --no-files -a "(__task_complete)"
|
||||
|
||||
171
completion/protocol_test.go
Normal file
171
completion/protocol_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Package completion_test black-box tests the `task __complete` wire protocol:
|
||||
// the candidates and directive the real binary emits for a command line. The
|
||||
// shell wrappers only need to be smoke-tested for how they interpret the
|
||||
// directive (see completion/tests/wrapper.*).
|
||||
package completion_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
)
|
||||
|
||||
var taskBin string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "task-completion-test")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
taskBin = filepath.Join(dir, "task")
|
||||
if runtime.GOOS == "windows" {
|
||||
taskBin += ".exe"
|
||||
}
|
||||
if out, err := exec.Command("go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out)
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(1)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
const fixtureTaskfile = `version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
desc: Build it
|
||||
deploy:
|
||||
desc: Deploy the application
|
||||
aliases: [dep, ship]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: [dev, staging, prod]
|
||||
- REGION
|
||||
docs:serve:
|
||||
desc: Serve docs locally
|
||||
`
|
||||
|
||||
// completeArgs runs `task __complete <args>` in a fresh fixture directory and
|
||||
// returns the offered candidate values plus the emitted directive.
|
||||
func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644))
|
||||
|
||||
cmd := exec.Command(taskBin, append([]string{complete.CommandName}, args...)...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
require.NoError(t, err)
|
||||
|
||||
lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
|
||||
require.NotEmpty(t, lines, "protocol output must end with a directive line")
|
||||
|
||||
last := lines[len(lines)-1]
|
||||
require.True(t, strings.HasPrefix(last, ":"), "last line must be the :<directive> line, got %q", last)
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(last, ":"))
|
||||
require.NoError(t, err)
|
||||
|
||||
values := make([]string, 0, len(lines)-1)
|
||||
for _, line := range lines[:len(lines)-1] {
|
||||
values = append(values, strings.SplitN(line, "\t", 2)[0])
|
||||
}
|
||||
return values, complete.Directive(n)
|
||||
}
|
||||
|
||||
func TestProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string // candidate values that must be offered
|
||||
absent []string // candidate values that must NOT be offered
|
||||
directive complete.Directive
|
||||
}{
|
||||
{
|
||||
name: "task names and aliases",
|
||||
args: []string{""},
|
||||
want: []string{"build", "deploy", "dep", "ship", "docs:serve"},
|
||||
directive: complete.DirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
name: "no-aliases drops aliases",
|
||||
args: []string{"--no-aliases", ""},
|
||||
want: []string{"build", "deploy"},
|
||||
absent: []string{"dep", "ship"},
|
||||
directive: complete.DirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
name: "flag names",
|
||||
args: []string{"-"},
|
||||
want: []string{"--taskfile", "--dir", "--output"},
|
||||
directive: complete.DirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
name: "separate flag value is bare",
|
||||
args: []string{"--output", ""},
|
||||
want: []string{"interleaved", "group", "prefixed"},
|
||||
directive: complete.DirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
name: "inline flag value is full form",
|
||||
args: []string{"--output="},
|
||||
want: []string{"--output=interleaved", "--output=group", "--output=prefixed"},
|
||||
directive: complete.DirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
name: "sort enum values",
|
||||
args: []string{"--sort", ""},
|
||||
want: []string{"default", "alphanumeric", "none"},
|
||||
directive: complete.DirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
name: "taskfile filters by extension",
|
||||
args: []string{"--taskfile", ""},
|
||||
want: []string{"yml", "yaml"},
|
||||
directive: complete.DirectiveFilterFileExt,
|
||||
},
|
||||
{
|
||||
name: "dir filters to directories",
|
||||
args: []string{"--dir", ""},
|
||||
directive: complete.DirectiveFilterDirs,
|
||||
},
|
||||
{
|
||||
name: "task variables keep order and suppress the space",
|
||||
args: []string{"deploy", ""},
|
||||
want: []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="},
|
||||
directive: complete.DirectiveNoSpace | complete.DirectiveNoFileComp | complete.DirectiveKeepOrder,
|
||||
},
|
||||
{
|
||||
name: "after -- yields default file completion",
|
||||
args: []string{"deploy", "--", ""},
|
||||
directive: complete.DirectiveDefault,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
values, directive := completeArgs(t, tt.args...)
|
||||
require.Equal(t, tt.directive, directive)
|
||||
require.Subset(t, values, tt.want)
|
||||
for _, a := range tt.absent {
|
||||
require.NotContains(t, values, a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,88 @@
|
||||
using namespace System.Management.Automation
|
||||
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
$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')
|
||||
)
|
||||
$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')
|
||||
# Words after the program name, truncated to the cursor.
|
||||
$argsToPass = @()
|
||||
$elements = $commandAst.CommandElements
|
||||
if ($elements.Count -gt 1) {
|
||||
for ($i = 1; $i -lt $elements.Count; $i++) {
|
||||
$el = $elements[$i]
|
||||
if ($el.Extent.StartOffset -ge $cursorPosition) { break }
|
||||
$argsToPass += $el.ToString()
|
||||
}
|
||||
|
||||
if ($experiments -match '\* REMOTE_TASKFILES:.*on') {
|
||||
# Options
|
||||
$completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles')
|
||||
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
|
||||
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry')
|
||||
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory')
|
||||
$completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate')
|
||||
$completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate')
|
||||
$completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key')
|
||||
# Operations
|
||||
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
|
||||
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
||||
}
|
||||
|
||||
return $completions.Where{ $_.CompletionText.StartsWith($commandName) }
|
||||
}
|
||||
# The trailing word (possibly empty) must reach the engine so it knows
|
||||
# the cursor sits on a fresh word. It is already present when it coincides
|
||||
# with the last command element captured above.
|
||||
if ($argsToPass.Count -eq 0 -or $argsToPass[-1] -ne $wordToComplete) {
|
||||
$argsToPass += $wordToComplete
|
||||
}
|
||||
|
||||
return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " }
|
||||
$output = & $TaskExe __complete @argsToPass 2>$null
|
||||
if (-not $output) { return }
|
||||
|
||||
$lines = @($output)
|
||||
if ($lines.Count -eq 0) { return }
|
||||
$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
|
||||
|
||||
# Note: DirectiveNoSpace (bit 2) cannot be honored here — PowerShell's
|
||||
# CompletionResult API has no per-item "no trailing space" option, so a
|
||||
# suggestion like `VAR=` gets a trailing space. This is a PowerShell limit.
|
||||
|
||||
# FilterFileExt: keep files whose extension matches, plus directories so the
|
||||
# user can still descend into them. `-Include` is unreliable without
|
||||
# `-Recurse`, so filter with Where-Object instead.
|
||||
if ($directive -band $FilterFileExt) {
|
||||
$exts = $data | ForEach-Object { ".$_" }
|
||||
return Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.PSIsContainer -or $exts -contains $_.Extension } |
|
||||
ForEach-Object {
|
||||
$type = if ($_.PSIsContainer) { [CompletionResultType]::ProviderContainer } else { [CompletionResultType]::ProviderItem }
|
||||
[CompletionResult]::new($_.Name, $_.Name, $type, $_.Name)
|
||||
}
|
||||
}
|
||||
|
||||
# FilterDirs
|
||||
if ($directive -band $FilterDirs) {
|
||||
return Get-ChildItem -Path "$wordToComplete*" -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) }
|
||||
}
|
||||
|
||||
# Build candidates, filtering by the current word. PowerShell does not filter
|
||||
# native argument-completer results itself, so without this every suggestion
|
||||
# would be offered regardless of what the user typed.
|
||||
$results = @($data | ForEach-Object {
|
||||
$parts = $_ -split "`t", 2
|
||||
$value = $parts[0]
|
||||
if ($wordToComplete -and -not $value.StartsWith($wordToComplete)) { return }
|
||||
$desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value }
|
||||
[CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc)
|
||||
})
|
||||
|
||||
# NoFileComp (bit 4) unset and nothing matched → fall back to file completion,
|
||||
# since the engine returned DirectiveDefault (e.g. --cacert, after `--`).
|
||||
if ($results.Count -eq 0 -and -not ($directive -band $NoFileComp)) {
|
||||
return Get-ChildItem -Path . -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) }
|
||||
}
|
||||
|
||||
return $results
|
||||
}
|
||||
|
||||
97
completion/tests/run.sh
Executable file
97
completion/tests/run.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the completion test suite: builds the task binary, creates a fixture
|
||||
# Taskfile with sample files and directories, then exercises the engine and
|
||||
# every installed shell wrapper. Skips shells that are not installed.
|
||||
set -u
|
||||
|
||||
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
root=$(cd "$here/../.." && pwd)
|
||||
|
||||
# Temp dirs for the binary and the fixture; removed on exit (including on early
|
||||
# failure via the trap).
|
||||
bindir=$(mktemp -d)
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf "$bindir" "$fixture"' EXIT
|
||||
|
||||
# Build the binary under test.
|
||||
if ! go build -o "$bindir/task" "$root/cmd/task"; then
|
||||
echo "failed to build task binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
export TASK_BIN="$bindir/task"
|
||||
# fish and PowerShell register completion for the command name `task`, so make
|
||||
# `task` on PATH resolve to the binary under test.
|
||||
export PATH="$bindir:$PATH"
|
||||
|
||||
# Fixture: a Taskfile plus files/dirs so file/dir completion has real entries.
|
||||
cat > "$fixture/Taskfile.yml" <<'YML'
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
desc: Build it
|
||||
deploy:
|
||||
desc: Deploy it
|
||||
aliases: [dep]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum: [dev, prod]
|
||||
- REGION
|
||||
docs:serve:
|
||||
desc: Serve docs
|
||||
YML
|
||||
touch "$fixture/extra.yaml" "$fixture/notes.txt"
|
||||
mkdir -p "$fixture/sub" "$fixture/other"
|
||||
export TASK_FIXTURE="$fixture"
|
||||
|
||||
# In strict mode (set TASK_COMPLETION_STRICT=1, used in CI) a missing shell is
|
||||
# a failure instead of a skip, so we never get a false pass when a shell the
|
||||
# environment was expected to provide (e.g. pwsh on CI runners) is absent.
|
||||
strict=${TASK_COMPLETION_STRICT:-}
|
||||
|
||||
fails=0
|
||||
run() { # LABEL COMMAND...
|
||||
echo "== $1 =="
|
||||
if "${@:2}"; then :; else fails=$((fails + 1)); fi
|
||||
echo
|
||||
}
|
||||
skip() { # LABEL
|
||||
if [[ -n "$strict" ]]; then
|
||||
echo "== $1 == (MISSING — required under TASK_COMPLETION_STRICT)"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "== $1 == (skipped: not installed)"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# The engine/protocol itself is covered by the Go tests (completion/protocol_test.go
|
||||
# and internal/complete); these smokes only check how each shell wrapper
|
||||
# interprets the directive.
|
||||
run "bash wrapper" bash "$here/wrapper.bash"
|
||||
|
||||
if command -v zsh >/dev/null 2>&1; then
|
||||
run "zsh wrapper" zsh "$here/wrapper.zsh"
|
||||
else
|
||||
skip "zsh wrapper"
|
||||
fi
|
||||
|
||||
if command -v fish >/dev/null 2>&1; then
|
||||
run "fish wrapper" fish "$here/wrapper.fish"
|
||||
else
|
||||
skip "fish wrapper"
|
||||
fi
|
||||
|
||||
pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true)
|
||||
if [[ -n "$pwsh_bin" ]]; then
|
||||
run "powershell wrapper" "$pwsh_bin" -NoProfile -File "$here/wrapper.ps1"
|
||||
else
|
||||
skip "powershell wrapper"
|
||||
fi
|
||||
|
||||
if ((fails)); then
|
||||
echo "completion tests: $fails suite(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "completion tests: all suites passed"
|
||||
77
completion/tests/wrapper.bash
Executable file
77
completion/tests/wrapper.bash
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-tests how the bash wrapper routes each directive by stubbing the
|
||||
# bash-completion helpers (_filedir / compopt / …) and asserting what it calls.
|
||||
# Suggestion logic lives in the Go tests. 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=""
|
||||
|
||||
# Stubs standing in for the bash-completion runtime.
|
||||
_init_completion() {
|
||||
words=("${TEST_WORDS[@]}")
|
||||
cword=$TEST_CWORD
|
||||
cur="${TEST_WORDS[$TEST_CWORD]}"
|
||||
prev="${TEST_WORDS[$((TEST_CWORD - 1))]}"
|
||||
return 0
|
||||
}
|
||||
_filedir() { CAP+="filedir:$*"$'\n'; }
|
||||
compopt() { CAP+="compopt:$*"$'\n'; }
|
||||
__ltrim_colon_completions() { :; }
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../bash/task.bash"
|
||||
|
||||
run() {
|
||||
CAP=""
|
||||
TEST_WORDS=("$@")
|
||||
TEST_CWORD=$((${#TEST_WORDS[@]} - 1))
|
||||
COMPREPLY=()
|
||||
_task
|
||||
}
|
||||
|
||||
reply_has() { # LABEL VALUE
|
||||
local v
|
||||
for v in "${COMPREPLY[@]}"; do [[ "$v" == "$2" ]] && { echo " ok $1"; return; }; done
|
||||
echo " FAIL $1 — '$2' missing from COMPREPLY: ${COMPREPLY[*]}"
|
||||
fails=$((fails + 1))
|
||||
}
|
||||
cap_has() { # LABEL PATTERN
|
||||
if [[ "$CAP" == *"$2"* ]]; then echo " ok $1"; else
|
||||
echo " FAIL $1 — expected '$2' in: $CAP"; fails=$((fails + 1)); fi
|
||||
}
|
||||
cap_hasnot() { # LABEL PATTERN
|
||||
if [[ "$CAP" == *"$2"* ]]; then
|
||||
echo " FAIL $1 — '$2' should be absent in: $CAP"; fails=$((fails + 1)); else
|
||||
echo " ok $1"; fi
|
||||
}
|
||||
|
||||
echo "bash: :4 (NoFileComp) forwards candidates, no file fallback"
|
||||
run task ''
|
||||
reply_has "candidate forwarded" build
|
||||
cap_hasnot "no file fallback" "filedir:"
|
||||
|
||||
echo "bash: :2 (NoSpace) disables the trailing space"
|
||||
run task deploy ''
|
||||
cap_has "nospace applied" "compopt:-o nospace"
|
||||
|
||||
echo "bash: :8 (FilterFileExt) routes to extension-filtered files"
|
||||
run task --taskfile ''
|
||||
cap_has "filedir ext glob" "filedir:@(yml|yaml)"
|
||||
|
||||
echo "bash: :16 (FilterDirs) routes to directory completion"
|
||||
run task --dir ''
|
||||
cap_has "filedir -d" "filedir:-d"
|
||||
|
||||
echo "bash: :0 (Default) falls back to files"
|
||||
run task build -- ''
|
||||
cap_has "filedir default" "filedir:"
|
||||
|
||||
if ((fails)); then
|
||||
echo "bash: $fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "bash: all passed"
|
||||
52
completion/tests/wrapper.fish
Executable file
52
completion/tests/wrapper.fish
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env fish
|
||||
# Smoke-tests how the fish wrapper routes each directive, via `complete -C`
|
||||
# (real completions, no TTY). Suggestion logic lives in the Go tests.
|
||||
# Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test.
|
||||
|
||||
cd $TASK_FIXTURE
|
||||
source (dirname (status -f))/../fish/task.fish
|
||||
|
||||
set -g fails 0
|
||||
|
||||
function cands
|
||||
complete -C $argv[1] | string split -f1 \t
|
||||
end
|
||||
|
||||
function has # LABEL LINE VALUE
|
||||
if contains -- $argv[3] (cands $argv[2])
|
||||
echo " ok $argv[1]"
|
||||
else
|
||||
echo " FAIL $argv[1] — '$argv[3]' missing from: "(cands $argv[2])
|
||||
set fails (math $fails + 1)
|
||||
end
|
||||
end
|
||||
|
||||
function hasnot # LABEL LINE VALUE
|
||||
if contains -- $argv[3] (cands $argv[2])
|
||||
echo " FAIL $argv[1] — '$argv[3]' should be absent"
|
||||
set fails (math $fails + 1)
|
||||
else
|
||||
echo " ok $argv[1]"
|
||||
end
|
||||
end
|
||||
|
||||
echo "fish: :4 (NoFileComp) forwards candidates, offers no files"
|
||||
has "candidate forwarded" 'task ' build
|
||||
hasnot "no file fallback" 'task ' notes.txt
|
||||
|
||||
echo "fish: :16 (FilterDirs) offers directories only"
|
||||
has "dir offered" 'task --dir ' sub/
|
||||
hasnot "no plain file" 'task --dir ' notes.txt
|
||||
|
||||
echo "fish: :8 (FilterFileExt) filters by extension"
|
||||
has "matching file" 'task --taskfile ' Taskfile.yml
|
||||
hasnot "non-matching file" 'task --taskfile ' notes.txt
|
||||
|
||||
echo "fish: :0 (Default) falls back to files"
|
||||
has "file offered" 'task build -- ' notes.txt
|
||||
|
||||
if test $fails -ne 0
|
||||
echo "fish: $fails failure(s)"
|
||||
exit 1
|
||||
end
|
||||
echo "fish: all passed"
|
||||
54
completion/tests/wrapper.ps1
Normal file
54
completion/tests/wrapper.ps1
Normal file
@@ -0,0 +1,54 @@
|
||||
# Smoke-tests how the PowerShell wrapper routes each directive (plus its own
|
||||
# prefix filtering), via the completion API (real completions, no TTY).
|
||||
# Suggestion logic lives in the Go tests. Set up by run.sh: $env:TASK_FIXTURE,
|
||||
# and `task` on PATH = the binary under test.
|
||||
|
||||
Set-Location $env:TASK_FIXTURE
|
||||
. "$PSScriptRoot/../ps/task.ps1"
|
||||
|
||||
$fails = 0
|
||||
|
||||
function Cands($line) {
|
||||
([System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null)).CompletionMatches |
|
||||
ForEach-Object { $_.CompletionText }
|
||||
}
|
||||
|
||||
function Has($label, $line, $value) {
|
||||
if ((Cands $line) -contains $value) {
|
||||
Write-Output " ok $label"
|
||||
} else {
|
||||
Write-Output " FAIL $label — '$value' missing from: $((Cands $line) -join ' ')"
|
||||
$script:fails++
|
||||
}
|
||||
}
|
||||
|
||||
function HasNot($label, $line, $value) {
|
||||
if ((Cands $line) -contains $value) {
|
||||
Write-Output " FAIL $label — '$value' should be absent"
|
||||
$script:fails++
|
||||
} else {
|
||||
Write-Output " ok $label"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "powershell: :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'
|
||||
|
||||
if ($fails -ne 0) {
|
||||
Write-Output "powershell: $fails failure(s)"
|
||||
exit 1
|
||||
}
|
||||
Write-Output "powershell: all passed"
|
||||
77
completion/tests/wrapper.zsh
Executable file
77
completion/tests/wrapper.zsh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Smoke-tests how the zsh wrapper routes each directive by stubbing the
|
||||
# completion functions (_describe / _files / _path_files) and asserting what it
|
||||
# calls. Suggestion logic lives in the Go tests. Requires TASK_BIN, 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
|
||||
|
||||
_describe() {
|
||||
local arr=$4
|
||||
CAP+="describe_opts:${@[5,-1]}"$'\n'
|
||||
local c; for c in ${(P)arr}; do CAP+="cand:$c"$'\n'; done
|
||||
}
|
||||
_files() { CAP+="files:$*"$'\n' }
|
||||
_path_files() { CAP+="path_files:$*"$'\n' }
|
||||
|
||||
# Sourcing (not autoloading) defines _task and avoids the autoload first-call
|
||||
# quirk; the trailing `compdef` call is stubbed above.
|
||||
source ${0:A:h}/../zsh/_task
|
||||
|
||||
run() {
|
||||
CAP=""
|
||||
local -a words=("$@")
|
||||
integer CURRENT=$#words
|
||||
local curcontext=":completion:complete:task:"
|
||||
_task
|
||||
}
|
||||
|
||||
has() { # LABEL PATTERN
|
||||
if [[ "$CAP" == *"$2"* ]]; then
|
||||
echo " ok $1"
|
||||
else
|
||||
echo " FAIL $1 — expected '$2' in:"$'\n'"$CAP"
|
||||
(( fails++ ))
|
||||
fi
|
||||
}
|
||||
hasnot() { # LABEL PATTERN
|
||||
if [[ "$CAP" == *"$2"* ]]; then
|
||||
echo " FAIL $1 — '$2' should be absent in:"$'\n'"$CAP"
|
||||
(( fails++ ))
|
||||
else
|
||||
echo " ok $1"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "zsh: :4 (NoFileComp) forwards candidates, no file fallback"
|
||||
run task ''
|
||||
has "candidate forwarded" "cand:build"
|
||||
hasnot "no file fallback" "files:"
|
||||
|
||||
echo "zsh: :2|:32 (NoSpace|KeepOrder) map to -S and -V"
|
||||
run task deploy ''
|
||||
has "NoSpace -> -S" "describe_opts:-S"
|
||||
has "KeepOrder -> -V" "-V"
|
||||
|
||||
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"
|
||||
@@ -1,171 +1,74 @@
|
||||
#compdef task
|
||||
typeset -A opt_args
|
||||
#
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
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 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)'
|
||||
'(-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]'
|
||||
)
|
||||
# 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
|
||||
# Map the zsh completion zstyles to engine flags. `-T` is true when the
|
||||
# style is unset (its default) or explicitly true, so a flag is only passed
|
||||
# when the user turned the style off.
|
||||
zstyle -T ":completion:${curcontext}:" show-aliases || ctl+=(--no-aliases)
|
||||
zstyle -T ":completion:${curcontext}:" verbose || ctl+=(--no-descriptions)
|
||||
|
||||
if __task_is_experiment_enabled "REMOTE_TASKFILES"; then
|
||||
standard_args+=(
|
||||
'(--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'
|
||||
)
|
||||
fi
|
||||
# (@) preserves a trailing empty string, which the engine relies on to
|
||||
# know the cursor is on a fresh word.
|
||||
args=("${(@)words[2,CURRENT]}")
|
||||
(( ${#args} == 0 )) && args=("")
|
||||
|
||||
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]'
|
||||
)
|
||||
output=$("$TASK_CMD" __complete "${ctl[@]}" "${args[@]}" 2>/dev/null)
|
||||
if [[ -z "$output" ]]; then
|
||||
_files
|
||||
return
|
||||
fi
|
||||
|
||||
# Experimental operations (dynamically added based on enabled experiments)
|
||||
if __task_is_experiment_enabled "REMOTE_TASKFILES"; then
|
||||
standard_args+=(
|
||||
'(--offline --clear-cache)--download[download remote Taskfile]'
|
||||
)
|
||||
operation_args+=(
|
||||
'(* --download)--clear-cache[clear remote Taskfile cache]'
|
||||
)
|
||||
fi
|
||||
lines=("${(f)output}")
|
||||
directive="${lines[-1]#:}"
|
||||
lines=("${(@)lines[1,-2]}")
|
||||
|
||||
_arguments -S $standard_args $operation_args
|
||||
if (( directive & FILTER_FILE_EXT )); then
|
||||
local -a globs
|
||||
for line in "${lines[@]}"; do
|
||||
globs+=("*.${line}")
|
||||
done
|
||||
_files -g "(${(j:|:)globs})"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( directive & FILTER_DIRS )); then
|
||||
_path_files -/
|
||||
return
|
||||
fi
|
||||
|
||||
# `:` inside the value must be escaped: _describe splits on the first
|
||||
# unescaped colon (e.g. "docs:serve" would otherwise become value "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
|
||||
|
||||
(( directive & NO_SPACE )) && opts+=(-S '')
|
||||
(( directive & KEEP_ORDER )) && opts+=(-V)
|
||||
|
||||
if (( ${#completions} > 0 )); then
|
||||
_describe -t tasks 'task' completions "${opts[@]}"
|
||||
fi
|
||||
|
||||
(( directive & NO_FILE_COMP )) && return
|
||||
_files
|
||||
}
|
||||
|
||||
# don't run the completion function when being source-ed or eval-ed
|
||||
if [ "$funcstack[1]" = "_task" ]; then
|
||||
_task "$@"
|
||||
fi
|
||||
compdef _task "$TASK_CMD"
|
||||
|
||||
89
internal/complete/complete.go
Normal file
89
internal/complete/complete.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Package complete implements the `task __complete` protocol consumed by the
|
||||
// shell completion wrappers. The protocol mirrors cobra v2 so a future
|
||||
// migration stays cheap.
|
||||
package complete
|
||||
|
||||
import "os"
|
||||
|
||||
// CommandName is the hidden subcommand the shell wrappers invoke to drive
|
||||
// completion: `task __complete <words...>`.
|
||||
const CommandName = "__complete"
|
||||
|
||||
// IsActive reports whether the process was invoked in completion mode, i.e.
|
||||
// the first argument is the __complete subcommand.
|
||||
func IsActive() bool {
|
||||
return len(os.Args) >= 2 && os.Args[1] == CommandName
|
||||
}
|
||||
|
||||
// Directive mirrors cobra's ShellCompDirective bitfield. It is emitted on the
|
||||
// final output line as `:<directive>` and tells the shell wrapper how to treat
|
||||
// the suggestions (file fallback, trailing space, ordering, …).
|
||||
type Directive int
|
||||
|
||||
const (
|
||||
// DirectiveDefault leaves the shell to perform its default file completion.
|
||||
DirectiveDefault Directive = 0
|
||||
// DirectiveError signals an error; the shell should not offer completion.
|
||||
DirectiveError Directive = 1 << 0
|
||||
// DirectiveNoSpace prevents the shell from appending a space after the
|
||||
// suggestion (e.g. so `VAR=` can be followed by a value).
|
||||
DirectiveNoSpace Directive = 1 << 1
|
||||
// DirectiveNoFileComp disables the shell's fallback file completion.
|
||||
DirectiveNoFileComp Directive = 1 << 2
|
||||
// DirectiveFilterFileExt restricts file completion to the emitted extensions.
|
||||
DirectiveFilterFileExt Directive = 1 << 3
|
||||
// DirectiveFilterDirs restricts completion to directories.
|
||||
DirectiveFilterDirs Directive = 1 << 4
|
||||
// DirectiveKeepOrder tells the shell to preserve the emitted order instead
|
||||
// of sorting alphabetically.
|
||||
DirectiveKeepOrder Directive = 1 << 5
|
||||
)
|
||||
|
||||
// Suggestion is a single completion candidate: the Value inserted on the
|
||||
// command line and an optional human-readable Description.
|
||||
type Suggestion struct {
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Options tunes what the engine emits. The zero value shows everything; use
|
||||
// DefaultOptions for the default and flip fields off from the __complete flags.
|
||||
type Options struct {
|
||||
ShowAliases bool
|
||||
ShowDescriptions bool
|
||||
}
|
||||
|
||||
// DefaultOptions returns the options used when no completion-control flag is
|
||||
// passed: aliases and descriptions are both shown.
|
||||
func DefaultOptions() Options {
|
||||
return Options{ShowAliases: true, ShowDescriptions: true}
|
||||
}
|
||||
|
||||
// Completion-control flags. Shell wrappers prepend these to the __complete
|
||||
// invocation to tune the output (e.g. zsh maps its show-aliases / verbose
|
||||
// zstyles to them). They are consumed by ParseOptions before the remaining
|
||||
// args are treated as the user's command line.
|
||||
const (
|
||||
FlagNoAliases = "--no-aliases"
|
||||
FlagNoDescriptions = "--no-descriptions"
|
||||
)
|
||||
|
||||
// ParseOptions peels the leading completion-control flags off args and returns
|
||||
// the resulting Options together with the remaining args (the user's command
|
||||
// line to complete). Only leading flags are consumed, so a `--no-aliases` typed
|
||||
// by the user further down the line is left untouched.
|
||||
func ParseOptions(args []string) (Options, []string) {
|
||||
opts := DefaultOptions()
|
||||
for len(args) > 0 {
|
||||
switch args[0] {
|
||||
case FlagNoAliases:
|
||||
opts.ShowAliases = false
|
||||
case FlagNoDescriptions:
|
||||
opts.ShowDescriptions = false
|
||||
default:
|
||||
return opts, args
|
||||
}
|
||||
args = args[1:]
|
||||
}
|
||||
return opts, args
|
||||
}
|
||||
367
internal/complete/complete_test.go
Normal file
367
internal/complete/complete_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package complete_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
)
|
||||
|
||||
func newTestFlagSet() *pflag.FlagSet {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
var b bool
|
||||
var s string
|
||||
fs.BoolVarP(&b, "list-all", "a", false, "Lists all tasks")
|
||||
fs.BoolVarP(&b, "list", "l", false, "Lists tasks with descriptions")
|
||||
fs.BoolVarP(&b, "verbose", "v", false, "Verbose mode")
|
||||
fs.StringVarP(&s, "taskfile", "t", "", "Taskfile path")
|
||||
fs.StringVarP(&s, "dir", "d", "", "Run dir")
|
||||
fs.StringVarP(&s, "output", "o", "", "Output style")
|
||||
fs.StringVar(&s, "sort", "", "Sort order")
|
||||
fs.StringVar(&s, "cacert", "", "CA cert path")
|
||||
return fs
|
||||
}
|
||||
|
||||
const testTaskfile = `version: '3'
|
||||
|
||||
vars:
|
||||
ALLOWED_ENVS:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
|
||||
tasks:
|
||||
deploy:
|
||||
desc: Deploy the application
|
||||
aliases: [dep, ship]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
- REGION
|
||||
cmds:
|
||||
- 'echo {{.ENV}} {{.REGION}}'
|
||||
|
||||
build:
|
||||
desc: Build it
|
||||
cmds:
|
||||
- 'echo build'
|
||||
|
||||
dynenum:
|
||||
desc: Dynamic enum
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum:
|
||||
ref: .ALLOWED_ENVS
|
||||
cmds:
|
||||
- 'echo {{.ENV}}'
|
||||
|
||||
docs:serve:
|
||||
desc: Serve docs locally
|
||||
cmds:
|
||||
- 'echo serving'
|
||||
`
|
||||
|
||||
func setupExecutor(t *testing.T) *task.Executor {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(testTaskfile), 0o644))
|
||||
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(io.Discard),
|
||||
task.WithStderr(io.Discard),
|
||||
task.WithVersionCheck(false),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
return e
|
||||
}
|
||||
|
||||
func TestComplete_TaskNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""}, complete.DefaultOptions())
|
||||
|
||||
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_AliasResolvesToTaskVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""}, complete.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
|
||||
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.DefaultOptions())
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_NoRequires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}, complete.DefaultOptions())
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""}, complete.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagValueInlineEquals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="}, complete.DefaultOptions())
|
||||
// Inline form returns full `--output=value` tokens so the shell can match
|
||||
// against the whole current word.
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
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.DefaultOptions())
|
||||
require.NotEmpty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NoAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
opts := complete.Options{ShowAliases: false, ShowDescriptions: 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{ShowAliases: true, ShowDescriptions: false}
|
||||
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.DefaultOptions(), 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.False(t, opts.ShowAliases)
|
||||
require.False(t, opts.ShowDescriptions)
|
||||
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.True(t, opts.ShowAliases)
|
||||
require.Equal(t, []string{"deploy", "--no-aliases"}, rest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNeedsTaskfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]struct {
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
"task name": {[]string{""}, true},
|
||||
"partial task name": {[]string{"bui"}, true},
|
||||
"task var": {[]string{"deploy", ""}, true},
|
||||
"value flag then name": {[]string{"--dir", "/tmp", ""}, true},
|
||||
"flag name": {[]string{"-"}, false},
|
||||
"long flag name": {[]string{"--li"}, false},
|
||||
"inline flag value": {[]string{"--output="}, false},
|
||||
"flag value": {[]string{"--output", ""}, false},
|
||||
"path flag value": {[]string{"--taskfile", ""}, false},
|
||||
"after dash": {[]string{"deploy", "--", ""}, false},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, complete.NeedsTaskfile(tt.args, newTestFlagSet()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite_Format(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
complete.Write(&buf, []complete.Suggestion{
|
||||
{Value: "deploy", Description: "Deploy the app"},
|
||||
{Value: "build"},
|
||||
}, complete.DirectiveNoSpace|complete.DirectiveNoFileComp)
|
||||
require.Equal(t, "deploy\tDeploy the app\nbuild\n:6\n", buf.String())
|
||||
}
|
||||
|
||||
func TestWrite_EmptyWithDirective(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
complete.Write(&buf, nil, complete.DirectiveFilterDirs)
|
||||
require.Equal(t, ":16\n", buf.String())
|
||||
}
|
||||
|
||||
func values(suggs []complete.Suggestion) []string {
|
||||
out := make([]string, 0, len(suggs))
|
||||
for _, s := range suggs {
|
||||
out = append(out, s.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func descriptions(suggs []complete.Suggestion) []string {
|
||||
out := make([]string, 0, len(suggs))
|
||||
for _, s := range suggs {
|
||||
out = append(out, s.Description)
|
||||
}
|
||||
return out
|
||||
}
|
||||
80
internal/complete/context.go
Normal file
80
internal/complete/context.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type completionContext struct {
|
||||
toComplete string
|
||||
prev string
|
||||
afterDash bool
|
||||
}
|
||||
|
||||
// parseContext infers the cursor position from args alone. It deliberately
|
||||
// avoids the task list so flag completion never pays to load it; the task word
|
||||
// is resolved separately by detectTaskName only once a task context is reached.
|
||||
func parseContext(args []string) completionContext {
|
||||
ctx := completionContext{}
|
||||
if len(args) == 0 {
|
||||
return ctx
|
||||
}
|
||||
|
||||
ctx.toComplete = args[len(args)-1]
|
||||
if len(args) >= 2 {
|
||||
ctx.prev = args[len(args)-2]
|
||||
}
|
||||
|
||||
for _, w := range args[:len(args)-1] {
|
||||
if w == "--" {
|
||||
ctx.afterDash = true
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// detectTaskName scans args for the task word the cursor is completing under
|
||||
// (e.g. "deploy" in `task deploy ENV=<tab>`). fs is needed to skip the word
|
||||
// following a value-taking flag, otherwise `task --dir deploy` would mistake
|
||||
// "deploy" (the directory) for a task name.
|
||||
func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) string {
|
||||
if len(args) <= 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(knownTasks))
|
||||
for _, t := range knownTasks {
|
||||
known[t] = struct{}{}
|
||||
}
|
||||
|
||||
taskName := ""
|
||||
skipNext := false
|
||||
for _, w := range args[:len(args)-1] {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if w == "--" {
|
||||
return taskName
|
||||
}
|
||||
if strings.HasPrefix(w, "-") {
|
||||
if !strings.Contains(w, "=") {
|
||||
if f := matchFlagName(fs, w); f != nil && flagTakesValue(f) {
|
||||
skipNext = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(w, "=") {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[w]; ok {
|
||||
taskName = w
|
||||
}
|
||||
}
|
||||
|
||||
return taskName
|
||||
}
|
||||
201
internal/complete/engine.go
Normal file
201
internal/complete/engine.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
// Complete is the single entry point used by `task __complete`. e may be nil
|
||||
// when the Taskfile failed to load; flag completion still works in that case.
|
||||
func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) ([]Suggestion, Directive) {
|
||||
ctx := parseContext(args)
|
||||
|
||||
if ctx.afterDash {
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
|
||||
if ctx.prev != "" {
|
||||
if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) {
|
||||
return completeFlagValue(flag.Name, "")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ctx.toComplete, "-") {
|
||||
if eqIdx := strings.Index(ctx.toComplete, "="); eqIdx != -1 {
|
||||
flagWord := ctx.toComplete[:eqIdx]
|
||||
if f := matchFlagName(fs, flagWord); f != nil && flagTakesValue(f) {
|
||||
// Return full `--flag=value` candidates: shells match/insert
|
||||
// against the whole current token, so bare values never match.
|
||||
return completeFlagValue(f.Name, flagWord+"=")
|
||||
}
|
||||
}
|
||||
return listFlags(fs), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
// Only a task context needs the task list, so it is loaded lazily here.
|
||||
if e != nil && e.Taskfile != nil {
|
||||
if taskName := detectTaskName(args, taskNames(e), fs); taskName != "" {
|
||||
return completeTaskVars(e, taskName)
|
||||
}
|
||||
}
|
||||
|
||||
return completeTaskNames(e, opts), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
// NeedsTaskfile reports whether completing args requires a loaded Taskfile.
|
||||
// Flag-name and flag-value completion (and words after `--`) do not, so the
|
||||
// caller can skip the potentially expensive Taskfile parse for those keystrokes.
|
||||
func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool {
|
||||
ctx := parseContext(args)
|
||||
if ctx.afterDash {
|
||||
return false
|
||||
}
|
||||
if ctx.prev != "" {
|
||||
if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !strings.HasPrefix(ctx.toComplete, "-")
|
||||
}
|
||||
|
||||
func taskNames(e *task.Executor) []string {
|
||||
if e == nil || e.Taskfile == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for t := range e.Taskfile.Tasks.Values(nil) {
|
||||
if t.Internal {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.TrimSuffix(t.Task, ":"))
|
||||
for _, alias := range t.Aliases {
|
||||
out = append(out, strings.TrimSuffix(alias, ":"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func completeTaskNames(e *task.Executor, opts Options) []Suggestion {
|
||||
if e == nil || e.Taskfile == nil {
|
||||
return nil
|
||||
}
|
||||
tasks, err := e.GetTaskList(task.FilterOutInternal)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
desc := func(t *ast.Task) string {
|
||||
if !opts.ShowDescriptions {
|
||||
return ""
|
||||
}
|
||||
return t.Desc
|
||||
}
|
||||
out := make([]Suggestion, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
out = append(out, Suggestion{
|
||||
Value: strings.TrimSuffix(t.Task, ":"),
|
||||
Description: desc(t),
|
||||
})
|
||||
if !opts.ShowAliases {
|
||||
continue
|
||||
}
|
||||
for _, alias := range t.Aliases {
|
||||
out = append(out, Suggestion{
|
||||
Value: strings.TrimSuffix(alias, ":"),
|
||||
Description: desc(t),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// completeFlagValue completes the value of a value-taking flag. prefix is empty
|
||||
// for the separate-argument form (`--output <TAB>`) and `<flag>=` for the inline
|
||||
// form (`--output=<TAB>`), so enum candidates come back as full `--output=value`
|
||||
// tokens the shell can match against the current word.
|
||||
func completeFlagValue(flagName, prefix string) ([]Suggestion, Directive) {
|
||||
// Absent keys yield the zero value (DirectiveDefault), which falls through
|
||||
// to the enum lookup below.
|
||||
switch flagDirective[flagName] {
|
||||
case DirectiveFilterFileExt:
|
||||
suggs := make([]Suggestion, 0, len(taskfileExtensions))
|
||||
for _, ext := range taskfileExtensions {
|
||||
suggs = append(suggs, Suggestion{Value: ext})
|
||||
}
|
||||
return suggs, DirectiveFilterFileExt
|
||||
case DirectiveFilterDirs:
|
||||
return nil, DirectiveFilterDirs
|
||||
}
|
||||
|
||||
if values, ok := flagEnums[flagName]; ok {
|
||||
out := make([]Suggestion, 0, len(values))
|
||||
for _, v := range values {
|
||||
out = append(out, Suggestion{Value: prefix + v})
|
||||
}
|
||||
return out, DirectiveNoFileComp
|
||||
}
|
||||
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
|
||||
func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directive) {
|
||||
compiled, err := e.FastCompiledTask(&task.Call{Task: taskName})
|
||||
if err != nil || compiled == nil || compiled.Requires == nil {
|
||||
return nil, DirectiveNoFileComp
|
||||
}
|
||||
|
||||
cache := &templater.Cache{Vars: compiled.Vars}
|
||||
out := make([]Suggestion, 0, 8)
|
||||
for _, v := range compiled.Requires.Vars {
|
||||
if v == nil || v.Name == "" {
|
||||
continue
|
||||
}
|
||||
values := enumValues(v.Enum, cache)
|
||||
if len(values) == 0 {
|
||||
out = append(out, Suggestion{Value: v.Name + "="})
|
||||
continue
|
||||
}
|
||||
for _, val := range values {
|
||||
out = append(out, Suggestion{Value: v.Name + "=" + val})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, DirectiveNoFileComp
|
||||
}
|
||||
// KeepOrder preserves the declaration order of the `requires` block instead
|
||||
// of letting the shell sort the variables alphabetically.
|
||||
return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder
|
||||
}
|
||||
|
||||
func enumValues(enum *ast.Enum, cache *templater.Cache) []string {
|
||||
if enum == nil {
|
||||
return nil
|
||||
}
|
||||
if len(enum.Value) > 0 {
|
||||
return enum.Value
|
||||
}
|
||||
if enum.Ref == "" {
|
||||
return nil
|
||||
}
|
||||
resolved := templater.ResolveRef(enum.Ref, cache)
|
||||
if cache.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
arr, ok := resolved.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
74
internal/complete/flags.go
Normal file
74
internal/complete/flags.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// flagEnums lists allowed values for enum-style flags. 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": {"bash", "zsh", "fish", "powershell"},
|
||||
}
|
||||
|
||||
// flagDirective maps value-taking flags to a file-completion directive.
|
||||
// DirectiveDefault entries (and any flag absent here) fall back to the shell's
|
||||
// default file completion.
|
||||
var flagDirective = map[string]Directive{
|
||||
"taskfile": DirectiveFilterFileExt,
|
||||
"dir": DirectiveFilterDirs,
|
||||
"remote-cache-dir": DirectiveFilterDirs,
|
||||
"cacert": DirectiveDefault,
|
||||
"cert": DirectiveDefault,
|
||||
"cert-key": DirectiveDefault,
|
||||
}
|
||||
|
||||
var taskfileExtensions = []string{"yml", "yaml"}
|
||||
|
||||
// flagTakesValue is false for boolean switches (NoOptDefVal == "true").
|
||||
func flagTakesValue(f *pflag.Flag) bool {
|
||||
return f.NoOptDefVal == ""
|
||||
}
|
||||
|
||||
// listFlags walks fs at call time so experiment-gated flags appear or
|
||||
// disappear based on 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Value < out[j].Value })
|
||||
return out
|
||||
}
|
||||
|
||||
func matchFlagName(fs *pflag.FlagSet, word string) *pflag.Flag {
|
||||
if fs == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(word, "--"):
|
||||
return fs.Lookup(strings.TrimPrefix(word, "--"))
|
||||
case strings.HasPrefix(word, "-") && len(word) == 2:
|
||||
return fs.ShorthandLookup(word[1:])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
internal/complete/output.go
Normal file
28
internal/complete/output.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Write emits the cobra-v2 completion protocol: one `value\tdescription` (or
|
||||
// bare `value`) per suggestion, followed by a trailing `:<directive>` line
|
||||
// that shell wrappers split off even when there are 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)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
r := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -13,13 +13,18 @@ type (
|
||||
}
|
||||
// Task describes a single task
|
||||
Task struct {
|
||||
Name string `json:"name"`
|
||||
Task string `json:"task"`
|
||||
Desc string `json:"desc"`
|
||||
Summary string `json:"summary"`
|
||||
Aliases []string `json:"aliases"`
|
||||
UpToDate *bool `json:"up_to_date,omitempty"`
|
||||
Location *Location `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Task string `json:"task"`
|
||||
Desc string `json:"desc"`
|
||||
Summary string `json:"summary"`
|
||||
Aliases []string `json:"aliases"`
|
||||
UpToDate *bool `json:"up_to_date,omitempty"`
|
||||
Location *Location `json:"location"`
|
||||
Requires []RequiredVar `json:"requires,omitempty"`
|
||||
}
|
||||
RequiredVar struct {
|
||||
Name string `json:"name"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
// Location describes a task's location in a taskfile
|
||||
Location struct {
|
||||
@@ -45,9 +50,28 @@ func NewTask(task *ast.Task) Task {
|
||||
Column: task.Location.Column,
|
||||
Taskfile: task.Location.Taskfile,
|
||||
},
|
||||
Requires: newRequiredVars(task.Requires),
|
||||
}
|
||||
}
|
||||
|
||||
func newRequiredVars(requires *ast.Requires) []RequiredVar {
|
||||
if requires == nil || len(requires.Vars) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]RequiredVar, 0, len(requires.Vars))
|
||||
for _, v := range requires.Vars {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
rv := RequiredVar{Name: v.Name}
|
||||
if v.Enum != nil && len(v.Enum.Value) > 0 {
|
||||
rv.Enum = append([]string{}, v.Enum.Value...)
|
||||
}
|
||||
out = append(out, rv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (parent *Namespace) AddNamespace(namespacePath []string, task Task) {
|
||||
if len(namespacePath) == 0 {
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/experiments"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
"github.com/go-task/task/v3/internal/env"
|
||||
"github.com/go-task/task/v3/internal/sort"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
@@ -177,6 +178,13 @@ func init() {
|
||||
pflag.StringVar(&Cert, "cert", getConfig(config, "REMOTE_CERT", func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
|
||||
pflag.StringVar(&CertKey, "cert-key", getConfig(config, "REMOTE_CERT_KEY", func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
|
||||
}
|
||||
// In completion mode the user's `--flag` words must reach the engine
|
||||
// untouched. The BoolVar/StringVar calls above already populated
|
||||
// pflag.CommandLine, which is all the engine needs.
|
||||
if complete.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
pflag.Parse()
|
||||
|
||||
// Auto-detect color based on environment when not explicitly configured
|
||||
|
||||
Reference in New Issue
Block a user