Skip to content
Open
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 @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Fixed

- Restore `py.typed` marker so type checkers recognize `hcl2` (and `cli`) as typed packages. ([#298](https://github.com/amplify-education/python-hcl2/issues/298))
- 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
18 changes: 18 additions & 0 deletions test/integration/hcl2_original/integers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
locals {
simple_int = 123
zero = 0
large_int = 9876543210
negative_int = -42
negative_one = -1
negative_large = -9876543210
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]
}
}
28 changes: 28 additions & 0 deletions test/integration/hcl2_reconstructed/integers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
locals {
simple_int = 123
zero = 0
large_int = 9876543210
negative_int = -42
negative_one = -1
negative_large = -9876543210
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,
],
}
}
33 changes: 33 additions & 0 deletions test/integration/json_reserialized/integers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"locals": [
{
"simple_int": 123,
"zero": 0,
"large_int": 9876543210,
"negative_int": -42,
"negative_one": -1,
"negative_large": -9876543210,
"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
}
]
}
33 changes: 33 additions & 0 deletions test/integration/json_serialized/integers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"locals": [
{
"simple_int": 123,
"zero": 0,
"large_int": 9876543210,
"negative_int": -42,
"negative_one": -1,
"negative_large": -9876543210,
"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
}
]
}
49 changes: 49 additions & 0 deletions test/unit/rules/test_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,55 @@ 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):
rule = self._make_unary("-", True)
self.assertEqual(rule.serialize(), "${-True}")


# --- ExpressionRule._wrap_into_parentheses tests ---


Expand Down
43 changes: 43 additions & 0 deletions test/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,46 @@ def test_query_file_object(self):
self.assertIsInstance(result, DocumentView)
attr = result.attribute("x")
self.assertIsNotNone(attr)


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):
self.assertEqual(loads("x = -1e10\n"), {"x": "${-1e10}"})

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