Skip to content

Commit f06a2e1

Browse files
fix: load negative integer literals as numbers (#307)
`x = -3` serialized to the expression string `${-3}` rather than the int `-3`, while `x = -3.5` still produced a float. Downstream code reading numbers out of a parsed configuration got a string whenever the value happened to be integral, and the 7.x behaviour (fixed in #182) was lost. MINUS is both the unary sign and the binary subtraction operator, so the sign cannot simply be folded into INT_LITERAL: `10 -3` has to keep parsing as a subtraction. FLOAT_LITERAL escapes this only because its pattern cannot be confused with an operator followed by a digit. Recombine the two at serialization instead, where the parse has already settled the question: when a unary `-` is applied to something that serialized to a number, and the operation is the whole value, emit the negated number. Everything else keeps the `${...}` form -- `-var.count` has no literal value, `!flag` is not arithmetic, `1 + -3` is a larger expression whose operand must stay concatenable text, and a `force_operation_parentheses` result cannot carry its parentheses as a bare number. Add an `integers` round-trip suite mirroring the existing `floats` one, plus unit tests covering both halves of the trade-off. Without the fix, 12 of the new tests fail.
1 parent 1432aea commit f06a2e1

8 files changed

Lines changed: 236 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Fixed
1111

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

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

hcl2/rules/expressions.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Rule classes for HCL2 expressions, conditionals, and binary/unary operations."""
22

33
from abc import ABC
4-
from typing import Any, Optional, Tuple
4+
from typing import Any, Optional, Tuple, Union
55

66
from lark.tree import Meta
77

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

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

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

316326
return result
327+
328+
@staticmethod
329+
def _negate_numeric_literal(
330+
operator: str, operand: Any, options: SerializationOptions
331+
) -> Optional[Union[int, float]]:
332+
"""Return the negated value when this is `-` applied to a number.
333+
334+
Returns None when the operation is anything else, so that the caller
335+
falls back to the `${...}` expression form: `-var.x` has no literal
336+
value, `!flag` is not arithmetic, and a scientific-notation operand
337+
serializes to a string when `preserve_scientific_notation` is set.
338+
Parenthesised output is likewise left alone, since a bare number cannot
339+
carry the parentheses that option asks for.
340+
"""
341+
if operator != "-" or options.force_operation_parentheses:
342+
return None
343+
if isinstance(operand, bool) or not isinstance(operand, (int, float)):
344+
return None
345+
return -operand
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
locals {
2+
simple_int = 123
3+
zero = 0
4+
large_int = 9876543210
5+
negative_int = -42
6+
negative_one = -1
7+
negative_large = -9876543210
8+
int_calculation = 105 * 3 / 2
9+
int_subtraction = 10 - 3
10+
int_negated_reference = -var.count
11+
int_comparison = 5 > 2 ? 1 : 0
12+
int_list = [1, 2, 3, -4, -5]
13+
int_object = {
14+
positive = 7
15+
negative = -7
16+
mixed = [-1, 0, 1]
17+
}
18+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
locals {
2+
simple_int = 123
3+
zero = 0
4+
large_int = 9876543210
5+
negative_int = -42
6+
negative_one = -1
7+
negative_large = -9876543210
8+
int_calculation = 105 * 3 / 2
9+
int_subtraction = 10 - 3
10+
int_negated_reference = -var.count
11+
int_comparison = 5 > 2 ? 1 : 0
12+
int_list = [
13+
1,
14+
2,
15+
3,
16+
-4,
17+
-5,
18+
]
19+
int_object = {
20+
positive = 7,
21+
negative = -7,
22+
mixed = [
23+
-1,
24+
0,
25+
1,
26+
],
27+
}
28+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"locals": [
3+
{
4+
"simple_int": 123,
5+
"zero": 0,
6+
"large_int": 9876543210,
7+
"negative_int": -42,
8+
"negative_one": -1,
9+
"negative_large": -9876543210,
10+
"int_calculation": "${105 * 3 / 2}",
11+
"int_subtraction": "${10 - 3}",
12+
"int_negated_reference": "${-var.count}",
13+
"int_comparison": "${5 > 2 ? 1 : 0}",
14+
"int_list": [
15+
1,
16+
2,
17+
3,
18+
-4,
19+
-5
20+
],
21+
"int_object": {
22+
"positive": 7,
23+
"negative": -7,
24+
"mixed": [
25+
-1,
26+
0,
27+
1
28+
]
29+
},
30+
"__is_block__": true
31+
}
32+
]
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"locals": [
3+
{
4+
"simple_int": 123,
5+
"zero": 0,
6+
"large_int": 9876543210,
7+
"negative_int": -42,
8+
"negative_one": -1,
9+
"negative_large": -9876543210,
10+
"int_calculation": "${105 * 3 / 2}",
11+
"int_subtraction": "${10 - 3}",
12+
"int_negated_reference": "${-var.count}",
13+
"int_comparison": "${5 > 2 ? 1 : 0}",
14+
"int_list": [
15+
1,
16+
2,
17+
3,
18+
-4,
19+
-5
20+
],
21+
"int_object": {
22+
"positive": 7,
23+
"negative": -7,
24+
"mixed": [
25+
-1,
26+
0,
27+
1
28+
]
29+
},
30+
"__is_block__": true
31+
}
32+
]
33+
}

test/unit/rules/test_expressions.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,55 @@ def test_serialize_force_parens_with_expression_parent(self):
424424
self.assertEqual(result, "${(-x)}")
425425

426426

427+
class TestUnaryOpRuleNegativeNumbers(TestCase):
428+
"""`-3` is the number -3, not the expression string "${-3}".
429+
430+
The lexer cannot fold the sign into INT_LITERAL, because MINUS is also the
431+
binary subtraction operator and `10 -3` has to stay a subtraction, so a
432+
negative integer reaches serialization as MINUS applied to a literal.
433+
"""
434+
435+
def _make_unary(self, op_str, operand_val):
436+
token_cls = MINUS_TOKEN if op_str == "-" else NOT_TOKEN
437+
return UnaryOpRule([token_cls(op_str), _make_expr_term(operand_val)])
438+
439+
def test_negative_int_serializes_to_int(self):
440+
rule = self._make_unary("-", 3)
441+
self.assertEqual(rule.serialize(), -3)
442+
443+
def test_negative_zero_serializes_to_int(self):
444+
rule = self._make_unary("-", 0)
445+
self.assertEqual(rule.serialize(), 0)
446+
447+
def test_negative_float_serializes_to_float(self):
448+
rule = self._make_unary("-", 3.5)
449+
self.assertEqual(rule.serialize(), -3.5)
450+
451+
def test_negated_identifier_stays_an_expression(self):
452+
rule = self._make_unary("-", "var.count")
453+
self.assertEqual(rule.serialize(), "${-var.count}")
454+
455+
def test_not_operator_is_untouched(self):
456+
rule = self._make_unary("!", 1)
457+
self.assertEqual(rule.serialize(), "${!1}")
458+
459+
def test_inside_dollar_string_stays_text(self):
460+
"""Within a larger expression the operand must remain concatenable."""
461+
rule = self._make_unary("-", 3)
462+
ctx = SerializationContext(inside_dollar_string=True)
463+
self.assertEqual(rule.serialize(context=ctx), "-3")
464+
465+
def test_force_parens_keeps_expression_form(self):
466+
"""A bare number cannot carry the parentheses that option requests."""
467+
rule = self._make_unary("-", 3)
468+
opts = SerializationOptions(force_operation_parentheses=True)
469+
self.assertEqual(rule.serialize(options=opts), "${-3}")
470+
471+
def test_boolean_operand_is_not_treated_as_a_number(self):
472+
rule = self._make_unary("-", True)
473+
self.assertEqual(rule.serialize(), "${-True}")
474+
475+
427476
# --- ExpressionRule._wrap_into_parentheses tests ---
428477

429478

test/unit/test_api.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,46 @@ def test_query_file_object(self):
288288
self.assertIsInstance(result, DocumentView)
289289
attr = result.attribute("x")
290290
self.assertIsNotNone(attr)
291+
292+
293+
class TestNegativeIntegerLiterals(TestCase):
294+
"""`-3` loads as the int -3, without disturbing subtraction.
295+
296+
MINUS serves as both the unary sign and the binary subtraction operator, so
297+
a negative integer cannot be folded into INT_LITERAL by the lexer without
298+
breaking `10 -3`. These cases pin both halves of that trade-off.
299+
"""
300+
301+
def test_negative_int_is_an_int(self):
302+
self.assertEqual(loads("x = -3\n"), {"x": -3})
303+
304+
def test_negative_int_matches_negative_float_handling(self):
305+
self.assertEqual(loads("x = -3\ny = -3.5\n"), {"x": -3, "y": -3.5})
306+
307+
def test_negative_ints_in_tuple(self):
308+
self.assertEqual(loads("x = [-1, 2, -30]\n"), {"x": [-1, 2, -30]})
309+
310+
def test_negative_ints_in_object(self):
311+
self.assertEqual(loads("x = { a = -1, b = 2 }\n"), {"x": {"a": -1, "b": 2}})
312+
313+
def test_spaced_subtraction_is_still_an_expression(self):
314+
self.assertEqual(loads("x = 10 - 3\n"), {"x": "${10 - 3}"})
315+
316+
def test_tight_subtraction_is_still_an_expression(self):
317+
"""`10 -3` is a subtraction, not two adjacent literals."""
318+
self.assertEqual(loads("x = 10 -3\n"), {"x": "${10 - 3}"})
319+
320+
def test_negated_reference_is_still_an_expression(self):
321+
self.assertEqual(loads("x = -var.count\n"), {"x": "${-var.count}"})
322+
323+
def test_negation_inside_a_larger_expression(self):
324+
self.assertEqual(loads("x = 1 + -3\n"), {"x": "${1 + -3}"})
325+
326+
def test_parenthesised_negation_is_still_an_expression(self):
327+
self.assertEqual(loads("x = -(3)\n"), {"x": "${-(3)}"})
328+
329+
def test_scientific_notation_is_unaffected(self):
330+
self.assertEqual(loads("x = -1e10\n"), {"x": "${-1e10}"})
331+
332+
def test_round_trip_through_dumps(self):
333+
self.assertEqual(loads(dumps(loads("x = -3\n"))), {"x": -3})

0 commit comments

Comments
 (0)