Skip to content

Commit 919e4b2

Browse files
Add literal_value grammar rule for true, false, and null (#293) (#294)
Introduces a dedicated `literal_value` grammar rule and `LiteralValueRule` class so that `true`, `false`, and `null` serialize to native Python/JSON types (True, False, None) instead of strings. Updates grammar, transformer, deserializer, reconstructor, and all affected golden files. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 447a50c commit 919e4b2

19 files changed

Lines changed: 186 additions & 23 deletions

File tree

CHANGELOG.md

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

88
## \[Unreleased\]
99

10-
- Nothing yet.
10+
### Fixed
11+
12+
- `true`, `false`, and `null` now serialize to native JSON types instead of strings. ([#293](https://github.com/amplify-education/python-hcl2/issues/293))
1113

1214
## \[8.1.1\] - 2026-04-07
1315

hcl2/deserializer.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
IdentifierRule,
3131
IntLitRule,
3232
FloatLitRule,
33+
LiteralValueRule,
3334
)
3435
from hcl2.rules.strings import (
3536
StringRule,
@@ -55,6 +56,9 @@
5556
HEREDOC_TRIM_TEMPLATE,
5657
HEREDOC_TEMPLATE,
5758
COLON,
59+
TRUE,
60+
FALSE,
61+
NULL,
5862
)
5963
from hcl2.transformer import RuleTransformer
6064
from hcl2.utils import HEREDOC_TRIM_PATTERN, HEREDOC_PATTERN
@@ -152,7 +156,12 @@ def _deserialize_block_elements(self, value: dict) -> List[LarkElement]:
152156
def _deserialize_text(self, value: Any) -> LarkRule:
153157
# bool must be checked before int since bool is a subclass of int
154158
if isinstance(value, bool):
155-
return self._deserialize_identifier(str(value).lower())
159+
if value:
160+
return LiteralValueRule([TRUE()])
161+
return LiteralValueRule([FALSE()])
162+
163+
if value is None:
164+
return LiteralValueRule([NULL()])
156165

157166
if isinstance(value, float):
158167
return FloatLitRule([FloatLiteral(value)])

hcl2/hcl2.lark

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ ELSE : "else"
1414
ENDIF : "endif"
1515
ENDFOR : "endfor"
1616

17+
// Literal value keywords
18+
NULL : "null"
19+
TRUE : "true"
20+
FALSE : "false"
1721

1822
// Literals
1923
NAME : /[a-zA-Z_][a-zA-Z0-9_-]*/
@@ -94,7 +98,7 @@ start : body
9498
// Body and basic constructs
9599
body : (new_line_or_comment? (attribute | block))* new_line_or_comment?
96100
attribute : _attribute_name EQ expression
97-
_attribute_name : identifier | keyword
101+
_attribute_name : identifier | keyword | literal_value
98102
block : identifier (identifier | string)* new_line_or_comment? LBRACE body RBRACE
99103

100104
// Whitespace and comments
@@ -103,6 +107,7 @@ new_line_or_comment: ( NL_OR_COMMENT )+
103107
// Basic literals and identifiers
104108
identifier : NAME
105109
keyword: IN | FOR | IF | FOR_EACH | ELSE | ENDIF | ENDFOR
110+
literal_value: TRUE | FALSE | NULL
106111
int_lit: INT_LITERAL
107112
float_lit: FLOAT_LITERAL
108113
string: DBLQUOTE string_part* DBLQUOTE
@@ -189,6 +194,7 @@ expr_term : LPAR new_line_or_comment? expression new_line_or_comment? RPAR
189194
| tuple
190195
| object
191196
| identifier
197+
| literal_value
192198
| function_call
193199
| heredoc_template
194200
| heredoc_template_trim
@@ -223,7 +229,7 @@ full_splat_expr_term : expr_term full_splat
223229
?index : braces_index | short_index
224230
braces_index : LSQB new_line_or_comment? expression new_line_or_comment? RSQB
225231
short_index : DOT INT_LITERAL
226-
get_attr : DOT identifier
232+
get_attr : DOT (identifier | literal_value)
227233
attr_splat : ATTR_SPLAT (get_attr | index)*
228234
full_splat : FULL_SPLAT_START (get_attr | index)*
229235

hcl2/reconstructor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
TemplateEndforRule,
1717
)
1818
from hcl2.rules.for_expressions import ForIntroRule, ForTupleExprRule, ForObjectExprRule
19-
from hcl2.rules.literal_rules import IdentifierRule
19+
from hcl2.rules.literal_rules import IdentifierRule, LiteralValueRule
2020
from hcl2.rules.strings import StringRule
2121
from hcl2.rules.expressions import (
2222
ExprTermRule,
@@ -228,9 +228,11 @@ def _should_add_space_before(
228228
if rule_name in [
229229
StringRule.lark_name(),
230230
IdentifierRule.lark_name(),
231+
LiteralValueRule.lark_name(),
231232
] and self._last_rule_name in [
232233
StringRule.lark_name(),
233234
IdentifierRule.lark_name(),
235+
LiteralValueRule.lark_name(),
234236
]:
235237
return True
236238

hcl2/rules/literal_rules.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@ def lark_name() -> str:
3333
return "keyword"
3434

3535

36+
class LiteralValueRule(TokenRule):
37+
"""Rule for HCL2 literal value keywords (true, false, null)."""
38+
39+
_SERIALIZE_MAP = {"true": True, "false": False, "null": None}
40+
41+
@staticmethod
42+
def lark_name() -> str:
43+
"""Return the grammar rule name."""
44+
return "literal_value"
45+
46+
def serialize(
47+
self, options=SerializationOptions(), context=SerializationContext()
48+
) -> Any:
49+
"""Serialize to Python True, False, or None."""
50+
value = self.token.value
51+
if context.inside_dollar_string:
52+
return str(value)
53+
return self._SERIALIZE_MAP.get(str(value), str(value))
54+
55+
3656
class IdentifierRule(TokenRule):
3757
"""Rule for HCL2 identifiers."""
3858

hcl2/rules/strings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def serialize(
4848
self, options=SerializationOptions(), context=SerializationContext()
4949
) -> Any:
5050
"""Serialize to ${expression} string."""
51-
return to_dollar_string(self.expression.serialize(options, context))
51+
with context.modify(inside_dollar_string=True):
52+
return to_dollar_string(self.expression.serialize(options, context))
5253

5354

5455
class StringPartRule(LarkRule):

hcl2/rules/tokens.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ def serialize_conversion(self) -> Callable[[Any], str]:
125125
ELSE = StaticStringToken[("ELSE", "else")] # type: ignore
126126
ENDIF = StaticStringToken[("ENDIF", "endif")] # type: ignore
127127
ENDFOR = StaticStringToken[("ENDFOR", "endfor")] # type: ignore
128+
TRUE = StaticStringToken[("TRUE", "true")] # type: ignore
129+
FALSE = StaticStringToken[("FALSE", "false")] # type: ignore
130+
NULL = StaticStringToken[("NULL", "null")] # type: ignore
128131

129132
# pylint: enable=invalid-name
130133

hcl2/transformer.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
IdentifierRule,
4949
BinaryOperatorRule,
5050
KeywordRule,
51+
LiteralValueRule,
5152
)
5253
from hcl2.rules.strings import (
5354
InterpolationRule,
@@ -133,8 +134,9 @@ def block(self, meta: Meta, args) -> BlockRule:
133134

134135
@v_args(meta=True)
135136
def attribute(self, meta: Meta, args) -> AttributeRule:
136-
# _attribute_name is flattened, so args[0] may be KeywordRule or IdentifierRule
137-
if isinstance(args[0], KeywordRule):
137+
# _attribute_name is flattened, so args[0] may be KeywordRule,
138+
# LiteralValueRule, or IdentifierRule
139+
if isinstance(args[0], (KeywordRule, LiteralValueRule)):
138140
args[0] = IdentifierRule([NAME(args[0].token.value)], meta)
139141
return AttributeRule(args, meta)
140142

@@ -154,6 +156,10 @@ def identifier(self, meta: Meta, args) -> IdentifierRule:
154156
def keyword(self, meta: Meta, args) -> KeywordRule:
155157
return KeywordRule(args, meta)
156158

159+
@v_args(meta=True)
160+
def literal_value(self, meta: Meta, args) -> LiteralValueRule:
161+
return LiteralValueRule(args, meta)
162+
157163
@v_args(meta=True)
158164
def int_lit(self, meta: Meta, args) -> IntLitRule:
159165
return IntLitRule(args, meta)
@@ -333,8 +339,18 @@ def object_elem_key(self, meta: Meta, args):
333339
if isinstance(expr, ExprTermRule) and len(expr.children) == 5:
334340
inner = expr.children[2] # position 2 in [None, None, inner, None, None]
335341
if isinstance(
336-
inner, (IdentifierRule, StringRule, IntLitRule, FloatLitRule)
342+
inner,
343+
(
344+
IdentifierRule,
345+
StringRule,
346+
IntLitRule,
347+
FloatLitRule,
348+
LiteralValueRule,
349+
),
337350
):
351+
# Convert literal_value to identifier for dict key compatibility
352+
if isinstance(inner, LiteralValueRule):
353+
inner = IdentifierRule([NAME(inner.token.value)], meta)
338354
return ObjectElemKeyRule([inner], meta)
339355
# Any other expression (parenthesized or bare)
340356
return ObjectElemKeyExpressionRule([expr], meta)
@@ -361,6 +377,10 @@ def short_index(self, meta: Meta, args) -> ShortIndexRule:
361377

362378
@v_args(meta=True)
363379
def get_attr(self, meta: Meta, args) -> GetAttrRule:
380+
# Convert literal_value (true/false/null) to identifier in attr access
381+
if len(args) >= 2 and isinstance(args[1], LiteralValueRule):
382+
args = list(args)
383+
args[1] = IdentifierRule([NAME(args[1].token.value)], meta)
364384
return GetAttrRule(args, meta)
365385

366386
@v_args(meta=True)

test/integration/hcl2_original/smoke.tf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ block label1 label2 {
3838
l = a.*.b
3939
m = a[*][c].a.*.1
4040

41+
n = [
42+
null,
43+
"null"
44+
]
45+
o = [
46+
true,
47+
"true"]
48+
49+
p = [
50+
false,
51+
"false"
52+
]
53+
4154
block b1 {
4255
a = 1
4356
}

test/integration/hcl2_reconstructed/smoke.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ block label1 label2 {
3232
k = a.b.5
3333
l = a.*.b
3434
m = a[*][c].a.*.1
35+
n = [
36+
null,
37+
"null",
38+
]
39+
o = [
40+
true,
41+
"true",
42+
]
43+
p = [
44+
false,
45+
"false",
46+
]
3547

3648
block b1 {
3749
a = 1

0 commit comments

Comments
 (0)