diff --git a/cmd/task/task.go b/cmd/task/task.go index b81e23dd5f..ae1090e927 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -139,12 +139,23 @@ func run() error { return os.RemoveAll(cachePath) } + // Extract an optional filter pattern from positional args when listing + var filterPattern string + if flags.List || flags.ListAll { + if positionalArgs := pflag.Args(); len(positionalArgs) > 0 { + filterPattern = positionalArgs[0] + } + } listOptions := task.NewListOptions( flags.List, flags.ListAll, flags.ListJson, flags.NoStatus, flags.Nested, + flags.Long, + flags.Tree, + filterPattern, + flags.TaskSort, ) if listOptions.ShouldListTasks() { if flags.Silent { diff --git a/formatter_test.go b/formatter_test.go index b92c8d5579..45ee6538f4 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -234,3 +234,148 @@ func TestJsonListFormat(t *testing.T) { WithFixtureTemplating(), ) } + +func TestListRequires(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_requires"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + }), + ) +} + +func TestListLong(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_long"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Long: true, + }), + ) +} + +func TestListTree(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_tree"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Tree: true, + }), + ) +} + +func TestListTreeLong(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_tree_long"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Tree: true, + Long: true, + }), + ) +} + +func TestListFilter(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_filter"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Filter: "docker", + }), + ) +} + +func TestListFilterNoMatch(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_filter"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Filter: "nonexistent", + }), + ) +} + +func TestListTreeFilter(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_tree_filter"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Tree: true, + Filter: "docker", + }), + ) +} + +func TestListTreeFilterLong(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_tree_filter"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Tree: true, + Long: true, + Filter: "docker", + }), + ) +} + +func TestListFlatFilterLong(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/list_tree_filter"), + ), + WithListOptions(task.ListOptions{ + ListOnlyTasksWithDescriptions: true, + Long: true, + Filter: "docker", + }), + ) +} + +func TestJsonListLong(t *testing.T) { + t.Parallel() + + NewFormatterTest(t, + WithExecutorOptions( + task.WithDir("testdata/json_list_long"), + ), + WithListOptions(task.ListOptions{ + FormatTaskListAsJSON: true, + Long: true, + }), + WithFixtureTemplating(), + ) +} diff --git a/help.go b/help.go index 9998bd38ad..0c1943d6ef 100644 --- a/help.go +++ b/help.go @@ -13,6 +13,7 @@ import ( "github.com/go-task/task/v3/internal/editors" "github.com/go-task/task/v3/internal/fingerprint" + "github.com/go-task/task/v3/internal/listing" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/taskfile/ast" @@ -25,16 +26,24 @@ type ListOptions struct { FormatTaskListAsJSON bool NoStatus bool Nested bool + Long bool + Tree bool + Filter string + SortMode string } // NewListOptions creates a new ListOptions instance -func NewListOptions(list, listAll, listAsJson, noStatus, nested bool) ListOptions { +func NewListOptions(list, listAll, listAsJson, noStatus, nested, long, tree bool, filter, sortMode string) ListOptions { return ListOptions{ ListOnlyTasksWithDescriptions: list, ListAllTasks: listAll, FormatTaskListAsJSON: listAsJson, NoStatus: noStatus, Nested: nested, + Long: long, + Tree: tree, + Filter: filter, + SortMode: sortMode, } } @@ -64,8 +73,11 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) { if err != nil { return false, err } + if o.Filter != "" { + tasks = listing.FilterTasks(tasks, o.Filter) + } if o.FormatTaskListAsJSON { - output, err := e.ToEditorOutput(tasks, o.NoStatus, o.Nested) + output, err := e.ToEditorOutput(tasks, o.NoStatus, o.Nested, o.Long) if err != nil { return false, err } @@ -79,26 +91,32 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) { return len(tasks) > 0, nil } if len(tasks) == 0 { - if o.ListOnlyTasksWithDescriptions { + if o.Filter != "" { + e.Logger.Outf(logger.Yellow, "task: No tasks matching %q\n", o.Filter) + } else if o.ListOnlyTasksWithDescriptions { e.Logger.Outf(logger.Yellow, "task: No tasks with description available. Try --list-all to list all tasks\n") } else if o.ListAllTasks { e.Logger.Outf(logger.Yellow, "task: No tasks available\n") } return false, nil } + if o.Tree { + return e.listTasksTree(tasks, o) + } e.Logger.Outf(logger.Default, "task: Available tasks for this project:\n") // Format in tab-separated columns with a tab stop of 8. w := tabwriter.NewWriter(e.Stdout, 0, 8, 6, ' ', 0) for _, task := range tasks { e.Logger.FOutf(w, logger.Yellow, "* ") - e.Logger.FOutf(w, logger.Green, task.Task) + e.writeHighlighted(w, logger.Green, task.Task, o.Filter) desc := strings.ReplaceAll(task.Desc, "\n", " ") e.Logger.FOutf(w, logger.Default, ": \t%s", desc) if len(task.Aliases) > 0 { e.Logger.FOutf(w, logger.Cyan, "\t(aliases: %s)", strings.Join(task.Aliases, ", ")) } _, _ = fmt.Fprint(w, "\n") + e.writeTaskDetails(w, task, " \t", o.Long) } if err := w.Flush(); err != nil { return false, err @@ -106,6 +124,42 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) { return true, nil } +func (e *Executor) writeHighlighted(w io.Writer, baseColor logger.Color, text, filter string) { + if filter == "" || listing.IsGlobPattern(filter) { + e.Logger.FOutf(w, baseColor, "%s", text) + return + } + idx := strings.Index(strings.ToLower(text), strings.ToLower(filter)) + if idx == -1 { + e.Logger.FOutf(w, baseColor, "%s", text) + return + } + e.Logger.FOutf(w, baseColor, "%s", text[:idx]) + e.Logger.FOutf(w, logger.Bold, "%s", text[idx:idx+len(filter)]) + e.Logger.FOutf(w, baseColor, "%s", text[idx+len(filter):]) +} + +func (e *Executor) writeTaskDetails(w io.Writer, task *ast.Task, indent string, long bool) { + if listing.HasRequires(task) { + e.Logger.FOutf(w, logger.Default, indent) + e.Logger.FOutf(w, logger.Yellow, "requires:") + e.Logger.FOutf(w, logger.Default, " %s\n", listing.FormatRequires(task.Requires)) + } + if long { + if deps := listing.FormatDeps(task.Deps); deps != "" { + e.Logger.FOutf(w, logger.Default, indent) + e.Logger.FOutf(w, logger.Yellow, "deps:") + e.Logger.FOutf(w, logger.Default, " %s\n", deps) + } + if task.Summary != "" { + summary := strings.TrimSpace(strings.ReplaceAll(task.Summary, "\n", " ")) + e.Logger.FOutf(w, logger.Default, indent) + e.Logger.FOutf(w, logger.Yellow, "summary:") + e.Logger.FOutf(w, logger.Default, " %s\n", summary) + } + } +} + // ListTaskNames prints only the task names in a Taskfile. // Only tasks with a non-empty description are printed if allTasks is false. // Otherwise, all task names are printed. @@ -137,14 +191,77 @@ func (e *Executor) ListTaskNames(allTasks bool) error { return nil } -func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool, nested bool) (*editors.Namespace, error) { +func (e *Executor) listTasksTree(tasks []*ast.Task, o ListOptions) (bool, error) { + e.Logger.Outf(logger.Default, "task: Available tasks for this project:\n") + + groups := listing.GroupByNamespace(tasks) + hasNamespaced := listing.HasNamespacedGroups(groups) + hasRoot := listing.HasRootGroup(groups) + showSeparator := hasNamespaced && hasRoot && (o.SortMode == "" || o.SortMode == "default") + + // Move root group to end so namespaced groups appear first + if showSeparator { + for i, g := range groups { + if g.Namespace == "" && i < len(groups)-1 { + rootGroup := groups[i] + groups = append(groups[:i], groups[i+1:]...) + groups = append(groups, rootGroup) + break + } + } + } + + w := tabwriter.NewWriter(e.Stdout, 0, 8, 6, ' ', 0) + firstGroup := true + for _, group := range groups { + isRoot := group.Namespace == "" + if !firstGroup { + _, _ = fmt.Fprint(w, "\n") + } + firstGroup = false + if isRoot && showSeparator { + e.Logger.FOutf(w, logger.Dim, " ─────\n\n") + } + if !isRoot { + e.Logger.FOutf(w, logger.Dim, " %s\n", group.Namespace) + } + for _, task := range group.Tasks { + name := group.LocalName(task) + desc := strings.ReplaceAll(task.Desc, "\n", " ") + indent := " " + if !isRoot { + indent = " " + } + nameColor := logger.Green + if task.Internal { + nameColor = logger.Dim + } + e.Logger.FOutf(w, nameColor, "%s", indent) + e.writeHighlighted(w, nameColor, name, o.Filter) + e.Logger.FOutf(w, logger.Default, ":\t%s", desc) + if len(task.Aliases) > 0 { + e.Logger.FOutf(w, logger.Cyan, "\t(aliases: %s)", strings.Join(task.Aliases, ", ")) + } + _, _ = fmt.Fprint(w, "\n") + e.writeTaskDetails(w, task, indent+" \t", o.Long) + } + } + return true, w.Flush() +} + +func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool, nested bool, long bool) (*editors.Namespace, error) { var g errgroup.Group editorTasks := make([]editors.Task, len(tasks)) // Look over each task in parallel and turn it into an editor task for i := range tasks { g.Go(func() error { - editorTask := editors.NewTask(tasks[i]) + var editorTask editors.Task + if long { + editorTask = editors.NewTaskLong(tasks[i]) + } else { + editorTask = editors.NewTask(tasks[i]) + } if noStatus { editorTasks[i] = editorTask diff --git a/internal/editors/output.go b/internal/editors/output.go index eff0a0cb3e..7f7943599a 100644 --- a/internal/editors/output.go +++ b/internal/editors/output.go @@ -13,13 +13,20 @@ type ( } // Task describes a single task Task struct { - Name string `json:"name"` - Task string `json:"task"` - Desc string `json:"desc"` - Summary string `json:"summary"` - Aliases []string `json:"aliases"` - UpToDate *bool `json:"up_to_date,omitempty"` - Location *Location `json:"location"` + Name string `json:"name"` + Task string `json:"task"` + Desc string `json:"desc"` + Summary string `json:"summary"` + Aliases []string `json:"aliases"` + UpToDate *bool `json:"up_to_date,omitempty"` + Location *Location `json:"location"` + Deps []string `json:"deps,omitempty"` + Requires []RequiredVar `json:"requires,omitempty"` + } + // RequiredVar describes a required variable for a task + RequiredVar struct { + Name string `json:"name"` + Enum []string `json:"enum,omitempty"` } // Location describes a task's location in a taskfile Location struct { @@ -48,6 +55,27 @@ func NewTask(task *ast.Task) Task { } } +func NewTaskLong(task *ast.Task) Task { + t := NewTask(task) + if len(task.Deps) > 0 { + t.Deps = make([]string, len(task.Deps)) + for i, d := range task.Deps { + t.Deps[i] = d.Task + } + } + if task.Requires != nil && len(task.Requires.Vars) > 0 { + t.Requires = make([]RequiredVar, len(task.Requires.Vars)) + for i, v := range task.Requires.Vars { + rv := RequiredVar{Name: v.Name} + if v.Enum != nil { + rv.Enum = v.Enum.Value + } + t.Requires[i] = rv + } + } + return t +} + func (parent *Namespace) AddNamespace(namespacePath []string, task Task) { if len(namespacePath) == 0 { return diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 51bec00468..057c9c545a 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -52,6 +52,7 @@ var ( ListAll bool ListJson bool TaskSort string + Long bool Status bool NoStatus bool Nested bool @@ -87,6 +88,7 @@ var ( Cert string CertKey string Interactive bool + Tree bool ) func init() { @@ -127,6 +129,8 @@ func init() { pflag.BoolVarP(&ListAll, "list-all", "a", false, "Lists tasks with or without a description.") pflag.BoolVarP(&ListJson, "json", "j", false, "Formats task list as JSON.") pflag.StringVar(&TaskSort, "sort", "", "Changes the order of the tasks when listed. [default|alphanumeric|none].") + pflag.BoolVarP(&Long, "long", "L", false, "Show detailed task information when listing.") + pflag.BoolVarP(&Tree, "tree", "T", false, "Display tasks grouped by namespace.") pflag.BoolVar(&Status, "status", false, "Exits with non-zero exit code if any of the given tasks is not up-to-date.") pflag.BoolVar(&NoStatus, "no-status", false, "Ignore status when listing tasks as JSON") pflag.BoolVar(&Nested, "nested", false, "Nest namespaces when listing tasks as JSON") @@ -230,6 +234,10 @@ func Validate() error { return errors.New("task: cannot use --list and --list-all at the same time") } + if Long && !List && !ListAll { + return errors.New("task: --long only applies to --list or --list-all") + } + if ListJson && !List && !ListAll { return errors.New("task: --json only applies to --list or --list-all") } @@ -242,6 +250,14 @@ func Validate() error { return errors.New("task: --nested only applies to --json with --list or --list-all") } + if Tree && !List && !ListAll { + return errors.New("task: --tree only applies to --list or --list-all") + } + + if Tree && ListJson { + return errors.New("task: --tree and --json are mutually exclusive") + } + // Validate certificate flags if (Cert != "" && CertKey == "") || (Cert == "" && CertKey != "") { return errors.New("task: --cert and --cert-key must be provided together") diff --git a/internal/listing/detail.go b/internal/listing/detail.go new file mode 100644 index 0000000000..64f5f34373 --- /dev/null +++ b/internal/listing/detail.go @@ -0,0 +1,37 @@ +package listing + +import ( + "strings" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func FormatRequires(req *ast.Requires) string { + if req == nil || len(req.Vars) == 0 { + return "" + } + parts := make([]string, len(req.Vars)) + for i, v := range req.Vars { + if v.Enum != nil && len(v.Enum.Value) > 0 { + parts[i] = v.Name + " (enum: " + strings.Join(v.Enum.Value, ", ") + ")" + } else { + parts[i] = v.Name + } + } + return strings.Join(parts, ", ") +} + +func FormatDeps(deps []*ast.Dep) string { + if len(deps) == 0 { + return "" + } + names := make([]string, len(deps)) + for i, d := range deps { + names[i] = d.Task + } + return strings.Join(names, ", ") +} + +func HasRequires(t *ast.Task) bool { + return t.Requires != nil && len(t.Requires.Vars) > 0 +} diff --git a/internal/listing/detail_test.go b/internal/listing/detail_test.go new file mode 100644 index 0000000000..63461c6bfc --- /dev/null +++ b/internal/listing/detail_test.go @@ -0,0 +1,56 @@ +package listing_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/listing" + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestFormatRequires_Simple(t *testing.T) { + t.Parallel() + req := &ast.Requires{Vars: []*ast.VarsWithValidation{{Name: "VERSION"}, {Name: "TAG"}}} + require.Equal(t, "VERSION, TAG", listing.FormatRequires(req)) +} + +func TestFormatRequires_WithEnum(t *testing.T) { + t.Parallel() + req := &ast.Requires{Vars: []*ast.VarsWithValidation{ + {Name: "REGISTRY", Enum: &ast.Enum{Value: []string{"ecr", "gcr", "dockerhub"}}}, {Name: "TAG"}, + }} + require.Equal(t, "REGISTRY (enum: ecr, gcr, dockerhub), TAG", listing.FormatRequires(req)) +} + +func TestFormatRequires_Nil(t *testing.T) { + t.Parallel() + require.Equal(t, "", listing.FormatRequires(nil)) +} + +func TestFormatRequires_Empty(t *testing.T) { + t.Parallel() + require.Equal(t, "", listing.FormatRequires(&ast.Requires{})) +} + +func TestFormatDeps(t *testing.T) { + t.Parallel() + deps := []*ast.Dep{{Task: "lint"}, {Task: "test:unit"}} + require.Equal(t, "lint, test:unit", listing.FormatDeps(deps)) +} + +func TestFormatDeps_Empty(t *testing.T) { + t.Parallel() + require.Equal(t, "", listing.FormatDeps(nil)) +} + +func TestHasRequires_True(t *testing.T) { + t.Parallel() + task := &ast.Task{Task: "build", Requires: &ast.Requires{Vars: []*ast.VarsWithValidation{{Name: "V"}}}} + require.True(t, listing.HasRequires(task)) +} + +func TestHasRequires_False(t *testing.T) { + t.Parallel() + require.False(t, listing.HasRequires(&ast.Task{Task: "build"})) +} diff --git a/internal/listing/filter.go b/internal/listing/filter.go new file mode 100644 index 0000000000..e3522b9d39 --- /dev/null +++ b/internal/listing/filter.go @@ -0,0 +1,52 @@ +package listing + +import ( + "path" + "strings" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func IsGlobPattern(pattern string) bool { + return strings.ContainsAny(pattern, "*?[") +} + +// FilterTasks returns tasks whose name or description matches the pattern. +func FilterTasks(tasks []*ast.Task, pattern string) []*ast.Task { + if pattern == "" { + return tasks + } + if IsGlobPattern(pattern) { + return filterByGlob(tasks, pattern) + } + return filterBySubstring(tasks, pattern) +} + +func filterBySubstring(tasks []*ast.Task, pattern string) []*ast.Task { + lower := strings.ToLower(pattern) + var result []*ast.Task + for _, t := range tasks { + nameLower := strings.ToLower(t.Task) + descLower := strings.ToLower(t.Desc) + if strings.Contains(nameLower, lower) || + strings.Contains(descLower, lower) { + result = append(result, t) + } + } + return result +} + +func filterByGlob(tasks []*ast.Task, pattern string) []*ast.Task { + lowerPattern := strings.ToLower(pattern) + var result []*ast.Task + for _, t := range tasks { + matched, err := path.Match(lowerPattern, strings.ToLower(t.Task)) + if err != nil { + return filterBySubstring(tasks, pattern) + } + if matched { + result = append(result, t) + } + } + return result +} diff --git a/internal/listing/filter_test.go b/internal/listing/filter_test.go new file mode 100644 index 0000000000..19fc4006fa --- /dev/null +++ b/internal/listing/filter_test.go @@ -0,0 +1,100 @@ +package listing_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/go-task/task/v3/internal/listing" + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestIsGlobPattern(t *testing.T) { + t.Parallel() + assert.True(t, listing.IsGlobPattern("docker:*")) + assert.True(t, listing.IsGlobPattern("test?")) + assert.True(t, listing.IsGlobPattern("[ab]")) + assert.False(t, listing.IsGlobPattern("docker")) + assert.False(t, listing.IsGlobPattern("")) +} + +func TestFilterTasks_EmptyPattern(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{newTask("build", "Build it")} + result := listing.FilterTasks(tasks, "") + assert.Equal(t, tasks, result) +} + +func TestFilterTasks_Substring(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("docker:build", "Build image"), + newTask("docker:push", "Push image"), + newTask("test:unit", "Run unit tests"), + newTask("lint", "Run linters"), + } + result := listing.FilterTasks(tasks, "docker") + assert.Equal(t, []string{"docker:build", "docker:push"}, taskNames(result)) +} + +func TestFilterTasks_SubstringCaseInsensitive(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("Docker:Build", "Build image"), + newTask("lint", "Run linters"), + } + result := listing.FilterTasks(tasks, "docker") + assert.Equal(t, []string{"Docker:Build"}, taskNames(result)) +} + +func TestFilterTasks_MatchesDescription(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("build", "Build the Docker image"), + newTask("lint", "Run linters"), + } + result := listing.FilterTasks(tasks, "docker") + assert.Equal(t, []string{"build"}, taskNames(result)) +} + +func TestFilterTasks_NamespacePrefix(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("docker:build", "Build image"), + newTask("docker:push", "Push image"), + newTask("undocker", "Not a namespace match but substring"), + } + result := listing.FilterTasks(tasks, "docker") + assert.Equal(t, []string{"docker:build", "docker:push", "undocker"}, taskNames(result)) +} + +func TestFilterTasks_Glob(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("docker:build", "Build image"), + newTask("docker:push", "Push image"), + newTask("test:unit", "Run unit tests"), + newTask("lint", "Run linters"), + } + result := listing.FilterTasks(tasks, "docker:*") + assert.Equal(t, []string{"docker:build", "docker:push"}, taskNames(result)) +} + +func TestFilterTasks_GlobNoMatch(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("build", "Build it"), + newTask("lint", "Run linters"), + } + result := listing.FilterTasks(tasks, "xyz:*") + assert.Empty(t, result) +} + +func TestFilterTasks_NoMatch(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("build", "Build it"), + } + result := listing.FilterTasks(tasks, "zzzzz") + assert.Empty(t, result) +} diff --git a/internal/listing/helpers_test.go b/internal/listing/helpers_test.go new file mode 100644 index 0000000000..4996bb6539 --- /dev/null +++ b/internal/listing/helpers_test.go @@ -0,0 +1,15 @@ +package listing_test + +import "github.com/go-task/task/v3/taskfile/ast" + +func newTask(name, desc string) *ast.Task { + return &ast.Task{Task: name, Desc: desc} +} + +func taskNames(tasks []*ast.Task) []string { + names := make([]string, len(tasks)) + for i, t := range tasks { + names[i] = t.Task + } + return names +} diff --git a/internal/listing/tree.go b/internal/listing/tree.go new file mode 100644 index 0000000000..53cbcf2ead --- /dev/null +++ b/internal/listing/tree.go @@ -0,0 +1,62 @@ +package listing + +import ( + "strings" + + "github.com/go-task/task/v3/taskfile/ast" +) + +// TaskGroup represents a set of tasks under a common top-level namespace. +type TaskGroup struct { + Namespace string + Tasks []*ast.Task +} + +func (g TaskGroup) LocalName(t *ast.Task) string { + if g.Namespace == "" { + return t.Task + } + return strings.TrimPrefix(t.Task, g.Namespace+":") +} + +// GroupByNamespace partitions tasks into groups by their top-level namespace. +func GroupByNamespace(tasks []*ast.Task) []TaskGroup { + groupMap := make(map[string]int) + var groups []TaskGroup + for _, t := range tasks { + ns := topLevelNamespace(t.Task) + idx, exists := groupMap[ns] + if !exists { + idx = len(groups) + groupMap[ns] = idx + groups = append(groups, TaskGroup{Namespace: ns}) + } + groups[idx].Tasks = append(groups[idx].Tasks, t) + } + return groups +} + +func HasNamespacedGroups(groups []TaskGroup) bool { + for _, g := range groups { + if g.Namespace != "" { + return true + } + } + return false +} + +func HasRootGroup(groups []TaskGroup) bool { + for _, g := range groups { + if g.Namespace == "" { + return true + } + } + return false +} + +func topLevelNamespace(taskName string) string { + if ns, _, found := strings.Cut(taskName, ":"); found { + return ns + } + return "" +} diff --git a/internal/listing/tree_test.go b/internal/listing/tree_test.go new file mode 100644 index 0000000000..9b337a40a7 --- /dev/null +++ b/internal/listing/tree_test.go @@ -0,0 +1,101 @@ +package listing_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/go-task/task/v3/internal/listing" + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestGroupByNamespace_Empty(t *testing.T) { + t.Parallel() + groups := listing.GroupByNamespace(nil) + assert.Empty(t, groups) +} + +func TestGroupByNamespace_OnlyRoot(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("build", "Build"), + newTask("test", "Test"), + } + groups := listing.GroupByNamespace(tasks) + assert.Len(t, groups, 1) + assert.Equal(t, "", groups[0].Namespace) + assert.Len(t, groups[0].Tasks, 2) +} + +func TestGroupByNamespace_OnlyNamespaced(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("ns1:build", "Build"), + newTask("ns1:test", "Test"), + newTask("ns2:deploy", "Deploy"), + } + groups := listing.GroupByNamespace(tasks) + assert.Len(t, groups, 2) + assert.Equal(t, "ns1", groups[0].Namespace) + assert.Len(t, groups[0].Tasks, 2) + assert.Equal(t, "ns2", groups[1].Namespace) + assert.Len(t, groups[1].Tasks, 1) +} + +func TestGroupByNamespace_Mixed(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("build", "Build"), + newTask("ns1:lint", "Lint"), + newTask("ns1:test", "Test"), + newTask("deploy", "Deploy"), + } + groups := listing.GroupByNamespace(tasks) + assert.Len(t, groups, 2) + assert.Equal(t, "", groups[0].Namespace) + assert.Len(t, groups[0].Tasks, 2) + assert.Equal(t, "ns1", groups[1].Namespace) + assert.Len(t, groups[1].Tasks, 2) +} + +func TestGroupByNamespace_PreservesOrder(t *testing.T) { + t.Parallel() + tasks := []*ast.Task{ + newTask("z:first", ""), + newTask("a:second", ""), + newTask("m:third", ""), + } + groups := listing.GroupByNamespace(tasks) + assert.Len(t, groups, 3) + assert.Equal(t, "z", groups[0].Namespace) + assert.Equal(t, "a", groups[1].Namespace) + assert.Equal(t, "m", groups[2].Namespace) +} + +func TestLocalName_Namespaced(t *testing.T) { + t.Parallel() + g := listing.TaskGroup{Namespace: "ns1"} + task := newTask("ns1:build", "Build") + assert.Equal(t, "build", g.LocalName(task)) +} + +func TestLocalName_Root(t *testing.T) { + t.Parallel() + g := listing.TaskGroup{Namespace: ""} + task := newTask("build", "Build") + assert.Equal(t, "build", g.LocalName(task)) +} + +func TestHasNamespacedGroups(t *testing.T) { + t.Parallel() + assert.False(t, listing.HasNamespacedGroups([]listing.TaskGroup{{Namespace: ""}})) + assert.True(t, listing.HasNamespacedGroups([]listing.TaskGroup{{Namespace: "ns"}})) + assert.True(t, listing.HasNamespacedGroups([]listing.TaskGroup{{Namespace: ""}, {Namespace: "ns"}})) +} + +func TestHasRootGroup(t *testing.T) { + t.Parallel() + assert.True(t, listing.HasRootGroup([]listing.TaskGroup{{Namespace: ""}})) + assert.False(t, listing.HasRootGroup([]listing.TaskGroup{{Namespace: "ns"}})) + assert.True(t, listing.HasRootGroup([]listing.TaskGroup{{Namespace: ""}, {Namespace: "ns"}})) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 2c962f4ea3..d6784b2f27 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -35,6 +35,8 @@ var ( attrsFgHiYellow = envColor("COLOR_BRIGHT_YELLOW", color.FgHiYellow) attrsFgHiMagenta = envColor("COLOR_BRIGHT_MAGENTA", color.FgHiMagenta) attrsFgHiRed = envColor("COLOR_BRIGHT_RED", color.FgHiRed) + attrsBold = envColor("COLOR_BOLD", color.Bold) + attrsDim = envColor("COLOR_DIM", color.Faint) ) type ( @@ -100,6 +102,14 @@ func BrightRed() PrintFunc { return color.New(attrsFgHiRed...).FprintfFunc() } +func Bold() PrintFunc { + return color.New(attrsBold...).FprintfFunc() +} + +func Dim() PrintFunc { + return color.New(attrsDim...).FprintfFunc() +} + func envColor(name string, defaultColor color.Attribute) []color.Attribute { // Fetch the environment variable override := env.GetTaskEnv(name) diff --git a/testdata/json_list_long/Taskfile.yml b/testdata/json_list_long/Taskfile.yml new file mode 100644 index 0000000000..10c5a4a57c --- /dev/null +++ b/testdata/json_list_long/Taskfile.yml @@ -0,0 +1,17 @@ +version: '3' + +tasks: + build: + desc: Build the project + deps: + - lint + requires: + vars: + - VERSION + cmds: + - go build ./... + + lint: + desc: Run linters + cmds: + - golangci-lint run diff --git a/testdata/json_list_long/testdata/TestJsonListLong.golden b/testdata/json_list_long/testdata/TestJsonListLong.golden new file mode 100644 index 0000000000..254c16bd08 --- /dev/null +++ b/testdata/json_list_long/testdata/TestJsonListLong.golden @@ -0,0 +1,39 @@ +{ + "tasks": [ + { + "name": "build", + "task": "build", + "desc": "Build the project", + "summary": "", + "aliases": [], + "up_to_date": false, + "location": { + "line": 4, + "column": 3, + "taskfile": "{{.TEST_DIR}}/testdata/json_list_long/Taskfile.yml" + }, + "deps": [ + "lint" + ], + "requires": [ + { + "name": "VERSION" + } + ] + }, + { + "name": "lint", + "task": "lint", + "desc": "Run linters", + "summary": "", + "aliases": [], + "up_to_date": false, + "location": { + "line": 14, + "column": 3, + "taskfile": "{{.TEST_DIR}}/testdata/json_list_long/Taskfile.yml" + } + } + ], + "location": "{{.TEST_DIR}}/testdata/json_list_long/Taskfile.yml" +} diff --git a/testdata/list_filter/Taskfile.yml b/testdata/list_filter/Taskfile.yml new file mode 100644 index 0000000000..71fa1b6e17 --- /dev/null +++ b/testdata/list_filter/Taskfile.yml @@ -0,0 +1,22 @@ +version: '3' + +tasks: + docker:build: + desc: Build the Docker image + cmds: + - docker build . + + docker:push: + desc: Push the Docker image + cmds: + - docker push + + test:unit: + desc: Run unit tests + cmds: + - go test ./... + + lint: + desc: Run linters + cmds: + - golangci-lint run diff --git a/testdata/list_filter/testdata/TestListFilter.golden b/testdata/list_filter/testdata/TestListFilter.golden new file mode 100644 index 0000000000..1c68babaf5 --- /dev/null +++ b/testdata/list_filter/testdata/TestListFilter.golden @@ -0,0 +1,3 @@ +task: Available tasks for this project: +* docker:build: Build the Docker image +* docker:push: Push the Docker image diff --git a/testdata/list_filter/testdata/TestListFilterNoMatch.golden b/testdata/list_filter/testdata/TestListFilterNoMatch.golden new file mode 100644 index 0000000000..2573ec0a20 --- /dev/null +++ b/testdata/list_filter/testdata/TestListFilterNoMatch.golden @@ -0,0 +1 @@ +task: No tasks matching "nonexistent" diff --git a/testdata/list_long/Taskfile.yml b/testdata/list_long/Taskfile.yml new file mode 100644 index 0000000000..1d0c03cb56 --- /dev/null +++ b/testdata/list_long/Taskfile.yml @@ -0,0 +1,39 @@ +version: '3' + +tasks: + build: + desc: Build the project + summary: | + Compiles the Go binary for the current platform. + deps: + - lint + - test:unit + cmds: + - go build ./... + + lint: + desc: Run linters + cmds: + - golangci-lint run + + test:unit: + desc: Run unit tests + requires: + vars: + - COVERAGE + cmds: + - go test ./... + + deploy: + desc: Deploy the application + deps: + - build + requires: + vars: + - VERSION + - name: ENV + enum: [staging, production] + summary: | + Deploys the built artifact to the target environment. + cmds: + - echo "deploying" diff --git a/testdata/list_long/testdata/TestListLong.golden b/testdata/list_long/testdata/TestListLong.golden new file mode 100644 index 0000000000..38feeefa2f --- /dev/null +++ b/testdata/list_long/testdata/TestListLong.golden @@ -0,0 +1,11 @@ +task: Available tasks for this project: +* build: Build the project + deps: lint, test:unit + summary: Compiles the Go binary for the current platform. +* deploy: Deploy the application + requires: VERSION, ENV (enum: staging, production) + deps: build + summary: Deploys the built artifact to the target environment. +* lint: Run linters +* test:unit: Run unit tests + requires: COVERAGE diff --git a/testdata/list_requires/Taskfile.yml b/testdata/list_requires/Taskfile.yml new file mode 100644 index 0000000000..8f4578deb0 --- /dev/null +++ b/testdata/list_requires/Taskfile.yml @@ -0,0 +1,17 @@ +version: '3' + +tasks: + deploy: + desc: Deploy the application + requires: + vars: + - VERSION + - name: REGISTRY + enum: [ecr, gcr, dockerhub] + cmds: + - echo "deploying {{.VERSION}} to {{.REGISTRY}}" + + build: + desc: Build the project + cmds: + - echo "building" diff --git a/testdata/list_requires/testdata/TestListRequires.golden b/testdata/list_requires/testdata/TestListRequires.golden new file mode 100644 index 0000000000..47d1976707 --- /dev/null +++ b/testdata/list_requires/testdata/TestListRequires.golden @@ -0,0 +1,4 @@ +task: Available tasks for this project: +* build: Build the project +* deploy: Deploy the application + requires: VERSION, REGISTRY (enum: ecr, gcr, dockerhub) diff --git a/testdata/list_tree/Taskfile.yml b/testdata/list_tree/Taskfile.yml new file mode 100644 index 0000000000..9084b86774 --- /dev/null +++ b/testdata/list_tree/Taskfile.yml @@ -0,0 +1,36 @@ +version: '3' + +tasks: + build: + desc: Build the project + aliases: [b] + cmds: + - go build ./... + + deploy: + desc: Deploy the application + requires: + vars: + - ENV + cmds: + - echo "deploying" + + docker:build: + desc: Build the Docker image + cmds: + - docker build . + + docker:push: + desc: Push the Docker image + cmds: + - docker push + + k8s:apply: + desc: Apply Kubernetes manifests + cmds: + - kubectl apply -f . + + k8s:rollout: + desc: Watch rollout status + cmds: + - kubectl rollout status diff --git a/testdata/list_tree/testdata/TestListTree.golden b/testdata/list_tree/testdata/TestListTree.golden new file mode 100644 index 0000000000..aefba380b5 --- /dev/null +++ b/testdata/list_tree/testdata/TestListTree.golden @@ -0,0 +1,14 @@ +task: Available tasks for this project: + docker + build: Build the Docker image + push: Push the Docker image + + k8s + apply: Apply Kubernetes manifests + rollout: Watch rollout status + + ───── + + build: Build the project (aliases: b) + deploy: Deploy the application + requires: ENV diff --git a/testdata/list_tree_filter/Taskfile.yml b/testdata/list_tree_filter/Taskfile.yml new file mode 100644 index 0000000000..93a3f70075 --- /dev/null +++ b/testdata/list_tree_filter/Taskfile.yml @@ -0,0 +1,44 @@ +version: '3' + +tasks: + docker:build: + desc: Build the Docker image + aliases: [db] + deps: + - lint + summary: | + Builds a production Docker image using the project Dockerfile. + cmds: + - docker build . + + docker:push: + desc: Push the Docker image + requires: + vars: + - REGISTRY + cmds: + - docker push + + test:unit: + desc: Run unit tests + cmds: + - go test ./... + + test:integration: + desc: Run integration tests + deps: + - docker:build + cmds: + - go test -tags=integration ./... + + build: + desc: Build the project + deps: + - lint + cmds: + - go build ./... + + lint: + desc: Run linters + cmds: + - golangci-lint run diff --git a/testdata/list_tree_filter/testdata/TestListFlatFilterLong.golden b/testdata/list_tree_filter/testdata/TestListFlatFilterLong.golden new file mode 100644 index 0000000000..1bc5bc7be5 --- /dev/null +++ b/testdata/list_tree_filter/testdata/TestListFlatFilterLong.golden @@ -0,0 +1,6 @@ +task: Available tasks for this project: +* docker:build: Build the Docker image (aliases: db) + deps: lint + summary: Builds a production Docker image using the project Dockerfile. +* docker:push: Push the Docker image + requires: REGISTRY diff --git a/testdata/list_tree_filter/testdata/TestListTreeFilter.golden b/testdata/list_tree_filter/testdata/TestListTreeFilter.golden new file mode 100644 index 0000000000..bfd097a632 --- /dev/null +++ b/testdata/list_tree_filter/testdata/TestListTreeFilter.golden @@ -0,0 +1,5 @@ +task: Available tasks for this project: + docker + build: Build the Docker image (aliases: db) + push: Push the Docker image + requires: REGISTRY diff --git a/testdata/list_tree_filter/testdata/TestListTreeFilterLong.golden b/testdata/list_tree_filter/testdata/TestListTreeFilterLong.golden new file mode 100644 index 0000000000..a8ef050c55 --- /dev/null +++ b/testdata/list_tree_filter/testdata/TestListTreeFilterLong.golden @@ -0,0 +1,7 @@ +task: Available tasks for this project: + docker + build: Build the Docker image (aliases: db) + deps: lint + summary: Builds a production Docker image using the project Dockerfile. + push: Push the Docker image + requires: REGISTRY diff --git a/testdata/list_tree_long/Taskfile.yml b/testdata/list_tree_long/Taskfile.yml new file mode 100644 index 0000000000..bb1cf9d478 --- /dev/null +++ b/testdata/list_tree_long/Taskfile.yml @@ -0,0 +1,48 @@ +version: '3' + +tasks: + build: + desc: Build the project + summary: | + Compiles the Go binary for the current platform. + deps: + - lint + - test:unit + cmds: + - go build ./... + + lint: + desc: Run linters + cmds: + - golangci-lint run + + test:unit: + desc: Run unit tests + requires: + vars: + - COVERAGE + cmds: + - go test ./... + + test:integration: + desc: Run integration tests + deps: + - build + summary: | + Runs the full integration test suite against a live environment. + cmds: + - go test -tags=integration ./... + + deploy: + desc: Deploy the application + deps: + - build + requires: + vars: + - VERSION + - name: ENV + enum: [staging, production] + summary: | + Deploys the built artifact to the target environment. + cmds: + - echo "deploying" diff --git a/testdata/list_tree_long/testdata/TestListTreeLong.golden b/testdata/list_tree_long/testdata/TestListTreeLong.golden new file mode 100644 index 0000000000..19b9c20228 --- /dev/null +++ b/testdata/list_tree_long/testdata/TestListTreeLong.golden @@ -0,0 +1,18 @@ +task: Available tasks for this project: + test + integration: Run integration tests + deps: build + summary: Runs the full integration test suite against a live environment. + unit: Run unit tests + requires: COVERAGE + + ───── + + build: Build the project + deps: lint, test:unit + summary: Compiles the Go binary for the current platform. + deploy: Deploy the application + requires: VERSION, ENV (enum: staging, production) + deps: build + summary: Deploys the built artifact to the target environment. + lint: Run linters diff --git a/variables.go b/variables.go index c7c6cc8493..9f33ddab4f 100644 --- a/variables.go +++ b/variables.go @@ -49,6 +49,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) { Desc: templater.Replace(origTask.Desc, cache), Prompt: templater.Replace(origTask.Prompt, cache), Summary: templater.Replace(origTask.Summary, cache), + Deps: origTask.Deps, Aliases: origTask.Aliases, Sources: origTask.Sources, Generates: origTask.Generates, diff --git a/website/src/docs/reference/cli.md b/website/src/docs/reference/cli.md index aeb417f1f4..e0f0dc4d0e 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -306,6 +306,40 @@ Change task listing order. Available modes: task --list --sort alphanumeric ``` +#### `-T, --tree` + +Display tasks grouped by namespace in a tree layout. Namespaced tasks are +indented beneath their namespace header. Use with `--list` or `--list-all`. + +```bash +task --list --tree +task -lT +``` + +#### `-L, --long` + +Show additional task details (dependencies, summary) beneath each task entry. +Use with `--list` or `--list-all`. Combines with `--tree`. + +```bash +task --list --long +task -lTL +``` + +#### Filter pattern + +An optional positional argument after `--list` or `--list-all` narrows output +to tasks whose name or description matches the given pattern. Matching is +case-insensitive. A namespace prefix (e.g. `docker`) includes all tasks under +that namespace. Glob metacharacters (`*`, `?`, `[`) trigger `path.Match` +semantics. Matched portions of task names are bolded in the output. + +```bash +task --list docker # substring match +task --list-all 'docker:*' # glob match +task -lT test # tree view, filtered +``` + ### Watch Mode #### `-w, --watch`