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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- Restore `py.typed` marker so type checkers recognize `hcl2` (and `cli`) as typed packages. ([#298](https://github.com/amplify-education/python-hcl2/issues/298))
- 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))
- 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))

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

Expand Down
33 changes: 31 additions & 2 deletions hcl2/rules/expressions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Rule classes for HCL2 expressions, conditionals, and binary/unary operations."""

from abc import ABC
from typing import Any, Optional, Tuple
from typing import Any, Optional, Tuple, Union

from lark.tree import Meta

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

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

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

return result

@staticmethod
def _negate_numeric_literal(
operator: str, operand: Any, options: SerializationOptions
) -> Optional[Union[int, float]]:
"""Return the negated value when this is `-` applied to a number.

Returns None when the operation is anything else, so that the caller
falls back to the `${...}` expression form: `-var.x` has no literal
value, `!flag` is not arithmetic, and a scientific-notation operand
serializes to a string when `preserve_scientific_notation` is set.
Parenthesised output is likewise left alone, since a bare number cannot
carry the parentheses that option asks for.
"""
if operator != "-" or options.force_operation_parentheses:
return None
if isinstance(operand, bool) or not isinstance(operand, (int, float)):
return None
return -operand
20 changes: 20 additions & 0 deletions test/integration/hcl2_original/integers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
locals {
simple_int = 123
zero = 0
large_int = 9876543210
negative_int = -42
negative_one = -1
negative_large = -9876543210
negative_zero = -0
negated_keyword = -true
int_calculation = 105 * 3 / 2
int_subtraction = 10 - 3
int_negated_reference = -var.count
int_comparison = 5 > 2 ? 1 : 0
int_list = [1, 2, 3, -4, -5]
int_object = {
positive = 7
negative = -7
mixed = [-1, 0, 1]
}
}
30 changes: 30 additions & 0 deletions test/integration/hcl2_reconstructed/integers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
locals {
simple_int = 123
zero = 0
large_int = 9876543210
negative_int = -42
negative_one = -1
negative_large = -9876543210
negative_zero = 0
negated_keyword = -true
int_calculation = 105 * 3 / 2
int_subtraction = 10 - 3
int_negated_reference = -var.count
int_comparison = 5 > 2 ? 1 : 0
int_list = [
1,
2,
3,
-4,
-5,
]
int_object = {
positive = 7,
negative = -7,
mixed = [
-1,
0,
1,
],
}
}
35 changes: 35 additions & 0 deletions test/integration/json_reserialized/integers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"locals": [
{
"simple_int": 123,
"zero": 0,
"large_int": 9876543210,
"negative_int": -42,
"negative_one": -1,
"negative_large": -9876543210,
"negative_zero": 0,
"negated_keyword": "${-true}",
"int_calculation": "${105 * 3 / 2}",
"int_subtraction": "${10 - 3}",
"int_negated_reference": "${-var.count}",
"int_comparison": "${5 > 2 ? 1 : 0}",
"int_list": [
1,
2,
3,
-4,
-5
],
"int_object": {
"positive": 7,
"negative": -7,
"mixed": [
-1,
0,
1
]
},
"__is_block__": true
}
]
}
35 changes: 35 additions & 0 deletions test/integration/json_serialized/integers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"locals": [
{
"simple_int": 123,
"zero": 0,
"large_int": 9876543210,
"negative_int": -42,
"negative_one": -1,
"negative_large": -9876543210,
"negative_zero": 0,
"negated_keyword": "${-true}",
"int_calculation": "${105 * 3 / 2}",
"int_subtraction": "${10 - 3}",
"int_negated_reference": "${-var.count}",
"int_comparison": "${5 > 2 ? 1 : 0}",
"int_list": [
1,
2,
3,
-4,
-5
],
"int_object": {
"positive": 7,
"negative": -7,
"mixed": [
-1,
0,
1
]
},
"__is_block__": true
}
]
}
63 changes: 63 additions & 0 deletions test/unit/rules/test_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,69 @@ def test_serialize_force_parens_with_expression_parent(self):
self.assertEqual(result, "${(-x)}")


class TestUnaryOpRuleNegativeNumbers(TestCase):
"""`-3` is the number -3, not the expression string "${-3}".

The lexer cannot fold the sign into INT_LITERAL, because MINUS is also the
binary subtraction operator and `10 -3` has to stay a subtraction, so a
negative integer reaches serialization as MINUS applied to a literal.
"""

def _make_unary(self, op_str, operand_val):
token_cls = MINUS_TOKEN if op_str == "-" else NOT_TOKEN
return UnaryOpRule([token_cls(op_str), _make_expr_term(operand_val)])

def test_negative_int_serializes_to_int(self):
rule = self._make_unary("-", 3)
self.assertEqual(rule.serialize(), -3)

def test_negative_zero_serializes_to_int(self):
rule = self._make_unary("-", 0)
self.assertEqual(rule.serialize(), 0)

def test_negative_float_serializes_to_float(self):
rule = self._make_unary("-", 3.5)
self.assertEqual(rule.serialize(), -3.5)

def test_negated_identifier_stays_an_expression(self):
rule = self._make_unary("-", "var.count")
self.assertEqual(rule.serialize(), "${-var.count}")

def test_not_operator_is_untouched(self):
rule = self._make_unary("!", 1)
self.assertEqual(rule.serialize(), "${!1}")

def test_inside_dollar_string_stays_text(self):
"""Within a larger expression the operand must remain concatenable."""
rule = self._make_unary("-", 3)
ctx = SerializationContext(inside_dollar_string=True)
self.assertEqual(rule.serialize(context=ctx), "-3")

def test_force_parens_keeps_expression_form(self):
"""A bare number cannot carry the parentheses that option requests."""
rule = self._make_unary("-", 3)
opts = SerializationOptions(force_operation_parentheses=True)
self.assertEqual(rule.serialize(options=opts), "${-3}")

def test_boolean_operand_is_not_treated_as_a_number(self):
"""`bool` subclasses `int`, so without the guard `-true` would serialize to -1.

Asserted against the helper rather than `serialize()`: no HCL input can
route a Python bool here, because `UnaryOpRule` serializes its operand
with `inside_dollar_string` set, and `LiteralValueRule` yields the string
"true" in that context. See `TestNegatedKeywords` for the parsed path.
"""
options = SerializationOptions()
self.assertIsNone(UnaryOpRule._negate_numeric_literal("-", True, options))
self.assertIsNone(UnaryOpRule._negate_numeric_literal("-", False, options))

def test_zero_operand_returns_zero_not_none(self):
"""The caller must test `is not None`; plain truthiness would drop `-0`."""
result = UnaryOpRule._negate_numeric_literal("-", 0, SerializationOptions())
self.assertEqual(result, 0)
self.assertIsNotNone(result)


# --- ExpressionRule._wrap_into_parentheses tests ---


Expand Down
87 changes: 87 additions & 0 deletions test/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,90 @@ def test_empty_heredoc_as_an_object_value(self):

def test_empty_heredoc_as_a_function_argument(self):
self.assertEqual(loads("a = trimspace(<<EOF\nEOF\n)\n"), {"a": '${trimspace("<<EOF\nEOF")}'})


class TestNegativeIntegerLiterals(TestCase):
"""`-3` loads as the int -3, without disturbing subtraction.

MINUS serves as both the unary sign and the binary subtraction operator, so
a negative integer cannot be folded into INT_LITERAL by the lexer without
breaking `10 -3`. These cases pin both halves of that trade-off.
"""

def test_negative_int_is_an_int(self):
self.assertEqual(loads("x = -3\n"), {"x": -3})

def test_negative_int_matches_negative_float_handling(self):
self.assertEqual(loads("x = -3\ny = -3.5\n"), {"x": -3, "y": -3.5})

def test_negative_ints_in_tuple(self):
self.assertEqual(loads("x = [-1, 2, -30]\n"), {"x": [-1, 2, -30]})

def test_negative_ints_in_object(self):
self.assertEqual(loads("x = { a = -1, b = 2 }\n"), {"x": {"a": -1, "b": 2}})

def test_spaced_subtraction_is_still_an_expression(self):
self.assertEqual(loads("x = 10 - 3\n"), {"x": "${10 - 3}"})

def test_tight_subtraction_is_still_an_expression(self):
"""`10 -3` is a subtraction, not two adjacent literals."""
self.assertEqual(loads("x = 10 -3\n"), {"x": "${10 - 3}"})

def test_negated_reference_is_still_an_expression(self):
self.assertEqual(loads("x = -var.count\n"), {"x": "${-var.count}"})

def test_negation_inside_a_larger_expression(self):
self.assertEqual(loads("x = 1 + -3\n"), {"x": "${1 + -3}"})

def test_parenthesised_negation_is_still_an_expression(self):
self.assertEqual(loads("x = -(3)\n"), {"x": "${-(3)}"})

def test_scientific_notation_is_unaffected(self):
"""`-1e10` never reaches the unary path: it lexes as a single FLOAT_LITERAL."""
self.assertEqual(loads("x = -1e10\n"), {"x": "${-1e10}"})

def test_spaced_negation_of_scientific_notation_stays_an_expression(self):
"""The spaced form *is* a unary op, and its operand is a string.

`preserve_scientific_notation` (on by default) keeps `1e10` as source
text, so there is no number to negate and the expression form stands.
"""
self.assertEqual(loads("x = - 1e10\n"), {"x": "${-1e10}"})

def test_spaced_negation_of_integer_is_still_a_number(self):
self.assertEqual(loads("x = - 3\n"), {"x": -3})

def test_negative_zero_normalises_to_zero(self):
"""`-0` is 0. The dict path drops the sign; the direct path keeps the source."""
self.assertEqual(loads("x = -0\n"), {"x": 0})
self.assertEqual(dumps(loads("x = -0\n")), "x = 0\n")

def test_round_trip_through_dumps(self):
self.assertEqual(loads(dumps(loads("x = -3\n"))), {"x": -3})


class TestNegatedKeywords(TestCase):
"""`-true` is not arithmetic, so it stays an expression.

This is the parsed counterpart to the `bool` guard in
`UnaryOpRule._negate_numeric_literal`: a keyword operand is serialized with
`inside_dollar_string` set and so arrives as the string "true", never as a
Python bool. Without that distinction `bool` subclassing `int` would turn
`-true` into -1.
"""

def test_negated_true(self):
self.assertEqual(loads("x = -true\n"), {"x": "${-true}"})

def test_negated_false(self):
self.assertEqual(loads("x = -false\n"), {"x": "${-false}"})

def test_negated_null(self):
self.assertEqual(loads("x = -null\n"), {"x": "${-null}"})

def test_not_operator_on_keyword(self):
self.assertEqual(loads("x = !true\n"), {"x": "${!true}"})

def test_bare_keywords_are_still_python_values(self):
"""Outside an expression the keywords keep their Python mappings."""
self.assertEqual(loads("x = true\ny = false\nz = null\n"), {"x": True, "y": False, "z": None})