diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fda71d..ec5be326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Python 3.14 is now tested and declared as supported. No source changes were needed; the full suite passes on 3.14 as-is. +### Fixed + +- `strip_string_quotes` now writes a heredoc inside an expression as a string instead of splicing its body in bare. `StringRule` checks `inside_dollar_string` to keep its quotes for exactly this reason; the heredoc rules did not, so `upper(< ExpressionRule: def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any: """Serialize, handling parenthesized expression wrapping.""" - with context.modify(inside_parentheses=self.parentheses or context.inside_parentheses): + # A parenthesised term is written as `${(...)}`, so what it wraps is + # expression source, exactly as a function's arguments are. Serialized + # as a value, `(true)` came back as `${(True)}` and `(null)` as + # `${(None)}` -- Python's spelling, which OpenTofu reads as references + # to undeclared variables -- and a tuple or object inside came back as + # a Python repr that did not parse. + with context.modify( + inside_parentheses=self.parentheses or context.inside_parentheses, + inside_dollar_string=self.parentheses or context.inside_dollar_string, + ): result = self.expression.serialize(options, context) if self.parentheses: diff --git a/hcl2/rules/strings.py b/hcl2/rules/strings.py index 76ce0660..482a2fb6 100644 --- a/hcl2/rules/strings.py +++ b/hcl2/rules/strings.py @@ -170,10 +170,18 @@ def serialize(self, options=SerializationOptions(), context=SerializationContext if not match: raise RuntimeError(f"Invalid Heredoc token: {heredoc}") heredoc = _strip_closing_marker_line(match.group(2)) - if options.strip_string_quotes: + if options.strip_string_quotes and not context.inside_dollar_string: # The caller asked for the value, so hand back the body as-is: # real newlines, no escaping. The escaping below exists only to # build the quoted-string *source* form returned otherwise. + # + # Not inside an expression, though. There the heredoc is an + # argument, and its text is part of that expression's source: + # `upper(< "X\n", so the argument is a string + "%{ if local.x == "y" }t%{ endif }" -> "t", with plain quotes + +The escaped spelling this grammar also accepts, `\"y\"` inside a directive, +Terraform rejects outright with "Invalid character". That divergence is filed +as #341's sibling, #353; the tests below only pin that the value form stops +mangling it into a reference, which is what #341 asks for. +""" + +from unittest import TestCase + +from hcl2.api import dumps, loads +from hcl2.utils import SerializationOptions + +VALUE = SerializationOptions(preserve_heredocs=False, strip_string_quotes=True) +QUOTED = SerializationOptions(strip_string_quotes=True) +SOURCE = SerializationOptions(preserve_heredocs=False) +DEFAULT = SerializationOptions() + + +class TestAHeredocInsideAnExpression(TestCase): + """#340: the body was spliced in bare, so it read as a reference.""" + + def value(self, source: str) -> str: + return loads(source, serialization_options=VALUE)["a"] + + def test_it_stays_a_string(self): + self.assertEqual(self.value("a = upper(< str: + return loads(source, serialization_options=VALUE)["a"] + + def test_a_nested_call(self): + self.assertEqual(self.value("a = upper(lower(< "true" (null) -> "null" ("s") -> "\"s\"" + ([1, "a"]) -> "[1,\"a\"]" ({a = 1}) -> "{\"a\":1}" + """ + + CASES = { + "(true)": "${(true)}", + "(null)": "${(null)}", + '("s")': '${("s")}', + "(1)": "${(1)}", + '([1, "a"])': '${([1, "a"])}', + "(1 + 2)": "${(1 + 2)}", + "((true))": "${((true))}", + } + + def test_default_options(self): + for source, expected in self.CASES.items(): + with self.subTest(source=source): + self.assertEqual(loads(f"x = {source}\n")["x"], expected) + + def test_the_value_form(self): + for source, expected in self.CASES.items(): + with self.subTest(source=source): + self.assertEqual(loads(f"x = {source}\n", serialization_options=QUOTED)["x"], expected) + + def test_an_object_inside_parentheses(self): + self.assertEqual(loads("x = ({a = 1})\n")["x"], "${({a = 1})}") + + def test_inside_a_tuple(self): + self.assertEqual(loads("x = [(true)]\n")["x"], ["${(true)}"]) + + def test_the_round_trip_reads_back_the_same_value(self): + for source in (*self.CASES, "({a = 1})", "[(true)]"): + for options in (DEFAULT, QUOTED, VALUE): + with self.subTest(source=source, options=options): + original = loads(f"x = {source}\n", serialization_options=options) + written = dumps(original) + self.assertEqual(loads(written, serialization_options=options), original) diff --git a/test/unit/test_api.py b/test/unit/test_api.py index d15224ce..ba8d88ce 100644 --- a/test/unit/test_api.py +++ b/test/unit/test_api.py @@ -359,6 +359,10 @@ def test_empty_heredoc_as_an_object_value(self): self.assertEqual(loads("a = {\n k = <