diff --git a/errors.go b/errors.go index cc2e65ed..3979f798 100644 --- a/errors.go +++ b/errors.go @@ -13,11 +13,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 taskInternalError struct { diff --git a/go.mod b/go.mod index acb991e6..1fdefabe 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/sync v0.0.0-20210220032951-036812b2e83c diff --git a/go.sum b/go.sum index e6ab020a..8c3c66d5 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 b47350e2..cf069aec 100644 --- a/help.go +++ b/help.go @@ -78,7 +78,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..5e197024 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,21 @@ 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) + } + + 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 59cde1f1..b63a8cf9 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/sync/errgroup" ) @@ -51,7 +52,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 @@ -68,7 +70,12 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error { if !ok { // FIXME: move to the main package e.ListTasksWithDesc() - return &taskNotFoundError{taskName: c.Task} + + didYouMean := "" + if e.fuzzyModel != nil { + didYouMean = e.fuzzyModel.SpellCheck(c.Task) + } + return &taskNotFoundError{taskName: c.Task, didYouMean: didYouMean} } if t.Internal { e.ListTasksWithDesc() diff --git a/variables.go b/variables.go index fd2124f0..8f82954d 100644 --- a/variables.go +++ b/variables.go @@ -24,7 +24,11 @@ func (e *Executor) FastCompiledTask(call taskfile.Call) (*taskfile.Task, error) func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskfile.Task, error) { origTask, ok := e.Taskfile.Tasks[call.Task] if !ok { - return nil, &taskNotFoundError{call.Task} + didYouMean := "" + if e.fuzzyModel != nil { + didYouMean = e.fuzzyModel.SpellCheck(call.Task) + } + return nil, &taskNotFoundError{taskName: call.Task, didYouMean: didYouMean} } var vars *taskfile.Vars