Skip to content

feat(completion): unify shell completions behind task __complete - #2897

Open
vmaerten wants to merge 45 commits into
mainfrom
feat/completion-engine
Open

feat(completion): unify shell completions behind task __complete#2897
vmaerten wants to merge 45 commits into
mainfrom
feat/completion-engine

Conversation

@vmaerten

@vmaerten vmaerten commented Jun 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a unified shell completion engine: a single task __complete command, backed by a shared Go package (internal/complete), that drives Bash, Fish, Zsh and PowerShell. The shell scripts become thin wrappers that forward the current words to task __complete and render the suggestions, so every shell gets the same, richer behaviour and there is no more per-shell drift.

The wire protocol mirrors cobra v2 (value\tdescription lines followed by a :<directive> bitfield) behavior.

Why

It's painful to maintain 4 (or more) different completion scripts, and it's also difficult to test them. With this approach, all new flags will be included in every shell by default.

Opt-in rollout

Rather than switching every user at once (millions of users, 4 shells, multiple OSes), the new engine ships opt-in:

  • task --completion <shell> is unchanged and keeps returning the current, stable scripts (still what goreleaser/Homebrew package).
  • task --new-completion <shell> returns the new __complete-powered wrappers.
  • The new wrappers live under completion/next/; the stable ones stay at their canonical completion/<shell>/ paths.

--new-completion will become the default of --completion in a future release, at which point the next/ wrappers move to the canonical paths and the flag is removed.

What the engine completes

  • Task names (with descriptions) and aliases
  • Flags (--xxx) and their shorthands
  • Flag values: file extensions for --taskfile, directories for --dir, enums for --output / --sort / --completion / --new-completion
  • Per-task required variables, including their enum values (task build VAR=)
  • Both separate (--taskfile foo) and inline (--taskfile=foo) flag forms, including nested paths (--dir=sub/…)

The engine skips loading the Taskfile entirely when only flags/flag-values are being completed (NeedsTaskfile), and remote-Taskfile completion never blocks: the approval prompt is a no-op without a TTY, so completion degrades to flag-only suggestions until the remote is approved by running task normally.

Testing

Following the cobra approach, the protocol is tested in Go and the generated scripts are only smoke-tested for how they route each directive:

  • internal/complete/complete_test.go: white-box tests of the engine.
  • completion/protocol_test.go: black-box test of the real binary's __complete output (candidates + directive).
  • completion/tests/: thin per-shell smokes (bash/zsh/fish/powershell) that assert directive routing (no-files, dir-only, extension filter, inline --flag=, …), runnable via task test:completion.
  • A completion CI job runs the smokes on Ubuntu and macOS (with fish and PowerShell installed, strict mode).

Disclaimer: I used AI to write the completion scripts & the testing protocol (in bash).

@vmaerten
vmaerten force-pushed the feat/completion-engine branch 3 times, most recently from b13cc89 to f9f2ecb Compare June 29, 2026 15:42
@vmaerten

Copy link
Copy Markdown
Member Author

@arturict, please don't comment on draft PRs. Also, your comments appear to be AI-generated. We're still open to meaningful, human-written comments. Finally, please avoid giving instructions to maintainers as if you were one of them.

@arturict

Copy link
Copy Markdown

Understood, and sorry about that. I should not have commented on a draft or phrased the comment as an instruction. I'll step back.

@vmaerten
vmaerten force-pushed the feat/completion-engine branch from 1521c9e to 32cfaec Compare July 19, 2026 15:47
@vmaerten
vmaerten marked this pull request as ready for review July 19, 2026 18:20
@vmaerten
vmaerten force-pushed the feat/completion-engine branch from 0b986fe to 3a37bf5 Compare August 3, 2026 20:39
@vmaerten
vmaerten force-pushed the feat/completion-engine branch 3 times, most recently from b7f3248 to 9e8b673 Compare August 13, 2026 13:55
Engine:
- Emit DirectiveKeepOrder for task variables so the `requires` declaration
  order is preserved instead of being sorted by the shell.
- Return full `--flag=value` candidates for the inline `--output=` form so all
  shells match against the whole current token.
- Add `--no-aliases` / `--no-descriptions` completion flags (via complete.Options)
  parsed from the __complete invocation; the zsh wrapper maps its show-aliases
  and verbose zstyles onto them.
- Skip Taskfile setup when completing flags (NeedsTaskfile) and load the task
  list lazily; drop unused parameters; document exported identifiers.

Shell wrappers:
- zsh: reindent to tabs (.editorconfig), bridge zstyles to engine flags.
- fish: reindent to 2 spaces, handle every file-completion directive in the
  wrapper (--no-files disables the native fallback), drop the duplicated
  yml/yaml extension list now that it lives only in the engine.
- bash: prefix-filter by hand to preserve values containing spaces, exclude `=`
  from word breaks so `--output=` reaches the engine as one token.
- powershell: filter candidates by the current word, fall back to file
  completion for DirectiveDefault.

NoSpace is not representable in fish/PowerShell completion APIs; documented in
the wrappers.
Add completion/tests/ harnesses that exercise the engine protocol and every
shell wrapper without a TTY: the engine via `task __complete`, bash/zsh by
stubbing the completion-system helpers, fish via `complete -C`, and PowerShell
via the completion API. A `test:completion` task and a matching CI job (with a
strict mode that fails when an expected shell is missing) run them all.

Writing the suite surfaced and fixed real wrapper bugs:
- fish: `math` has no bitwise `&`, so every directive check errored; test bits
  with integer division + modulo instead. Also filter FilterFileExt results
  ourselves, as __fish_complete_suffix only prioritizes the extension.
- powershell: `Get-ChildItem -Include` is ignored without -Recurse, so file
  extension filtering returned nothing; filter with Where-Object instead.
- bash: guard empty-array expansion so completion with zero candidates does not
  trip `set -u` on bash 3.2.

Also name the completion directive bits (NO_SPACE, FILTER_FILE_EXT, …) in every
wrapper instead of using raw numbers.
Follow cobra's testing philosophy: the completion protocol (candidates +
directive) is business logic and belongs in Go, while the shell wrappers only
need to be checked for how they interpret each directive.

- Add completion/protocol_test.go: a table-driven black-box test that runs the
  real `task __complete` binary and asserts the offered values and the emitted
  directive. Unlike the in-process engine tests, it exercises the actual
  entrypoint dispatch, runComplete wiring and — crucially — the real flag set,
  so it catches drift between the completion enum/directive maps and the flag
  definitions. Runs on every OS, including Windows where the shell smokes don't.
- Delete completion/tests/engine.sh (the protocol is now covered in Go) and drop
  it from run.sh.
- Reduce the bash/zsh/fish/powershell smokes to directive routing only (files vs
  dirs vs no-files vs no-space), removing the task/alias/var assertions that the
  Go tests already own.
Move the thin __complete wrappers under completion/next/ and restore the
stable, hand-written scripts at their canonical paths so the default output
of `task --completion <shell>` (and the goreleaser/brew packaged scripts)
is unchanged. Repoint the cross-shell test harness at completion/next/.

This is the groundwork for offering the new engine as opt-in before it
becomes the default.
Embed the completion/next/ wrappers and expose them through a new
`task --new-completion <shell>` flag, alongside the unchanged
`task --completion <shell>`. This lets users try the unified __complete
engine (same suggestions across bash/zsh/fish/powershell: task names,
aliases, flags, flag values and requires vars) while it is opt-in; it will
become the default of --completion in a future release.
Add a subsection to the installation guide explaining how to try the new
completion engine by swapping --completion for --new-completion, and noting
it will become the default in a future release.
PowerShell replaces the whole token being completed with the CompletionResult
text, so returning `$_.Name` (the basename) for file/dir suggestions dropped
any directory the user had already typed (e.g. `task --dir sub/<TAB>` turned
`sub/` into `foo`). Prepend the typed path prefix to each candidate and make
the default file fallback honor the current word instead of always listing the
working directory. Covered by a nested-path smoke assertion.
…st word

The task-var detection evaluated taskNames(e) — a full walk of every task and
alias — unconditionally, even though detectTaskName bails out when there is no
prior word. Guarding on len(args) > 1 avoids that walk (and a second one via
GetTaskList) for the common `task <TAB>` case, which matters on large monorepo
Taskfiles where completion latency is user-visible.
The --new-completion flag takes a shell name (bash/zsh/fish/powershell) just
like --completion, but it was missing from flagEnums, so `task --new-completion
<TAB>` offered nothing. Add the matching entry.
sanitize built a new strings.Replacer on every call (twice per suggestion).
Hoist it to a package var.
Inline path flags (`--dir=foo`, `--taskfile=foo`, `--cacert=foo`) were broken:
the shells ran file/dir completion against the whole `--flag=value` token, so
nothing matched. Each wrapper now strips the `--flag=` prefix before completing
and re-applies it to the results (zsh via `compset -P '*='`, the others by hand).

Also fixes two smaller wrapper issues in the process: PowerShell prefix matching
is now case-insensitive, and the fish wrapper erases inherited completion rules
before registering (fish accumulates them, unlike bash/zsh/PowerShell). Adds
inline smoke assertions for bash, fish and PowerShell.
The requires field added to the --list --json output is unused by the new
completion engine (which reads requires via FastCompiledTask), untested, and did
not handle enum.ref. Revert it to keep the JSON API change out of this branch.
…text)

Use slices.Contains for the after-dash scan (modernize), and exec.CommandContext
in the protocol test (noctx), with a targeted gosec exception for launching the
test-built binary with test-controlled args.
…nt label

The Options doc claimed the zero value shows everything, but it shows neither
aliases nor descriptions (DefaultOptions enables both). Also remove the
redundant `# FilterDirs` label in the PowerShell wrapper.
Nushell exposes a single global external completer rather than a per-command
registration, so the wrapper chains to whatever completer is already installed
and delegates command lines that do not start with Task.

Two directives are free here and one is unavailable: Nushell never appends a
space after an external completion (NoSpace) and never re-sorts the results
(KeepOrder), while `{completions, options}` records are rejected for an external
completer, so prefix filtering is done in the wrapper as PowerShell does.

`flagEnums["completion"]` gains `nu` too, which assumes the standalone Nushell
script lands on main first.
Naming the fields after the --no-aliases / --no-descriptions flags removes DefaultOptions and the trap of a zero value that means the opposite of the default.
The four call sites also used TrimSuffix, which drops a single colon where --list uses TrimRight.
GetTaskList compiles every task (vars, labels, prompts) on every keystroke, while the suggestions read Task, Aliases and Desc. Walking the Taskfile directly and falling back to a full compilation only for templated descriptions cuts most of the per-TAB work, and a single broken task no longer empties the list.
The engine had its own copy, which only accepted a []any and so completed nothing for a ref resolving to the []string or []int that resolvedAsAnySlice already handles for the runtime.
NeedsTaskfile repeated three of Complete's branch conditions, so a new context had to be added to both or the Taskfile would silently stop loading.
A DirectiveDefault entry is what an absent key already yields, so the three had to be kept in sync with the flag definitions for no effect.
The five shells were spelled out in four places: two identical switches and the two completion flag enums. A shell can no longer be advertised by the completion of --completion while the lookup rejects it.
strings.Cut for the inline --flag=value split, slices.Contains instead of a set built to answer a few lookups, slices.SortFunc so the stdlib sort no longer shadows internal/sort, slicesext.Convert for the candidate mapping.
One write syscall per suggestion, for a list emitted in full on every keystroke.
…ecutor

The dir was set twice, once in the constructor and once over it.
SetOutput(io.Discard) already silences the only path that reaches usage.
…the wrappers

The fish NoSpace and KeepOrder variables were never tested, the bash cword guard is covered by the empty-args fallback right below, and the PowerShell element-count and line-count checks cannot be false.
The Get-ChildItem to CompletionResult conversion was written three times, twice verbatim.
Four copies of the same command -v / run / skip block.
CommandElements were forwarded via ToString(), i.e. their source text, so `task --dir "/a b" <TAB>` reached the engine with the quotes, found no Taskfile and fell back to file completion. A string element now yields its Value, the current word is unquoted before use, and a candidate holding a space is quoted back so insertion does not split it into several arguments.
…aded

Completion read only --dir, --taskfile and --global, dropping --sort, --insecure, --trusted-hosts, the certificates, the timeout and the remote cache options: a Taskfile that loads fine on a real run could complete nothing. The flag package now parses the words being completed leniently into pflag.CommandLine, and the executor is built from flags.WithFlags(), so a new flag needs no change here.
A remote Taskfile whose cache was missing or expired made TAB download it, which could freeze the shell for up to --timeout (10s by default), prompt for trust and write the cache. Completion now runs offline: the cache is served when present, and a missing one fails instantly. The empty stdin keeps a prompt from ever reading the terminal.
`task -t - <TAB>` reached NewRootNode with a "-" entrypoint, whose StdinNode reads os.Stdin to completion: the shell stayed frozen until the completion was killed. The test drives it with an unwritten pipe.
The enum ref resolution was exported from the root package only so the completion engine could reach it, which committed a library API to an internal wiring need. It now lives beside the matrix ref helper it shares, and asAnySlice joins slicesext. The moved test also covers a ref resolving to a []string, the case the engine's former copy rejected.
CompletionShells was exported from the root package for the engine alone. The list is private to the engine again, and a test asserts every shell it offers is one the root package can serve — the drift the shared symbol was meant to prevent.
`-V` was appended to the options forwarded to compadd, where it takes the next argument as a group name: it swallowed the `-d` that _describe appends, and zsh offered _describe's own _tmpd and _tmpm variables as candidates. Completing a task with `requires: vars` — the only context emitting KeepOrder — was therefore unusable.

The test covered that context already, but its _describe stub ignored where an option landed. It now mirrors the real signature and asserts each option in its own zone.
A wildcard task name is a pattern, not a runnable name: inserting
`release:*` on the command line only gets the shell to escape the glob,
and running it leaves `.MATCH` empty. Suggest the literal prefix up to
the first `*` instead, with NoSpace so the cursor stays against it.

The directive is only raised when such a candidate is in the response, so
Taskfiles without wildcard tasks keep the trailing space.
…emote flags

The remote Taskfile flags left the experiment in #2906, but the legacy Bash, Fish, Zsh and PowerShell wrappers this branch restores still gated them behind `__task_is_experiment_enabled REMOTE_TASKFILES`.
… official

Remote includes are resolved for everyone since #2906, so a keystroke on a Taskfile including an uncached remote one is now a real freeze: without the offline override it waits the full --timeout and hits the network. The experiment-gated flag test moved to --force-all, the only flag still behind an experiment.
@vmaerten
vmaerten force-pushed the feat/completion-engine branch from 9e8b673 to d0a53f4 Compare August 13, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants