From 01e47048000097b78e8fa075f502da9d716259ad Mon Sep 17 00:00:00 2001 From: krish-mittal1 Date: Fri, 28 Aug 2026 18:40:09 +0530 Subject: [PATCH] feat: implement prefix matching experiment (#2947) --- experiments/experiments.go | 6 +- prefix_matching_test.go | 163 ++++++++++++++++++ task.go | 38 +++- taskfile/ast/task.go | 31 ++++ taskfile/ast/task_prefix_test.go | 123 +++++++++++++ website/.vitepress/sidebar/next.ts | 4 + .../next/docs/experiments/prefix-matching.md | 47 +++++ website/src/public/next-schema-taskrc.json | 4 + 8 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 prefix_matching_test.go create mode 100644 taskfile/ast/task_prefix_test.go create mode 100644 website/src/next/docs/experiments/prefix-matching.md diff --git a/experiments/experiments.go b/experiments/experiments.go index 76e48397fd..443af79a10 100644 --- a/experiments/experiments.go +++ b/experiments/experiments.go @@ -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 @@ -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) diff --git a/prefix_matching_test.go b/prefix_matching_test.go new file mode 100644 index 0000000000..2eba2ae7a7 --- /dev/null +++ b/prefix_matching_test.go @@ -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 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 } diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 9465c77770..2733f51d02 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -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 { diff --git a/taskfile/ast/task_prefix_test.go b/taskfile/ast/task_prefix_test.go new file mode 100644 index 0000000000..4ba00b76b6 --- /dev/null +++ b/taskfile/ast/task_prefix_test.go @@ -0,0 +1,123 @@ +package ast_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestMatchesPrefix(t *testing.T) { + tests := []struct { + name string + input string + target string + expected bool + }{ + { + name: "Exact match single segment", + input: "build", + target: "build", + expected: true, + }, + { + name: "Prefix match single segment", + input: "b", + target: "build", + expected: true, + }, + { + name: "Non-matching single segment", + input: "c", + target: "build", + expected: false, + }, + { + name: "Exact multi-segment match", + input: "api:openapi:export", + target: "api:openapi:export", + expected: true, + }, + { + name: "Segment abbreviation m=n", + input: "a:o:e", + target: "api:openapi:export", + expected: true, + }, + { + name: "Segment abbreviation partial m=n", + input: "api:open:ex", + target: "api:openapi:export", + expected: true, + }, + { + name: "Shorter input segments m < n", + input: "api:o", + target: "api:openapi:export", + expected: true, + }, + { + name: "Shorter input segments single segment m < n", + input: "a", + target: "api:openapi:export", + expected: true, + }, + { + name: "Mismatch in first segment", + input: "d:o:e", + target: "api:openapi:export", + expected: false, + }, + { + name: "Mismatch in middle segment", + input: "a:x:e", + target: "api:openapi:export", + expected: false, + }, + { + name: "Mismatch in last segment", + input: "a:o:x", + target: "api:openapi:export", + expected: false, + }, + { + name: "Longer input segments m > n", + input: "a:o:e:extra", + target: "api:openapi:export", + expected: false, + }, + { + name: "Empty input", + input: "", + target: "build", + expected: false, + }, + { + name: "Empty target", + input: "build", + target: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := ast.MatchesPrefix(tt.input, tt.target) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestTaskMatchesPrefixWithAliases(t *testing.T) { + task := &ast.Task{ + Task: "docker:build:production", + Aliases: []string{"d:b:prod", "prod-build"}, + } + + assert.True(t, task.MatchesPrefix("d:b:p")) + assert.True(t, task.MatchesPrefix("docker:b")) + assert.True(t, task.MatchesPrefix("prod-b")) + assert.False(t, task.MatchesPrefix("docker:push")) + assert.False(t, task.MatchesPrefix("staging")) +} diff --git a/website/.vitepress/sidebar/next.ts b/website/.vitepress/sidebar/next.ts index c7171547bc..4239eebbfc 100644 --- a/website/.vitepress/sidebar/next.ts +++ b/website/.vitepress/sidebar/next.ts @@ -63,6 +63,10 @@ export const sidebar: DefaultTheme.SidebarItem[] = [ text: 'Gentle Force (#1200)', link: '/docs/experiments/gentle-force' }, + { + text: 'Prefix Matching (#2947)', + link: '/docs/experiments/prefix-matching' + }, { text: 'Remote Taskfiles (#1317)', link: '/docs/experiments/remote-taskfiles' diff --git a/website/src/next/docs/experiments/prefix-matching.md b/website/src/next/docs/experiments/prefix-matching.md new file mode 100644 index 0000000000..357b8599af --- /dev/null +++ b/website/src/next/docs/experiments/prefix-matching.md @@ -0,0 +1,47 @@ +--- +title: 'Prefix Matching (#2947)' +description: Experiment to enable shortest unique prefix and segment matching for task names +outline: deep +--- + +# Prefix Matching (#2947) + +::: warning + +All experimental features are subject to breaking changes and/or removal _at any +time_. We strongly recommend that you do not use these features in a production +environment. They are intended for testing and feedback only. + +::: + +::: info + +To enable this experiment, set the environment variable: +`TASK_X_PREFIX_MATCHING=1` or configure it in `.taskrc.yml`: + +```yaml +experiments: + PREFIX_MATCHING: 1 +``` + +Check out [our guide to enabling experiments](./index.md#enabling-experiments) for more information. + +::: + +This experiment adds support for **Shortest Unique Prefix Matching** (segment-wise abbreviation matching) when running tasks. + +In large Taskfiles with multi-level namespaces (for example, `api:openapi:export`), typing the full task name or manually defining short aliases for each task can be tedious. + +### How It Works + +1. **Unique Match:** When an input prefix uniquely matches a task (or one of its aliases), Task will execute it immediately. + - `task a:o:e` matches `api:openapi:export` + - `task api:o` matches `api:openapi:export` (if no other `api:o*` tasks exist) + - `task b` matches `build` (if no other `b*` tasks exist) + +2. **Ambiguous Match:** If the prefix matches multiple tasks, Task will halt execution and return a conflict error listing all candidate tasks: + ```text + task: Found multiple tasks (docker:build:production, docker:build:staging) that match "doc" + ``` + +3. **Precedence:** Direct task name matches, alias matches, and wildcard matches always take precedence over prefix matching. diff --git a/website/src/public/next-schema-taskrc.json b/website/src/public/next-schema-taskrc.json index d12f4460bc..fadaa434fe 100644 --- a/website/src/public/next-schema-taskrc.json +++ b/website/src/public/next-schema-taskrc.json @@ -14,6 +14,10 @@ "GENTLE_FORCE": { "type": "number", "enum": [0, 1] + }, + "PREFIX_MATCHING": { + "type": "number", + "enum": [0, 1] } } },