mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat(cli): prompt for misspelled parameter names (#10350)
* feat(cli): add cliutil/levenshtein package * feat(cli): attempt to catch misspelled parameter names
This commit is contained in:
99
cli/cliutil/levenshtein/levenshtein.go
Normal file
99
cli/cliutil/levenshtein/levenshtein.go
Normal file
@ -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
|
||||||
|
}
|
194
cli/cliutil/levenshtein/levenshtein_test.go
Normal file
194
cli/cliutil/levenshtein/levenshtein_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -391,6 +391,31 @@ func TestCreateWithRichParameters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
<-doneChan
|
<-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) {
|
func TestCreateValidateRichParameters(t *testing.T) {
|
||||||
|
@ -2,14 +2,15 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/pretty"
|
|
||||||
|
|
||||||
"github.com/coder/coder/v2/cli/clibase"
|
"github.com/coder/coder/v2/cli/clibase"
|
||||||
"github.com/coder/coder/v2/cli/cliui"
|
"github.com/coder/coder/v2/cli/cliui"
|
||||||
|
"github.com/coder/coder/v2/cli/cliutil/levenshtein"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
"github.com/coder/pretty"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspaceCLIAction int
|
type WorkspaceCLIAction int
|
||||||
@ -163,7 +164,7 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil
|
|||||||
for _, r := range resolved {
|
for _, r := range resolved {
|
||||||
tvp := findTemplateVersionParameter(r, templateVersionParameters)
|
tvp := findTemplateVersionParameter(r, templateVersionParameters)
|
||||||
if tvp == nil {
|
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 {
|
if tvp.Ephemeral && !pr.promptBuildOptions && findWorkspaceBuildParameter(tvp.Name, pr.buildOptions) == nil {
|
||||||
@ -254,3 +255,19 @@ func isValidTemplateParameterOption(buildParameter codersdk.WorkspaceBuildParame
|
|||||||
}
|
}
|
||||||
return false
|
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())
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user