fix(completion): complete task names and required vars together

CLI variables are global to the invocation, not scoped to a task: `args.Parse` turns every word holding `=` into a global and every other word into a call, so `task build ENV=dev deploy` and `task build deploy ENV=dev` are the same command. The engine assumed the opposite and, as soon as a word matched a task, served only that task's variables — nothing at all when it had none. `task build <TAB>` and even `task build de<TAB>` went silent, where all five legacy wrappers offered task names at every position.

The engine now unions the still-unset requirements of every task named on the line, and falls through to task names once they are all set. The line resolves itself: fill in what blocks execution, then add another task. Keeping the two families exclusive means each keeps a coherent directive, so nothing loses its trailing space.

Task words are matched with FindMatchingTasks instead of a hand-built list of names truncated at their first `*`, which is why `task wildcard-foo <TAB>` used to offer task names rather than the variables of `wildcard-*`. Completion also disables fuzzy matching: a suggestion list has no "did you mean".

Three fixes ride along. `--sort default` left the sorter nil and cleared the one NewExecutor had set, so completion listed tasks in Taskfile order while `--list` sorted them — and a single templated description silently restored the sort through GetTaskList. The bash wrapper never defined KeepOrder, losing the declaration order of `requires`; it now passes `compopt -o nosort`, which bash 3.2 ignores as it already ignores nospace. And the shell suite unsets TASK_EXE and GO_TASK_PROGNAME: fish, Nushell and PowerShell resolve the binary through them, so an ambient value silently tested something other than the binary just built.
This commit is contained in:
Valentin Maerten
2026-08-20 16:11:49 +02:00
parent 5fe752d48a
commit 978277273e
9 changed files with 171 additions and 65 deletions

View File

@@ -24,6 +24,7 @@ func runComplete(args []string) error {
task.WithStderr(io.Discard),
task.WithStdin(strings.NewReader("")),
task.WithVersionCheck(false),
task.WithDisableFuzzy(true),
task.WithOffline(true),
task.WithDownload(false),
)

View File

@@ -23,7 +23,7 @@ _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
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
@@ -79,6 +79,11 @@ _task() {
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

View File

@@ -3,6 +3,10 @@
# wrapper against them. The engine itself is covered by the Go tests.
set -u
# fish, Nushell and PowerShell resolve the binary through these; an ambient value
# would silently test something other than the binary built below.
unset TASK_EXE GO_TASK_PROGNAME
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
root=$(cd "$here/../.." && pwd)

View File

@@ -53,9 +53,10 @@ run task ''
reply_has "candidate forwarded" build
cap_hasnot "no file fallback" "filedir:"
echo "bash: :2 (NoSpace) disables the trailing space"
echo "bash: :2|:32 (NoSpace|KeepOrder) disable the trailing space and the sort"
run task deploy ''
cap_has "nospace applied" "compopt:-o nospace"
cap_has "keeporder applied" "compopt:-o nosort"
echo "bash: :8 (FilterFileExt) routes to extension-filtered files"
run task --taskfile ''

View File

@@ -73,8 +73,12 @@ tasks:
docs:serve:
desc: Serve docs locally
requires:
vars:
- PORT
cmds:
- 'echo serving'
`
const wildcardTaskfile = `version: '3'
@@ -102,6 +106,17 @@ tasks:
cmds:
- 'echo {{index .MATCH 0}}'
release-*:
desc: Release a component
requires:
vars:
- name: CHANNEL
enum:
- beta
- stable
cmds:
- 'echo {{index .MATCH 0}}'
build:
desc: Build it
cmds:
@@ -150,13 +165,13 @@ func TestComplete_WildcardTaskNames(t *testing.T) {
// Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*`
// collapse into one candidate, and `*-wildcard-*` leaves nothing to insert.
require.Equal(t, []string{"build", "matches-exactly-", "start-", "s-", "wildcard-"}, values(suggs))
require.Equal(t, []string{"build", "matches-exactly-", "release-", "start-", "s-", "wildcard-"}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
// Without a desc, the pattern says what the prefix stands for.
require.Contains(t, descriptions(suggs), "wildcard-*")
suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true})
require.Equal(t, []string{"", "", "", "", ""}, descriptions(suggs))
require.Equal(t, []string{"", "", "", "", "", ""}, descriptions(suggs))
}
func TestComplete_AliasResolvesToTaskVars(t *testing.T) {
@@ -186,15 +201,6 @@ func TestComplete_EnumRef(t *testing.T) {
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs))
}
func TestComplete_NoRequires(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""}, complete.Options{})
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) {
t.Parallel()
@@ -212,8 +218,8 @@ func TestComplete_NamespacedTaskName(t *testing.T) {
e := setupExecutor(t)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""}, complete.Options{})
require.Empty(t, suggs)
require.Equal(t, complete.DirectiveNoFileComp, dir)
require.Equal(t, []string{"PORT="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
}
func TestComplete_FlagValueInlineEquals(t *testing.T) {
@@ -444,6 +450,68 @@ func TestWrite_EmptyWithDirective(t *testing.T) {
require.Equal(t, ":16\n", buf.String())
}
// CLI variables are global to the invocation, so once every requirement on the
// line is met the engine goes back to offering task names.
func TestComplete_TaskNamesAfterTaskWithoutRequires(t *testing.T) {
t.Parallel()
suggs, dir := complete.Complete(setupExecutor(t), newTestFlagSet(), []string{"build", ""}, complete.Options{})
require.Subset(t, values(suggs), []string{"build", "deploy", "docs:serve"})
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
func TestComplete_RequiredVarsThenTaskNames(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
fs := newTestFlagSet()
suggs, dir := complete.Complete(e, fs, []string{"deploy", ""}, complete.Options{})
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
suggs, dir = complete.Complete(e, fs, []string{"deploy", "ENV=dev", ""}, complete.Options{})
require.Equal(t, []string{"REGION="}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
suggs, dir = complete.Complete(e, fs, []string{"deploy", "ENV=dev", "REGION=eu", ""}, complete.Options{})
require.Subset(t, values(suggs), []string{"build", "deploy"})
require.Equal(t, complete.DirectiveNoFileComp, dir)
}
func TestComplete_RequiredVarsUnionAcrossTasks(t *testing.T) {
t.Parallel()
e := setupExecutor(t)
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", "deploy", ""}, complete.Options{})
// ENV is required by both and appears once, in the order the line names them.
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
}
func TestComplete_WildcardTaskRequiredVars(t *testing.T) {
t.Parallel()
e := setupExecutorWith(t, wildcardTaskfile)
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"release-cli", ""}, complete.Options{})
require.Equal(t, []string{"CHANNEL=beta", "CHANNEL=stable"}, values(suggs))
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp|complete.DirectiveKeepOrder, dir)
}
// flags.WithFlags() applies WithTaskSorter after NewExecutor set its default, so
// the engine must not assume a sorter is present.
func TestComplete_DefaultSorterFallback(t *testing.T) {
t.Parallel()
e := setupExecutorWith(t, testTaskfile)
e.Options(task.WithTaskSorter(nil))
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{})
require.Equal(t, []string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"}, values(suggs))
}
func values(suggs []complete.Suggestion) []string {
return slicesext.Convert(suggs, func(s complete.Suggestion) string { return s.Value })
}

View File

@@ -42,16 +42,15 @@ func (ctx completionContext) inTaskContext(fs *pflag.FlagSet) bool {
return !ctx.afterDash && ctx.flagValue(fs) == nil && !strings.HasPrefix(ctx.toComplete, "-")
}
// fs is needed to skip the word after a value-taking flag: `task --dir deploy`
// must not read "deploy" as a task name.
func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) string {
if len(args) <= 1 {
return ""
}
// parsePriorWords splits the words before the cursor into task candidates and
// the names of the variables already set. fs is needed to skip the word after a
// value-taking flag: `task --dir deploy` must not read "deploy" as a task name.
func parsePriorWords(prior []string, fs *pflag.FlagSet) ([]string, map[string]bool) {
var tasks []string
setVars := make(map[string]bool, len(prior))
taskName := ""
skipNext := false
for _, w := range args[:len(args)-1] {
for _, w := range prior {
if skipNext {
skipNext = false
continue
@@ -64,13 +63,12 @@ func detectTaskName(args []string, knownTasks []string, fs *pflag.FlagSet) strin
}
continue
}
if strings.Contains(w, "=") {
if name, _, ok := strings.Cut(w, "="); ok {
setVars[name] = true
continue
}
if slices.Contains(knownTasks, w) {
taskName = w
}
tasks = append(tasks, w)
}
return taskName
return tasks, setVars
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/internal/refs"
"github.com/go-task/task/v3/internal/slicesext"
"github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile/ast"
)
@@ -33,10 +34,10 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options)
return listFlags(fs), DirectiveNoFileComp
}
// No prior arg means no task word, so `task <tab>` never builds the list.
// No prior arg means nothing can require a variable yet.
if e != nil && e.Taskfile != nil && len(args) > 1 {
if taskName := detectTaskName(args, taskNames(e), fs); taskName != "" {
return completeTaskVars(e, taskName)
if suggs, dir, ok := completeRequiredVars(e, args[:len(args)-1], fs); ok {
return suggs, dir
}
}
@@ -52,25 +53,6 @@ func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool {
return f == nil || f.Value.String() != "-"
}
func taskNames(e *task.Executor) []string {
if e == nil || e.Taskfile == nil {
return nil
}
var out []string
for t := range e.Taskfile.Tasks.Values(nil) {
if t.Internal {
continue
}
name, _ := suggestedName(t.Task)
out = append(out, name)
for _, alias := range t.Aliases {
name, _ := suggestedName(alias)
out = append(out, name)
}
}
return out
}
func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive) {
if e == nil || e.Taskfile == nil {
return nil, DirectiveNoFileComp
@@ -122,9 +104,15 @@ func completeTaskNames(e *task.Executor, opts Options) ([]Suggestion, Directive)
// GetTaskList compiles every task, on every keystroke, and a description is the
// only compiled field read: worth its cost only when one holds a template.
func listTasks(e *task.Executor, opts Options) []*ast.Task {
// Not dead defence: flags.WithFlags() clobbers the sorter NewExecutor set.
sorter := e.TaskSorter
if sorter == nil {
sorter = sort.AlphaNumericWithRootTasksFirst
}
out := make([]*ast.Task, 0, e.Taskfile.Tasks.Len())
templated := false
for t := range e.Taskfile.Tasks.Values(e.TaskSorter) {
for t := range e.Taskfile.Tasks.Values(sorter) {
if t.Internal {
continue
}
@@ -172,31 +160,46 @@ func suggest(prefix string, values []string) []Suggestion {
})
}
func completeTaskVars(e *task.Executor, taskName string) ([]Suggestion, Directive) {
compiled, err := e.FastCompiledTask(&task.Call{Task: taskName})
if err != nil || compiled == nil || compiled.Requires == nil {
return nil, DirectiveNoFileComp
}
// CLI variables are global to the invocation, not scoped to a task, so this
// unions the still-unset requirements of every task named on the line. Reporting
// none lets the caller offer task names instead, which is how the line resolves
// itself: fill in what blocks execution, then add another task.
func completeRequiredVars(e *task.Executor, prior []string, fs *pflag.FlagSet) ([]Suggestion, Directive, bool) {
taskWords, setVars := parsePriorWords(prior, fs)
out := make([]Suggestion, 0, 8)
for _, v := range compiled.Requires.Vars {
if v == nil || v.Name == "" {
seen := make(map[string]bool, 8)
for _, w := range taskWords {
// FindMatchingTasks resolves aliases and wildcards, and unlike GetTask it
// does not build the fuzzy model to spell-check a word that is not a task.
if matches, err := e.FindMatchingTasks(&task.Call{Task: w}); err != nil || len(matches) == 0 {
continue
}
values := enumValues(v, compiled.Vars)
if len(values) == 0 {
out = append(out, Suggestion{Value: v.Name + "="})
compiled, err := e.FastCompiledTask(&task.Call{Task: w})
if err != nil || compiled == nil || compiled.Requires == nil {
continue
}
for _, val := range values {
out = append(out, Suggestion{Value: v.Name + "=" + val})
for _, v := range compiled.Requires.Vars {
if v == nil || v.Name == "" || setVars[v.Name] || seen[v.Name] {
continue
}
seen[v.Name] = true
values := enumValues(v, compiled.Vars)
if len(values) == 0 {
out = append(out, Suggestion{Value: v.Name + "="})
continue
}
for _, val := range values {
out = append(out, Suggestion{Value: v.Name + "=" + val})
}
}
}
if len(out) == 0 {
return nil, DirectiveNoFileComp
return nil, 0, false
}
// KeepOrder preserves the declaration order of the `requires` block.
return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder
return out, DirectiveNoSpace | DirectiveNoFileComp | DirectiveKeepOrder, true
}
func enumValues(v *ast.VarsWithValidation, vars *ast.Vars) []string {

View File

@@ -279,6 +279,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
sorter = sort.NoSort
case "alphanumeric":
sorter = sort.AlphaNumeric
default:
// Not nil: this overwrites the sorter NewExecutor already set.
sorter = sort.AlphaNumericWithRootTasksFirst
}
// Change the directory to the user's home directory if the global flag is set

View File

@@ -0,0 +1,23 @@
package flags_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/internal/flags"
)
// WithFlags applies WithTaskSorter after NewExecutor set its default, so an
// unset --sort must still resolve to a sorter instead of clearing it.
func TestWithFlags_DefaultSorterIsNotCleared(t *testing.T) { //nolint:paralleltest // mutates package state
original := flags.TaskSort
t.Cleanup(func() { flags.TaskSort = original })
for _, sort := range []string{"", "default"} {
flags.TaskSort = sort
e := task.NewExecutor(flags.WithFlags())
require.NotNilf(t, e.TaskSorter, "--sort %q left the executor without a sorter", sort)
}
}