Compare commits

..

2 Commits

Author SHA1 Message Date
Valentin Maerten
f9f2ecb8be chore: changelog for completion engine 2026-06-29 17:38:24 +02:00
Valentin Maerten
46201bcac9 feat(completion): unify shell wrappers behind task __complete 2026-06-29 17:38:24 +02:00
23 changed files with 947 additions and 596 deletions

View File

@@ -10,11 +10,11 @@
by @Legimity). by @Legimity).
- PowerShell completions now work with aliases of the `task` command, not just - PowerShell completions now work with aliases of the `task` command, not just
the `task` binary itself (#2852 by @kojiishi). the `task` binary itself (#2852 by @kojiishi).
- Fixed task and namespace aliases not being completed by the Zsh completion. A - Fixed task and namespace aliases not being completed by the Zsh completion.
`show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten). A `show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten).
- Fixed task names containing certain characters (e.g. `\`, `_`, `^`) leaking - Fixed task names containing certain characters (e.g. `\`, `_`, `^`) leaking
into checksum/timestamp filenames, breaking `sources:`/`generates:` up-to-date into checksum/timestamp filenames, breaking `sources:`/`generates:`
detection (#2886 by @s3onghyun). up-to-date detection (#2886 by @s3onghyun).
- Fixed `for: matrix:` loops using `ref:` rows producing wrong values when the - Fixed `for: matrix:` loops using `ref:` rows producing wrong values when the
same task was run concurrently (e.g. by parallel `deps`) with different vars same task was run concurrently (e.g. by parallel `deps`) with different vars
(#2890, #2894 by @amitmishra11). (#2890, #2894 by @amitmishra11).
@@ -23,15 +23,16 @@
- Added the `use_gitignore` setting (global or per-task) to skip files matched - Added the `use_gitignore` setting (global or per-task) to skip files matched
by your `.gitignore` when fingerprinting `sources`/`generates` and when by your `.gitignore` when fingerprinting `sources`/`generates` and when
watching (#2773 by @vmaerten). watching (#2773 by @vmaerten).
- Added support for configuring output flags (`--output`, - Added support for configuring output flags (`--output`, `--output-group-begin`,
`--output-group-begin`, `--output-group-end`, `--output-group-error-only`) via `--output-group-end`, `--output-group-error-only`) via the `TASK_OUTPUT*`
the `TASK_OUTPUT*` environment variables (#2873 by @liiight). environment variables (#2873 by @liiight).
- Added a `--temp-dir` flag (with `TASK_TEMP_DIR` env var and `temp-dir` taskrc - Added a `--temp-dir` flag (with `TASK_TEMP_DIR` env var and `temp-dir` taskrc
config) to customise the directory where Task stores temporary files such as config) to customise the directory where Task stores temporary files such as
checksums. Relative paths are resolved against the root Taskfile (#2891 by checksums. Relative paths are resolved against the root Taskfile (#2891 by
@kjasn). @kjasn).
- Defined environment variable behavior for remote taskfiles (#2267, #2847 by - Unified Bash, Fish, Zsh and PowerShell completions behind a single `task
@vmaerten). __complete` engine, so every shell offers the same suggestions: task names,
aliases, flags, flag values and per-task CLI variables (#2897 by @vmaerten).
## v3.51.1 - 2026-05-16 ## v3.51.1 - 2026-05-16

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

@@ -0,0 +1,47 @@
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 {
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))
}
}
// Best-effort: a missing or broken Taskfile must not break completion.
_ = e.Setup()
suggs, dirv := complete.Complete(e, pflag.CommandLine, args)
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
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/go-task/task/v3/args" "github.com/go-task/task/v3/args"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments" "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/filepathext"
"github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/flags"
"github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/logger"
@@ -58,6 +59,12 @@ func emitCIErrorAnnotation(err error) {
} }
func run() 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{ log := &logger.Logger{
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,

View File

@@ -15,7 +15,6 @@ import (
"github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast" "github.com/go-task/task/v3/taskfile/ast"
) )
@@ -207,19 +206,10 @@ func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, e
// Use filepath.ToSlash for all paths to ensure consistent forward slashes // Use filepath.ToSlash for all paths to ensure consistent forward slashes
// across platforms. This prevents issues with backslashes being interpreted // across platforms. This prevents issues with backslashes being interpreted
// as escape sequences when paths are used in shell commands on Windows. // as escape sequences when paths are used in shell commands on Windows.
var rootTaskfile, rootDir string
if taskfile.IsRemoteEntrypoint(c.Entrypoint) {
rootTaskfile = c.Entrypoint
rootDir = ""
} else {
rootTaskfile = filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint))
rootDir = filepath.ToSlash(c.Dir)
}
allVars := map[string]string{ allVars := map[string]string{
"TASK_EXE": filepath.ToSlash(os.Args[0]), "TASK_EXE": filepath.ToSlash(os.Args[0]),
"ROOT_TASKFILE": rootTaskfile, "ROOT_TASKFILE": filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint)),
"ROOT_DIR": rootDir, "ROOT_DIR": filepath.ToSlash(c.Dir),
"USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir), "USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir),
"TASK_VERSION": version.GetVersion(), "TASK_VERSION": version.GetVersion(),
"PATH_LIST_SEPARATOR": string(os.PathListSeparator), "PATH_LIST_SEPARATOR": string(os.PathListSeparator),
@@ -227,22 +217,9 @@ func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, e
} }
if t != nil { if t != nil {
allVars["TASK"] = t.Task allVars["TASK"] = t.Task
if taskfile.IsRemoteEntrypoint(t.Location.Taskfile) {
allVars["TASKFILE"] = t.Location.Taskfile
allVars["TASKFILE_DIR"] = ""
switch {
case t.Dir == "":
allVars["TASK_DIR"] = filepath.ToSlash(c.UserWorkingDir)
case filepath.IsAbs(t.Dir):
allVars["TASK_DIR"] = filepath.ToSlash(t.Dir)
default:
allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.UserWorkingDir, t.Dir))
}
} else {
allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.Dir, t.Dir)) allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.Dir, t.Dir))
allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile) allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile)
allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile)) allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile))
}
} else { } else {
allVars["TASK"] = "" allVars["TASK"] = ""
allVars["TASK_DIR"] = "" allVars["TASK_DIR"] = ""

View File

@@ -1,135 +0,0 @@
package task
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/taskfile/ast"
)
func TestGetSpecialVarsRemote(t *testing.T) {
t.Parallel()
uwd := t.TempDir()
uwdSlash := filepath.ToSlash(uwd)
localProj := filepath.Join(uwd, "proj")
localProjSlash := filepath.ToSlash(localProj)
localTaskfile := filepath.Join(localProj, "Taskfile.yml")
localTaskfileSlash := filepath.ToSlash(localTaskfile)
absTaskDir := filepath.Join(uwd, "opt", "work")
absTaskDirSlash := filepath.ToSlash(absTaskDir)
tests := []struct {
name string
entrypoint string
compilerDir string
taskDir string
taskfileLocation string
wantRootTaskfile string
wantRootDir string
wantTaskfile string
wantTaskfileDir string
wantTaskDir string
}{
{
name: "local entrypoint, local task",
entrypoint: localTaskfile,
compilerDir: localProj,
taskDir: "",
taskfileLocation: localTaskfile,
wantRootTaskfile: localTaskfileSlash,
wantRootDir: localProjSlash,
wantTaskfile: localTaskfileSlash,
wantTaskfileDir: localProjSlash,
wantTaskDir: localProjSlash,
},
{
name: "https entrypoint, empty task.dir",
entrypoint: "https://taskfile.dev/Taskfile.yml",
compilerDir: "",
taskDir: "",
taskfileLocation: "https://taskfile.dev/Taskfile.yml",
wantRootTaskfile: "https://taskfile.dev/Taskfile.yml",
wantRootDir: "",
wantTaskfile: "https://taskfile.dev/Taskfile.yml",
wantTaskfileDir: "",
wantTaskDir: uwdSlash,
},
{
name: "https entrypoint, relative task.dir",
entrypoint: "https://taskfile.dev/Taskfile.yml",
compilerDir: "",
taskDir: "subdir",
taskfileLocation: "https://taskfile.dev/Taskfile.yml",
wantRootTaskfile: "https://taskfile.dev/Taskfile.yml",
wantRootDir: "",
wantTaskfile: "https://taskfile.dev/Taskfile.yml",
wantTaskfileDir: "",
wantTaskDir: filepath.ToSlash(filepathext.SmartJoin(uwd, "subdir")),
},
{
name: "https entrypoint, absolute task.dir",
entrypoint: "https://taskfile.dev/Taskfile.yml",
compilerDir: "",
taskDir: absTaskDir,
taskfileLocation: "https://taskfile.dev/Taskfile.yml",
wantRootTaskfile: "https://taskfile.dev/Taskfile.yml",
wantRootDir: "",
wantTaskfile: "https://taskfile.dev/Taskfile.yml",
wantTaskfileDir: "",
wantTaskDir: absTaskDirSlash,
},
{
name: "git entrypoint",
entrypoint: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
compilerDir: "",
taskDir: "",
taskfileLocation: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
wantRootTaskfile: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
wantRootDir: "",
wantTaskfile: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
wantTaskfileDir: "",
wantTaskDir: uwdSlash,
},
{
name: "local root, remote included task",
entrypoint: localTaskfile,
compilerDir: localProj,
taskDir: "",
taskfileLocation: "https://taskfile.dev/included.yml",
wantRootTaskfile: localTaskfileSlash,
wantRootDir: localProjSlash,
wantTaskfile: "https://taskfile.dev/included.yml",
wantTaskfileDir: "",
wantTaskDir: uwdSlash,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := &Compiler{
Dir: tt.compilerDir,
Entrypoint: tt.entrypoint,
UserWorkingDir: uwd,
}
task := &ast.Task{
Task: "mytask",
Dir: tt.taskDir,
Location: &ast.Location{Taskfile: tt.taskfileLocation},
}
vars, err := c.getSpecialVars(task, nil)
assert.NoError(t, err)
assert.Equal(t, tt.wantRootTaskfile, vars["ROOT_TASKFILE"], "ROOT_TASKFILE")
assert.Equal(t, tt.wantRootDir, vars["ROOT_DIR"], "ROOT_DIR")
assert.Equal(t, tt.wantTaskfile, vars["TASKFILE"], "TASKFILE")
assert.Equal(t, tt.wantTaskfileDir, vars["TASKFILE_DIR"], "TASKFILE_DIR")
assert.Equal(t, tt.wantTaskDir, vars["TASK_DIR"], "TASK_DIR")
})
}
}

View File

@@ -1,60 +1,69 @@
# vim: set tabstop=2 shiftwidth=2 expandtab: # 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}" TASK_CMD="${TASK_EXE:-task}"
function _task() _task() {
{
local cur prev words cword local cur prev words cword
_init_completion -n : || return _init_completion -n : || return
# Check for `--` within command-line and quit or strip suffix. local -a args=()
local i if (( cword > 0 )); then
for i in "${!words[@]}"; do args=( "${words[@]:1:cword}" )
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
fi 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 & 8 )); then
local exts=""
for line in "${lines[@]}"; do
exts+="${exts:+|}$line"
done
_filedir "@($exts)"
return
fi
if (( directive & 16 )); then
_filedir -d
return
fi
local -a values=()
for line in "${lines[@]}"; do
values+=( "${line%%$'\t'*}" )
done done
# Handle special arguments of options. COMPREPLY=( $( compgen -W "${values[*]}" -- "$cur" ) )
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
# Handle normal options. if (( directive & 2 )); then
case "$cur" in compopt -o nospace 2>/dev/null
-*) fi
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" __ltrim_colon_completions "$cur"
if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then
_filedir
fi
} }
complete -F _task "$TASK_CMD" complete -F _task "$TASK_CMD"

View File

@@ -1,120 +1,46 @@
# 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) 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) function __task_complete --inherit-variable GO_TASK_PROGNAME
set -g __task_experiments_cache "" set -l tokens (commandline -opc)
set -g __task_experiments_cache_time 0 set -l current (commandline -ct)
set -l args
if test (count $tokens) -gt 1
set args $tokens[2..-1]
end
set args $args $current
# Helper function to get experiments with 1-second cache set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
function __task_get_experiments --inherit-variable GO_TASK_PROGNAME set -l count (count $output)
set -l now (date +%s) if test $count -eq 0
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 return
end end
# Refresh cache set -l last $output[$count]
set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null) if not string match -q ':*' -- $last
set -g __task_experiments_cache_time $now # Protocol violation: emit raw lines as a fallback.
printf '%s\n' $__task_experiments_cache for line in $output
end echo $line
# 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 end
if test "_$arg" = "_--global" -o "_$arg" = "_-g"
set global_task true
break
end
end
# 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
return return
end end
# Grab names and descriptions (if any) of the tasks set -l directive (string replace -r '^:' '' -- $last)
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) # FilterFileExt / FilterDirs are handled by fish's native file completion
if test $output # via the separate `complete` registrations below.
echo $output if test (math "$directive & 8") -ne 0; or test (math "$directive & 16") -ne 0
return
end
if test $count -gt 1
for line in $output[1..(math $count - 1)]
echo $line
end
end end
end end
complete -c $GO_TASK_PROGNAME \ complete -c $GO_TASK_PROGNAME --no-files -a "(__task_complete)"
-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.' \ complete -c $GO_TASK_PROGNAME -s t -l taskfile -r -k -a "(__fish_complete_suffix .yml .yaml)"
-xa "(__task_get_tasks)" \ complete -c $GO_TASK_PROGNAME -s d -l dir -xa "(__fish_complete_directories)"
-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'

View File

@@ -1,94 +1,61 @@
using namespace System.Management.Automation 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 $cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique
Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock { Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) param($wordToComplete, $commandAst, $cursorPosition)
if ($commandName.StartsWith('-')) { $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' }
$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')
)
# Experimental flags (dynamically added based on enabled experiments) # Words after the program name, truncated to the cursor.
$experiments = & task --experiments 2>$null | Out-String $argsToPass = @()
$elements = $commandAst.CommandElements
if ($experiments -match '\* GENTLE_FORCE:.*on') { if ($elements.Count -gt 1) {
$completions += [CompletionResult]::new('--force-all', '--force-all', [CompletionResultType]::ParameterName, 'force all dependencies') for ($i = 1; $i -lt $elements.Count; $i++) {
$el = $elements[$i]
if ($el.Extent.StartOffset -ge $cursorPosition) { break }
$argsToPass += $el.ToString()
}
}
# The trailing word (possibly empty) must reach the engine so it knows
# the cursor sits on a fresh word.
if ($argsToPass.Count -gt 0 -and $argsToPass[-1] -eq $wordToComplete) {
$argsToPass[-1] = $wordToComplete
} else {
$argsToPass += $wordToComplete
} }
if ($experiments -match '\* REMOTE_TASKFILES:.*on') { $output = & $TaskExe __complete @argsToPass 2>$null
# Options if (-not $output) { return }
$completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles')
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout') $lines = @($output)
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry') if ($lines.Count -eq 0) { return }
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory') $last = $lines[-1]
$completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate') if (-not $last.StartsWith(':')) { return }
$completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate')
$completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key') $directive = [int]($last.Substring(1))
# Operations $data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() }
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') # FilterFileExt
if ($directive -band 8) {
$patterns = $data | ForEach-Object { "*.$_" }
return Get-ChildItem -Path . -Include $patterns -File -ErrorAction SilentlyContinue |
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) }
} }
return $completions.Where{ $_.CompletionText.StartsWith($commandName) } # FilterDirs
if ($directive -band 16) {
return Get-ChildItem -Path . -Directory -ErrorAction SilentlyContinue |
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) }
} }
return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " } return $data | ForEach-Object {
$parts = $_ -split "`t", 2
$value = $parts[0]
$desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value }
[CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc)
}
} }

View File

@@ -1,171 +1,65 @@
#compdef task #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}" 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() { _task() {
local -a standard_args operation_args local -a args lines completions opts
local output directive line
standard_args=( # (@) preserves a trailing empty string, which the engine relies on to
'(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' # know the cursor is on a fresh word.
'(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' args=("${(@)words[2,CURRENT]}")
'(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]' (( ${#args} == 0 )) && args=("")
'(-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]'
)
# Experimental flags (dynamically added based on enabled experiments) output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
# Options (modify behavior) if [[ -z "$output" ]]; then
if __task_is_experiment_enabled "GENTLE_FORCE"; then _files
standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]') return
fi fi
if __task_is_experiment_enabled "REMOTE_TASKFILES"; then lines=("${(f)output}")
standard_args+=( directive="${lines[-1]#:}"
'(--offline --download)--offline[use only local or cached Taskfiles]' lines=("${(@)lines[1,-2]}")
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
'(--expiry)--expiry[cache expiry duration]:duration: ' if (( directive & 8 )); then
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' local -a globs
'(--cacert)--cacert[custom CA certificate for TLS]:file:_files' for line in "${lines[@]}"; do
'(--cert)--cert[client certificate for mTLS]:file:_files' globs+=("*.${line}")
'(--cert-key)--cert-key[client certificate private key]:file:_files' done
) _files -g "(${(j:|:)globs})"
return
fi fi
operation_args=( if (( directive & 16 )); then
# Task names completion (can be specified multiple times) _path_files -/
'(operation)*: :__task_list' return
# 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]'
)
# 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 fi
_arguments -S $standard_args $operation_args # `:` 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 & 2 )) && opts+=(-S '')
(( directive & 32 )) && opts+=(-V)
if (( ${#completions} > 0 )); then
_describe -t tasks 'task' completions "${opts[@]}"
fi
(( directive & 4 )) && return
_files
} }
# don't run the completion function when being source-ed or eval-ed compdef _task "$TASK_CMD"
if [ "$funcstack[1]" = "_task" ]; then
_task "$@"
fi

View File

@@ -0,0 +1,30 @@
// 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"
const CommandName = "__complete"
func IsActive() bool {
return len(os.Args) >= 2 && os.Args[1] == CommandName
}
// Directive mirrors cobra's ShellCompDirective bitfield.
type Directive int
const (
DirectiveDefault Directive = 0
DirectiveError Directive = 1 << 0
DirectiveNoSpace Directive = 1 << 1
DirectiveNoFileComp Directive = 1 << 2
DirectiveFilterFileExt Directive = 1 << 3
DirectiveFilterDirs Directive = 1 << 4
DirectiveKeepOrder Directive = 1 << 5
)
type Suggestion struct {
Value string
Description string
}

View File

@@ -0,0 +1,279 @@
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{""})
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", ""})
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
}
func TestComplete_StaticEnum(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""})
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
}
func TestComplete_EnumRef(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""})
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", ""})
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", ""})
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", ""})
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="})
require.Equal(t, []string{"interleaved", "group", "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", "--", ""})
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{"-"})
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", ""})
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", ""})
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", ""})
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", ""})
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", ""})
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{"-"})
require.NotEmpty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
func TestWrite_Format(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
complete.Write(&buf, []complete.Suggestion{
{Value: "deploy", Description: "Deploy the app"},
{Value: "build"},
}, complete.DirectiveNoSpace|complete.DirectiveNoFileComp)
require.Equal(t, "deploy\tDeploy the app\nbuild\n:6\n", buf.String())
}
func TestWrite_EmptyWithDirective(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
complete.Write(&buf, nil, complete.DirectiveFilterDirs)
require.Equal(t, ":16\n", buf.String())
}
func values(suggs []complete.Suggestion) []string {
out := make([]string, 0, len(suggs))
for _, s := range suggs {
out = append(out, s.Value)
}
return out
}
func descriptions(suggs []complete.Suggestion) []string {
out := make([]string, 0, len(suggs))
for _, s := range suggs {
out = append(out, s.Description)
}
return out
}

View File

@@ -0,0 +1,65 @@
package complete
import (
"strings"
"github.com/spf13/pflag"
)
type completionContext struct {
toComplete string
prev string
taskName string
afterDash bool
}
// parseContext infers the cursor position from args. 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 parseContext(args []string, knownTasks []string, fs *pflag.FlagSet) 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]
}
known := make(map[string]struct{}, len(knownTasks))
for _, t := range knownTasks {
known[t] = struct{}{}
}
skipNext := false
for _, w := range args[:len(args)-1] {
if skipNext {
skipNext = false
continue
}
if w == "--" {
ctx.afterDash = true
continue
}
if ctx.afterDash {
continue
}
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 {
ctx.taskName = w
}
}
return ctx
}

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

@@ -0,0 +1,171 @@
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) ([]Suggestion, Directive) {
knownTasks := taskNames(e)
ctx := parseContext(args, knownTasks, fs)
if ctx.afterDash {
return nil, DirectiveDefault
}
if ctx.prev != "" {
if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) {
return completeFlagValue(flag.Name, ctx.toComplete)
}
}
if strings.HasPrefix(ctx.toComplete, "-") {
if eqIdx := strings.Index(ctx.toComplete, "="); eqIdx != -1 {
flagWord := ctx.toComplete[:eqIdx]
partial := ctx.toComplete[eqIdx+1:]
if f := matchFlagName(fs, flagWord); f != nil && flagTakesValue(f) {
return completeFlagValue(f.Name, partial)
}
}
return listFlags(fs), DirectiveNoFileComp
}
if ctx.taskName != "" && e != nil && e.Taskfile != nil {
return completeTaskVars(e, ctx.taskName, ctx.toComplete)
}
return completeTaskNames(e), DirectiveNoFileComp
}
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) []Suggestion {
if e == nil || e.Taskfile == nil {
return nil
}
tasks, err := e.GetTaskList(task.FilterOutInternal)
if err != nil {
return nil
}
out := make([]Suggestion, 0, len(tasks))
for _, t := range tasks {
out = append(out, Suggestion{
Value: strings.TrimSuffix(t.Task, ":"),
Description: t.Desc,
})
for _, alias := range t.Aliases {
out = append(out, Suggestion{
Value: strings.TrimSuffix(alias, ":"),
Description: t.Desc,
})
}
}
return out
}
func completeFlagValue(flagName, toComplete string) ([]Suggestion, Directive) {
if dir, ok := flagDirective[flagName]; ok {
switch dir {
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
default:
return nil, DirectiveDefault
}
}
if values, ok := flagEnums[flagName]; ok {
out := make([]Suggestion, 0, len(values))
for _, v := range values {
out = append(out, Suggestion{Value: v})
}
_ = toComplete
return out, DirectiveNoFileComp
}
return nil, DirectiveDefault
}
func completeTaskVars(e *task.Executor, taskName, toComplete 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})
}
}
_ = toComplete
if len(out) == 0 {
return nil, DirectiveNoFileComp
}
return out, DirectiveNoSpace | DirectiveNoFileComp
}
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
}

View File

@@ -0,0 +1,71 @@
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"},
}
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
}

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

View File

@@ -20,6 +20,11 @@ type (
Aliases []string `json:"aliases"` Aliases []string `json:"aliases"`
UpToDate *bool `json:"up_to_date,omitempty"` UpToDate *bool `json:"up_to_date,omitempty"`
Location *Location `json:"location"` 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 describes a task's location in a taskfile
Location struct { Location struct {
@@ -45,9 +50,28 @@ func NewTask(task *ast.Task) Task {
Column: task.Location.Column, Column: task.Location.Column,
Taskfile: task.Location.Taskfile, 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) { func (parent *Namespace) AddNamespace(namespacePath []string, task Task) {
if len(namespacePath) == 0 { if len(namespacePath) == 0 {
return return

View File

@@ -14,6 +14,7 @@ import (
"github.com/go-task/task/v3" "github.com/go-task/task/v3"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments" "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/env"
"github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile/ast" "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(&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.") 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() pflag.Parse()
// Auto-detect color based on environment when not explicitly configured // Auto-detect color based on environment when not explicitly configured

View File

@@ -73,7 +73,7 @@ func NewNode(
return node, err return node, err
} }
func IsRemoteEntrypoint(entrypoint string) bool { func isRemoteEntrypoint(entrypoint string) bool {
scheme, _ := getScheme(entrypoint) scheme, _ := getScheme(entrypoint)
switch scheme { switch scheme {
case "git", "http", "https": case "git", "http", "https":

View File

@@ -60,7 +60,7 @@ func (node *FileNode) Read() ([]byte, error) {
func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) { func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path // If the file is remote, we don't need to resolve the path
if IsRemoteEntrypoint(entrypoint) { if isRemoteEntrypoint(entrypoint) {
return entrypoint, nil return entrypoint, nil
} }

View File

@@ -188,7 +188,7 @@ func (node *GitNode) ReadContext(ctx context.Context) ([]byte, error) {
func (node *GitNode) ResolveEntrypoint(entrypoint string) (string, error) { func (node *GitNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path // If the file is remote, we don't need to resolve the path
if IsRemoteEntrypoint(entrypoint) { if isRemoteEntrypoint(entrypoint) {
return entrypoint, nil return entrypoint, nil
} }

View File

@@ -42,7 +42,7 @@ func (node *StdinNode) Read() ([]byte, error) {
func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) { func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path // If the file is remote, we don't need to resolve the path
if IsRemoteEntrypoint(entrypoint) { if isRemoteEntrypoint(entrypoint) {
return entrypoint, nil return entrypoint, nil
} }

View File

@@ -190,21 +190,6 @@ includes:
my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml
``` ```
## Special Variables
The file-path [special variables](../reference/templating.md#file-paths) behave
differently when a Taskfile is loaded from a remote source, because there is no
local file or directory that corresponds 1:1 to the Taskfile:
| Variable | Value when loaded remotely |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `TASKFILE` / `ROOT_TASKFILE` | The original URL, unchanged |
| `TASKFILE_DIR` / `ROOT_DIR` | Empty string — a directory variable cannot point to a URL |
| `TASK_DIR` | Resolved against `USER_WORKING_DIR` (relative `dir:` → joined with `USER_WORKING_DIR`, empty `dir:` → `USER_WORKING_DIR`, absolute `dir:` → kept as-is) |
If a remote Taskfile includes a local Taskfile (or vice-versa), each variable
reflects the source of the Taskfile it refers to.
## Security ## Security
### Automatic checksums ### Automatic checksums

View File

@@ -565,9 +565,6 @@ tasks:
- echo "{{.MULTILINE | catLines}}" # Replace newlines with spaces - echo "{{.MULTILINE | catLines}}" # Replace newlines with spaces
``` ```
In pipeline form, `join` receives the list from the left-hand side. The
equivalent non-pipeline form is `{{join " " .WORDS}}`.
#### Shell Argument Parsing #### Shell Argument Parsing
```yaml ```yaml