diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8f8fed..c68f5900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/hcl2/rules/expressions.py b/hcl2/rules/expressions.py index 5aa5f76e..15caa1c3 100644 --- a/hcl2/rules/expressions.py +++ b/hcl2/rules/expressions.py @@ -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 @@ -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 diff --git a/test/integration/hcl2_original/integers.tf b/test/integration/hcl2_original/integers.tf new file mode 100644 index 00000000..5a92f56e --- /dev/null +++ b/test/integration/hcl2_original/integers.tf @@ -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] + } +} diff --git a/test/integration/hcl2_reconstructed/integers.tf b/test/integration/hcl2_reconstructed/integers.tf new file mode 100644 index 00000000..01918622 --- /dev/null +++ b/test/integration/hcl2_reconstructed/integers.tf @@ -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, + ], + } +} diff --git a/test/integration/json_reserialized/integers.json b/test/integration/json_reserialized/integers.json new file mode 100644 index 00000000..3502c100 --- /dev/null +++ b/test/integration/json_reserialized/integers.json @@ -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 + } + ] +} diff --git a/test/integration/json_serialized/integers.json b/test/integration/json_serialized/integers.json new file mode 100644 index 00000000..3502c100 --- /dev/null +++ b/test/integration/json_serialized/integers.json @@ -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 + } + ] +} diff --git a/test/unit/rules/test_expressions.py b/test/unit/rules/test_expressions.py index 9b7e9a7f..ea59209a 100644 --- a/test/unit/rules/test_expressions.py +++ b/test/unit/rules/test_expressions.py @@ -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 --- diff --git a/test/unit/test_api.py b/test/unit/test_api.py index 6d32b78a..a96acdda 100644 --- a/test/unit/test_api.py +++ b/test/unit/test_api.py @@ -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(<