diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index 0d5f64e3..c074aef3 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -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), ) diff --git a/completion/next/bash/task.bash b/completion/next/bash/task.bash index 90205f1c..e1e2a3b8 100644 --- a/completion/next/bash/task.bash +++ b/completion/next/bash/task.bash @@ -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 diff --git a/completion/tests/run.sh b/completion/tests/run.sh index 891884f8..478de99d 100755 --- a/completion/tests/run.sh +++ b/completion/tests/run.sh @@ -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) diff --git a/completion/tests/wrapper.bash b/completion/tests/wrapper.bash index a234e215..5ea17454 100755 --- a/completion/tests/wrapper.bash +++ b/completion/tests/wrapper.bash @@ -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 '' diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index 222f059b..d047c8f0 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -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 }) } diff --git a/internal/complete/context.go b/internal/complete/context.go index 5616fd1d..1502ecd6 100644 --- a/internal/complete/context.go +++ b/internal/complete/context.go @@ -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 } diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 1994f8bc..99afea88 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -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 ` 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 { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 04342004..ccefcc52 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -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 diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 00000000..1fa6347a --- /dev/null +++ b/internal/flags/flags_test.go @@ -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) + } +}