Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion hcl2/deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
IdentifierRule,
IntLitRule,
FloatLitRule,
LiteralValueRule,
)
from hcl2.rules.strings import (
StringRule,
Expand All @@ -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
Expand Down Expand Up @@ -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)])
Expand Down
10 changes: 8 additions & 2 deletions hcl2/hcl2.lark
Original file line number Diff line number Diff line change
Expand Up @@ -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_-]*/
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)*

Expand Down
4 changes: 3 additions & 1 deletion hcl2/reconstructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions hcl2/rules/literal_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
3 changes: 2 additions & 1 deletion hcl2/rules/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions hcl2/rules/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 23 additions & 3 deletions hcl2/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
IdentifierRule,
BinaryOperatorRule,
KeywordRule,
LiteralValueRule,
)
from hcl2.rules.strings import (
InterpolationRule,
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions test/integration/hcl2_original/smoke.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions test/integration/hcl2_reconstructed/smoke.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions test/integration/json_reserialized/nulls.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"unary": "${!null}",
"binary": "${(a == null)}",
"tuple": [
"null",
null,
1,
2
],
"single": "null",
"single": null,
"conditional": "${null ? null : null}"
}
}
12 changes: 12 additions & 0 deletions test/integration/json_reserialized/smoke.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions test/integration/json_serialized/nulls.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"unary": "${!null}",
"binary": "${(a == null)}",
"tuple": [
"null",
null,
1,
2
],
"single": "null",
"single": null,
"conditional": "${null ? null : null}"
}
}
12 changes: 12 additions & 0 deletions test/integration/json_serialized/smoke.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion test/integration/specialized/builder_basic_reparsed.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"locals": [
{
"port": 8080,
"enabled": "true",
"enabled": true,
"name": "\"my-app\"",
"__is_block__": true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"locals": [
{
"port": 8080,
"enabled": "true",
"enabled": true,
"name": "\"my-app\"",
"__is_block__": true
}
Expand Down
2 changes: 1 addition & 1 deletion test/integration/specialized/comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"Name": "\"web\"",
"Env": "\"prod\""
},
"enabled": "true",
"enabled": true,
"nested": [
{
"key": "\"value\"",
Expand Down
Loading
Loading