mirror of
https://github.com/go-task/task.git
synced 2026-08-29 10:08:27 +02:00
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:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
23
internal/flags/flags_test.go
Normal file
23
internal/flags/flags_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user