diff --git a/cmd/task/complete_cmd.go b/cmd/task/complete_cmd.go index bc06d02b..0d5f64e3 100644 --- a/cmd/task/complete_cmd.go +++ b/cmd/task/complete_cmd.go @@ -28,8 +28,8 @@ func runComplete(args []string) error { task.WithDownload(false), ) - // Best-effort, and never from stdin: that would hang the shell. - if complete.NeedsTaskfile(args, pflag.CommandLine) && flags.Entrypoint != "-" { + // Best-effort: a missing or broken Taskfile must not break completion. + if complete.NeedsTaskfile(args, pflag.CommandLine) { _ = e.Setup() } diff --git a/completion/protocol_test.go b/completion/protocol_test.go deleted file mode 100644 index 6a7e1420..00000000 --- a/completion/protocol_test.go +++ /dev/null @@ -1,328 +0,0 @@ -// Black-box tests of the `task __complete` wire protocol. How each shell -// wrapper interprets the directive is smoke-tested in completion/tests/. -package completion_test - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/go-task/task/v3" - "github.com/go-task/task/v3/internal/complete" -) - -var taskBin string - -func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "task-completion-test") - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - taskBin = filepath.Join(dir, "task") - if runtime.GOOS == "windows" { - taskBin += ".exe" - } - if out, err := exec.CommandContext(context.Background(), "go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil { - fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out) - os.RemoveAll(dir) - os.Exit(1) - } - code := m.Run() - os.RemoveAll(dir) - os.Exit(code) -} - -const fixtureTaskfile = `version: '3' - -tasks: - build: - desc: Build it - deploy: - desc: Deploy the application - aliases: [dep, ship] - requires: - vars: - - name: ENV - enum: [dev, staging, prod] - - REGION - docs:serve: - desc: Serve docs locally -` - -// completeArgs runs `task __complete ` in a fresh fixture directory. -func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) { - t.Helper() - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) - - cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec - cmd.Dir = dir - out, err := cmd.Output() - require.NoError(t, err) - - return parseProtocol(t, out) -} - -func parseProtocol(t *testing.T, out []byte) ([]string, complete.Directive) { - t.Helper() - - lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") - require.NotEmpty(t, lines, "protocol output must end with a directive line") - - last := lines[len(lines)-1] - require.True(t, strings.HasPrefix(last, ":"), "last line must be the : line, got %q", last) - n, err := strconv.Atoi(strings.TrimPrefix(last, ":")) - require.NoError(t, err) - - values := make([]string, 0, len(lines)-1) - for _, line := range lines[:len(lines)-1] { - values = append(values, strings.SplitN(line, "\t", 2)[0]) - } - return values, complete.Directive(n) -} - -func TestProtocol(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - args []string - want []string // candidate values that must be offered - absent []string // candidate values that must NOT be offered - directive complete.Directive - }{ - { - name: "task names and aliases", - args: []string{""}, - want: []string{"build", "deploy", "dep", "ship", "docs:serve"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "no-aliases drops aliases", - args: []string{"--no-aliases", ""}, - want: []string{"build", "deploy"}, - absent: []string{"dep", "ship"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "flag names", - args: []string{"-"}, - want: []string{"--taskfile", "--dir", "--output"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "separate flag value is bare", - args: []string{"--output", ""}, - want: []string{"interleaved", "group", "prefixed"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "inline flag value is full form", - args: []string{"--output="}, - want: []string{"--output=interleaved", "--output=group", "--output=prefixed"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "sort enum values", - args: []string{"--sort", ""}, - want: []string{"default", "alphanumeric", "none"}, - directive: complete.DirectiveNoFileComp, - }, - { - name: "taskfile filters by extension", - args: []string{"--taskfile", ""}, - want: []string{"yml", "yaml"}, - directive: complete.DirectiveFilterFileExt, - }, - { - name: "dir filters to directories", - args: []string{"--dir", ""}, - directive: complete.DirectiveFilterDirs, - }, - { - name: "task variables keep order and suppress the space", - args: []string{"deploy", ""}, - want: []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, - directive: complete.DirectiveNoSpace | complete.DirectiveNoFileComp | complete.DirectiveKeepOrder, - }, - { - name: "after -- yields default file completion", - args: []string{"deploy", "--", ""}, - directive: complete.DirectiveDefault, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - values, directive := completeArgs(t, tt.args...) - require.Equal(t, tt.directive, directive) - require.Subset(t, values, tt.want) - for _, a := range tt.absent { - require.NotContains(t, values, a) - } - }) - } -} - -// --sort is the flag deciding how the Taskfile is read with a visible order. -func TestProtocol_SortFlagIsApplied(t *testing.T) { - t.Parallel() - - const taskfile = `version: '3' - -tasks: - zebra: - desc: Declared first, last alphabetically - alpha: - desc: Declared last, first alphabetically -` - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) - - sorted, _ := completeInDir(t, dir, nil, "") - require.Equal(t, []string{"alpha", "zebra"}, sorted) - - declared, _ := completeInDir(t, dir, nil, "--sort", "none", "") - require.Equal(t, []string{"zebra", "alpha"}, declared) -} - -func TestProtocol_ExperimentGatedFlag(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644)) - - values, directive := completeInDir(t, dir, []string{"TASK_X_GENTLE_FORCE=1"}, "--force-all", "") - require.Equal(t, complete.DirectiveNoFileComp, directive) - require.Subset(t, values, []string{"build", "deploy"}) -} - -// Downloading an uncached remote include would freeze the shell for up to -// --timeout and prompt for trust. -func TestProtocol_RemoteIncludeStaysOffline(t *testing.T) { - t.Parallel() - - var hits atomic.Int64 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits.Add(1) - <-r.Context().Done() - })) - defer srv.Close() - - taskfile := fmt.Sprintf(`version: '3' - -includes: - remote: %s/Taskfile.yml - -tasks: - build: - desc: Build it -`, srv.URL) - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) - - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - - // A fresh cache dir leaves a download as the only way to resolve the - // include; the insecure opt-in keeps the plain-HTTP server from being - // rejected before it. - cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "") //nolint:gosec - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "TASK_REMOTE_DIR="+t.TempDir(), - "TASK_REMOTE_INSECURE=1", - ) - out, err := cmd.Output() - require.NoError(t, err, "completion must not hang on a remote include") - - _, directive := parseProtocol(t, out) - require.Equal(t, complete.DirectiveNoFileComp, directive) - require.Zero(t, hits.Load(), "completion must not reach the network") -} - -// `--taskfile -` would otherwise read the Taskfile from the terminal. -func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) { - t.Parallel() - - // An unwritten pipe: reading it would block until the context expires. - r, w, err := os.Pipe() - require.NoError(t, err) - t.Cleanup(func() { - r.Close() - w.Close() - }) - - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") //nolint:gosec - cmd.Dir = t.TempDir() - cmd.Stdin = r - out, err := cmd.Output() - require.NoError(t, err, "completion must not read the Taskfile from stdin") - - _, directive := parseProtocol(t, out) - require.Equal(t, complete.DirectiveNoFileComp, directive) -} - -func TestProtocol_WildcardTaskNames(t *testing.T) { - t.Parallel() - - values, directive := completeInDir(t, filepath.Join("..", "testdata", "wildcards"), nil, "") - require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, directive) - require.Subset(t, values, []string{"start-", "s-", "wildcard-", "matches-exactly-"}) - for _, v := range values { - require.NotEmpty(t, v) - require.NotContains(t, v, "*") - } -} - -// completeInDir runs `task __complete ` in dir, with env appended to the -// current environment. -func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) { - t.Helper() - - cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec - cmd.Dir = dir - cmd.Env = append(os.Environ(), env...) - out, err := cmd.Output() - require.NoError(t, err) - - return parseProtocol(t, out) -} - -// Keeps the shells the engine offers in step with the scripts the root package -// can actually serve. -func TestCompletionShells(t *testing.T) { - t.Parallel() - - for _, flag := range []string{"--completion", "--new-completion"} { - shells, directive := completeArgs(t, flag, "") - require.Equal(t, complete.DirectiveNoFileComp, directive) - require.NotEmpty(t, shells) - - for _, shell := range shells { - _, err := task.Completion(shell) - require.NoErrorf(t, err, "%s offers %q", flag, shell) - _, err = task.CompletionNext(shell) - require.NoErrorf(t, err, "%s offers %q", flag, shell) - } - } -} diff --git a/internal/complete/complete_test.go b/internal/complete/complete_test.go index a5fc438f..e40b0748 100644 --- a/internal/complete/complete_test.go +++ b/internal/complete/complete_test.go @@ -26,6 +26,8 @@ func newTestFlagSet() *pflag.FlagSet { fs.StringVarP(&s, "output", "o", "", "Output style") fs.StringVar(&s, "sort", "", "Sort order") fs.StringVar(&s, "cacert", "", "CA cert path") + fs.StringVar(&s, "completion", "", "Generate a completion script") + fs.StringVar(&s, "new-completion", "", "Generate a completion script") return fs } @@ -88,6 +90,10 @@ tasks: cmds: - 'echo {{index .MATCH 0}}' + matches-exactly-*: + cmds: + - 'echo {{.MATCH}}' + start-*: desc: Start a service aliases: [s-*] @@ -142,13 +148,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", "start-", "s-", "wildcard-"}, values(suggs)) + require.Equal(t, []string{"build", "matches-exactly-", "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) { @@ -379,6 +385,36 @@ func TestNeedsTaskfile(t *testing.T) { } } +// Reading the Taskfile from standard input would block until EOF, freezing the +// shell on a keystroke. +func TestNeedsTaskfile_StdinEntrypoint(t *testing.T) { + t.Parallel() + + fs := newTestFlagSet() + require.NoError(t, fs.Set("taskfile", "-")) + require.False(t, complete.NeedsTaskfile([]string{""}, fs)) + require.False(t, complete.NeedsTaskfile([]string{"deploy", ""}, fs)) +} + +// Keeps the shells the engine offers in step with the scripts the root package +// can actually serve. +func TestCompletionShells(t *testing.T) { + t.Parallel() + + for _, flag := range []string{"--completion", "--new-completion"} { + suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{flag, ""}, complete.Options{}) + require.Equal(t, complete.DirectiveNoFileComp, dir) + require.NotEmpty(t, suggs) + + for _, shell := range values(suggs) { + _, err := task.Completion(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + _, err = task.CompletionNext(shell) + require.NoErrorf(t, err, "%s offers %q", flag, shell) + } + } +} + func TestWrite_Format(t *testing.T) { t.Parallel() diff --git a/internal/complete/engine.go b/internal/complete/engine.go index 4e0b853c..6c0b9b27 100644 --- a/internal/complete/engine.go +++ b/internal/complete/engine.go @@ -45,7 +45,12 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options) } func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool { - return parseContext(args).inTaskContext(fs) + if !parseContext(args).inTaskContext(fs) { + return false + } + // Reading the Taskfile from standard input would hang the shell on a keystroke. + f := fs.Lookup("taskfile") + return f == nil || f.Value.String() != "-" } func taskNames(e *task.Executor) []string {