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

- Added 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. 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).

### 📦 Package API

- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support
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, nu, 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
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.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
65 changes: 50 additions & 15 deletions completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,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)
// Thin wrappers around the `task __complete` engine, served via
// `--new-completion` until 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/nu/task-completions.nu
var completionNuNext string

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

//go:embed completion/next/zsh/_task
var completionZshNext 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 completionScriptsNext = map[string]string{
"bash": completionBashNext,
"fish": completionFishNext,
"nu": completionNuNext,
"nushell": completionNuNext,
"powershell": completionPowershellNext,
"zsh": completionZshNext,
}

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

func CompletionNext(shell string) (string, error) {
return completionScript(completionScriptsNext, 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
}
94 changes: 94 additions & 0 deletions completion/next/bash/task.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# vim: set tabstop=2 shiftwidth=2 expandtab:
#
# Thin wrapper around `task __complete`: all suggestion logic lives in the Go engine.

TASK_CMD="${TASK_EXE:-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

# 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

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