Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions cmd/task/complete_cmd.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions cmd/task/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
117 changes: 69 additions & 48 deletions completion/bash/task.bash
Original file line number Diff line number Diff line change
@@ -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

# 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.
if (( directive & NO_SPACE )); then
compopt -o nospace 2>/dev/null
fi

__ltrim_colon_completions "$cur"

if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & NO_FILE_COMP )); then
_filedir
fi
}

complete -F _task "$TASK_CMD"
Loading