Skip to content
Open
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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,49 @@ jobs:
- name: 🧪 Test
run: task test --output group --output-group-begin '::group::{{.TASK}}' --output-group-end '::endgroup::'

completion:
name: 🐚 Completion (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: ⬇️ Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.26.x

- name: ⬇️ Setup Task
uses: go-task/setup-task@v2

# 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

# Nushell ships in no runner image and is not packaged by apt, so it comes
# from its own release archives.
- name: ⬇️ Install Nushell
uses: hustcer/setup-nu@f3fd65374ffc4d60974c0dd2f7263c6c5c285f81 # v3.26
with:
version: "*"

- 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: task test:completion

lint:
name: 🔍 Lint (${{ matrix.go-version }})
strategy:
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

### 🚀 Features

- `task --completion <shell>` now serves a new completion engine that unifies
Bash, Fish, Zsh, Nushell 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. The previous hand-written scripts remain
available as `task --legacy-completion <shell>`; they are deprecated and will
be removed in a future release (#2897 by @vmaerten).

### 📦 Package API

- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support
Expand Down
10 changes: 10 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ tasks:
cmds:
- go test -bench=. -benchmem -tags=fsbench -run=^$ ./...

test:completion:
desc: Tests the shell completion engine and wrappers (bash, zsh, fish, nu, powershell)
sources:
- internal/complete/**/*.go
- cmd/task/**/*.go
- completion/**/*
- testdata/completion/*
cmds:
- bash testdata/completion/run.sh

goreleaser:test:
desc: Tests release process without publishing
cmds:
Expand Down
42 changes: 42 additions & 0 deletions cmd/task/complete_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"bufio"
"io"
"os"
"strings"

"github.com/spf13/pflag"

"github.com/go-task/task/v3"
"github.com/go-task/task/v3/internal/complete"
"github.com/go-task/task/v3/internal/flags"
)

func runComplete(args []string) error {
opts, args := complete.ParseOptions(args)

// Overridden after WithFlags: a keystroke stays silent and never touches the
// network, whatever the user typed.
e := task.NewExecutor(
flags.WithFlags(),
task.WithStdout(io.Discard),
task.WithStderr(io.Discard),
task.WithStdin(strings.NewReader("")),
task.WithVersionCheck(false),
task.WithDisableFuzzy(true),
task.WithOffline(true),
task.WithDownload(false),
)

// 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)

out := bufio.NewWriter(os.Stdout)
complete.Write(out, suggs, dirv)
return out.Flush()
}
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(complete.Words())
}

log := &logger.Logger{
Stdout: os.Stdout,
Stderr: os.Stderr,
Expand Down Expand Up @@ -126,6 +133,15 @@ func run() error {
return nil
}

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

e := task.NewExecutor(
flags.WithFlags(),
task.WithVersionCheck(true),
Expand Down
67 changes: 52 additions & 15 deletions completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
)

// Thin wrappers around the `task __complete` engine, served by `--completion`.

//go:embed completion/bash/task.bash
var completionBash string

Expand All @@ -20,20 +22,55 @@ 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 {
case "bash":
return completionBash, nil
case "fish":
return completionFish, nil
case "nu", "nushell":
return completionNu, nil
case "powershell":
return completionPowershell, nil
case "zsh":
return completionZsh, nil
default:
return "", fmt.Errorf("unknown shell: %s", completion)
// The self-contained scripts that predate the engine, kept behind
// `--legacy-completion` as an escape hatch for a couple of releases.

//go:embed completion/legacy/bash/task.bash
var completionBashLegacy string

//go:embed completion/legacy/fish/task.fish
var completionFishLegacy string

//go:embed completion/legacy/nu/task-completions.nu
var completionNuLegacy string

//go:embed completion/legacy/ps/task.ps1
var completionPowershellLegacy string

//go:embed completion/legacy/zsh/_task
var completionZshLegacy string

// The maps accept `nushell` as an alias of `nu`.
var completionScripts = map[string]string{
"bash": completionBash,
"fish": completionFish,
"nu": completionNu,
"nushell": completionNu,
"powershell": completionPowershell,
"zsh": completionZsh,
}

var completionScriptsLegacy = map[string]string{
"bash": completionBashLegacy,
"fish": completionFishLegacy,
"nu": completionNuLegacy,
"nushell": completionNuLegacy,
"powershell": completionPowershellLegacy,
"zsh": completionZshLegacy,
}

func Completion(shell string) (string, error) {
return completionScript(completionScripts, shell)
}

func LegacyCompletion(shell string) (string, error) {
return completionScript(completionScriptsLegacy, shell)
}

func completionScript(scripts map[string]string, shell string) (string, error) {
script, ok := scripts[shell]
if !ok {
return "", fmt.Errorf("unknown shell: %s", shell)
}
return script, nil
}
130 changes: 82 additions & 48 deletions completion/bash/task.bash
Original file line number Diff line number Diff line change
@@ -1,60 +1,94 @@
# vim: set tabstop=2 shiftwidth=2 expandtab:
#
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.

_GO_TASK_COMPLETION_LIST_OPTION='--list-all'
TASK_CMD="${TASK_EXE:-task}"

function _task()
{
# `=` stays inside the current word (see `_init_completion -n =:`), so an inline
# `--flag=` prefix must be stripped before _filedir and re-applied after.
_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
_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 KEEP_ORDER=32

# `=` and `:` out of the word breaks: `--output=`, `docs:serve` stay one token.
_init_completion -n =: || return

local -a args=( "${words[@]:1:cword}" )
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 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

# Not `compgen -W`: it splits the word list on IFS, mangling values with spaces.
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

# nosort needs bash 4.4; the 3.2 shipped by macOS ignores it and stays sorted.
if (( directive & KEEP_ORDER )); then
compopt -o nosort 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