Skip to content
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
6 changes: 4 additions & 2 deletions experiments/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ const envPrefix = "TASK_X_"

// Active experiments.
var (
GentleForce Experiment
EnvPrecedence Experiment
GentleForce Experiment
EnvPrecedence Experiment
PrefixMatching Experiment
)

// Inactive experiments. These are experiments that cannot be enabled, but are
Expand All @@ -42,6 +43,7 @@ func ParseWithConfig(dir string, config *ast.TaskRC) {
// Initialize the experiments
GentleForce = New("GENTLE_FORCE", config, 1)
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
PrefixMatching = New("PREFIX_MATCHING", config, 1)
// Inactive experiments
AnyVariables = NewReleased("ANY_VARIABLES", config)
MapVariables = NewReleased("MAP_VARIABLES", config)
Expand Down
163 changes: 163 additions & 0 deletions prefix_matching_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package task_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/go-task/task/v3"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/taskfile/ast"
)

func TestPrefixMatching(t *testing.T) {
tasks := ast.NewTasks(
&ast.TaskElement{
Key: "api:openapi:export",
Value: &ast.Task{
Task: "api:openapi:export",
},
},
&ast.TaskElement{
Key: "api:openapi:import",
Value: &ast.Task{
Task: "api:openapi:import",
},
},
&ast.TaskElement{
Key: "docker:build:production",
Value: &ast.Task{
Task: "docker:build:production",
Aliases: []string{"d:b:prod"},
},
},
&ast.TaskElement{
Key: "docker:build:staging",
Value: &ast.Task{
Task: "docker:build:staging",
},
},
&ast.TaskElement{
Key: "build",
Value: &ast.Task{
Task: "build",
},
},
&ast.TaskElement{
Key: "internal:secret",
Value: &ast.Task{
Task: "internal:secret",
Internal: true,
},
},
&ast.TaskElement{
Key: "wild-*",
Value: &ast.Task{
Task: "wild-*",
},
},
)

taskfile := &ast.Taskfile{
Tasks: tasks,
}

e := &task.Executor{
Taskfile: taskfile,
}

t.Run("Experiment Disabled", func(t *testing.T) {
// When experiment is not enabled, prefix matching shouldn't happen
matching, err := e.FindMatchingTasks(&task.Call{Task: "a:o:e"})
require.NoError(t, err)
assert.Empty(t, matching)

matching, err = e.FindMatchingTasks(&task.Call{Task: "b"})
require.NoError(t, err)
// "b" should only match if exact task name exists (it doesn't, exact is "build")
assert.Empty(t, matching)
})

t.Run("Experiment Enabled", func(t *testing.T) {
enableExperimentForTest(t, &experiments.PrefixMatching, 1)

t.Run("Unique match with equal segments (m=n)", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "a:o:e"})
require.NoError(t, err)
require.Len(t, matching, 1)
assert.Equal(t, "api:openapi:export", matching[0].Task.Task)
})

t.Run("Unique match with fewer segments (m<n)", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "d:b:s"})
require.NoError(t, err)
require.Len(t, matching, 1)
assert.Equal(t, "docker:build:staging", matching[0].Task.Task)
})

t.Run("Unique match single segment", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "b"})
require.NoError(t, err)
require.Len(t, matching, 1)
assert.Equal(t, "build", matching[0].Task.Task)
})

t.Run("Unique match via alias prefix", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "d:b:p"})
require.NoError(t, err)
require.Len(t, matching, 1)
assert.Equal(t, "docker:build:production", matching[0].Task.Task)
})

t.Run("Ambiguous match returns TaskNameConflictError", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "a:o"})
assert.Nil(t, matching)
require.Error(t, err)

var conflictErr *errors.TaskNameConflictError
require.ErrorAs(t, err, &conflictErr)
assert.Equal(t, "a:o", conflictErr.Call)
assert.ElementsMatch(t, []string{"api:openapi:export", "api:openapi:import"}, conflictErr.TaskNames)
})

t.Run("Ambiguous match single segment returns TaskNameConflictError", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "doc"})
assert.Nil(t, matching)
require.Error(t, err)

var conflictErr *errors.TaskNameConflictError
require.ErrorAs(t, err, &conflictErr)
assert.Equal(t, "doc", conflictErr.Call)
assert.ElementsMatch(t, []string{"docker:build:production", "docker:build:staging"}, conflictErr.TaskNames)
})

t.Run("Internal tasks are ignored", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "int:s"})
require.NoError(t, err)
assert.Empty(t, matching)
})

t.Run("Exact match takes precedence", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "build"})
require.NoError(t, err)
require.Len(t, matching, 1)
assert.Equal(t, "build", matching[0].Task.Task)
})

t.Run("Wildcard match takes precedence", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "wild-test"})
require.NoError(t, err)
require.Len(t, matching, 1)
assert.Equal(t, "wild-*", matching[0].Task.Task)
assert.Equal(t, []string{"test"}, matching[0].Wildcards)
})

t.Run("No match returns empty slice without error", func(t *testing.T) {
matching, err := e.FindMatchingTasks(&task.Call{Task: "nonexistent"})
require.NoError(t, err)
assert.Empty(t, matching)
})
})
}
38 changes: 35 additions & 3 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"mvdan.cc/sh/v3/interp"

"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/logger"
Expand Down Expand Up @@ -528,10 +529,11 @@ 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 one of aliases, or if it matches
// a wildcard pattern. The function returns a list of MatchingTask structs, each
// matches a call if its name is equal to the call's task name, one of its aliases,
// matches a wildcard pattern, or (if PREFIX_MATCHING experiment is enabled) matches
// as a unique prefix. The function returns a list of MatchingTask structs, each
// containing a task and a list of wildcards that were matched.
// If multiple tasks match due to aliases, a TaskNameConflictError is returned.
// If multiple tasks match due to aliases or ambiguous prefixes, a TaskNameConflictError is returned.
func (e *Executor) FindMatchingTasks(call *Call) ([]*MatchingTask, error) {
if call == nil {
return nil, nil
Expand Down Expand Up @@ -571,6 +573,36 @@ func (e *Executor) FindMatchingTasks(call *Call) ([]*MatchingTask, error) {
})
}
}
if len(matchingTasks) > 0 {
return matchingTasks, nil
}

if experiments.PrefixMatching.Enabled() {
var matchedTasks []string
for task := range e.Taskfile.Tasks.Values(nil) {
if task.Internal {
continue
}
if task.MatchesPrefix(call.Task) {
matchedTasks = append(matchedTasks, task.Task)
matchingTasks = append(matchingTasks, &MatchingTask{
Task: task,
})
}
}

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

if len(matchingTasks) > 1 {
return nil, &errors.TaskNameConflictError{
Call: call.Task,
TaskNames: matchedTasks,
}
}
}

return matchingTasks, nil
}

Expand Down
31 changes: 31 additions & 0 deletions taskfile/ast/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,37 @@ func (t *Task) WildcardMatch(name string) (bool, []string) {
return false, nil
}

// MatchesPrefix will check if the given string matches the prefix of the Task's name or any of its aliases.
func (t *Task) MatchesPrefix(name string) bool {
if MatchesPrefix(name, t.Task) {
return true
}
for _, alias := range t.Aliases {
if MatchesPrefix(name, alias) {
return true
}
}
return false
}

// MatchesPrefix checks if the input is a valid segment-wise prefix for the target task name.
func MatchesPrefix(input, target string) bool {
if input == "" || target == "" {
return false
}
inputParts := strings.Split(input, NamespaceSeparator)
targetParts := strings.Split(target, NamespaceSeparator)
if len(inputParts) > len(targetParts) {
return false
}
for i, part := range inputParts {
if !strings.HasPrefix(targetParts[i], part) {
return false
}
}
return true
}

func (t *Task) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {

Expand Down
Loading