Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/cli/did-you-mean-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Failed key lookups and variable references now suggest the closest matching key. For example, a mistyped variable reference like `${var.hst}` now reports `reference does not exist: ${hst}, did you mean "host"?` instead of failing with no hint. Suggestions are only shown when a valid key is within a small edit distance of the one that was typed.
12 changes: 12 additions & 0 deletions acceptance/bundle/variables/reference-typo/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
bundle:
name: reference-typo

variables:
host:
default: https://example.com

resources:
jobs:
one:
# "hst" is a typo of the "host" variable defined above; the error suggests it.
name: ${var.hst}
2 changes: 2 additions & 0 deletions acceptance/bundle/variables/reference-typo/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions acceptance/bundle/variables/reference-typo/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

>>> errcode [CLI] bundle validate
Error: reference does not exist: ${var.hst}, did you mean "host"?

Name: reference-typo
Target: default
Workspace:
User: [USERNAME]
Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo/default

Found 1 error

Exit code: 1
1 change: 1 addition & 0 deletions acceptance/bundle/variables/reference-typo/script
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
trace errcode $CLI bundle validate
4 changes: 3 additions & 1 deletion libs/dyn/dynvar/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ func (r *resolver) resolveKey(key string, seen []string) (dyn.Value, error) {
v, err := r.fn(p)
if err != nil {
if dyn.IsNoSuchKeyError(err) {
err = fmt.Errorf("reference does not exist: ${%s}", key)
// The not-found message from dyn is discarded here, so re-attach the
// key suggestions it computed before we lose the original error.
err = fmt.Errorf("reference does not exist: ${%s}%s", key, dyn.DidYouMeanSuffix(err))
}

// Cache the return value and return to the caller.
Expand Down
10 changes: 10 additions & 0 deletions libs/dyn/dynvar/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ func TestResolveNotFound(t *testing.T) {
require.ErrorContains(t, err, `reference does not exist: ${a}`)
}

func TestResolveNotFoundSuggestsCloseKey(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"host": dyn.V("example.com"),
"b": dyn.V("${hst}"),
})

_, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.ErrorContains(t, err, `reference does not exist: ${hst}, did you mean "host"?`)
}

func TestResolveWithNesting(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("${f.a}"),
Expand Down
91 changes: 91 additions & 0 deletions libs/dyn/suggest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package dyn

import (
"fmt"
"slices"
"strings"
)

const maxSuggestionDistance = 2

// levenshteinDistance computes the edit distance between two strings.
func levenshteinDistance(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}

// Use a single row for the DP table.
prev := make([]int, len(b)+1)
for j := range len(b) + 1 {
prev[j] = j
}

for i := range len(a) {
curr := make([]int, len(b)+1)
curr[0] = i + 1
for j := range len(b) {
cost := 1
if a[i] == b[j] {
cost = 0
}
curr[j+1] = min(
curr[j]+1, // insertion
prev[j+1]+1, // deletion
prev[j]+cost, // substitution
)
}
prev = curr
}

return prev[len(b)]
}

// suggestKeys returns the keys in m whose edit distance from name is at most
// maxSuggestionDistance, ordered by increasing distance. It is used to build
// "did you mean" hints for a key that was not found in the map.
func suggestKeys(m Mapping, name string) []string {
type candidate struct {
key string
dist int
}

var candidates []candidate
for _, kv := range m.Keys() {
key := kv.MustString()
d := levenshteinDistance(name, key)
if d <= maxSuggestionDistance {
candidates = append(candidates, candidate{key, d})
}
}

slices.SortStableFunc(candidates, func(a, b candidate) int {
return a.dist - b.dist
})

suggestions := make([]string, len(candidates))
for i, c := range candidates {
suggestions[i] = c.key
}
return suggestions
}

// didYouMean formats a suggestion clause like `, did you mean "x"?` (or, for
// multiple candidates, `, did you mean one of: "x", "y"?`). It returns an empty
// string when there are no suggestions.
func didYouMean(suggestions []string) string {
switch len(suggestions) {
case 0:
return ""
case 1:
return fmt.Sprintf(", did you mean %q?", suggestions[0])
default:
quoted := make([]string, len(suggestions))
for i, s := range suggestions {
quoted[i] = fmt.Sprintf("%q", s)
}
return fmt.Sprintf(", did you mean one of: %s?", strings.Join(quoted, ", "))
}
}
72 changes: 72 additions & 0 deletions libs/dyn/suggest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package dyn

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
)

func TestLevenshteinDistance(t *testing.T) {
tests := []struct {
a string
b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"abc", "abd", 1},
{"kitten", "sitting", 3},
{"host", "hosts", 1},
}
for _, tt := range tests {
assert.Equal(t, tt.want, levenshteinDistance(tt.a, tt.b), "levenshteinDistance(%q, %q)", tt.a, tt.b)
}
}

func newSuggestMapping(keys ...string) Mapping {
var m Mapping
for _, k := range keys {
m.SetLoc(k, nil, V(k))
}
return m
}

func TestSuggestKeys(t *testing.T) {
// Keys within distance 2 are returned ordered by increasing distance;
// ties keep the map's insertion order.
m := newSuggestMapping("host", "hosts", "token", "auth_type")
assert.Equal(t, []string{"host", "hosts"}, suggestKeys(m, "host"))

// No key is close enough.
assert.Empty(t, suggestKeys(m, "completely_different"))

// Distance-2 substitutions and insertions are both included.
m = newSuggestMapping("profile", "prfile", "prof")
assert.Equal(t, []string{"prfile", "profile"}, suggestKeys(m, "prfil"))

// Empty map yields no suggestions.
assert.Empty(t, suggestKeys(NewMapping(), "anything"))
}

func TestDidYouMean(t *testing.T) {
assert.Empty(t, didYouMean(nil))
assert.Empty(t, didYouMean([]string{}))
assert.Equal(t, `, did you mean "host"?`, didYouMean([]string{"host"}))
assert.Equal(t, `, did you mean one of: "host", "hosts"?`, didYouMean([]string{"host", "hosts"}))
}

func TestDidYouMeanSuffix(t *testing.T) {
// A noSuchKeyError with suggestions produces the clause.
err := noSuchKeyError{p: NewPath(Key("hst")), suggestions: []string{"host"}}
assert.Equal(t, `, did you mean "host"?`, DidYouMeanSuffix(err))

// A noSuchKeyError without suggestions produces nothing.
err = noSuchKeyError{p: NewPath(Key("xyz"))}
assert.Empty(t, DidYouMeanSuffix(err))

// Any other error type produces nothing.
assert.Empty(t, DidYouMeanSuffix(errors.New("some other error")))
}
19 changes: 16 additions & 3 deletions libs/dyn/visit.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,31 @@ func IsCannotTraverseNilError(err error) bool {
}

type noSuchKeyError struct {
p Path
p Path
suggestions []string
}

func (e noSuchKeyError) Error() string {
return fmt.Sprintf("key not found at %q", e.p)
return fmt.Sprintf("key not found at %q%s", e.p, didYouMean(e.suggestions))
}

func IsNoSuchKeyError(err error) bool {
_, ok := errors.AsType[noSuchKeyError](err)
return ok
}

// DidYouMeanSuffix returns the "did you mean" clause for a noSuchKeyError, or an
// empty string for any other error. Callers that rewrite the not-found message
// (e.g. variable interpolation in libs/dyn/dynvar) use this to preserve the key
// suggestions that would otherwise be lost when the original error is discarded.
func DidYouMeanSuffix(err error) string {
e, ok := errors.AsType[noSuchKeyError](err)
if !ok {
return ""
}
return didYouMean(e.suggestions)
}

type indexOutOfBoundsError struct {
p Path
}
Expand Down Expand Up @@ -124,7 +137,7 @@ func (c pathComponent) visit(v Value, prefix Path, suffix Pattern, opts visitOpt
// Lookup current value in the map.
ev, ok := m.GetByString(c.key)
if !ok {
return InvalidValue, noSuchKeyError{path}
return InvalidValue, noSuchKeyError{p: path, suggestions: suggestKeys(m, c.key)}
}

// Recursively transform the value.
Expand Down
9 changes: 8 additions & 1 deletion libs/dyn/visit_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ func TestGetOnMap(t *testing.T) {

_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Key("baz")))
assert.True(t, dyn.IsNoSuchKeyError(err))
assert.ErrorContains(t, err, `key not found at "baz"`)
// "baz" is one edit away from "bar", so the error suggests it.
assert.ErrorContains(t, err, `key not found at "baz", did you mean "bar"?`)

// A key that is close to no existing key gets no suggestion.
_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Key("completely_different")))
assert.True(t, dyn.IsNoSuchKeyError(err))
assert.ErrorContains(t, err, `key not found at "completely_different"`)
assert.NotContains(t, err.Error(), "did you mean")

vfoo, err := dyn.GetByPath(vin, dyn.NewPath(dyn.Key("foo")))
assert.NoError(t, err)
Expand Down
Loading