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
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)
}
}
81 changes: 81 additions & 0 deletions completion/next/bash/task.bash
Original file line number Diff line number Diff line change
@@ -0,0 +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.

TASK_CMD="${TASK_EXE:-task}"

_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
_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

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"
91 changes: 91 additions & 0 deletions completion/next/fish/task.fish
Original file line number Diff line number Diff line change
@@ -0,0 +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)

# 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

function __task_test_bit --argument-names value bit
test (math "floor($value / $bit) % 2") -eq 1
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

set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
set -l count (count $output)
if test $count -eq 0
return
end

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

# 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)"
Loading
Loading