diff --git a/CHANGELOG.md b/CHANGELOG.md index 3987a2a7..7d377c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add a "Did you mean ...?" suggestion when a task does not exits another one + with a similar name is found + ([#867](https://github.com/go-task/task/issues/867), [#880](https://github.com/go-task/task/pull/880)). - Now YAML parse errors will print which Taskfile failed to parse ([#885](https://github.com/go-task/task/issues/885), [#887](https://github.com/go-task/task/pull/887)). - Add ability to set `aliases` for tasks and namespaces ([#268](https://github.com/go-task/task/pull/268), [#340](https://github.com/go-task/task/pull/340), [#879](https://github.com/go-task/task/pull/879)). diff --git a/errors.go b/errors.go index 1be4c15e..e34d02cd 100644 --- a/errors.go +++ b/errors.go @@ -14,11 +14,20 @@ var ( ) type taskNotFoundError struct { - taskName string + taskName string + didYouMean string } func (err *taskNotFoundError) Error() string { - return fmt.Sprintf(`task: Task %q not found`, err.taskName) + if err.didYouMean != "" { + return fmt.Sprintf( + `task: Task %q does not exist. Did you mean %q?`, + err.taskName, + err.didYouMean, + ) + } + + return fmt.Sprintf(`task: Task %q does not exist`, err.taskName) } type multipleTasksWithAliasError struct { diff --git a/go.mod b/go.mod index e8cec71f..8968e1e8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/mattn/go-zglob v0.0.3 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/radovskyb/watcher v1.0.7 + github.com/sajari/fuzzy v1.0.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.0 golang.org/x/exp v0.0.0-20220930202632-ec3f01382ef9 diff --git a/go.sum b/go.sum index 7a4d3b13..96ffadbb 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= +github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/help.go b/help.go index e1ae8e6d..eed0a27c 100644 --- a/help.go +++ b/help.go @@ -81,7 +81,7 @@ func (e *Executor) tasksWithDesc() (tasks []*taskfile.Task) { return } -// PrintTaskNames prints only the task names in a Taskfile. +// 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. func (e *Executor) ListTaskNames(allTasks bool) { diff --git a/setup.go b/setup.go index ceacb6b3..32aa4e4a 100644 --- a/setup.go +++ b/setup.go @@ -17,6 +17,8 @@ import ( "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/read" + + "github.com/sajari/fuzzy" ) func (e *Executor) Setup() error { @@ -28,6 +30,8 @@ func (e *Executor) Setup() error { return err } + e.setupFuzzyModel() + v, err := e.Taskfile.ParsedVersion() if err != nil { return err @@ -85,6 +89,25 @@ func (e *Executor) readTaskfile() error { return err } +func (e *Executor) setupFuzzyModel() { + if e.Taskfile != nil { + model := fuzzy.NewModel() + model.SetThreshold(1) // because we want to build grammar based on every task name + + var words []string + for taskName := range e.Taskfile.Tasks { + words = append(words, taskName) + + for _, task := range e.Taskfile.Tasks { + words = append(words, task.Aliases...) + } + } + + model.Train(words) + e.fuzzyModel = model + } +} + func (e *Executor) setupTempDir() error { if e.TempDir != "" { return nil diff --git a/task.go b/task.go index b5dbcce9..a534caa0 100644 --- a/task.go +++ b/task.go @@ -16,6 +16,7 @@ import ( "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile" + "github.com/sajari/fuzzy" "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" ) @@ -53,7 +54,8 @@ type Executor struct { Output output.Output OutputStyle taskfile.Output - taskvars *taskfile.Vars + taskvars *taskfile.Vars + fuzzyModel *fuzzy.Model concurrencySemaphore chan struct{} taskCallCount map[string]*int32 @@ -71,6 +73,7 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error { e.ListTasksWithDesc() return err } + if task.Internal { e.ListTasksWithDesc() return &taskInternalError{taskName: call.Task} @@ -359,8 +362,13 @@ func (e *Executor) GetTask(call taskfile.Call) (*taskfile.Task, error) { } // If we found no tasks if len(aliasedTasks) == 0 { + didYouMean := "" + if e.fuzzyModel != nil { + didYouMean = e.fuzzyModel.SpellCheck(call.Task) + } return nil, &taskNotFoundError{ - taskName: call.Task, + taskName: call.Task, + didYouMean: didYouMean, } }