From 5cb240b54e065d796896ec7ff88e7964890f2a59 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Mon, 10 Aug 2026 09:35:55 +0000 Subject: [PATCH 01/15] add code for did you mean recommendations --- libs/dyn/dynvar/resolve.go | 4 +- libs/dyn/suggest.go | 91 ++++++++++++++++++++++++++++++++++++++ libs/dyn/visit.go | 19 ++++++-- 3 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 libs/dyn/suggest.go diff --git a/libs/dyn/dynvar/resolve.go b/libs/dyn/dynvar/resolve.go index 612f9c26fad..5e79a90b2f9 100644 --- a/libs/dyn/dynvar/resolve.go +++ b/libs/dyn/dynvar/resolve.go @@ -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. diff --git a/libs/dyn/suggest.go b/libs/dyn/suggest.go new file mode 100644 index 00000000000..6d18b61cc8b --- /dev/null +++ b/libs/dyn/suggest.go @@ -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, ", ")) + } +} diff --git a/libs/dyn/visit.go b/libs/dyn/visit.go index 7ae00fa8e08..4ba59c05a3a 100644 --- a/libs/dyn/visit.go +++ b/libs/dyn/visit.go @@ -29,11 +29,12 @@ 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 { @@ -41,6 +42,18 @@ func IsNoSuchKeyError(err error) bool { 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 } @@ -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. From 39013a5db4fc2220cffdf0d2ea95663e7125442c Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Mon, 10 Aug 2026 09:40:39 +0000 Subject: [PATCH 02/15] add unit test coverage --- libs/dyn/dynvar/resolve_test.go | 10 +++++ libs/dyn/suggest_test.go | 72 +++++++++++++++++++++++++++++++++ libs/dyn/visit_get_test.go | 9 ++++- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 libs/dyn/suggest_test.go diff --git a/libs/dyn/dynvar/resolve_test.go b/libs/dyn/dynvar/resolve_test.go index 3399f2057a6..e339c5c19f1 100644 --- a/libs/dyn/dynvar/resolve_test.go +++ b/libs/dyn/dynvar/resolve_test.go @@ -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}"), diff --git a/libs/dyn/suggest_test.go b/libs/dyn/suggest_test.go new file mode 100644 index 00000000000..70f0526032c --- /dev/null +++ b/libs/dyn/suggest_test.go @@ -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"))) +} diff --git a/libs/dyn/visit_get_test.go b/libs/dyn/visit_get_test.go index 22dce0858b3..524b4ed5cd9 100644 --- a/libs/dyn/visit_get_test.go +++ b/libs/dyn/visit_get_test.go @@ -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) From d02728c1eec757a1197adc1fe71710007e8c5b49 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Mon, 10 Aug 2026 09:44:44 +0000 Subject: [PATCH 03/15] add changelog fragment Co-authored-by: Isaac --- .nextchanges/cli/did-you-mean-variables.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/cli/did-you-mean-variables.md diff --git a/.nextchanges/cli/did-you-mean-variables.md b/.nextchanges/cli/did-you-mean-variables.md new file mode 100644 index 00000000000..75512e37a5c --- /dev/null +++ b/.nextchanges/cli/did-you-mean-variables.md @@ -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. From 2a1187e17d3b2608c2a7e725d40ca6aa0adda7e1 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Mon, 10 Aug 2026 09:50:33 +0000 Subject: [PATCH 04/15] add acceptance test for new feature --- .../bundle/variables/reference-typo/databricks.yml | 12 ++++++++++++ .../bundle/variables/reference-typo/out.test.toml | 2 ++ .../bundle/variables/reference-typo/output.txt | 13 +++++++++++++ acceptance/bundle/variables/reference-typo/script | 1 + 4 files changed, 28 insertions(+) create mode 100644 acceptance/bundle/variables/reference-typo/databricks.yml create mode 100644 acceptance/bundle/variables/reference-typo/out.test.toml create mode 100644 acceptance/bundle/variables/reference-typo/output.txt create mode 100644 acceptance/bundle/variables/reference-typo/script diff --git a/acceptance/bundle/variables/reference-typo/databricks.yml b/acceptance/bundle/variables/reference-typo/databricks.yml new file mode 100644 index 00000000000..bfb6d810ab5 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/databricks.yml @@ -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} diff --git a/acceptance/bundle/variables/reference-typo/out.test.toml b/acceptance/bundle/variables/reference-typo/out.test.toml new file mode 100644 index 00000000000..98ea5040486 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/variables/reference-typo/output.txt b/acceptance/bundle/variables/reference-typo/output.txt new file mode 100644 index 00000000000..575a98fdaff --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/output.txt @@ -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 diff --git a/acceptance/bundle/variables/reference-typo/script b/acceptance/bundle/variables/reference-typo/script new file mode 100644 index 00000000000..9ecda517f9b --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/script @@ -0,0 +1 @@ +trace errcode $CLI bundle validate From 9c128cbdc7e795e63e3617713273dbf1058ee28c Mon Sep 17 00:00:00 2001 From: Sankalp Mittal <120563575+Sankalp-Mittal@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:02:59 +0200 Subject: [PATCH 05/15] Apply suggestion from @janniklasrose Co-authored-by: Jan N Rose --- .nextchanges/cli/did-you-mean-variables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/did-you-mean-variables.md b/.nextchanges/cli/did-you-mean-variables.md index 75512e37a5c..623ccd8f196 100644 --- a/.nextchanges/cli/did-you-mean-variables.md +++ b/.nextchanges/cli/did-you-mean-variables.md @@ -1 +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. +Error messages for failed key lookups and variable references now suggest the closest matching key if one is found. ([#6208](https://github.com/databricks/cli/pull/6208)) From 66e740651fcc5fd2f4002b2647b4713da976bd02 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 13 Aug 2026 15:15:32 +0000 Subject: [PATCH 06/15] convert suggest tests to table-driven form Co-authored-by: Isaac --- libs/dyn/suggest_test.go | 108 +++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 28 deletions(-) diff --git a/libs/dyn/suggest_test.go b/libs/dyn/suggest_test.go index 70f0526032c..6f515b8876c 100644 --- a/libs/dyn/suggest_test.go +++ b/libs/dyn/suggest_test.go @@ -35,38 +35,90 @@ func newSuggestMapping(keys ...string) Mapping { } 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")) + tests := []struct { + name string + keys []string + typo string + want []string + }{ + { + // Keys within distance 2 are returned ordered by increasing + // distance; ties keep the map's insertion order. + name: "ordered by distance", + keys: []string{"host", "hosts", "token", "auth_type"}, + typo: "host", + want: []string{"host", "hosts"}, + }, + { + name: "no key close enough", + keys: []string{"host", "hosts", "token", "auth_type"}, + typo: "completely_different", + want: []string{}, + }, + { + // Distance-2 substitutions and insertions are both included. + name: "distance two included", + keys: []string{"profile", "prfile", "prof"}, + typo: "prfil", + want: []string{"prfile", "profile"}, + }, + { + name: "empty map", + keys: nil, + typo: "anything", + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, suggestKeys(newSuggestMapping(tt.keys...), tt.typo)) + }) + } } 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"})) + tests := []struct { + name string + suggestions []string + want string + }{ + {"nil", nil, ""}, + {"empty", []string{}, ""}, + {"single", []string{"host"}, `, did you mean "host"?`}, + {"multiple", []string{"host", "hosts"}, `, did you mean one of: "host", "hosts"?`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, didYouMean(tt.suggestions)) + }) + } } 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"))) + tests := []struct { + name string + err error + want string + }{ + { + name: "no such key with suggestions", + err: noSuchKeyError{p: NewPath(Key("hst")), suggestions: []string{"host"}}, + want: `, did you mean "host"?`, + }, + { + name: "no such key without suggestions", + err: noSuchKeyError{p: NewPath(Key("xyz"))}, + want: "", + }, + { + name: "other error type", + err: errors.New("some other error"), + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, DidYouMeanSuffix(tt.err)) + }) + } } From ca109ce62cdba8987293b454f409d854c55b6f7a Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 13 Aug 2026 15:22:01 +0000 Subject: [PATCH 07/15] change TLD --- acceptance/bundle/variables/reference-typo/databricks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/variables/reference-typo/databricks.yml b/acceptance/bundle/variables/reference-typo/databricks.yml index bfb6d810ab5..5811547a582 100644 --- a/acceptance/bundle/variables/reference-typo/databricks.yml +++ b/acceptance/bundle/variables/reference-typo/databricks.yml @@ -3,7 +3,7 @@ bundle: variables: host: - default: https://example.com + default: https://example.test resources: jobs: From 5d08da7f7385fcc24e728ce6ebca5c5a3e06c700 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 13 Aug 2026 15:28:20 +0000 Subject: [PATCH 08/15] fix typo --- acceptance/bundle/variables/reference-typo/databricks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/variables/reference-typo/databricks.yml b/acceptance/bundle/variables/reference-typo/databricks.yml index 5811547a582..6e4a3b438d1 100644 --- a/acceptance/bundle/variables/reference-typo/databricks.yml +++ b/acceptance/bundle/variables/reference-typo/databricks.yml @@ -8,5 +8,5 @@ variables: resources: jobs: one: - # "hst" is a typo of the "host" variable defined above; the error suggests it. + # "hst" is a typo of the "host" variable defined above name: ${var.hst} From a1d22d17b180bd7e35f678953c42acff5850409e Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 13 Aug 2026 15:43:50 +0000 Subject: [PATCH 09/15] add requested tests --- .../first-of-many/databricks.yml | 13 ++++++ .../reference-typo/multiple/databricks.yml | 14 ++++++ .../non-var-multiple/databricks.yml | 9 ++++ .../variables/reference-typo/output.txt | 43 ++++++++++++++++++- .../bundle/variables/reference-typo/script | 12 +++++- .../{ => single}/databricks.yml | 0 6 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml create mode 100644 acceptance/bundle/variables/reference-typo/multiple/databricks.yml create mode 100644 acceptance/bundle/variables/reference-typo/non-var-multiple/databricks.yml rename acceptance/bundle/variables/reference-typo/{ => single}/databricks.yml (100%) diff --git a/acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml b/acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml new file mode 100644 index 00000000000..77219fdd251 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml @@ -0,0 +1,13 @@ +bundle: + name: reference-typo-first-of-many + +variables: + host: + default: https://example.test + +resources: + jobs: + one: + # Two typos in one value. Resolution stops at the first unresolved + # reference, so only "hst" is reported. + name: ${var.hst}-${var.hostt} diff --git a/acceptance/bundle/variables/reference-typo/multiple/databricks.yml b/acceptance/bundle/variables/reference-typo/multiple/databricks.yml new file mode 100644 index 00000000000..2071ff28c32 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/multiple/databricks.yml @@ -0,0 +1,14 @@ +bundle: + name: reference-typo-multiple + +variables: + host: + default: https://example.test + hosts: + default: https://example.test + +resources: + jobs: + one: + # "hst" is within edit distance 2 of both "host" and "hosts" + name: ${var.hst} diff --git a/acceptance/bundle/variables/reference-typo/non-var-multiple/databricks.yml b/acceptance/bundle/variables/reference-typo/non-var-multiple/databricks.yml new file mode 100644 index 00000000000..0f43e908687 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/non-var-multiple/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: reference-typo-non-var-multiple + +resources: + jobs: + one: + # A non-"var" reference. "stot_path" is within edit distance 2 of both + # workspace.root_path and workspace.state_path + name: ${workspace.stot_path} diff --git a/acceptance/bundle/variables/reference-typo/output.txt b/acceptance/bundle/variables/reference-typo/output.txt index 575a98fdaff..4b50105c875 100644 --- a/acceptance/bundle/variables/reference-typo/output.txt +++ b/acceptance/bundle/variables/reference-typo/output.txt @@ -1,5 +1,7 @@ ->>> errcode [CLI] bundle validate +=== Typo of a defined variable: single suggestion + +>>> [CLI] bundle validate Error: reference does not exist: ${var.hst}, did you mean "host"? Name: reference-typo @@ -10,4 +12,41 @@ Workspace: Found 1 error -Exit code: 1 +=== Typo close to two defined variables: multiple suggestions + +>>> [CLI] bundle validate +Error: reference does not exist: ${var.hst}, did you mean one of: "host", "hosts"? + +Name: reference-typo-multiple +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-multiple/default + +Found 1 error + +=== Typo in a non-variable reference (workspace field): multiple suggestions + +>>> [CLI] bundle validate +Error: reference does not exist: ${workspace.stot_path}, did you mean one of: "root_path", "state_path"? + +Name: reference-typo-non-var-multiple +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-non-var-multiple/default + +Found 1 error + +=== Multiple typos in one value: only the first reference is reported + +>>> [CLI] bundle validate +Error: reference does not exist: ${var.hst}, did you mean "host"? + +Name: reference-typo-first-of-many +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-first-of-many/default + +Found 1 error diff --git a/acceptance/bundle/variables/reference-typo/script b/acceptance/bundle/variables/reference-typo/script index 9ecda517f9b..2b27be7df88 100644 --- a/acceptance/bundle/variables/reference-typo/script +++ b/acceptance/bundle/variables/reference-typo/script @@ -1 +1,11 @@ -trace errcode $CLI bundle validate +title "Typo of a defined variable: single suggestion\n" +withdir single musterr trace $CLI bundle validate + +title "Typo close to two defined variables: multiple suggestions\n" +withdir multiple musterr trace $CLI bundle validate + +title "Typo in a non-variable reference (workspace field): multiple suggestions\n" +withdir non-var-multiple musterr trace $CLI bundle validate + +title "Multiple typos in one value: only the first reference is reported\n" +withdir first-of-many musterr trace $CLI bundle validate diff --git a/acceptance/bundle/variables/reference-typo/databricks.yml b/acceptance/bundle/variables/reference-typo/single/databricks.yml similarity index 100% rename from acceptance/bundle/variables/reference-typo/databricks.yml rename to acceptance/bundle/variables/reference-typo/single/databricks.yml From e0942a859585b67d8cf286bb5c64a526a24ae2c9 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 13 Aug 2026 15:59:24 +0000 Subject: [PATCH 10/15] remove test that doesn't test behaviour --- .../reference-typo/first-of-many/databricks.yml | 13 ------------- .../bundle/variables/reference-typo/output.txt | 13 ------------- acceptance/bundle/variables/reference-typo/script | 3 --- 3 files changed, 29 deletions(-) delete mode 100644 acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml diff --git a/acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml b/acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml deleted file mode 100644 index 77219fdd251..00000000000 --- a/acceptance/bundle/variables/reference-typo/first-of-many/databricks.yml +++ /dev/null @@ -1,13 +0,0 @@ -bundle: - name: reference-typo-first-of-many - -variables: - host: - default: https://example.test - -resources: - jobs: - one: - # Two typos in one value. Resolution stops at the first unresolved - # reference, so only "hst" is reported. - name: ${var.hst}-${var.hostt} diff --git a/acceptance/bundle/variables/reference-typo/output.txt b/acceptance/bundle/variables/reference-typo/output.txt index 4b50105c875..f2eba9cfbf8 100644 --- a/acceptance/bundle/variables/reference-typo/output.txt +++ b/acceptance/bundle/variables/reference-typo/output.txt @@ -37,16 +37,3 @@ Workspace: Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-non-var-multiple/default Found 1 error - -=== Multiple typos in one value: only the first reference is reported - ->>> [CLI] bundle validate -Error: reference does not exist: ${var.hst}, did you mean "host"? - -Name: reference-typo-first-of-many -Target: default -Workspace: - User: [USERNAME] - Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-first-of-many/default - -Found 1 error diff --git a/acceptance/bundle/variables/reference-typo/script b/acceptance/bundle/variables/reference-typo/script index 2b27be7df88..4030d5ed2b9 100644 --- a/acceptance/bundle/variables/reference-typo/script +++ b/acceptance/bundle/variables/reference-typo/script @@ -6,6 +6,3 @@ withdir multiple musterr trace $CLI bundle validate title "Typo in a non-variable reference (workspace field): multiple suggestions\n" withdir non-var-multiple musterr trace $CLI bundle validate - -title "Multiple typos in one value: only the first reference is reported\n" -withdir first-of-many musterr trace $CLI bundle validate From 1656b78a34b9e156c2282f31461b08c68a2bd5d4 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 14 Aug 2026 08:32:26 +0000 Subject: [PATCH 11/15] add nested and multiple-typo suggestion acceptance cases Cover deeply-nested references, two typos in one reference (outermost key wins), and multiple suggestions for the nested cases. Co-authored-by: Isaac --- .../databricks.yml | 21 ++++++++++ .../double-nested-typo/databricks.yml | 16 ++++++++ .../multiple-typos/databricks.yml | 21 ++++++++++ .../variables/reference-typo/output.txt | 39 +++++++++++++++++++ .../bundle/variables/reference-typo/script | 9 +++++ 5 files changed, 106 insertions(+) create mode 100644 acceptance/bundle/variables/reference-typo/double-nested-typo-multiple/databricks.yml create mode 100644 acceptance/bundle/variables/reference-typo/double-nested-typo/databricks.yml create mode 100644 acceptance/bundle/variables/reference-typo/multiple-typos/databricks.yml diff --git a/acceptance/bundle/variables/reference-typo/double-nested-typo-multiple/databricks.yml b/acceptance/bundle/variables/reference-typo/double-nested-typo-multiple/databricks.yml new file mode 100644 index 00000000000..284414fb427 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/double-nested-typo-multiple/databricks.yml @@ -0,0 +1,21 @@ +bundle: + name: reference-typo-double-nested-typo-multiple + +variables: + cluster: + type: complex + default: + spark_version: "13.2.x" + clusters: + type: complex + default: + spark_version: "13.2.x" + +resources: + jobs: + one: + # Two typos in a single reference across nesting layers, where the outermost + # key "clustr" is within distance 2 of both "cluster" and "clusters". The + # lookup fails at the outermost key, so both are suggested and the inner typo + # ("spark_versio") is never reached. + name: ${var.clustr.spark_versio} diff --git a/acceptance/bundle/variables/reference-typo/double-nested-typo/databricks.yml b/acceptance/bundle/variables/reference-typo/double-nested-typo/databricks.yml new file mode 100644 index 00000000000..627adcff302 --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/double-nested-typo/databricks.yml @@ -0,0 +1,16 @@ +bundle: + name: reference-typo-double-nested-typo + +variables: + cluster: + type: complex + default: + spark_version: "13.2.x" + +resources: + jobs: + one: + # Two typos in a single reference across nesting layers: "clustr" (cluster) + # and "spark_versio" (spark_version). The lookup fails at the outermost key, + # so only "cluster" is suggested; the inner typo is never reached. + name: ${var.clustr.spark_versio} diff --git a/acceptance/bundle/variables/reference-typo/multiple-typos/databricks.yml b/acceptance/bundle/variables/reference-typo/multiple-typos/databricks.yml new file mode 100644 index 00000000000..53b7d7ffeba --- /dev/null +++ b/acceptance/bundle/variables/reference-typo/multiple-typos/databricks.yml @@ -0,0 +1,21 @@ +bundle: + name: reference-typo-multiple-typos + +variables: + cluster: + type: complex + default: + spark_version: "13.2.x" + node_type_id: Standard_DS3_v2 + +resources: + jobs: + # Two typos in references to a complex (nested) variable. References are + # resolved in sorted order of their config path, and resolution stops at the + # first unresolved one, so only job "a" is reported even though "b" is wrong too. + a: + # Deeply nested typo: ${var.cluster.spark_versio} should be spark_version. + name: ${var.cluster.spark_versio} + b: + # Typo at the variable name: ${var.clustr.node_type_id} should be cluster. + name: ${var.clustr.node_type_id} diff --git a/acceptance/bundle/variables/reference-typo/output.txt b/acceptance/bundle/variables/reference-typo/output.txt index f2eba9cfbf8..bba19e11e04 100644 --- a/acceptance/bundle/variables/reference-typo/output.txt +++ b/acceptance/bundle/variables/reference-typo/output.txt @@ -37,3 +37,42 @@ Workspace: Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-non-var-multiple/default Found 1 error + +=== Nested complex variable with multiple typos: only the first is reported + +>>> [CLI] bundle validate +Error: reference does not exist: ${var.cluster.spark_versio}, did you mean "spark_version"? + +Name: reference-typo-multiple-typos +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-multiple-typos/default + +Found 1 error + +=== Two typos in one reference: lookup fails at the outermost key + +>>> [CLI] bundle validate +Error: reference does not exist: ${var.clustr.spark_versio}, did you mean "cluster"? + +Name: reference-typo-double-nested-typo +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-double-nested-typo/default + +Found 1 error + +=== Two typos in one reference, outermost close to two variables: multiple suggestions + +>>> [CLI] bundle validate +Error: reference does not exist: ${var.clustr.spark_versio}, did you mean one of: "cluster", "clusters"? + +Name: reference-typo-double-nested-typo-multiple +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/reference-typo-double-nested-typo-multiple/default + +Found 1 error diff --git a/acceptance/bundle/variables/reference-typo/script b/acceptance/bundle/variables/reference-typo/script index 4030d5ed2b9..5fb854524c5 100644 --- a/acceptance/bundle/variables/reference-typo/script +++ b/acceptance/bundle/variables/reference-typo/script @@ -6,3 +6,12 @@ withdir multiple musterr trace $CLI bundle validate title "Typo in a non-variable reference (workspace field): multiple suggestions\n" withdir non-var-multiple musterr trace $CLI bundle validate + +title "Nested complex variable with multiple typos: only the first is reported\n" +withdir multiple-typos musterr trace $CLI bundle validate + +title "Two typos in one reference: lookup fails at the outermost key\n" +withdir double-nested-typo musterr trace $CLI bundle validate + +title "Two typos in one reference, outermost close to two variables: multiple suggestions\n" +withdir double-nested-typo-multiple musterr trace $CLI bundle validate From 7eadf952ab4c623a9fbd8e001c7ddcdd98db3306 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 14 Aug 2026 09:25:00 +0000 Subject: [PATCH 12/15] add drop in messages --- .../variables/reference-typo/output.txt | 33 +++++++-- libs/dyn/dynvar/resolve.go | 6 +- libs/dyn/dynvar/resolve_test.go | 2 +- libs/dyn/suggest.go | 32 +++++++++ libs/dyn/suggest_test.go | 68 +++++++++++++++---- libs/dyn/visit.go | 14 ++-- 6 files changed, 125 insertions(+), 30 deletions(-) diff --git a/acceptance/bundle/variables/reference-typo/output.txt b/acceptance/bundle/variables/reference-typo/output.txt index bba19e11e04..b2b5d6216b9 100644 --- a/acceptance/bundle/variables/reference-typo/output.txt +++ b/acceptance/bundle/variables/reference-typo/output.txt @@ -2,7 +2,10 @@ === Typo of a defined variable: single suggestion >>> [CLI] bundle validate -Error: reference does not exist: ${var.hst}, did you mean "host"? +Error: reference does not exist: ${var.hst} + +did you mean: + ${var.host} Name: reference-typo Target: default @@ -15,7 +18,11 @@ Found 1 error === Typo close to two defined variables: multiple suggestions >>> [CLI] bundle validate -Error: reference does not exist: ${var.hst}, did you mean one of: "host", "hosts"? +Error: reference does not exist: ${var.hst} + +did you mean: + ${var.host} + ${var.hosts} Name: reference-typo-multiple Target: default @@ -28,7 +35,11 @@ Found 1 error === Typo in a non-variable reference (workspace field): multiple suggestions >>> [CLI] bundle validate -Error: reference does not exist: ${workspace.stot_path}, did you mean one of: "root_path", "state_path"? +Error: reference does not exist: ${workspace.stot_path} + +did you mean: + ${workspace.root_path} + ${workspace.state_path} Name: reference-typo-non-var-multiple Target: default @@ -41,7 +52,10 @@ Found 1 error === Nested complex variable with multiple typos: only the first is reported >>> [CLI] bundle validate -Error: reference does not exist: ${var.cluster.spark_versio}, did you mean "spark_version"? +Error: reference does not exist: ${var.cluster.spark_versio} + +did you mean: + ${var.cluster.spark_version} Name: reference-typo-multiple-typos Target: default @@ -54,7 +68,10 @@ Found 1 error === Two typos in one reference: lookup fails at the outermost key >>> [CLI] bundle validate -Error: reference does not exist: ${var.clustr.spark_versio}, did you mean "cluster"? +Error: reference does not exist: ${var.clustr.spark_versio} + +did you mean: + ${var.cluster.spark_versio} Name: reference-typo-double-nested-typo Target: default @@ -67,7 +84,11 @@ Found 1 error === Two typos in one reference, outermost close to two variables: multiple suggestions >>> [CLI] bundle validate -Error: reference does not exist: ${var.clustr.spark_versio}, did you mean one of: "cluster", "clusters"? +Error: reference does not exist: ${var.clustr.spark_versio} + +did you mean: + ${var.cluster.spark_versio} + ${var.clusters.spark_versio} Name: reference-typo-double-nested-typo-multiple Target: default diff --git a/libs/dyn/dynvar/resolve.go b/libs/dyn/dynvar/resolve.go index 5e79a90b2f9..6c9e86cfc58 100644 --- a/libs/dyn/dynvar/resolve.go +++ b/libs/dyn/dynvar/resolve.go @@ -215,9 +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) { - // 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)) + // Re-attach the key suggestions as drop-in references before the + // original not-found error is discarded. + err = fmt.Errorf("reference does not exist: ${%s}%s", key, dyn.DidYouMeanReferences(err, key)) } // Cache the return value and return to the caller. diff --git a/libs/dyn/dynvar/resolve_test.go b/libs/dyn/dynvar/resolve_test.go index e339c5c19f1..8aed7465b0c 100644 --- a/libs/dyn/dynvar/resolve_test.go +++ b/libs/dyn/dynvar/resolve_test.go @@ -46,7 +46,7 @@ func TestResolveNotFoundSuggestsCloseKey(t *testing.T) { }) _, err := dynvar.Resolve(in, dynvar.DefaultLookup(in)) - require.ErrorContains(t, err, `reference does not exist: ${hst}, did you mean "host"?`) + require.ErrorContains(t, err, "reference does not exist: ${hst}\n\ndid you mean:\n ${host}") } func TestResolveWithNesting(t *testing.T) { diff --git a/libs/dyn/suggest.go b/libs/dyn/suggest.go index 6d18b61cc8b..3c44c1bb591 100644 --- a/libs/dyn/suggest.go +++ b/libs/dyn/suggest.go @@ -89,3 +89,35 @@ func didYouMean(suggestions []string) string { return fmt.Sprintf(", did you mean one of: %s?", strings.Join(quoted, ", ")) } } + +// didYouMeanReferences formats a "did you mean" block listing each suggestion as +// a full drop-in reference on its own line, with only the failed segment swapped. +// Returns "" when there are no suggestions. +func didYouMeanReferences(reference, failedKey string, suggestions []string) string { + if len(suggestions) == 0 { + return "" + } + + lines := make([]string, len(suggestions)) + for i, s := range suggestions { + lines[i] = " ${" + replaceKey(reference, failedKey, s) + "}" + } + return "\n\ndid you mean:\n" + strings.Join(lines, "\n") +} + +// replaceKey returns reference with the component matching failedKey swapped for +// replacement, or just replacement if reference can't be parsed or has no match. +func replaceKey(reference, failedKey, replacement string) string { + p, err := NewPathFromString(reference) + if err != nil { + return replacement + } + for i, c := range p { + if c.Key() == failedKey { + out := p.Append() + out[i] = Key(replacement) + return out.String() + } + } + return replacement +} diff --git a/libs/dyn/suggest_test.go b/libs/dyn/suggest_test.go index 6f515b8876c..2789a97f739 100644 --- a/libs/dyn/suggest_test.go +++ b/libs/dyn/suggest_test.go @@ -94,31 +94,71 @@ func TestDidYouMean(t *testing.T) { } } -func TestDidYouMeanSuffix(t *testing.T) { +func TestDidYouMeanReferences(t *testing.T) { tests := []struct { - name string - err error - want string + name string + err error + reference string + want string }{ { - name: "no such key with suggestions", - err: noSuchKeyError{p: NewPath(Key("hst")), suggestions: []string{"host"}}, - want: `, did you mean "host"?`, + name: "single suggestion", + err: noSuchKeyError{p: NewPath(Key("variables"), Key("hst")), suggestions: []string{"host"}}, + reference: "var.hst", + want: "\n\ndid you mean:\n ${var.host}", + }, + { + name: "multiple suggestions", + err: noSuchKeyError{p: NewPath(Key("variables"), Key("hst")), suggestions: []string{"host", "hosts"}}, + reference: "var.hst", + want: "\n\ndid you mean:\n ${var.host}\n ${var.hosts}", + }, + { + name: "nested outer-key typo keeps suffix", + err: noSuchKeyError{p: NewPath(Key("variables"), Key("clustr")), suggestions: []string{"cluster"}}, + reference: "var.clustr.spark_version", + want: "\n\ndid you mean:\n ${var.cluster.spark_version}", + }, + { + name: "deep leaf typo keeps prefix", + err: noSuchKeyError{p: NewPath(Key("variables"), Key("cluster"), Key("value"), Key("spark_versio")), suggestions: []string{"spark_version"}}, + reference: "var.cluster.spark_versio", + want: "\n\ndid you mean:\n ${var.cluster.spark_version}", }, { - name: "no such key without suggestions", - err: noSuchKeyError{p: NewPath(Key("xyz"))}, - want: "", + name: "index component preserved", + err: noSuchKeyError{p: NewPath(Key("variables"), Key("librariez")), suggestions: []string{"libraries"}}, + reference: "var.librariez[0].jar", + want: "\n\ndid you mean:\n ${var.libraries[0].jar}", }, { - name: "other error type", - err: errors.New("some other error"), - want: "", + name: "non-var prefix", + err: noSuchKeyError{p: NewPath(Key("workspace"), Key("stot_path")), suggestions: []string{"root_path", "state_path"}}, + reference: "workspace.stot_path", + want: "\n\ndid you mean:\n ${workspace.root_path}\n ${workspace.state_path}", + }, + { + name: "no suggestions", + err: noSuchKeyError{p: NewPath(Key("xyz"))}, + reference: "var.xyz", + want: "", + }, + { + name: "other error type", + err: errors.New("some other error"), + reference: "var.xyz", + want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, DidYouMeanSuffix(tt.err)) + assert.Equal(t, tt.want, DidYouMeanReferences(tt.err, tt.reference)) }) } } + +func TestReplaceKeyFallsBackWhenKeyAbsent(t *testing.T) { + // The failed key is not present in the reference, so only the replacement + // itself is returned rather than a spliced reference. + assert.Equal(t, "host", replaceKey("var.foo", "missing", "host")) +} diff --git a/libs/dyn/visit.go b/libs/dyn/visit.go index 4ba59c05a3a..10e37afc4be 100644 --- a/libs/dyn/visit.go +++ b/libs/dyn/visit.go @@ -42,16 +42,18 @@ func IsNoSuchKeyError(err error) bool { 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 { +// DidYouMeanReferences returns a "did you mean" block of drop-in replacement +// references for a noSuchKeyError, or "" for any other error. reference is the +// original (pre-rewrite) reference text used to rebuild each suggestion. Used by +// variable interpolation, which discards the original not-found error message. +func DidYouMeanReferences(err error, reference string) string { e, ok := errors.AsType[noSuchKeyError](err) if !ok { return "" } - return didYouMean(e.suggestions) + // Last component of e.p is the failed key (same in original and rewritten space). + failedKey := e.p[len(e.p)-1].Key() + return didYouMeanReferences(reference, failedKey, e.suggestions) } type indexOutOfBoundsError struct { From 7d038a42d2186bb80553f931de0a26daae915d2f Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 14 Aug 2026 09:31:25 +0000 Subject: [PATCH 13/15] clearer message --- acceptance/bundle/variables/reference-typo/output.txt | 6 +++--- libs/dyn/suggest.go | 6 +++++- libs/dyn/suggest_test.go | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/acceptance/bundle/variables/reference-typo/output.txt b/acceptance/bundle/variables/reference-typo/output.txt index b2b5d6216b9..72de565c011 100644 --- a/acceptance/bundle/variables/reference-typo/output.txt +++ b/acceptance/bundle/variables/reference-typo/output.txt @@ -20,7 +20,7 @@ Found 1 error >>> [CLI] bundle validate Error: reference does not exist: ${var.hst} -did you mean: +did you mean one of: ${var.host} ${var.hosts} @@ -37,7 +37,7 @@ Found 1 error >>> [CLI] bundle validate Error: reference does not exist: ${workspace.stot_path} -did you mean: +did you mean one of: ${workspace.root_path} ${workspace.state_path} @@ -86,7 +86,7 @@ Found 1 error >>> [CLI] bundle validate Error: reference does not exist: ${var.clustr.spark_versio} -did you mean: +did you mean one of: ${var.cluster.spark_versio} ${var.clusters.spark_versio} diff --git a/libs/dyn/suggest.go b/libs/dyn/suggest.go index 3c44c1bb591..91450349a36 100644 --- a/libs/dyn/suggest.go +++ b/libs/dyn/suggest.go @@ -102,7 +102,11 @@ func didYouMeanReferences(reference, failedKey string, suggestions []string) str for i, s := range suggestions { lines[i] = " ${" + replaceKey(reference, failedKey, s) + "}" } - return "\n\ndid you mean:\n" + strings.Join(lines, "\n") + header := "did you mean:" + if len(suggestions) > 1 { + header = "did you mean one of:" + } + return "\n\n" + header + "\n" + strings.Join(lines, "\n") } // replaceKey returns reference with the component matching failedKey swapped for diff --git a/libs/dyn/suggest_test.go b/libs/dyn/suggest_test.go index 2789a97f739..9f0329ce4f1 100644 --- a/libs/dyn/suggest_test.go +++ b/libs/dyn/suggest_test.go @@ -111,7 +111,7 @@ func TestDidYouMeanReferences(t *testing.T) { name: "multiple suggestions", err: noSuchKeyError{p: NewPath(Key("variables"), Key("hst")), suggestions: []string{"host", "hosts"}}, reference: "var.hst", - want: "\n\ndid you mean:\n ${var.host}\n ${var.hosts}", + want: "\n\ndid you mean one of:\n ${var.host}\n ${var.hosts}", }, { name: "nested outer-key typo keeps suffix", @@ -135,7 +135,7 @@ func TestDidYouMeanReferences(t *testing.T) { name: "non-var prefix", err: noSuchKeyError{p: NewPath(Key("workspace"), Key("stot_path")), suggestions: []string{"root_path", "state_path"}}, reference: "workspace.stot_path", - want: "\n\ndid you mean:\n ${workspace.root_path}\n ${workspace.state_path}", + want: "\n\ndid you mean one of:\n ${workspace.root_path}\n ${workspace.state_path}", }, { name: "no suggestions", From ee324258626b830f72bb35319b4688e24e06378d Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 14 Aug 2026 10:37:27 +0000 Subject: [PATCH 14/15] build did-you-mean suggestions via libs/diag Detail Propagate reference suggestions as structured data (dynvar.ReferenceError + dyn.SuggestedReferences) instead of formatting a multi-line error string in libs/dyn. The mutator builds the diagnostic Detail so libs/diag owns terminal formatting. Rendered output is unchanged. Co-authored-by: Isaac --- .../mutator/resolve_variable_references.go | 26 ++++++++++++++++++- libs/dyn/dynvar/resolve.go | 16 +++++++++--- libs/dyn/dynvar/resolve_test.go | 6 ++++- libs/dyn/suggest.go | 19 -------------- libs/dyn/suggest_test.go | 22 ++++++++-------- libs/dyn/visit.go | 19 ++++++++------ 6 files changed, 65 insertions(+), 43 deletions(-) diff --git a/bundle/config/mutator/resolve_variable_references.go b/bundle/config/mutator/resolve_variable_references.go index 9a03c85d362..f0da20431f3 100644 --- a/bundle/config/mutator/resolve_variable_references.go +++ b/bundle/config/mutator/resolve_variable_references.go @@ -289,12 +289,36 @@ func (m *resolveVariableReferences) resolveOnce(b *bundle.Bundle, prefixes []dyn return root, nil }) if err != nil { - diags = diags.Extend(diag.FromErr(err)) + diags = diags.Extend(resolveErrorDiags(err)) } return hasUpdates, diags } +// resolveErrorDiags renders "did you mean" suggestions as a diagnostic Detail so +// libs/diag owns the multi-line formatting. +func resolveErrorDiags(err error) diag.Diagnostics { + var refErr *dynvar.ReferenceError + if !errors.As(err, &refErr) || len(refErr.Suggestions) == 0 { + return diag.FromErr(err) + } + + header := "did you mean:" + if len(refErr.Suggestions) > 1 { + header = "did you mean one of:" + } + detail := header + for _, ref := range refErr.Suggestions { + detail += "\n ${" + ref + "}" + } + + return diag.Diagnostics{{ + Severity: diag.Error, + Summary: refErr.Error(), + Detail: detail, + }} +} + // selectivelyMutate applies a function to a subset of the configuration func (m *resolveVariableReferences) selectivelyMutate(b *bundle.Bundle, fn func(value dyn.Value) (dyn.Value, error)) error { return b.Config.Mutate(func(root dyn.Value) (dyn.Value, error) { diff --git a/libs/dyn/dynvar/resolve.go b/libs/dyn/dynvar/resolve.go index 6c9e86cfc58..b7edf5efd7a 100644 --- a/libs/dyn/dynvar/resolve.go +++ b/libs/dyn/dynvar/resolve.go @@ -37,6 +37,17 @@ func Resolve(in dyn.Value, fn Lookup) (out dyn.Value, err error) { return resolver{in: in, fn: fn}.run() } +// ReferenceError is returned for an unresolved variable reference. Suggestions +// are carried as data so callers (which can import libs/diag) format them. +type ReferenceError struct { + Reference string // original reference text, e.g. "var.hst" + Suggestions []string // corrected references, e.g. ["var.host", "var.hosts"] +} + +func (e *ReferenceError) Error() string { + return fmt.Sprintf("reference does not exist: ${%s}", e.Reference) +} + type lookupResult struct { v dyn.Value err error @@ -215,9 +226,8 @@ func (r *resolver) resolveKey(key string, seen []string) (dyn.Value, error) { v, err := r.fn(p) if err != nil { if dyn.IsNoSuchKeyError(err) { - // Re-attach the key suggestions as drop-in references before the - // original not-found error is discarded. - err = fmt.Errorf("reference does not exist: ${%s}%s", key, dyn.DidYouMeanReferences(err, key)) + // Carry suggestions as data; the caller formats them via libs/diag. + err = &ReferenceError{Reference: key, Suggestions: dyn.SuggestedReferences(err, key)} } // Cache the return value and return to the caller. diff --git a/libs/dyn/dynvar/resolve_test.go b/libs/dyn/dynvar/resolve_test.go index 8aed7465b0c..f519272df85 100644 --- a/libs/dyn/dynvar/resolve_test.go +++ b/libs/dyn/dynvar/resolve_test.go @@ -46,7 +46,11 @@ func TestResolveNotFoundSuggestsCloseKey(t *testing.T) { }) _, err := dynvar.Resolve(in, dynvar.DefaultLookup(in)) - require.ErrorContains(t, err, "reference does not exist: ${hst}\n\ndid you mean:\n ${host}") + require.ErrorContains(t, err, "reference does not exist: ${hst}") + + var refErr *dynvar.ReferenceError + require.ErrorAs(t, err, &refErr) + assert.Equal(t, []string{"host"}, refErr.Suggestions) } func TestResolveWithNesting(t *testing.T) { diff --git a/libs/dyn/suggest.go b/libs/dyn/suggest.go index 91450349a36..ef8008aae6f 100644 --- a/libs/dyn/suggest.go +++ b/libs/dyn/suggest.go @@ -90,25 +90,6 @@ func didYouMean(suggestions []string) string { } } -// didYouMeanReferences formats a "did you mean" block listing each suggestion as -// a full drop-in reference on its own line, with only the failed segment swapped. -// Returns "" when there are no suggestions. -func didYouMeanReferences(reference, failedKey string, suggestions []string) string { - if len(suggestions) == 0 { - return "" - } - - lines := make([]string, len(suggestions)) - for i, s := range suggestions { - lines[i] = " ${" + replaceKey(reference, failedKey, s) + "}" - } - header := "did you mean:" - if len(suggestions) > 1 { - header = "did you mean one of:" - } - return "\n\n" + header + "\n" + strings.Join(lines, "\n") -} - // replaceKey returns reference with the component matching failedKey swapped for // replacement, or just replacement if reference can't be parsed or has no match. func replaceKey(reference, failedKey, replacement string) string { diff --git a/libs/dyn/suggest_test.go b/libs/dyn/suggest_test.go index 9f0329ce4f1..b0aa4a87168 100644 --- a/libs/dyn/suggest_test.go +++ b/libs/dyn/suggest_test.go @@ -94,65 +94,65 @@ func TestDidYouMean(t *testing.T) { } } -func TestDidYouMeanReferences(t *testing.T) { +func TestSuggestedReferences(t *testing.T) { tests := []struct { name string err error reference string - want string + want []string }{ { name: "single suggestion", err: noSuchKeyError{p: NewPath(Key("variables"), Key("hst")), suggestions: []string{"host"}}, reference: "var.hst", - want: "\n\ndid you mean:\n ${var.host}", + want: []string{"var.host"}, }, { name: "multiple suggestions", err: noSuchKeyError{p: NewPath(Key("variables"), Key("hst")), suggestions: []string{"host", "hosts"}}, reference: "var.hst", - want: "\n\ndid you mean one of:\n ${var.host}\n ${var.hosts}", + want: []string{"var.host", "var.hosts"}, }, { name: "nested outer-key typo keeps suffix", err: noSuchKeyError{p: NewPath(Key("variables"), Key("clustr")), suggestions: []string{"cluster"}}, reference: "var.clustr.spark_version", - want: "\n\ndid you mean:\n ${var.cluster.spark_version}", + want: []string{"var.cluster.spark_version"}, }, { name: "deep leaf typo keeps prefix", err: noSuchKeyError{p: NewPath(Key("variables"), Key("cluster"), Key("value"), Key("spark_versio")), suggestions: []string{"spark_version"}}, reference: "var.cluster.spark_versio", - want: "\n\ndid you mean:\n ${var.cluster.spark_version}", + want: []string{"var.cluster.spark_version"}, }, { name: "index component preserved", err: noSuchKeyError{p: NewPath(Key("variables"), Key("librariez")), suggestions: []string{"libraries"}}, reference: "var.librariez[0].jar", - want: "\n\ndid you mean:\n ${var.libraries[0].jar}", + want: []string{"var.libraries[0].jar"}, }, { name: "non-var prefix", err: noSuchKeyError{p: NewPath(Key("workspace"), Key("stot_path")), suggestions: []string{"root_path", "state_path"}}, reference: "workspace.stot_path", - want: "\n\ndid you mean one of:\n ${workspace.root_path}\n ${workspace.state_path}", + want: []string{"workspace.root_path", "workspace.state_path"}, }, { name: "no suggestions", err: noSuchKeyError{p: NewPath(Key("xyz"))}, reference: "var.xyz", - want: "", + want: nil, }, { name: "other error type", err: errors.New("some other error"), reference: "var.xyz", - want: "", + want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, DidYouMeanReferences(tt.err, tt.reference)) + assert.Equal(t, tt.want, SuggestedReferences(tt.err, tt.reference)) }) } } diff --git a/libs/dyn/visit.go b/libs/dyn/visit.go index 10e37afc4be..58223e792b9 100644 --- a/libs/dyn/visit.go +++ b/libs/dyn/visit.go @@ -42,18 +42,21 @@ func IsNoSuchKeyError(err error) bool { return ok } -// DidYouMeanReferences returns a "did you mean" block of drop-in replacement -// references for a noSuchKeyError, or "" for any other error. reference is the -// original (pre-rewrite) reference text used to rebuild each suggestion. Used by -// variable interpolation, which discards the original not-found error message. -func DidYouMeanReferences(err error, reference string) string { +// SuggestedReferences returns drop-in replacement references for a noSuchKeyError +// (nil otherwise), rebuilt by swapping the failed segment of reference for each +// suggestion (e.g. "var.hst" -> ["var.host"]). +func SuggestedReferences(err error, reference string) []string { e, ok := errors.AsType[noSuchKeyError](err) - if !ok { - return "" + if !ok || len(e.suggestions) == 0 { + return nil } // Last component of e.p is the failed key (same in original and rewritten space). failedKey := e.p[len(e.p)-1].Key() - return didYouMeanReferences(reference, failedKey, e.suggestions) + refs := make([]string, len(e.suggestions)) + for i, s := range e.suggestions { + refs[i] = replaceKey(reference, failedKey, s) + } + return refs } type indexOutOfBoundsError struct { From e6c54ea35507825fd1fe2ae3debbcc83c2c5e67f Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 14 Aug 2026 10:48:25 +0000 Subject: [PATCH 15/15] fix lint: use errors.AsType and strings.Builder Co-authored-by: Isaac --- bundle/config/mutator/resolve_variable_references.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bundle/config/mutator/resolve_variable_references.go b/bundle/config/mutator/resolve_variable_references.go index f0da20431f3..0b1a02680d4 100644 --- a/bundle/config/mutator/resolve_variable_references.go +++ b/bundle/config/mutator/resolve_variable_references.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "github.com/databricks/cli/libs/dyn/merge" @@ -298,8 +299,8 @@ func (m *resolveVariableReferences) resolveOnce(b *bundle.Bundle, prefixes []dyn // resolveErrorDiags renders "did you mean" suggestions as a diagnostic Detail so // libs/diag owns the multi-line formatting. func resolveErrorDiags(err error) diag.Diagnostics { - var refErr *dynvar.ReferenceError - if !errors.As(err, &refErr) || len(refErr.Suggestions) == 0 { + refErr, ok := errors.AsType[*dynvar.ReferenceError](err) + if !ok || len(refErr.Suggestions) == 0 { return diag.FromErr(err) } @@ -307,15 +308,16 @@ func resolveErrorDiags(err error) diag.Diagnostics { if len(refErr.Suggestions) > 1 { header = "did you mean one of:" } - detail := header + var detail strings.Builder + detail.WriteString(header) for _, ref := range refErr.Suggestions { - detail += "\n ${" + ref + "}" + detail.WriteString("\n ${" + ref + "}") } return diag.Diagnostics{{ Severity: diag.Error, Summary: refErr.Error(), - Detail: detail, + Detail: detail.String(), }} }