Skip to content

feat: wildcard match aliases #2234

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 38 additions & 33 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,19 +400,40 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func
}

// FindMatchingTasks returns a list of tasks that match the given call. A task
// matches a call if its name is equal to the call's task name or if it matches
// matches a call if its name is equal to the call's task name, or one of aliases, or if it matches
// a wildcard pattern. The function returns a list of MatchingTask structs, each
// containing a task and a list of wildcards that were matched.
func (e *Executor) FindMatchingTasks(call *Call) []*MatchingTask {
// If multiple tasks match due to aliases, a TaskNameConflictError is returned.
func (e *Executor) FindMatchingTasks(call *Call) ([]*MatchingTask, error) {
if call == nil {
return nil
return nil, nil
Comment on lines +407 to +409
Copy link
Preview

Copilot AI May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning nil for both matching tasks and error when 'call' is nil might be ambiguous for API consumers; consider returning an explicit error or adding documentation to clarify this behavior.

Copilot uses AI. Check for mistakes.

}
var matchingTasks []*MatchingTask
// If there is a direct match, return it
if task, ok := e.Taskfile.Tasks.Get(call.Task); ok {
matchingTasks = append(matchingTasks, &MatchingTask{Task: task, Wildcards: nil})
return matchingTasks
return matchingTasks, nil
}
var aliasedTasks []string
for task := range e.Taskfile.Tasks.Values(nil) {
if slices.Contains(task.Aliases, call.Task) {
aliasedTasks = append(aliasedTasks, task.Task)
matchingTasks = append(matchingTasks, &MatchingTask{Task: task, Wildcards: nil})
}
}

if len(aliasedTasks) == 1 {
return matchingTasks, nil
}

// If we found multiple tasks
if len(aliasedTasks) > 1 {
return nil, &errors.TaskNameConflictError{
Call: call.Task,
TaskNames: aliasedTasks,
}
}

// Attempt a wildcard match
for _, value := range e.Taskfile.Tasks.All(nil) {
if match, wildcards := value.WildcardMatch(call.Task); match {
Expand All @@ -422,15 +443,19 @@ func (e *Executor) FindMatchingTasks(call *Call) []*MatchingTask {
})
}
}
return matchingTasks
return matchingTasks, nil
}

// GetTask will return the task with the name matching the given call from the taskfile.
// If no task is found, it will search for tasks with a matching alias.
// If multiple tasks contain the same alias or no matches are found an error is returned.
func (e *Executor) GetTask(call *Call) (*ast.Task, error) {
// Search for a matching task
matchingTasks := e.FindMatchingTasks(call)
matchingTasks, err := e.FindMatchingTasks(call)
if err != nil {
return nil, err
}

if len(matchingTasks) > 0 {
if call.Vars == nil {
call.Vars = ast.NewVars()
Expand All @@ -439,35 +464,15 @@ func (e *Executor) GetTask(call *Call) (*ast.Task, error) {
return matchingTasks[0].Task, nil
}

// If didn't find one, search for a task with a matching alias
var matchingTask *ast.Task
var aliasedTasks []string
for task := range e.Taskfile.Tasks.Values(nil) {
if slices.Contains(task.Aliases, call.Task) {
aliasedTasks = append(aliasedTasks, task.Task)
matchingTask = task
}
}
// If we found multiple tasks
if len(aliasedTasks) > 1 {
return nil, &errors.TaskNameConflictError{
Call: call.Task,
TaskNames: aliasedTasks,
}
}
// If we found no tasks
if len(aliasedTasks) == 0 {
didYouMean := ""
if e.fuzzyModel != nil {
didYouMean = e.fuzzyModel.SpellCheck(call.Task)
}
return nil, &errors.TaskNotFoundError{
TaskName: call.Task,
DidYouMean: didYouMean,
}
didYouMean := ""
if e.fuzzyModel != nil {
didYouMean = e.fuzzyModel.SpellCheck(call.Task)
}
return nil, &errors.TaskNotFoundError{
TaskName: call.Task,
DidYouMean: didYouMean,
}

return matchingTask, nil
}

type FilterFunc func(task *ast.Task) bool
Expand Down
5 changes: 5 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,11 @@ func TestWildcard(t *testing.T) {
call: "start-foo",
expectedOutput: "Starting foo\n",
},
{
name: "alias",
call: "s-foo",
expectedOutput: "Starting foo\n",
},
{
name: "matches exactly",
call: "matches-exactly-*",
Expand Down
35 changes: 19 additions & 16 deletions taskfile/ast/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,26 +64,29 @@ func (t *Task) LocalName() string {

// WildcardMatch will check if the given string matches the name of the Task and returns any wildcard values.
func (t *Task) WildcardMatch(name string) (bool, []string) {
// Convert the name into a regex string
regexStr := fmt.Sprintf("^%s$", strings.ReplaceAll(t.Task, "*", "(.*)"))
regex := regexp.MustCompile(regexStr)
wildcards := regex.FindStringSubmatch(name)
wildcardCount := strings.Count(t.Task, "*")

// If there are no wildcards, return false
if len(wildcards) == 0 {
return false, nil
}
names := append([]string{t.Task}, t.Aliases...)

for _, taskName := range names {
Comment on lines +67 to +69
Copy link
Preview

Copilot AI May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider renaming 'names' to 'candidateNames' to improve clarity when iterating over both task names and aliases.

Suggested change
names := append([]string{t.Task}, t.Aliases...)
for _, taskName := range names {
candidateNames := append([]string{t.Task}, t.Aliases...)
for _, taskName := range candidateNames {

Copilot uses AI. Check for mistakes.

regexStr := fmt.Sprintf("^%s$", strings.ReplaceAll(taskName, "*", "(.*)"))
regex := regexp.MustCompile(regexStr)
wildcards := regex.FindStringSubmatch(name)

if len(wildcards) == 0 {
continue
}

// Remove the first match, which is the full string
wildcards = wildcards[1:]
wildcardCount := strings.Count(taskName, "*")

// Remove the first match, which is the full string
wildcards = wildcards[1:]
if len(wildcards) != wildcardCount {
continue
}

// If there are more/less wildcards than matches, return false
if len(wildcards) != wildcardCount {
return false, wildcards
return true, wildcards
}

return true, wildcards
return false, nil
}

func (t *Task) UnmarshalYAML(node *yaml.Node) error {
Expand Down
2 changes: 2 additions & 0 deletions testdata/wildcards/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ tasks:
- "echo \"I don't consume matches: {{.MATCH}}\""

start-*:
aliases:
- s-*
vars:
SERVICE: "{{index .MATCH 0}}"
cmds:
Expand Down
21 changes: 21 additions & 0 deletions website/docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,27 @@ $ task start:foo:3
Starting foo with 3 replicas
```

Using wildcards with aliases
Wildcards also work with aliases. If a task has an alias, you can use the alias name with wildcards to capture arguments. For example:

```yaml
version: '3'

tasks:
start:*:
aliases: [run:*]
vars:
SERVICE: "{{index .MATCH 0}}"
cmds:
- echo "Running {{.SERVICE}}"
```
In this example, you can call the task using the alias run:*:

```shell
$ task run:foo
Running foo
```

## Doing task cleanup with `defer`

With the `defer` keyword, it's possible to schedule cleanup to be run once the
Expand Down
Loading