diff --git a/cli/cliutil/levenshtein/levenshtein.go b/cli/cliutil/levenshtein/levenshtein.go new file mode 100644 index 0000000000000..f509e5b1000d1 --- /dev/null +++ b/cli/cliutil/levenshtein/levenshtein.go @@ -0,0 +1,99 @@ +package levenshtein + +import ( + "golang.org/x/exp/constraints" + "golang.org/x/xerrors" +) + +// Matches returns the closest matches to the needle from the haystack. +// The maxDistance parameter is the maximum Matches distance to consider. +// If no matches are found, an empty slice is returned. +func Matches(needle string, maxDistance int, haystack ...string) (matches []string) { + for _, hay := range haystack { + if d, err := Distance(needle, hay, maxDistance); err == nil && d <= maxDistance { + matches = append(matches, hay) + } + } + + return matches +} + +var ErrMaxDist = xerrors.New("levenshtein: maxDist exceeded") + +// Distance returns the edit distance between a and b using the +// Wagner-Fischer algorithm. +// A and B must be less than 255 characters long. +// maxDist is the maximum distance to consider. +// A value of -1 for maxDist means no maximum. +func Distance(a, b string, maxDist int) (int, error) { + if len(a) > 255 { + return 0, xerrors.Errorf("levenshtein: a must be less than 255 characters long") + } + if len(b) > 255 { + return 0, xerrors.Errorf("levenshtein: b must be less than 255 characters long") + } + m := uint8(len(a)) + n := uint8(len(b)) + + // Special cases for empty strings + if m == 0 { + return int(n), nil + } + if n == 0 { + return int(m), nil + } + + // Allocate a matrix of size m+1 * n+1 + d := make([][]uint8, 0) + var i, j uint8 + for i = 0; i < m+1; i++ { + di := make([]uint8, n+1) + d = append(d, di) + } + + // Source prefixes + for i = 1; i < m+1; i++ { + d[i][0] = i + } + + // Target prefixes + for j = 1; j < n; j++ { + d[0][j] = j // nolint:gosec // this cannot overflow + } + + // Compute the distance + for j = 0; j < n; j++ { + for i = 0; i < m; i++ { + var subCost uint8 + // Equal + if a[i] != b[j] { + subCost = 1 + } + // Don't forget: matrix is +1 size + d[i+1][j+1] = min( + d[i][j+1]+1, // deletion + d[i+1][j]+1, // insertion + d[i][j]+subCost, // substitution + ) + // check maxDist on the diagonal + if maxDist > -1 && i == j && d[i+1][j+1] > uint8(maxDist) { + return int(d[i+1][j+1]), ErrMaxDist + } + } + } + + return int(d[m][n]), nil +} + +func min[T constraints.Ordered](ts ...T) T { + if len(ts) == 0 { + panic("min: no arguments") + } + m := ts[0] + for _, t := range ts[1:] { + if t < m { + m = t + } + } + return m +} diff --git a/cli/cliutil/levenshtein/levenshtein_test.go b/cli/cliutil/levenshtein/levenshtein_test.go new file mode 100644 index 0000000000000..c635ad0564181 --- /dev/null +++ b/cli/cliutil/levenshtein/levenshtein_test.go @@ -0,0 +1,194 @@ +package levenshtein_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/cliutil/levenshtein" +) + +func Test_Levenshtein_Matches(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + Name string + Needle string + MaxDistance int + Haystack []string + Expected []string + }{ + { + Name: "empty", + Needle: "", + MaxDistance: 0, + Haystack: []string{}, + Expected: []string{}, + }, + { + Name: "empty haystack", + Needle: "foo", + MaxDistance: 0, + Haystack: []string{}, + Expected: []string{}, + }, + { + Name: "empty needle", + Needle: "", + MaxDistance: 0, + Haystack: []string{"foo"}, + Expected: []string{}, + }, + { + Name: "exact match distance 0", + Needle: "foo", + MaxDistance: 0, + Haystack: []string{"foo", "fob"}, + Expected: []string{"foo"}, + }, + { + Name: "exact match distance 1", + Needle: "foo", + MaxDistance: 1, + Haystack: []string{"foo", "bar"}, + Expected: []string{"foo"}, + }, + { + Name: "not found", + Needle: "foo", + MaxDistance: 1, + Haystack: []string{"bar"}, + Expected: []string{}, + }, + { + Name: "1 deletion", + Needle: "foo", + MaxDistance: 1, + Haystack: []string{"bar", "fo"}, + Expected: []string{"fo"}, + }, + { + Name: "one deletion, two matches", + Needle: "foo", + MaxDistance: 1, + Haystack: []string{"bar", "fo", "fou"}, + Expected: []string{"fo", "fou"}, + }, + { + Name: "one deletion, one addition", + Needle: "foo", + MaxDistance: 1, + Haystack: []string{"bar", "fo", "fou", "f"}, + Expected: []string{"fo", "fou"}, + }, + { + Name: "distance 2", + Needle: "foo", + MaxDistance: 2, + Haystack: []string{"bar", "boo", "boof"}, + Expected: []string{"boo", "boof"}, + }, + { + Name: "longer input", + Needle: "kuberenetes", + MaxDistance: 5, + Haystack: []string{"kubernetes", "kubeconfig", "kubectl", "kube"}, + Expected: []string{"kubernetes"}, + }, + } { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + actual := levenshtein.Matches(tt.Needle, tt.MaxDistance, tt.Haystack...) + require.ElementsMatch(t, tt.Expected, actual) + }) + } +} + +func Test_Levenshtein_Distance(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + Name string + A string + B string + MaxDist int + Expected int + Error string + }{ + { + Name: "empty", + A: "", + B: "", + MaxDist: -1, + Expected: 0, + }, + { + Name: "a empty", + A: "", + B: "foo", + MaxDist: -1, + Expected: 3, + }, + { + Name: "b empty", + A: "foo", + B: "", + MaxDist: -1, + Expected: 3, + }, + { + Name: "a is b", + A: "foo", + B: "foo", + MaxDist: -1, + Expected: 0, + }, + { + Name: "one addition", + A: "foo", + B: "fooo", + MaxDist: -1, + Expected: 1, + }, + { + Name: "one deletion", + A: "fooo", + B: "foo", + MaxDist: -1, + Expected: 1, + }, + { + Name: "one substitution", + A: "foo", + B: "fou", + MaxDist: -1, + Expected: 1, + }, + { + Name: "different strings entirely", + A: "foo", + B: "bar", + MaxDist: -1, + Expected: 3, + }, + { + Name: "different strings, max distance 2", + A: "foo", + B: "bar", + MaxDist: 2, + Error: levenshtein.ErrMaxDist.Error(), + }, + } { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + actual, err := levenshtein.Distance(tt.A, tt.B, tt.MaxDist) + if tt.Error == "" { + require.NoError(t, err) + require.Equal(t, tt.Expected, actual) + } else { + require.EqualError(t, err, tt.Error) + } + }) + } +} diff --git a/cli/create_test.go b/cli/create_test.go index 993ae9e57b441..0f3f06eb1db1d 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -391,6 +391,31 @@ func TestCreateWithRichParameters(t *testing.T) { } <-doneChan }) + + t.Run("WrongParameterName/DidYouMean", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + wrongFirstParameterName := "frst-prameter" + inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, + "--parameter", fmt.Sprintf("%s=%s", wrongFirstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue)) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err := inv.Run() + assert.ErrorContains(t, err, "parameter \""+wrongFirstParameterName+"\" is not present in the template") + assert.ErrorContains(t, err, "Did you mean: "+firstParameterName) + }) } func TestCreateValidateRichParameters(t *testing.T) { diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go index 3b8ee3a855ab3..21a31825bd0cf 100644 --- a/cli/parameterresolver.go +++ b/cli/parameterresolver.go @@ -2,14 +2,15 @@ package cli import ( "fmt" + "strings" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil/levenshtein" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) type WorkspaceCLIAction int @@ -163,7 +164,7 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil for _, r := range resolved { tvp := findTemplateVersionParameter(r, templateVersionParameters) if tvp == nil { - return xerrors.Errorf("parameter %q is not present in the template", r.Name) + return templateVersionParametersNotFound(r.Name, templateVersionParameters) } if tvp.Ephemeral && !pr.promptBuildOptions && findWorkspaceBuildParameter(tvp.Name, pr.buildOptions) == nil { @@ -254,3 +255,19 @@ func isValidTemplateParameterOption(buildParameter codersdk.WorkspaceBuildParame } return false } + +func templateVersionParametersNotFound(unknown string, params []codersdk.TemplateVersionParameter) error { + var sb strings.Builder + _, _ = sb.WriteString(fmt.Sprintf("parameter %q is not present in the template.", unknown)) + // Going with a fairly generous edit distance + maxDist := len(unknown) / 2 + var paramNames []string + for _, p := range params { + paramNames = append(paramNames, p.Name) + } + matches := levenshtein.Matches(unknown, maxDist, paramNames...) + if len(matches) > 0 { + _, _ = sb.WriteString(fmt.Sprintf("\nDid you mean: %s", strings.Join(matches, ", "))) + } + return xerrors.Errorf(sb.String()) +}