Skip to content

Commit 6c9811e

Browse files
Merge main into fix-heredoc-trailing-whitespace-issue-316
amplify-education#312, amplify-education#311 and amplify-education#313 have landed. Only CHANGELOG.md conflicted, resolved with the landed entries first and this branch's appended. hcl2/rules/strings.py auto-merged against amplify-education#313, which rewrote the same file's string-serialization path. Verified the two coexist: amplify-education#313's process_escape_sequences and lark_name() dispatch sit alongside this branch's _strip_closing_marker_line, and the full suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents 8a8a6c1 + 0015121 commit 6c9811e

19 files changed

Lines changed: 681 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Fixed
1111

1212
- Restore `py.typed` marker so type checkers recognize `hcl2` (and `cli`) as typed packages. ([#298](https://github.com/amplify-education/python-hcl2/issues/298))
13+
- Parse heredocs with an empty body again. A marker immediately followed by its closing delimiter failed to match, and the lexer then ran on to a later delimiter, silently absorbing the attributes in between. ([#309](https://github.com/amplify-education/python-hcl2/issues/309))
14+
- Negative integer literals load as numbers again instead of `${-N}` expression strings, matching negative floats and the pre-8.x behaviour. ([#307](https://github.com/amplify-education/python-hcl2/issues/307))
15+
- `strip_string_quotes` no longer unquotes string literals nested inside expressions, which produced invalid HCL such as `${upper(x)}` from `upper("x")`. ([#310](https://github.com/amplify-education/python-hcl2/issues/310))
16+
- `strip_string_quotes` now resolves escape sequences, so the values it yields match what the option documents. Escapes naming a codepoint outside the Unicode range, or a lone surrogate, are preserved verbatim rather than raising. ([#308](https://github.com/amplify-education/python-hcl2/issues/308))
1317
- Flattened heredoc bodies keep their trailing blank lines and trailing spaces instead of being right-stripped away, for both `<<MARKER` and `<<-MARKER`. The closing marker line's own indentation is still removed, and a blank line no longer cancels the `<<-` dedent. ([#316](https://github.com/amplify-education/python-hcl2/issues/316))
1418

1519
## \[8.1.2\] - 2026-04-10

docs/01_getting_started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ data = loads(text, serialization_options=SerializationOptions(
7777
| `preserve_heredocs` | `bool` | `True` | Keep heredocs in their original form |
7878
| `force_operation_parentheses` | `bool` | `False` | Force parentheses around all operations |
7979
| `preserve_scientific_notation` | `bool` | `True` | Keep scientific notation as-is |
80-
| `strip_string_quotes` | `bool` | `False` | Remove surrounding quotes from string values (e.g. `"hello"` instead of `'"hello"'`). **Breaks JSON->HCL2 deserialization and reconstruction.** |
80+
| `strip_string_quotes` | `bool` | `False` | Yield string *values* rather than source text: remove surrounding quotes (e.g. `"hello"` instead of `'"hello"'`) and resolve escape sequences (`"a\nb"` becomes a real newline). String literals inside expressions keep their quotes, so `upper("x")` stays `'${upper("x")}'`. **Breaks JSON->HCL2 deserialization and reconstruction.** |
8181

8282
### Comment Format
8383

docs/06_migrating_to_v8.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ data = hcl2.load(f, serialization_options=SerializationOptions(strip_string_quot
2929

3030
> **Note:** `strip_string_quotes=True` is one-way — dicts produced with it cannot round-trip back to HCL via `dumps()` because the quotes needed to distinguish strings from identifiers are gone.
3131
32+
Because the option asks for values rather than source text, it also resolves the escape sequences HCL defines (`\n`, `\r`, `\t`, `\"`, `\\`, `\uNNNN`, `\UNNNNNNNN`), as v7 did. Escapes are resolved in a single pass, so `\\n` is a backslash followed by `n` rather than a newline — v7 replaced sequentially and produced a newline there. Any other escape, including a codepoint outside the Unicode range, is left verbatim.
33+
34+
Quotes are stripped only from strings in *value* position. A string literal inside an expression is part of that expression's source text and keeps its quotes, so `upper("x")` serializes to `'${upper("x")}'` — unquoting it there would change what it refers to.
35+
3236
## New metadata keys in output dicts
3337

3438
**Impact: high** — code that iterates keys or does exact-match assertions will break.

hcl2/hcl2.lark

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ ELLIPSIS : "..."
8282
COLONS: "::"
8383

8484
// Heredocs
85-
HEREDOC_TEMPLATE : /<<(?P<heredoc>[a-zA-Z][a-zA-Z0-9._-]+)\n(?:.|\n)*?\n\s*(?P=heredoc)\n/
86-
HEREDOC_TEMPLATE_TRIM : /<<-(?P<heredoc_trim>[a-zA-Z][a-zA-Z0-9._-]+)\n(?:.|\n)*?\n\s*(?P=heredoc_trim)\n/
85+
HEREDOC_TEMPLATE : /<<(?P<heredoc>[a-zA-Z][a-zA-Z0-9._-]+)\n(?:(?:.|\n)*?\n)??\s*(?P=heredoc)\n/
86+
HEREDOC_TEMPLATE_TRIM : /<<-(?P<heredoc_trim>[a-zA-Z][a-zA-Z0-9._-]+)\n(?:(?:.|\n)*?\n)??\s*(?P=heredoc_trim)\n/
8787

8888
// Ignore whitespace (but not newlines, as they're significant in HCL)
8989
%ignore /[ \t]+/

hcl2/rules/expressions.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Rule classes for HCL2 expressions, conditionals, and binary/unary operations."""
22

33
from abc import ABC
4-
from typing import Any, Optional, Tuple
4+
from typing import Any, Optional, Tuple, Union
55

66
from lark.tree import Meta
77

@@ -305,12 +305,41 @@ def serialize(self, options=SerializationOptions(), context=SerializationContext
305305
"""Serialize to 'operator operand' string."""
306306
with context.modify(inside_dollar_string=True):
307307
operator = self.operator.rstrip()
308-
result = f"{operator}{self.expr_term.serialize(options, context)}"
308+
operand = self.expr_term.serialize(options, context)
309+
result = f"{operator}{operand}"
309310

310311
if not context.inside_dollar_string:
312+
# A negated numeric literal is a number, not an expression. The
313+
# lexer splits `-3` into MINUS and INT_LITERAL because MINUS is also
314+
# the binary operator (`1 -3` must stay a subtraction), so negative
315+
# integers arrive here rather than as a single token. Recombining
316+
# them keeps `-3` an int, matching `-3.5`, which FLOAT_LITERAL
317+
# already matches whole.
318+
negated = self._negate_numeric_literal(operator, operand, options)
319+
if negated is not None:
320+
return negated
311321
result = to_dollar_string(result)
312322

313323
if options.force_operation_parentheses:
314324
result = self._wrap_into_parentheses(result, options, context)
315325

316326
return result
327+
328+
@staticmethod
329+
def _negate_numeric_literal(
330+
operator: str, operand: Any, options: SerializationOptions
331+
) -> Optional[Union[int, float]]:
332+
"""Return the negated value when this is `-` applied to a number.
333+
334+
Returns None when the operation is anything else, so that the caller
335+
falls back to the `${...}` expression form: `-var.x` has no literal
336+
value, `!flag` is not arithmetic, and a scientific-notation operand
337+
serializes to a string when `preserve_scientific_notation` is set.
338+
Parenthesised output is likewise left alone, since a bare number cannot
339+
carry the parentheses that option asks for.
340+
"""
341+
if operator != "-" or options.force_operation_parentheses:
342+
return None
343+
if isinstance(operand, bool) or not isinstance(operand, (int, float)):
344+
return None
345+
return -operand

hcl2/rules/strings.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
HEREDOC_TRIM_PATTERN,
2323
SerializationContext,
2424
SerializationOptions,
25+
process_escape_sequences,
2526
to_dollar_string,
2627
)
2728

@@ -112,12 +113,35 @@ def string_parts(self):
112113
return self.children[1:-1]
113114

114115
def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
115-
"""Serialize to a quoted string."""
116+
"""Serialize to a quoted string.
117+
118+
`strip_string_quotes` asks for the string's value rather than its
119+
source form, so it applies only where a value is what the caller gets:
120+
a string nested inside an expression is part of that expression's text,
121+
and unquoting it there would produce something that is no longer valid
122+
HCL (`upper("x")` becoming `upper(x)`).
123+
"""
124+
if options.strip_string_quotes and not context.inside_dollar_string:
125+
return "".join(
126+
self._serialize_part_as_value(part, options, context) for part in self.string_parts
127+
)
128+
116129
inner = "".join(part.serialize(options, context) for part in self.string_parts)
117-
if options.strip_string_quotes:
118-
return inner
119130
return '"' + inner + '"'
120131

132+
@staticmethod
133+
def _serialize_part_as_value(part, options, context) -> str:
134+
"""Serialize one part, resolving escapes in literal text only.
135+
136+
Interpolations and escaped interpolation/directive markers are passed
137+
through untouched: their text is expression source, not literal
138+
content, so an escape inside them is not this string's to resolve.
139+
"""
140+
serialized = part.serialize(options, context)
141+
if part.content.lark_name() == "STRING_CHARS":
142+
return process_escape_sequences(serialized)
143+
return serialized
144+
121145

122146
class HeredocTemplateRule(LarkRule):
123147
"""Rule for heredoc template strings (<<MARKER)."""

hcl2/utils.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
from contextlib import contextmanager
55
from dataclasses import dataclass, replace
6+
from typing import Optional, Tuple
67

78
HEREDOC_PATTERN = re.compile(r"<<([a-zA-Z][a-zA-Z0-9._-]+)\n([\s\S]*)\1", re.S)
89
HEREDOC_TRIM_PATTERN = re.compile(r"<<-([a-zA-Z][a-zA-Z0-9._-]+)\n([\s\S]*)\1", re.S)
@@ -39,6 +40,81 @@ class SerializationOptions:
3940
strip_string_quotes: bool = False
4041

4142

43+
_SIMPLE_ESCAPES = {
44+
"n": "\n",
45+
"r": "\r",
46+
"t": "\t",
47+
'"': '"',
48+
"\\": "\\",
49+
}
50+
_UNICODE_ESCAPE_WIDTHS = {"u": 4, "U": 8}
51+
_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
52+
_MAX_CODEPOINT = 0x10FFFF
53+
_SURROGATES = range(0xD800, 0xE000)
54+
55+
56+
def _decode_unicode_escape(text: str, index: int) -> Optional[Tuple[str, int]]:
57+
"""Decode a \\uNNNN or \\UNNNNNNNN escape whose marker sits at `index`.
58+
59+
Returns None for anything that is not a usable character, leaving the
60+
caller to preserve the escape verbatim: too few digits, a non-hex digit, a
61+
codepoint past the Unicode maximum (`chr` raises for those), or a lone
62+
surrogate, which `chr` accepts but which cannot be encoded to UTF-8.
63+
"""
64+
width = _UNICODE_ESCAPE_WIDTHS[text[index]]
65+
digits = text[index + 1 : index + 1 + width]
66+
if len(digits) != width or any(char not in _HEX_DIGITS for char in digits):
67+
return None
68+
codepoint = int(digits, 16)
69+
if codepoint > _MAX_CODEPOINT or codepoint in _SURROGATES:
70+
return None
71+
return chr(codepoint), index + 1 + width
72+
73+
74+
def process_escape_sequences(value: str) -> str:
75+
"""Resolve the escape sequences HCL defines inside a quoted template.
76+
77+
Used when `strip_string_quotes` is set, which asks for the *value* of a
78+
string rather than its source form. Escapes are resolved in a single pass,
79+
so an escaped backslash cannot combine with the character after it: `\\\\n`
80+
is a backslash followed by "n", not a newline.
81+
82+
An unrecognized escape is preserved verbatim, backslash included. Terraform
83+
rejects those outright, but the grammar here accepts them, and a serializer
84+
is the wrong place to raise an error the parser did not.
85+
"""
86+
if "\\" not in value:
87+
return value
88+
89+
parts = []
90+
index = 0
91+
length = len(value)
92+
while index < length:
93+
char = value[index]
94+
if char != "\\" or index + 1 >= length:
95+
parts.append(char)
96+
index += 1
97+
continue
98+
99+
marker = value[index + 1]
100+
if marker in _SIMPLE_ESCAPES:
101+
parts.append(_SIMPLE_ESCAPES[marker])
102+
index += 2
103+
continue
104+
if marker in _UNICODE_ESCAPE_WIDTHS:
105+
decoded = _decode_unicode_escape(value, index + 1)
106+
if decoded is not None:
107+
parts.append(decoded[0])
108+
index = decoded[1]
109+
continue
110+
111+
parts.append(char)
112+
parts.append(marker)
113+
index += 2
114+
115+
return "".join(parts)
116+
117+
42118
@dataclass
43119
class SerializationContext:
44120
"""Mutable state tracked during serialization traversal."""
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
locals {
2+
simple_int = 123
3+
zero = 0
4+
large_int = 9876543210
5+
negative_int = -42
6+
negative_one = -1
7+
negative_large = -9876543210
8+
negative_zero = -0
9+
negated_keyword = -true
10+
int_calculation = 105 * 3 / 2
11+
int_subtraction = 10 - 3
12+
int_negated_reference = -var.count
13+
int_comparison = 5 > 2 ? 1 : 0
14+
int_list = [1, 2, 3, -4, -5]
15+
int_object = {
16+
positive = 7
17+
negative = -7
18+
mixed = [-1, 0, 1]
19+
}
20+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
locals {
2+
simple_int = 123
3+
zero = 0
4+
large_int = 9876543210
5+
negative_int = -42
6+
negative_one = -1
7+
negative_large = -9876543210
8+
negative_zero = 0
9+
negated_keyword = -true
10+
int_calculation = 105 * 3 / 2
11+
int_subtraction = 10 - 3
12+
int_negated_reference = -var.count
13+
int_comparison = 5 > 2 ? 1 : 0
14+
int_list = [
15+
1,
16+
2,
17+
3,
18+
-4,
19+
-5,
20+
]
21+
int_object = {
22+
positive = 7,
23+
negative = -7,
24+
mixed = [
25+
-1,
26+
0,
27+
1,
28+
],
29+
}
30+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"locals": [
3+
{
4+
"simple_int": 123,
5+
"zero": 0,
6+
"large_int": 9876543210,
7+
"negative_int": -42,
8+
"negative_one": -1,
9+
"negative_large": -9876543210,
10+
"negative_zero": 0,
11+
"negated_keyword": "${-true}",
12+
"int_calculation": "${105 * 3 / 2}",
13+
"int_subtraction": "${10 - 3}",
14+
"int_negated_reference": "${-var.count}",
15+
"int_comparison": "${5 > 2 ? 1 : 0}",
16+
"int_list": [
17+
1,
18+
2,
19+
3,
20+
-4,
21+
-5
22+
],
23+
"int_object": {
24+
"positive": 7,
25+
"negative": -7,
26+
"mixed": [
27+
-1,
28+
0,
29+
1
30+
]
31+
},
32+
"__is_block__": true
33+
}
34+
]
35+
}

0 commit comments

Comments
 (0)