Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f2fa581
feat(completion): unify shell wrappers behind `task __complete`
vmaerten Jun 29, 2026
6c9d3df
chore: changelog for completion engine
vmaerten Jun 29, 2026
5240f0f
fix(completion): harden the __complete engine and shell wrappers
vmaerten Jul 3, 2026
4c64a6a
test(completion): add cross-shell completion test suite and CI job
vmaerten Jul 3, 2026
b0d11f7
test(completion): clean up temp dirs via EXIT trap in run.sh
vmaerten Jul 3, 2026
91dab16
test(completion): test the __complete protocol in Go, thin shell smokes
vmaerten Jul 3, 2026
7f57a9f
chore(completion): trim redundant comments in tests and wrappers
vmaerten Jul 3, 2026
772600f
refactor(completion): serve the __complete engine from completion/next/
vmaerten Jul 19, 2026
d8a7b05
feat(completion): add --new-completion to opt into the new engine
vmaerten Jul 19, 2026
32cfaec
docs(completion): document the opt-in --new-completion engine
vmaerten Jul 19, 2026
c0b77b7
fix(completion): keep the directory prefix in PowerShell path completion
vmaerten Jul 19, 2026
4d71442
perf(completion): skip building the task list when completing the fir…
vmaerten Jul 19, 2026
467e9cc
fix(completion): complete shell values for --new-completion
vmaerten Jul 19, 2026
12e42d3
perf(completion): reuse a package-level output sanitizer
vmaerten Jul 19, 2026
bc6f4ff
fix(completion): support inline --flag=path completion across shells
vmaerten Jul 19, 2026
050914a
chore(editors): drop the unused requires field from --json task output
vmaerten Jul 19, 2026
8726b81
chore(completion): satisfy golangci-lint (slices.Contains, CommandCon…
vmaerten Jul 19, 2026
0b986fe
chore(completion): fix a wrong Options doc comment and drop a redunda…
vmaerten Jul 19, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
(#2184 by @jubr).
- Fixed `joinUrl` collapsing the `//` in a URL scheme (e.g. producing
`http:/localhost` instead of `http://localhost`) (#2915 by @vsaraikin).
- Added a new completion engine that unifies Bash, Fish, Zsh and PowerShell
behind a single `task __complete` command, so every shell offers the same
suggestions: task names, aliases, flags, flag values and per-task CLI
variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now
backed by the `--no-aliases` and `--no-descriptions` completion flags. It is
opt-in for now via `task --new-completion <shell>`, leaving `--completion`
unchanged, and will become the default in a future release (#2897 by
@vmaerten).

## v3.52.0 - 2026-07-02

Expand Down
9 changes: 9 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ tasks:
cmds:
- go test -bench=. -benchmem -tags=fsbench -run=^$ ./...

test:completion:
desc: Tests the shell completion engine and wrappers (bash, zsh, fish, 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
}
16 changes: 16 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 Expand Up @@ -126,6 +133,15 @@ func run() error {
return nil
}

if flags.NewCompletion != "" {
script, err := task.CompletionNext(flags.NewCompletion)
if err != nil {
return err
}
fmt.Println(script)
return nil
}

e := task.NewExecutor(
flags.WithFlags(),
task.WithVersionCheck(true),
Expand Down
41 changes: 37 additions & 4 deletions completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,25 @@ var completionPowershell string
//go:embed completion/zsh/_task
var completionZsh string

func Completion(completion string) (string, error) {
// Get the file extension for the selected shell
switch completion {
// The completion/next/* scripts are thin wrappers around the `task __complete`
// engine. They are served only via `--new-completion` for now (opt-in) and will
// replace the scripts above once the engine becomes the default.

//go:embed completion/next/bash/task.bash
var completionBashNext string

//go:embed completion/next/fish/task.fish
var completionFishNext string

//go:embed completion/next/ps/task.ps1
var completionPowershellNext string

//go:embed completion/next/zsh/_task
var completionZshNext string

// Completion returns the default (stable) completion script for the given shell.
func Completion(shell string) (string, error) {
switch shell {
case "bash":
return completionBash, nil
case "fish":
Expand All @@ -29,6 +45,23 @@ func Completion(completion string) (string, error) {
case "zsh":
return completionZsh, nil
default:
return "", fmt.Errorf("unknown shell: %s", completion)
return "", fmt.Errorf("unknown shell: %s", shell)
}
}

// CompletionNext returns the new `task __complete` engine wrapper for the given
// shell, exposed via `--new-completion` while the engine is opt-in.
func CompletionNext(shell string) (string, error) {
switch shell {
case "bash":
return completionBashNext, nil
case "fish":
return completionFishNext, nil
case "powershell":
return completionPowershellNext, nil
case "zsh":
return completionZshNext, nil
default:
return "", fmt.Errorf("unknown shell: %s", shell)
}
}
98 changes: 98 additions & 0 deletions completion/next/bash/task.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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.

TASK_CMD="${TASK_EXE:-task}"

# Wraps _filedir so an inline `--flag=` prefix is stripped before completion and
# re-applied to the results. `=` is kept inside the current word (see the
# `_init_completion -n =:` below), so the whole `--flag=value` token would
# otherwise be treated as the path and never match.
_task_filedir() {
local fpfx="" savecur="$cur"
if [[ "$cur" == -*=* ]]; then
fpfx="${cur%%=*}="
cur="${cur#*=}"
fi
_filedir ${1:+"$1"}
cur="$savecur"
if [[ -n "$fpfx" ]]; then
COMPREPLY=( ${COMPREPLY[@]+"${COMPREPLY[@]/#/$fpfx}"} )
fi
}

_task() {
local cur prev words cword

# Completion directives, mirroring internal/complete/complete.go.
local -ri NO_SPACE=2 NO_FILE_COMP=4 FILTER_FILE_EXT=8 FILTER_DIRS=16

# 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
_task_filedir
return
fi

local -a lines=()
local line
while IFS= read -r line; do
lines+=( "$line" )
done <<< "$output"

local last_idx=$(( ${#lines[@]} - 1 ))
local directive="${lines[$last_idx]#:}"
unset 'lines[$last_idx]'

if (( directive & FILTER_FILE_EXT )); then
local exts=""
# ${arr[@]+…} guards 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
_task_filedir "@($exts)"
return
fi

if (( directive & FILTER_DIRS )); then
_task_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

if (( directive & NO_SPACE )); then
compopt -o nospace 2>/dev/null
fi

__ltrim_colon_completions "$cur"

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

complete -F _task "$TASK_CMD"
Loading
Loading