diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2b5a61..4ca3fb20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## \[Unreleased\] -- Nothing yet. +### Fixed + +- `true`, `false`, and `null` now serialize to native JSON types instead of strings. ([#293](https://github.com/amplify-education/python-hcl2/issues/293)) ## \[8.1.1\] - 2026-04-07 diff --git a/hcl2/deserializer.py b/hcl2/deserializer.py index 3902f9ca..9927f3b0 100644 --- a/hcl2/deserializer.py +++ b/hcl2/deserializer.py @@ -30,6 +30,7 @@ IdentifierRule, IntLitRule, FloatLitRule, + LiteralValueRule, ) from hcl2.rules.strings import ( StringRule, @@ -55,6 +56,9 @@ HEREDOC_TRIM_TEMPLATE, HEREDOC_TEMPLATE, COLON, + TRUE, + FALSE, + NULL, ) from hcl2.transformer import RuleTransformer from hcl2.utils import HEREDOC_TRIM_PATTERN, HEREDOC_PATTERN @@ -152,7 +156,12 @@ def _deserialize_block_elements(self, value: dict) -> List[LarkElement]: def _deserialize_text(self, value: Any) -> LarkRule: # bool must be checked before int since bool is a subclass of int if isinstance(value, bool): - return self._deserialize_identifier(str(value).lower()) + if value: + return LiteralValueRule([TRUE()]) + return LiteralValueRule([FALSE()]) + + if value is None: + return LiteralValueRule([NULL()]) if isinstance(value, float): return FloatLitRule([FloatLiteral(value)]) diff --git a/hcl2/hcl2.lark b/hcl2/hcl2.lark index a9ae6128..7603ec2c 100644 --- a/hcl2/hcl2.lark +++ b/hcl2/hcl2.lark @@ -14,6 +14,10 @@ ELSE : "else" ENDIF : "endif" ENDFOR : "endfor" +// Literal value keywords +NULL : "null" +TRUE : "true" +FALSE : "false" // Literals NAME : /[a-zA-Z_][a-zA-Z0-9_-]*/ @@ -94,7 +98,7 @@ start : body // Body and basic constructs body : (new_line_or_comment? (attribute | block))* new_line_or_comment? attribute : _attribute_name EQ expression -_attribute_name : identifier | keyword +_attribute_name : identifier | keyword | literal_value block : identifier (identifier | string)* new_line_or_comment? LBRACE body RBRACE // Whitespace and comments @@ -103,6 +107,7 @@ new_line_or_comment: ( NL_OR_COMMENT )+ // Basic literals and identifiers identifier : NAME keyword: IN | FOR | IF | FOR_EACH | ELSE | ENDIF | ENDFOR +literal_value: TRUE | FALSE | NULL int_lit: INT_LITERAL float_lit: FLOAT_LITERAL string: DBLQUOTE string_part* DBLQUOTE @@ -189,6 +194,7 @@ expr_term : LPAR new_line_or_comment? expression new_line_or_comment? RPAR | tuple | object | identifier + | literal_value | function_call | heredoc_template | heredoc_template_trim @@ -223,7 +229,7 @@ full_splat_expr_term : expr_term full_splat ?index : braces_index | short_index braces_index : LSQB new_line_or_comment? expression new_line_or_comment? RSQB short_index : DOT INT_LITERAL -get_attr : DOT identifier +get_attr : DOT (identifier | literal_value) attr_splat : ATTR_SPLAT (get_attr | index)* full_splat : FULL_SPLAT_START (get_attr | index)* diff --git a/hcl2/reconstructor.py b/hcl2/reconstructor.py index 3f968627..a6fc4344 100644 --- a/hcl2/reconstructor.py +++ b/hcl2/reconstructor.py @@ -16,7 +16,7 @@ TemplateEndforRule, ) from hcl2.rules.for_expressions import ForIntroRule, ForTupleExprRule, ForObjectExprRule -from hcl2.rules.literal_rules import IdentifierRule +from hcl2.rules.literal_rules import IdentifierRule, LiteralValueRule from hcl2.rules.strings import StringRule from hcl2.rules.expressions import ( ExprTermRule, @@ -228,9 +228,11 @@ def _should_add_space_before( if rule_name in [ StringRule.lark_name(), IdentifierRule.lark_name(), + LiteralValueRule.lark_name(), ] and self._last_rule_name in [ StringRule.lark_name(), IdentifierRule.lark_name(), + LiteralValueRule.lark_name(), ]: return True diff --git a/hcl2/rules/literal_rules.py b/hcl2/rules/literal_rules.py index 1db333f5..4422aada 100644 --- a/hcl2/rules/literal_rules.py +++ b/hcl2/rules/literal_rules.py @@ -33,6 +33,26 @@ def lark_name() -> str: return "keyword" +class LiteralValueRule(TokenRule): + """Rule for HCL2 literal value keywords (true, false, null).""" + + _SERIALIZE_MAP = {"true": True, "false": False, "null": None} + + @staticmethod + def lark_name() -> str: + """Return the grammar rule name.""" + return "literal_value" + + def serialize( + self, options=SerializationOptions(), context=SerializationContext() + ) -> Any: + """Serialize to Python True, False, or None.""" + value = self.token.value + if context.inside_dollar_string: + return str(value) + return self._SERIALIZE_MAP.get(str(value), str(value)) + + class IdentifierRule(TokenRule): """Rule for HCL2 identifiers.""" diff --git a/hcl2/rules/strings.py b/hcl2/rules/strings.py index c71aeb87..f3e456eb 100644 --- a/hcl2/rules/strings.py +++ b/hcl2/rules/strings.py @@ -48,7 +48,8 @@ def serialize( self, options=SerializationOptions(), context=SerializationContext() ) -> Any: """Serialize to ${expression} string.""" - return to_dollar_string(self.expression.serialize(options, context)) + with context.modify(inside_dollar_string=True): + return to_dollar_string(self.expression.serialize(options, context)) class StringPartRule(LarkRule): diff --git a/hcl2/rules/tokens.py b/hcl2/rules/tokens.py index c182e62c..807791a6 100644 --- a/hcl2/rules/tokens.py +++ b/hcl2/rules/tokens.py @@ -125,6 +125,9 @@ def serialize_conversion(self) -> Callable[[Any], str]: ELSE = StaticStringToken[("ELSE", "else")] # type: ignore ENDIF = StaticStringToken[("ENDIF", "endif")] # type: ignore ENDFOR = StaticStringToken[("ENDFOR", "endfor")] # type: ignore +TRUE = StaticStringToken[("TRUE", "true")] # type: ignore +FALSE = StaticStringToken[("FALSE", "false")] # type: ignore +NULL = StaticStringToken[("NULL", "null")] # type: ignore # pylint: enable=invalid-name diff --git a/hcl2/transformer.py b/hcl2/transformer.py index 73146514..a7057c70 100644 --- a/hcl2/transformer.py +++ b/hcl2/transformer.py @@ -48,6 +48,7 @@ IdentifierRule, BinaryOperatorRule, KeywordRule, + LiteralValueRule, ) from hcl2.rules.strings import ( InterpolationRule, @@ -133,8 +134,9 @@ def block(self, meta: Meta, args) -> BlockRule: @v_args(meta=True) def attribute(self, meta: Meta, args) -> AttributeRule: - # _attribute_name is flattened, so args[0] may be KeywordRule or IdentifierRule - if isinstance(args[0], KeywordRule): + # _attribute_name is flattened, so args[0] may be KeywordRule, + # LiteralValueRule, or IdentifierRule + if isinstance(args[0], (KeywordRule, LiteralValueRule)): args[0] = IdentifierRule([NAME(args[0].token.value)], meta) return AttributeRule(args, meta) @@ -154,6 +156,10 @@ def identifier(self, meta: Meta, args) -> IdentifierRule: def keyword(self, meta: Meta, args) -> KeywordRule: return KeywordRule(args, meta) + @v_args(meta=True) + def literal_value(self, meta: Meta, args) -> LiteralValueRule: + return LiteralValueRule(args, meta) + @v_args(meta=True) def int_lit(self, meta: Meta, args) -> IntLitRule: return IntLitRule(args, meta) @@ -333,8 +339,18 @@ def object_elem_key(self, meta: Meta, args): if isinstance(expr, ExprTermRule) and len(expr.children) == 5: inner = expr.children[2] # position 2 in [None, None, inner, None, None] if isinstance( - inner, (IdentifierRule, StringRule, IntLitRule, FloatLitRule) + inner, + ( + IdentifierRule, + StringRule, + IntLitRule, + FloatLitRule, + LiteralValueRule, + ), ): + # Convert literal_value to identifier for dict key compatibility + if isinstance(inner, LiteralValueRule): + inner = IdentifierRule([NAME(inner.token.value)], meta) return ObjectElemKeyRule([inner], meta) # Any other expression (parenthesized or bare) return ObjectElemKeyExpressionRule([expr], meta) @@ -361,6 +377,10 @@ def short_index(self, meta: Meta, args) -> ShortIndexRule: @v_args(meta=True) def get_attr(self, meta: Meta, args) -> GetAttrRule: + # Convert literal_value (true/false/null) to identifier in attr access + if len(args) >= 2 and isinstance(args[1], LiteralValueRule): + args = list(args) + args[1] = IdentifierRule([NAME(args[1].token.value)], meta) return GetAttrRule(args, meta) @v_args(meta=True) diff --git a/test/integration/hcl2_original/smoke.tf b/test/integration/hcl2_original/smoke.tf index 3e10e856..017d3759 100644 --- a/test/integration/hcl2_original/smoke.tf +++ b/test/integration/hcl2_original/smoke.tf @@ -38,6 +38,19 @@ block label1 label2 { l = a.*.b m = a[*][c].a.*.1 + n = [ + null, + "null" + ] + o = [ + true, + "true"] + + p = [ + false, + "false" + ] + block b1 { a = 1 } diff --git a/test/integration/hcl2_reconstructed/smoke.tf b/test/integration/hcl2_reconstructed/smoke.tf index 29beb3ac..04523283 100644 --- a/test/integration/hcl2_reconstructed/smoke.tf +++ b/test/integration/hcl2_reconstructed/smoke.tf @@ -32,6 +32,18 @@ block label1 label2 { k = a.b.5 l = a.*.b m = a[*][c].a.*.1 + n = [ + null, + "null", + ] + o = [ + true, + "true", + ] + p = [ + false, + "false", + ] block b1 { a = 1 diff --git a/test/integration/json_reserialized/nulls.json b/test/integration/json_reserialized/nulls.json index 9cbdd755..bef7b6af 100644 --- a/test/integration/json_reserialized/nulls.json +++ b/test/integration/json_reserialized/nulls.json @@ -3,11 +3,11 @@ "unary": "${!null}", "binary": "${(a == null)}", "tuple": [ - "null", + null, 1, 2 ], - "single": "null", + "single": null, "conditional": "${null ? null : null}" } } diff --git a/test/integration/json_reserialized/smoke.json b/test/integration/json_reserialized/smoke.json index a2382778..f7e4bb3c 100644 --- a/test/integration/json_reserialized/smoke.json +++ b/test/integration/json_reserialized/smoke.json @@ -36,6 +36,18 @@ "k": "${a.b.5}", "l": "${a.*.b}", "m": "${a[*][c].a.*.1}", + "n": [ + null, + "\"null\"" + ], + "o": [ + true, + "\"true\"" + ], + "p": [ + false, + "\"false\"" + ], "block": [ { "b1": { diff --git a/test/integration/json_serialized/nulls.json b/test/integration/json_serialized/nulls.json index 9cbdd755..bef7b6af 100644 --- a/test/integration/json_serialized/nulls.json +++ b/test/integration/json_serialized/nulls.json @@ -3,11 +3,11 @@ "unary": "${!null}", "binary": "${(a == null)}", "tuple": [ - "null", + null, 1, 2 ], - "single": "null", + "single": null, "conditional": "${null ? null : null}" } } diff --git a/test/integration/json_serialized/smoke.json b/test/integration/json_serialized/smoke.json index a2382778..f7e4bb3c 100644 --- a/test/integration/json_serialized/smoke.json +++ b/test/integration/json_serialized/smoke.json @@ -36,6 +36,18 @@ "k": "${a.b.5}", "l": "${a.*.b}", "m": "${a[*][c].a.*.1}", + "n": [ + null, + "\"null\"" + ], + "o": [ + true, + "\"true\"" + ], + "p": [ + false, + "\"false\"" + ], "block": [ { "b1": { diff --git a/test/integration/specialized/builder_basic_reparsed.json b/test/integration/specialized/builder_basic_reparsed.json index 32e4954d..e869bc4e 100644 --- a/test/integration/specialized/builder_basic_reparsed.json +++ b/test/integration/specialized/builder_basic_reparsed.json @@ -56,7 +56,7 @@ "locals": [ { "port": 8080, - "enabled": "true", + "enabled": true, "name": "\"my-app\"", "__is_block__": true } diff --git a/test/integration/specialized/builder_basic_reserialized.json b/test/integration/specialized/builder_basic_reserialized.json index 364ef0c3..55b2556d 100644 --- a/test/integration/specialized/builder_basic_reserialized.json +++ b/test/integration/specialized/builder_basic_reserialized.json @@ -54,7 +54,7 @@ "locals": [ { "port": 8080, - "enabled": "true", + "enabled": true, "name": "\"my-app\"", "__is_block__": true } diff --git a/test/integration/specialized/comments.json b/test/integration/specialized/comments.json index 5d7e6ef4..20deaf91 100644 --- a/test/integration/specialized/comments.json +++ b/test/integration/specialized/comments.json @@ -10,7 +10,7 @@ "Name": "\"web\"", "Env": "\"prod\"" }, - "enabled": "true", + "enabled": true, "nested": [ { "key": "\"value\"", diff --git a/test/unit/rules/test_literal_rules.py b/test/unit/rules/test_literal_rules.py index 9a834e14..1bcdc0d2 100644 --- a/test/unit/rules/test_literal_rules.py +++ b/test/unit/rules/test_literal_rules.py @@ -7,8 +7,17 @@ IntLitRule, FloatLitRule, BinaryOperatorRule, + LiteralValueRule, +) +from hcl2.rules.tokens import ( + NAME, + BINARY_OP, + IntLiteral, + FloatLiteral, + TRUE, + FALSE, + NULL, ) -from hcl2.rules.tokens import NAME, BINARY_OP, IntLiteral, FloatLiteral from hcl2.utils import SerializationContext, SerializationOptions @@ -26,6 +35,38 @@ def test_serialize(self): self.assertEqual(rule.serialize(), "true") +class TestLiteralValueRule(TestCase): + def test_lark_name(self): + self.assertEqual(LiteralValueRule.lark_name(), "literal_value") + + def test_serialize_true(self): + rule = LiteralValueRule([TRUE()]) + self.assertIs(rule.serialize(), True) + + def test_serialize_false(self): + rule = LiteralValueRule([FALSE()]) + self.assertIs(rule.serialize(), False) + + def test_serialize_null(self): + rule = LiteralValueRule([NULL()]) + self.assertIs(rule.serialize(), None) + + def test_serialize_inside_dollar_string(self): + rule = LiteralValueRule([NULL()]) + ctx = SerializationContext(inside_dollar_string=True) + self.assertEqual(rule.serialize(context=ctx), "null") + + def test_serialize_true_inside_dollar_string(self): + rule = LiteralValueRule([TRUE()]) + ctx = SerializationContext(inside_dollar_string=True) + self.assertEqual(rule.serialize(context=ctx), "true") + + def test_token_property(self): + token = TRUE() + rule = LiteralValueRule([token]) + self.assertIs(rule.token, token) + + class TestIdentifierRule(TestCase): def test_lark_name(self): self.assertEqual(IdentifierRule.lark_name(), "identifier") diff --git a/test/unit/test_deserializer.py b/test/unit/test_deserializer.py index 092d3300..cfaa0ae7 100644 --- a/test/unit/test_deserializer.py +++ b/test/unit/test_deserializer.py @@ -11,7 +11,12 @@ ObjectElemKeyExpressionRule, ) from hcl2.rules.expressions import ExprTermRule -from hcl2.rules.literal_rules import IdentifierRule, IntLitRule, FloatLitRule +from hcl2.rules.literal_rules import ( + IdentifierRule, + IntLitRule, + FloatLitRule, + LiteralValueRule, +) from hcl2.rules.strings import ( StringRule, StringPartRule, @@ -98,13 +103,13 @@ class TestDeserializeText(TestCase): def test_bool_true(self): d = _deser() result = d._deserialize_text(True) - self.assertIsInstance(result, IdentifierRule) + self.assertIsInstance(result, LiteralValueRule) self.assertEqual(result.token.value, "true") def test_bool_false(self): d = _deser() result = d._deserialize_text(False) - self.assertIsInstance(result, IdentifierRule) + self.assertIsInstance(result, LiteralValueRule) self.assertEqual(result.token.value, "false") def test_bool_before_int(self): @@ -112,7 +117,13 @@ def test_bool_before_int(self): d = _deser() result = d._deserialize_text(True) self.assertNotIsInstance(result, IntLitRule) - self.assertIsInstance(result, IdentifierRule) + self.assertIsInstance(result, LiteralValueRule) + + def test_none_value(self): + d = _deser() + result = d._deserialize_text(None) + self.assertIsInstance(result, LiteralValueRule) + self.assertEqual(result.token.value, "null") def test_int_value(self): d = _deser() @@ -145,9 +156,8 @@ def test_expression_string(self): def test_non_string_non_numeric_fallback(self): """Non-string, non-numeric values get str()-converted to identifier.""" d = _deser() - result = d._deserialize_text(None) + result = d._deserialize_text(object()) self.assertIsInstance(result, IdentifierRule) - self.assertEqual(result.token.value, "None") def test_zero_int(self): d = _deser()