Skip to content

Commit aba419f

Browse files
Merge branch 'main' into transformer-overhaul
2 parents 42672f8 + e159a5a commit aba419f

File tree

7 files changed

+116
-39
lines changed

7 files changed

+116
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## \[Unreleased\]
99

10-
- Nothing yet.
10+
- Issue parsing interpolations and escaped interpolations in a single string. ([#239](https://github.com/amplify-education/python-hcl2/pull/239))
1111

1212
## \[7.2.1\] - 2025-05-16
1313

hcl2/dict_transformer.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,8 @@ def heredoc_template(self, args: List) -> str:
247247
raise RuntimeError(f"Invalid Heredoc token: {args[0]}")
248248

249249
trim_chars = "\n\t "
250-
return f'"{match.group(2).rstrip(trim_chars)}"'
250+
result = match.group(2).rstrip(trim_chars)
251+
return f'"{result}"'
251252

252253
def heredoc_template_trim(self, args: List) -> str:
253254
# See https://github.com/hashicorp/hcl2/blob/master/hcl/hclsyntax/spec.md#template-expressions
@@ -301,12 +302,17 @@ def for_object_expr(self, args: List) -> str:
301302
# e.g. f"{2 + 2} {{2 + 2}}" == "4 {2 + 2}"
302303
return f"{{{for_expr}}}"
303304

304-
def string_with_interpolation(self, args: List) -> str:
305-
return '"' + ("".join(args)) + '"'
305+
def string(self, args: List) -> str:
306+
return '"' + "".join(args) + '"'
306307

307-
def interpolation_maybe_nested(self, args: List) -> str:
308-
# return "".join(args)
309-
return "${" + ("".join(args)) + "}"
308+
def string_part(self, args: List) -> str:
309+
value = self.to_tf_inline(args[0])
310+
if value.startswith('"') and value.endswith('"'):
311+
value = value[1:-1]
312+
return value
313+
314+
def interpolation(self, args: List) -> str:
315+
return '"${' + str(args[0]) + '}"'
310316

311317
def strip_new_line_tokens(self, args: List) -> List:
312318
"""

hcl2/hcl2.lark

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
start : body
22
body : (new_line_or_comment? (attribute | block))* new_line_or_comment?
33
attribute : identifier EQ expression
4-
block : identifier (identifier | STRING_LIT | string_with_interpolation)* new_line_or_comment? "{" body "}"
4+
block : identifier (identifier | string)* new_line_or_comment? "{" body "}"
55
new_line_or_comment: ( NL_OR_COMMENT )+
66
NL_OR_COMMENT: /\n[ \t]*/ | /#.*\n/ | /\/\/.*\n/ | /\/\*(.|\n)*?(\*\/)/
77

@@ -44,8 +44,7 @@ COLON : ":"
4444
expr_term : LPAR new_line_or_comment? expression new_line_or_comment? RPAR
4545
| float_lit
4646
| int_lit
47-
| STRING_LIT
48-
| string_with_interpolation
47+
| string
4948
| tuple
5049
| object
5150
| function_call
@@ -60,11 +59,13 @@ expr_term : LPAR new_line_or_comment? expression new_line_or_comment? RPAR
6059
| for_tuple_expr
6160
| for_object_expr
6261

63-
STRING_LIT : "\"" STRING_CHARS? "\""
64-
STRING_CHARS : /(?:(?!\${)([^"\\]|\\.|\$\$))+/ // any character except '"', including escaped $$
65-
string_with_interpolation: "\"" (STRING_CHARS)* interpolation_maybe_nested (STRING_CHARS | interpolation_maybe_nested)* "\""
66-
interpolation_maybe_nested: "${" expression "}"
67-
62+
string: "\"" string_part* "\""
63+
string_part: STRING_CHARS
64+
| ESCAPED_INTERPOLATION
65+
| interpolation
66+
interpolation: "${" expression "}"
67+
ESCAPED_INTERPOLATION.2: /\$\$\{[^}]*\}/
68+
STRING_CHARS.1: /(?:(?!\$\$\{)(?!\$\{)[^"\\]|\\.|(?:\$(?!\$?\{)))+/
6869

6970
int_lit : NEGATIVE_DECIMAL? DECIMAL+ | NEGATIVE_DECIMAL+
7071
!float_lit: (NEGATIVE_DECIMAL? DECIMAL+ | NEGATIVE_DECIMAL+) "." DECIMAL+ (EXP_MARK)?
@@ -77,7 +78,7 @@ EQ : /[ \t]*=(?!=|>)/
7778
tuple : "[" (new_line_or_comment* expression new_line_or_comment* ",")* (new_line_or_comment* expression)? new_line_or_comment* "]"
7879
object : "{" new_line_or_comment? (new_line_or_comment* (object_elem | (object_elem COMMA)) new_line_or_comment*)* "}"
7980
object_elem : object_elem_key ( EQ | COLON ) expression
80-
object_elem_key : float_lit | int_lit | identifier | STRING_LIT | object_elem_key_dot_accessor | object_elem_key_expression | string_with_interpolation
81+
object_elem_key : float_lit | int_lit | identifier | string | object_elem_key_dot_accessor | object_elem_key_expression
8182
object_elem_key_expression : LPAR expression RPAR
8283
object_elem_key_dot_accessor : identifier (DOT identifier)+
8384

hcl2/reconstructor.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""A reconstructor for HCL2 implemented using Lark's experimental reconstruction functionality"""
22

33
import re
4-
import json
54
from typing import List, Dict, Callable, Optional, Union, Any, Tuple
65

76
from lark import Lark, Tree
@@ -137,7 +136,7 @@ def _is_equals_sign(self, terminal) -> bool:
137136
)
138137

139138
# pylint: disable=too-many-branches, too-many-return-statements
140-
def _should_add_space(self, rule, current_terminal):
139+
def _should_add_space(self, rule, current_terminal, is_block_label: bool = False):
141140
"""
142141
This method documents the situations in which we add space around
143142
certain tokens while reconstructing the generated HCL.
@@ -155,6 +154,7 @@ def _should_add_space(self, rule, current_terminal):
155154
156155
This should be sufficient to make a spacing decision.
157156
"""
157+
158158
# we don't need to add multiple spaces
159159
if self._last_char_space:
160160
return False
@@ -166,6 +166,14 @@ def _should_add_space(self, rule, current_terminal):
166166
if self._is_equals_sign(current_terminal):
167167
return True
168168

169+
if is_block_label and isinstance(rule, Token) and rule.value == "string":
170+
if (
171+
current_terminal == self._last_terminal == Terminal("DBLQUOTE")
172+
or current_terminal == Terminal("DBLQUOTE")
173+
and self._last_terminal == Terminal("NAME")
174+
):
175+
return True
176+
169177
# if we're in a ternary or binary operator, add space around the operator
170178
if (
171179
isinstance(rule, Token)
@@ -235,7 +243,7 @@ def _should_add_space(self, rule, current_terminal):
235243
return True
236244

237245
# always add space between string literals
238-
if current_terminal == Terminal("STRING_LIT"):
246+
if current_terminal == Terminal("STRING_CHARS"):
239247
return True
240248

241249
# if we just opened a block, add a space, unless the block is empty
@@ -257,7 +265,7 @@ def _should_add_space(self, rule, current_terminal):
257265
# preceded by a space if they're following a comma in a tuple or
258266
# function arg
259267
if current_terminal in [
260-
Terminal("STRING_LIT"),
268+
Terminal("DBLQUOTE"),
261269
Terminal("DECIMAL"),
262270
Terminal("NAME"),
263271
Terminal("NEGATIVE_DECIMAL"),
@@ -267,13 +275,15 @@ def _should_add_space(self, rule, current_terminal):
267275
# the catch-all case, we're not sure, so don't add a space
268276
return False
269277

270-
def _reconstruct(self, tree):
278+
def _reconstruct(self, tree, is_block_label=False):
271279
unreduced_tree = self.match_tree(tree, tree.data)
272280
res = self.write_tokens.transform(unreduced_tree)
273281
for item in res:
274282
# any time we encounter a child tree, we recurse
275283
if isinstance(item, Tree):
276-
yield from self._reconstruct(item)
284+
yield from self._reconstruct(
285+
item, (unreduced_tree.data == "block" and item.data != "body")
286+
)
277287

278288
# every leaf should be a tuple, which contains information about
279289
# which terminal the leaf represents
@@ -309,7 +319,7 @@ def _reconstruct(self, tree):
309319
self._deferred_item = None
310320

311321
# potentially add a space before the next token
312-
if self._should_add_space(rule, terminal):
322+
if self._should_add_space(rule, terminal, is_block_label):
313323
yield " "
314324
self._last_char_space = True
315325

@@ -353,21 +363,21 @@ def _name_to_identifier(name: str) -> Tree:
353363

354364
@staticmethod
355365
def _escape_interpolated_str(interp_s: str) -> str:
356-
if interp_s.strip().startswith('<<-') or interp_s.strip().startswith('<<'):
366+
if interp_s.strip().startswith("<<-") or interp_s.strip().startswith("<<"):
357367
# For heredoc strings, preserve their format exactly
358368
return reverse_quotes_within_interpolation(interp_s)
359369
# Escape backslashes first (very important to do this first)
360-
escaped = interp_s.replace('\\', '\\\\')
370+
escaped = interp_s.replace("\\", "\\\\")
361371
# Escape quotes
362372
escaped = escaped.replace('"', '\\"')
363373
# Escape control characters
364-
escaped = escaped.replace('\n', '\\n')
365-
escaped = escaped.replace('\r', '\\r')
366-
escaped = escaped.replace('\t', '\\t')
367-
escaped = escaped.replace('\b', '\\b')
368-
escaped = escaped.replace('\f', '\\f')
374+
escaped = escaped.replace("\n", "\\n")
375+
escaped = escaped.replace("\r", "\\r")
376+
escaped = escaped.replace("\t", "\\t")
377+
escaped = escaped.replace("\b", "\\b")
378+
escaped = escaped.replace("\f", "\\f")
369379
# find each interpolation within the string and remove the backslashes
370-
interp_s = reverse_quotes_within_interpolation(f'"{escaped}"')
380+
interp_s = reverse_quotes_within_interpolation(f"{escaped}")
371381
return interp_s
372382

373383
@staticmethod
@@ -420,6 +430,48 @@ def _newline(self, level: int, count: int = 1) -> Tree:
420430
[Token("NL_OR_COMMENT", f"\n{' ' * level}") for _ in range(count)],
421431
)
422432

433+
def _build_string_rule(self, string: str, level: int = 0) -> Tree:
434+
# grammar in hcl2.lark defines that a string is built of any number of string parts,
435+
# each string part can be either interpolation expression, escaped interpolation string
436+
# or regular string
437+
# this method build hcl2 string rule based on arbitrary string,
438+
# splitting such string into individual parts and building a lark tree out of them
439+
#
440+
result = []
441+
442+
pattern = re.compile(r"(\${1,2}\{(?:[^{}]|\{[^{}]*})*})")
443+
parts = re.split(pattern, string)
444+
# e.g. 'aaa$${bbb}ccc${"ddd-${eee}"}' -> ['aaa', '$${bbb}', 'ccc', '${"ddd-${eee}"}']
445+
446+
if parts[-1] == "":
447+
parts.pop()
448+
if len(parts) > 0 and parts[0] == "":
449+
parts.pop(0)
450+
451+
for part in parts:
452+
if part.startswith("$${") and part.endswith("}"):
453+
result.append(Token("ESCAPED_INTERPOLATION", part))
454+
455+
# unwrap interpolation expression and recurse into it
456+
elif part.startswith("${") and part.endswith("}"):
457+
part = part[2:-1]
458+
if part.startswith('"') and part.endswith('"'):
459+
part = part[1:-1]
460+
part = self._transform_value_to_expr_term(part, level)
461+
else:
462+
part = Tree(
463+
Token("RULE", "expr_term"),
464+
[Tree(Token("RULE", "identifier"), [Token("NAME", part)])],
465+
)
466+
467+
result.append(Tree(Token("RULE", "interpolation"), [part]))
468+
469+
else:
470+
result.append(Token("STRING_CHARS", part))
471+
472+
result = [Tree(Token("RULE", "string_part"), [element]) for element in result]
473+
return Tree(Token("RULE", "string"), result)
474+
423475
def _is_block(self, value: Any) -> bool:
424476
if isinstance(value, dict):
425477
block_body = value
@@ -485,8 +537,8 @@ def _transform_dict_to_body(self, hcl_dict: dict, level: int) -> Tree:
485537
block_labels, block_body_dict = self._calculate_block_labels(
486538
block_v
487539
)
488-
block_label_tokens = [
489-
Token("STRING_LIT", f'"{block_label}"')
540+
block_label_trees = [
541+
self._build_string_rule(block_label, level)
490542
for block_label in block_labels
491543
]
492544
block_body = self._transform_dict_to_body(
@@ -496,7 +548,7 @@ def _transform_dict_to_body(self, hcl_dict: dict, level: int) -> Tree:
496548
# create our actual block to add to our own body
497549
block = Tree(
498550
Token("RULE", "block"),
499-
[identifier_name] + block_label_tokens + [block_body],
551+
[identifier_name] + block_label_trees + [block_body],
500552
)
501553
children.append(block)
502554
# add empty line after block
@@ -675,10 +727,10 @@ def _transform_value_to_expr_term(self, value, level) -> Union[Token, Tree]:
675727
parsed_value = attribute.children[2]
676728
return parsed_value
677729

678-
# otherwise it's just a string.
730+
# otherwise it's a string
679731
return Tree(
680732
Token("RULE", "expr_term"),
681-
[Token("STRING_LIT", self._escape_interpolated_str(value))],
733+
[self._build_string_rule(self._escape_interpolated_str(value), level)],
682734
)
683735

684736
# otherwise, we don't know the type
Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
1-
{"locals": [{"simple_interpolation": "prefix:${var.foo}-suffix", "embedded_interpolation": "(long substring without interpolation); ${module.special_constants.aws_accounts[\"aaa-${local.foo}-${local.bar}\"]}/us-west-2/key_foo", "deeply_nested_interpolation": "prefix1-${\"prefix2-${\"prefix3-${local.foo}\"}\"}", "escaped_interpolation": "prefix:$${aws:username}-suffix"}]}
1+
{
2+
"locals": [
3+
{
4+
"simple_interpolation": "prefix:${var.foo}-suffix",
5+
"embedded_interpolation": "(long substring without interpolation); ${module.special_constants.aws_accounts[\"aaa-${local.foo}-${local.bar}\"]}/us-west-2/key_foo",
6+
"deeply_nested_interpolation": "prefix1-${\"prefix2-${\"prefix3-$${foo:bar}\"}\"}",
7+
"escaped_interpolation": "prefix:$${aws:username}-suffix",
8+
"simple_and_escaped": "${\"bar\"}$${baz:bat}",
9+
"simple_and_escaped_reversed": "$${baz:bat}${\"bar\"}",
10+
"nested_escaped": "bar-${\"$${baz:bat}\"}"
11+
}
12+
]
13+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
locals {
22
simple_interpolation = "prefix:${var.foo}-suffix"
33
embedded_interpolation = "(long substring without interpolation); ${module.special_constants.aws_accounts["aaa-${local.foo}-${local.bar}"]}/us-west-2/key_foo"
4-
deeply_nested_interpolation = "prefix1-${"prefix2-${"prefix3-${local.foo}"}"}"
4+
deeply_nested_interpolation = "prefix1-${"prefix2-${"prefix3-$${foo:bar}"}"}"
55
escaped_interpolation = "prefix:$${aws:username}-suffix"
6+
simple_and_escaped = "${"bar"}$${baz:bat}"
7+
simple_and_escaped_reversed = "$${baz:bat}${"bar"}"
8+
nested_escaped = "bar-${"$${baz:bat}"}"
69
}

test/unit/test_builder.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@ def test_locals_embedded_interpolation_tf(self):
7373
"simple_interpolation": "prefix:${var.foo}-suffix",
7474
"embedded_interpolation": "(long substring without interpolation); "
7575
'${module.special_constants.aws_accounts["aaa-${local.foo}-${local.bar}"]}/us-west-2/key_foo',
76-
"deeply_nested_interpolation": 'prefix1-${"prefix2-${"prefix3-${local.foo}"}"}',
76+
"deeply_nested_interpolation": 'prefix1-${"prefix2-${"prefix3-$${foo:bar}"}"}',
7777
"escaped_interpolation": "prefix:$${aws:username}-suffix",
78+
"simple_and_escaped": '${"bar"}$${baz:bat}',
79+
"simple_and_escaped_reversed": '$${baz:bat}${"bar"}',
80+
"nested_escaped": 'bar-${"$${baz:bat}"}',
7881
}
7982

8083
builder.block("locals", **attributes)

0 commit comments

Comments
 (0)