Skip to content

Commit dd3b6f9

Browse files
Remove escape sequence support from interpolation parser
Escape sequences (\$ and \\) are incompatible with multi-round variable resolution: escapes consumed in round N produce bare ${...} text that round N+1 incorrectly resolves as a real variable reference. Remove escape support entirely and defer to a follow-up if needed. Co-authored-by: Isaac
1 parent 8cddafa commit dd3b6f9

10 files changed

Lines changed: 37 additions & 189 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* Deduplicate grant entries with duplicate principals or privileges during initialization ([#4801](https://github.com/databricks/cli/pull/4801))
1313
* engine/direct: Fix unwanted recreation of secret scopes when scope_backend_type is not set ([#4834](https://github.com/databricks/cli/pull/4834))
1414

15-
* Replace regex-based variable interpolation with a character scanner. Escape sequences `\$` and `\\` are now supported ([#4747](https://github.com/databricks/cli/pull/4747)).
15+
* Replace regex-based variable interpolation with a character scanner ([#4747](https://github.com/databricks/cli/pull/4747)).
1616

1717
### Dependency updates
1818

acceptance/bundle/variables/escape_sequences/databricks.yml

Lines changed: 0 additions & 6 deletions
This file was deleted.

acceptance/bundle/variables/escape_sequences/out.test.toml

Lines changed: 0 additions & 5 deletions
This file was deleted.

acceptance/bundle/variables/escape_sequences/output.txt

Lines changed: 0 additions & 16 deletions
This file was deleted.

acceptance/bundle/variables/escape_sequences/script

Lines changed: 0 additions & 1 deletion
This file was deleted.

design/interpolation-parser.md

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ DABs variable interpolation (`${...}`) was regex-based. This caused:
99

1010
1. **Silent failures**`${foo.bar-}` silently treated as literal text with no warning.
1111
2. **No suggestions**`${bundle.nme}` produces "reference does not exist" with no hint.
12-
3. **No escape mechanism** — no way to produce a literal `${` in output.
13-
4. **No extensibility** — cannot support structured path features like key-value references `tasks[task_key="x"]` that exist in `libs/structs/structpath`.
12+
3. **No extensibility** — cannot support structured path features like key-value references `tasks[task_key="x"]` that exist in `libs/structs/structpath`.
1413

1514
## Background: How Other Systems Parse `${...}`
1615

@@ -25,7 +24,7 @@ DABs variable interpolation (`${...}`) was regex-based. This caused:
2524
For a syntax as simple as `${path.to.var[0]}` (no nesting, no functions, no
2625
operators), a full recursive descent parser is overkill. A **two-mode character
2726
scanner** — the same core pattern used by Go's `text/template` and HCL — gives
28-
proper error reporting and escape support without the complexity.
27+
proper error reporting without the complexity.
2928

3029
## Design Decisions
3130

@@ -36,22 +35,26 @@ No AST, no recursive descent. Easy to port to the Python implementation.
3635

3736
See `libs/interpolation/parse.go`.
3837

39-
### Nested `${` rejection
38+
### Nested `${` support
4039

4140
Nested `${...}` inside a reference (e.g., `${var.foo_${var.tail}}`) is
42-
rejected as an error. This construct is ambiguous and was never intentionally
43-
supported — the old regex happened to match only the innermost pair by
44-
coincidence.
45-
46-
### `\$` escape sequence
47-
48-
`\$` produces a literal `$`, and `\\` produces a literal `\`. This follows
49-
the same convention used by Bash for escaping `$` and is the least
50-
surprising option for users working in shell environments.
51-
52-
A standalone `\` before any character other than `$` or `\` is passed
53-
through as a literal backslash, so existing configurations that happen to
54-
contain backslashes are not affected.
41+
supported. When the parser encounters a nested `${` inside an outer
42+
reference, it treats the outer `${...` prefix as literal text so the inner
43+
reference is resolved first. Multi-round resolution (up to 11 rounds in
44+
`resolve_variable_references.go`) then progressively resolves from inside
45+
out. For example, `${a_${b_${c}}}` resolves in 3 rounds.
46+
47+
### No escape sequences
48+
49+
Escape sequences (e.g. `\$``$`) are intentionally omitted. Variable
50+
resolution uses multi-round processing (up to 11 rounds) where each round
51+
re-parses all string values. Escape sequences consumed in round N would
52+
produce bare `${...}` text that round N+1 would incorrectly resolve as a
53+
real variable reference. A safe escape mechanism would require either
54+
deferred consumption (escapes survive all rounds and are consumed in a
55+
final pass) or sentinel characters, adding significant complexity. Since
56+
there is no existing user demand for literal `${` in output, we defer
57+
escape support to a follow-up if needed.
5558

5659
### Malformed reference warnings
5760

@@ -68,5 +71,9 @@ loop. Each `TokenRef` maps 1:1 to a resolved value, eliminating the ambiguity.
6871

6972
## Python sync
7073

71-
The Python regex in `python/databricks/bundles/core/_transform.py` needs a
72-
corresponding update in a follow-up PR.
74+
PyDABs uses a regex in `python/databricks/bundles/core/_transform.py` to
75+
detect pure variable references (entire string is a single `${...}`). This
76+
regex must stay in sync with the Go parser's key/path validation. Shared
77+
test cases in `libs/interpolation/testdata/variable_references.json` are
78+
consumed by both Go (`TestParsePureVariableReferences`) and Python
79+
(`test_pure_variable_reference`) to verify agreement.

libs/dyn/dynvar/ref.go

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ func newRef(v dyn.Value) (Ref, bool, diag.Diagnostics) {
5757
}}
5858
}
5959

60-
// Check if any token is a variable reference or if escape sequences
61-
// were processed (the reconstructed string differs from the original).
60+
// Check if any token is a variable reference.
6261
hasRef := false
6362
for _, t := range tokens {
6463
if t.Kind == interpolation.TokenRef {
@@ -68,11 +67,7 @@ func newRef(v dyn.Value) (Ref, bool, diag.Diagnostics) {
6867
}
6968

7069
if !hasRef {
71-
// Even without references, if escape sequences were processed we need
72-
// to return a Ref so the resolver can reconstruct the literal string.
73-
if !hasEscapes(s, tokens) {
74-
return Ref{}, false, nil
75-
}
70+
return Ref{}, false, nil
7671
}
7772

7873
return Ref{
@@ -100,18 +95,6 @@ func (v Ref) References() []string {
10095
return out
10196
}
10297

103-
// hasEscapes checks whether escape sequences were processed during parsing.
104-
// Escape processing shortens the output (e.g., `\$` becomes `$`), so if the
105-
// sum of token value lengths is less than the original string length, escapes
106-
// were present.
107-
func hasEscapes(original string, tokens []interpolation.Token) bool {
108-
n := 0
109-
for _, t := range tokens {
110-
n += len(t.Value)
111-
}
112-
return n < len(original)
113-
}
114-
11598
// IsPureVariableReference returns true if s is a single variable reference
11699
// with no surrounding text.
117100
func IsPureVariableReference(s string) bool {

libs/dyn/dynvar/resolve_test.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -423,15 +423,3 @@ func TestResolveThreeLevelNestedVariableReference(t *testing.T) {
423423
assert.Equal(t, "${a_${b_z}}", getByPath(t, out, "final").MustString())
424424
}
425425

426-
func TestResolveEscapedRef(t *testing.T) {
427-
in := dyn.V(map[string]dyn.Value{
428-
"a": dyn.V("a"),
429-
"b": dyn.V(`\${a}`),
430-
})
431-
432-
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
433-
require.NoError(t, err)
434-
435-
// Escaped reference should produce literal ${a}.
436-
assert.Equal(t, "${a}", getByPath(t, out, "b").MustString())
437-
}

libs/interpolation/parse.go

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,9 @@ type Token struct {
3636
}
3737

3838
const (
39-
dollarChar = '$'
40-
openBrace = '{'
41-
closeBrace = '}'
42-
backslashChar = '\\'
39+
dollarChar = '$'
40+
openBrace = '{'
41+
closeBrace = '}'
4342
)
4443

4544
// keyPattern validates a single key segment in a variable path.
@@ -50,8 +49,8 @@ const (
5049
// (python/databricks/bundles/core/_transform.py). The patterns must stay in
5150
// sync. Cross-language test cases live in testdata/variable_references.json
5251
// and are run by both Go (TestParsePureVariableReferences) and Python
53-
// (test_pure_variable_reference). When changing key/index/path validation,
54-
// add cases to that file so both languages are tested.
52+
// (test_pure_variable_reference). When changing key/index/path validation
53+
// or reference syntax, add cases to that file so both languages are tested.
5554
var keyPattern = regexp.MustCompile(`^[a-zA-Z]+([-_]*[a-zA-Z0-9]+)*$`)
5655

5756
// indexPattern matches one or more [N] index suffixes.
@@ -60,10 +59,6 @@ var indexPattern = regexp.MustCompile(`^(\[[0-9]+\])+$`)
6059
// Parse parses a string that may contain ${...} variable references.
6160
// It returns a slice of tokens representing literal text and variable references.
6261
//
63-
// Escape sequences:
64-
// - "\$" produces a literal "$"
65-
// - "\\" produces a literal "\"
66-
//
6762
// Nested references like "${a.${b}}" are supported by treating the outer
6863
// "${a." as literal text so that inner references are resolved first.
6964
// After resolution the resulting string (e.g. "${a.x}") is re-parsed.
@@ -72,7 +67,6 @@ var indexPattern = regexp.MustCompile(`^(\[[0-9]+\])+$`)
7267
// - "hello" -> [Literal("hello")]
7368
// - "${a.b}" -> [Ref("a.b")]
7469
// - "pre ${a.b} post" -> [Literal("pre "), Ref("a.b"), Literal(" post")]
75-
// - "\${a.b}" -> [Literal("${a.b}")]
7670
// - "${a.${b}}" -> [Literal("${a."), Ref("b"), Literal("}")]
7771
func Parse(s string) ([]Token, error) {
7872
if len(s) == 0 {
@@ -98,30 +92,6 @@ func Parse(s string) ([]Token, error) {
9892

9993
for i < len(s) {
10094
switch s[i] {
101-
case backslashChar:
102-
// Handle escape sequences: \$ -> $, \\ -> \
103-
if buf.Len() == 0 {
104-
litStart = i
105-
}
106-
if i+1 < len(s) {
107-
switch s[i+1] {
108-
case dollarChar:
109-
buf.WriteByte(dollarChar)
110-
i += 2
111-
case backslashChar:
112-
buf.WriteByte(backslashChar)
113-
i += 2
114-
default:
115-
// Not a recognized escape; treat backslash as literal.
116-
buf.WriteByte(backslashChar)
117-
i++
118-
}
119-
} else {
120-
// Trailing backslash at end of string: treat as literal.
121-
buf.WriteByte(backslashChar)
122-
i++
123-
}
124-
12595
case dollarChar:
12696
// We see '$'. Look ahead.
12797
if i+1 >= len(s) {

libs/interpolation/parse_test.go

Lines changed: 3 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -41,64 +41,6 @@ func TestParseValidPaths(t *testing.T) {
4141
}
4242
}
4343

44-
func TestParseEscapeSequences(t *testing.T) {
45-
tests := []struct {
46-
name string
47-
input string
48-
tokens []Token
49-
}{
50-
{
51-
"escaped_dollar",
52-
`\$`,
53-
[]Token{{Kind: TokenLiteral, Value: "$", Start: 0, End: 2}},
54-
},
55-
{
56-
"escaped_ref",
57-
`\${a}`,
58-
[]Token{{Kind: TokenLiteral, Value: "${a}", Start: 0, End: 5}},
59-
},
60-
{
61-
"escaped_backslash",
62-
`\\`,
63-
[]Token{{Kind: TokenLiteral, Value: `\`, Start: 0, End: 2}},
64-
},
65-
{
66-
"double_escaped_backslash",
67-
`\\\\`,
68-
[]Token{{Kind: TokenLiteral, Value: `\\`, Start: 0, End: 4}},
69-
},
70-
{
71-
"escaped_backslash_then_ref",
72-
`\\${a.b}`,
73-
[]Token{
74-
{Kind: TokenLiteral, Value: `\`, Start: 0, End: 2},
75-
{Kind: TokenRef, Value: "a.b", Start: 2, End: 8},
76-
},
77-
},
78-
{
79-
"backslash_before_non_special",
80-
`\n`,
81-
[]Token{{Kind: TokenLiteral, Value: `\n`, Start: 0, End: 2}},
82-
},
83-
{
84-
"escaped_dollar_then_ref",
85-
`\$\$${a.b}`,
86-
[]Token{
87-
{Kind: TokenLiteral, Value: "$$", Start: 0, End: 4},
88-
{Kind: TokenRef, Value: "a.b", Start: 4, End: 10},
89-
},
90-
},
91-
}
92-
93-
for _, tt := range tests {
94-
t.Run(tt.name, func(t *testing.T) {
95-
tokens, err := Parse(tt.input)
96-
require.NoError(t, err)
97-
assert.Equal(t, tt.tokens, tokens)
98-
})
99-
}
100-
}
101-
10244
func TestParse(t *testing.T) {
10345
tests := []struct {
10446
name string
@@ -166,20 +108,6 @@ func TestParse(t *testing.T) {
166108
`abc\`,
167109
[]Token{{Kind: TokenLiteral, Value: `abc\`, Start: 0, End: 4}},
168110
},
169-
{
170-
"escaped_ref",
171-
`\${a}`,
172-
[]Token{{Kind: TokenLiteral, Value: "${a}", Start: 0, End: 5}},
173-
},
174-
{
175-
"escape_between_refs",
176-
`${a}\$${b}`,
177-
[]Token{
178-
{Kind: TokenRef, Value: "a", Start: 0, End: 4},
179-
{Kind: TokenLiteral, Value: "$", Start: 4, End: 6},
180-
{Kind: TokenRef, Value: "b", Start: 6, End: 10},
181-
},
182-
},
183111
{
184112
"nested_ref",
185113
"${var.foo_${var.tail}}",
@@ -256,9 +184,9 @@ func TestParseErrors(t *testing.T) {
256184
// (python/databricks_tests/core/test_variable_references.py) to
257185
// verify that the Python regex stays in sync with the Go parser.
258186
//
259-
// When modifying the parser (e.g. adding new key patterns, escape
260-
// sequences, or reference syntax), add test cases to the JSON file
261-
// so both Go and Python are validated.
187+
// When modifying the parser (e.g. adding new key patterns or
188+
// reference syntax), add test cases to the JSON file so both Go
189+
// and Python are validated.
262190
func TestParsePureVariableReferences(t *testing.T) {
263191
data, err := os.ReadFile("testdata/variable_references.json")
264192
require.NoError(t, err)

0 commit comments

Comments
 (0)