Skip to content

Commit 53bf8b4

Browse files
feat(list): add filter pattern and match highlighting
Adds an optional positional filter argument to --list and --list-all that narrows output by matching against task names and descriptions. Matched text is bolded in the output. Refs: #2091 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5830e66 commit 53bf8b4

14 files changed

Lines changed: 395 additions & 5 deletions

File tree

cmd/task/task.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ func run() error {
139139
return os.RemoveAll(cachePath)
140140
}
141141

142+
// Extract an optional filter pattern from positional args when listing
143+
var filterPattern string
144+
if flags.List || flags.ListAll {
145+
if positionalArgs := pflag.Args(); len(positionalArgs) > 0 {
146+
filterPattern = positionalArgs[0]
147+
}
148+
}
142149
listOptions := task.NewListOptions(
143150
flags.List,
144151
flags.ListAll,
@@ -147,6 +154,8 @@ func run() error {
147154
flags.Nested,
148155
flags.Long,
149156
flags.Tree,
157+
filterPattern,
158+
flags.TaskSort,
150159
)
151160
if listOptions.ShouldListTasks() {
152161
if flags.Silent {

formatter_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,80 @@ func TestListTreeLong(t *testing.T) {
291291
)
292292
}
293293

294+
func TestListFilter(t *testing.T) {
295+
t.Parallel()
296+
297+
NewFormatterTest(t,
298+
WithExecutorOptions(
299+
task.WithDir("testdata/list_filter"),
300+
),
301+
WithListOptions(task.ListOptions{
302+
ListOnlyTasksWithDescriptions: true,
303+
Filter: "docker",
304+
}),
305+
)
306+
}
307+
308+
func TestListFilterNoMatch(t *testing.T) {
309+
t.Parallel()
310+
311+
NewFormatterTest(t,
312+
WithExecutorOptions(
313+
task.WithDir("testdata/list_filter"),
314+
),
315+
WithListOptions(task.ListOptions{
316+
ListOnlyTasksWithDescriptions: true,
317+
Filter: "nonexistent",
318+
}),
319+
)
320+
}
321+
322+
func TestListTreeFilter(t *testing.T) {
323+
t.Parallel()
324+
325+
NewFormatterTest(t,
326+
WithExecutorOptions(
327+
task.WithDir("testdata/list_tree_filter"),
328+
),
329+
WithListOptions(task.ListOptions{
330+
ListOnlyTasksWithDescriptions: true,
331+
Tree: true,
332+
Filter: "docker",
333+
}),
334+
)
335+
}
336+
337+
func TestListTreeFilterLong(t *testing.T) {
338+
t.Parallel()
339+
340+
NewFormatterTest(t,
341+
WithExecutorOptions(
342+
task.WithDir("testdata/list_tree_filter"),
343+
),
344+
WithListOptions(task.ListOptions{
345+
ListOnlyTasksWithDescriptions: true,
346+
Tree: true,
347+
Long: true,
348+
Filter: "docker",
349+
}),
350+
)
351+
}
352+
353+
func TestListFlatFilterLong(t *testing.T) {
354+
t.Parallel()
355+
356+
NewFormatterTest(t,
357+
WithExecutorOptions(
358+
task.WithDir("testdata/list_tree_filter"),
359+
),
360+
WithListOptions(task.ListOptions{
361+
ListOnlyTasksWithDescriptions: true,
362+
Long: true,
363+
Filter: "docker",
364+
}),
365+
)
366+
}
367+
294368
func TestJsonListLong(t *testing.T) {
295369
t.Parallel()
296370

help.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ type ListOptions struct {
2828
Nested bool
2929
Long bool
3030
Tree bool
31+
Filter string
32+
SortMode string
3133
}
3234

3335
// NewListOptions creates a new ListOptions instance
34-
func NewListOptions(list, listAll, listAsJson, noStatus, nested, long, tree bool) ListOptions {
36+
func NewListOptions(list, listAll, listAsJson, noStatus, nested, long, tree bool, filter, sortMode string) ListOptions {
3537
return ListOptions{
3638
ListOnlyTasksWithDescriptions: list,
3739
ListAllTasks: listAll,
@@ -40,6 +42,8 @@ func NewListOptions(list, listAll, listAsJson, noStatus, nested, long, tree bool
4042
Nested: nested,
4143
Long: long,
4244
Tree: tree,
45+
Filter: filter,
46+
SortMode: sortMode,
4347
}
4448
}
4549

@@ -69,6 +73,9 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) {
6973
if err != nil {
7074
return false, err
7175
}
76+
if o.Filter != "" {
77+
tasks = listing.FilterTasks(tasks, o.Filter)
78+
}
7279
if o.FormatTaskListAsJSON {
7380
output, err := e.ToEditorOutput(tasks, o.NoStatus, o.Nested, o.Long)
7481
if err != nil {
@@ -84,7 +91,9 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) {
8491
return len(tasks) > 0, nil
8592
}
8693
if len(tasks) == 0 {
87-
if o.ListOnlyTasksWithDescriptions {
94+
if o.Filter != "" {
95+
e.Logger.Outf(logger.Yellow, "task: No tasks matching %q\n", o.Filter)
96+
} else if o.ListOnlyTasksWithDescriptions {
8897
e.Logger.Outf(logger.Yellow, "task: No tasks with description available. Try --list-all to list all tasks\n")
8998
} else if o.ListAllTasks {
9099
e.Logger.Outf(logger.Yellow, "task: No tasks available\n")
@@ -100,7 +109,7 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) {
100109
w := tabwriter.NewWriter(e.Stdout, 0, 8, 6, ' ', 0)
101110
for _, task := range tasks {
102111
e.Logger.FOutf(w, logger.Yellow, "* ")
103-
e.Logger.FOutf(w, logger.Green, task.Task)
112+
e.writeHighlighted(w, logger.Green, task.Task, o.Filter)
104113
desc := strings.ReplaceAll(task.Desc, "\n", " ")
105114
e.Logger.FOutf(w, logger.Default, ": \t%s", desc)
106115
if len(task.Aliases) > 0 {
@@ -115,6 +124,21 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) {
115124
return true, nil
116125
}
117126

127+
func (e *Executor) writeHighlighted(w io.Writer, baseColor logger.Color, text, filter string) {
128+
if filter == "" || listing.IsGlobPattern(filter) {
129+
e.Logger.FOutf(w, baseColor, "%s", text)
130+
return
131+
}
132+
idx := strings.Index(strings.ToLower(text), strings.ToLower(filter))
133+
if idx == -1 {
134+
e.Logger.FOutf(w, baseColor, "%s", text)
135+
return
136+
}
137+
e.Logger.FOutf(w, baseColor, "%s", text[:idx])
138+
e.Logger.FOutf(w, logger.Bold, "%s", text[idx:idx+len(filter)])
139+
e.Logger.FOutf(w, baseColor, "%s", text[idx+len(filter):])
140+
}
141+
118142
func (e *Executor) writeTaskDetails(w io.Writer, task *ast.Task, indent string, long bool) {
119143
if listing.HasRequires(task) {
120144
e.Logger.FOutf(w, logger.Default, indent)
@@ -173,7 +197,7 @@ func (e *Executor) listTasksTree(tasks []*ast.Task, o ListOptions) (bool, error)
173197
groups := listing.GroupByNamespace(tasks)
174198
hasNamespaced := listing.HasNamespacedGroups(groups)
175199
hasRoot := listing.HasRootGroup(groups)
176-
showSeparator := hasNamespaced && hasRoot
200+
showSeparator := hasNamespaced && hasRoot && (o.SortMode == "" || o.SortMode == "default")
177201

178202
// Move root group to end so namespaced groups appear first
179203
if showSeparator {
@@ -212,7 +236,8 @@ func (e *Executor) listTasksTree(tasks []*ast.Task, o ListOptions) (bool, error)
212236
if task.Internal {
213237
nameColor = logger.Dim
214238
}
215-
e.Logger.FOutf(w, nameColor, "%s%s", indent, name)
239+
e.Logger.FOutf(w, nameColor, "%s", indent)
240+
e.writeHighlighted(w, nameColor, name, o.Filter)
216241
e.Logger.FOutf(w, logger.Default, ":\t%s", desc)
217242
if len(task.Aliases) > 0 {
218243
e.Logger.FOutf(w, logger.Cyan, "\t(aliases: %s)", strings.Join(task.Aliases, ", "))

internal/listing/filter.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package listing
2+
3+
import (
4+
"path"
5+
"strings"
6+
7+
"github.com/go-task/task/v3/taskfile/ast"
8+
)
9+
10+
func IsGlobPattern(pattern string) bool {
11+
return strings.ContainsAny(pattern, "*?[")
12+
}
13+
14+
// FilterTasks returns tasks whose name or description matches the pattern.
15+
func FilterTasks(tasks []*ast.Task, pattern string) []*ast.Task {
16+
if pattern == "" {
17+
return tasks
18+
}
19+
if IsGlobPattern(pattern) {
20+
return filterByGlob(tasks, pattern)
21+
}
22+
return filterBySubstring(tasks, pattern)
23+
}
24+
25+
func filterBySubstring(tasks []*ast.Task, pattern string) []*ast.Task {
26+
lower := strings.ToLower(pattern)
27+
var result []*ast.Task
28+
for _, t := range tasks {
29+
nameLower := strings.ToLower(t.Task)
30+
descLower := strings.ToLower(t.Desc)
31+
if strings.Contains(nameLower, lower) ||
32+
strings.Contains(descLower, lower) {
33+
result = append(result, t)
34+
}
35+
}
36+
return result
37+
}
38+
39+
func filterByGlob(tasks []*ast.Task, pattern string) []*ast.Task {
40+
lowerPattern := strings.ToLower(pattern)
41+
var result []*ast.Task
42+
for _, t := range tasks {
43+
matched, err := path.Match(lowerPattern, strings.ToLower(t.Task))
44+
if err != nil {
45+
return filterBySubstring(tasks, pattern)
46+
}
47+
if matched {
48+
result = append(result, t)
49+
}
50+
}
51+
return result
52+
}

internal/listing/filter_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package listing_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/go-task/task/v3/internal/listing"
9+
"github.com/go-task/task/v3/taskfile/ast"
10+
)
11+
12+
func TestIsGlobPattern(t *testing.T) {
13+
t.Parallel()
14+
assert.True(t, listing.IsGlobPattern("docker:*"))
15+
assert.True(t, listing.IsGlobPattern("test?"))
16+
assert.True(t, listing.IsGlobPattern("[ab]"))
17+
assert.False(t, listing.IsGlobPattern("docker"))
18+
assert.False(t, listing.IsGlobPattern(""))
19+
}
20+
21+
func TestFilterTasks_EmptyPattern(t *testing.T) {
22+
t.Parallel()
23+
tasks := []*ast.Task{newTask("build", "Build it")}
24+
result := listing.FilterTasks(tasks, "")
25+
assert.Equal(t, tasks, result)
26+
}
27+
28+
func TestFilterTasks_Substring(t *testing.T) {
29+
t.Parallel()
30+
tasks := []*ast.Task{
31+
newTask("docker:build", "Build image"),
32+
newTask("docker:push", "Push image"),
33+
newTask("test:unit", "Run unit tests"),
34+
newTask("lint", "Run linters"),
35+
}
36+
result := listing.FilterTasks(tasks, "docker")
37+
assert.Equal(t, []string{"docker:build", "docker:push"}, taskNames(result))
38+
}
39+
40+
func TestFilterTasks_SubstringCaseInsensitive(t *testing.T) {
41+
t.Parallel()
42+
tasks := []*ast.Task{
43+
newTask("Docker:Build", "Build image"),
44+
newTask("lint", "Run linters"),
45+
}
46+
result := listing.FilterTasks(tasks, "docker")
47+
assert.Equal(t, []string{"Docker:Build"}, taskNames(result))
48+
}
49+
50+
func TestFilterTasks_MatchesDescription(t *testing.T) {
51+
t.Parallel()
52+
tasks := []*ast.Task{
53+
newTask("build", "Build the Docker image"),
54+
newTask("lint", "Run linters"),
55+
}
56+
result := listing.FilterTasks(tasks, "docker")
57+
assert.Equal(t, []string{"build"}, taskNames(result))
58+
}
59+
60+
func TestFilterTasks_NamespacePrefix(t *testing.T) {
61+
t.Parallel()
62+
tasks := []*ast.Task{
63+
newTask("docker:build", "Build image"),
64+
newTask("docker:push", "Push image"),
65+
newTask("undocker", "Not a namespace match but substring"),
66+
}
67+
result := listing.FilterTasks(tasks, "docker")
68+
assert.Equal(t, []string{"docker:build", "docker:push", "undocker"}, taskNames(result))
69+
}
70+
71+
func TestFilterTasks_Glob(t *testing.T) {
72+
t.Parallel()
73+
tasks := []*ast.Task{
74+
newTask("docker:build", "Build image"),
75+
newTask("docker:push", "Push image"),
76+
newTask("test:unit", "Run unit tests"),
77+
newTask("lint", "Run linters"),
78+
}
79+
result := listing.FilterTasks(tasks, "docker:*")
80+
assert.Equal(t, []string{"docker:build", "docker:push"}, taskNames(result))
81+
}
82+
83+
func TestFilterTasks_GlobNoMatch(t *testing.T) {
84+
t.Parallel()
85+
tasks := []*ast.Task{
86+
newTask("build", "Build it"),
87+
newTask("lint", "Run linters"),
88+
}
89+
result := listing.FilterTasks(tasks, "xyz:*")
90+
assert.Empty(t, result)
91+
}
92+
93+
func TestFilterTasks_NoMatch(t *testing.T) {
94+
t.Parallel()
95+
tasks := []*ast.Task{
96+
newTask("build", "Build it"),
97+
}
98+
result := listing.FilterTasks(tasks, "zzzzz")
99+
assert.Empty(t, result)
100+
}

internal/listing/helpers_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,11 @@ import "github.com/go-task/task/v3/taskfile/ast"
55
func newTask(name, desc string) *ast.Task {
66
return &ast.Task{Task: name, Desc: desc}
77
}
8+
9+
func taskNames(tasks []*ast.Task) []string {
10+
names := make([]string, len(tasks))
11+
for i, t := range tasks {
12+
names[i] = t.Task
13+
}
14+
return names
15+
}

testdata/list_filter/Taskfile.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
version: '3'
2+
3+
tasks:
4+
docker:build:
5+
desc: Build the Docker image
6+
cmds:
7+
- docker build .
8+
9+
docker:push:
10+
desc: Push the Docker image
11+
cmds:
12+
- docker push
13+
14+
test:unit:
15+
desc: Run unit tests
16+
cmds:
17+
- go test ./...
18+
19+
lint:
20+
desc: Run linters
21+
cmds:
22+
- golangci-lint run
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
task: Available tasks for this project:
2+
* docker:build: Build the Docker image
3+
* docker:push: Push the Docker image
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
task: No tasks matching "nonexistent"

0 commit comments

Comments
 (0)