Summary
A quoted template is not one flat string: the text inside ${...} is
expression source, where a nested "..." is a string literal of its own. Two
code paths treat the whole thing as literal text.
StringRule._serialize_part_as_value already has the right model -- it
resolves escapes only in STRING_CHARS parts, because interpolation text "is
expression source, not literal content". The two paths below do not.
Reproduction, writing
src = 'a = "${upper("a\\"b")}\\n"\n' # OpenTofu: A"B, then a newline
flat = hcl2.loads(src, serialization_options=SerializationOptions(preserve_heredocs=False))
hcl2.dumps(flat, deserializer_options=DeserializerOptions(strings_to_heredocs=True))
a = <<EOF
${upper("a"b")}
EOF
The \" belonging to the nested literal was resolved, so the expression now
reads upper("a"b"). OpenTofu reports "Missing argument separator"; this
library re-reads it as a different expression without error.
Reproduction, reading
hcl2.loads('a = <<EOT\n${upper("a")}\nEOT\n',
serialization_options=SerializationOptions(preserve_heredocs=False))["a"]
# '"${upper(\\"a\\")}"'
The quotes inside the interpolation were escaped as though they were literal
text. OpenTofu rejects that source with "Invalid character"; this library
re-reads it as ${upper(a)} -- the quotes gone and a now a bare reference.
Suggested shape of the fix
Not by splitting the text first. $${ means a literal ${, so a splitter run
ahead of unescaping sees the ${ inside it and opens an interpolation that is
not there.
_serialize_part_as_value avoids that by never splitting text at all: it works
on the parts the grammar already separated, and tests each one's terminal --
serialized = part.serialize(options, context)
if part.content.lark_name() == "STRING_CHARS":
return process_escape_sequences(serialized)
return serialized
The two paths above take the joined string instead. Both have the parts
available where they escape: StringRule.string_parts on the read side, and on
the write side the value arrives before the parts are rebuilt. If a text-level
pass is unavoidable somewhere, it has to recognise $${ and %%{ in the same
left-to-right pass that finds the boundaries, not before it.
This issue, and the investigation behind it, were produced by an AI assistant (Claude) working on behalf of the author. Please review with that provenance in mind.
Summary
A quoted template is not one flat string: the text inside
${...}isexpression source, where a nested
"..."is a string literal of its own. Twocode paths treat the whole thing as literal text.
StringRule._serialize_part_as_valuealready has the right model -- itresolves escapes only in
STRING_CHARSparts, because interpolation text "isexpression source, not literal content". The two paths below do not.
Reproduction, writing
The
\"belonging to the nested literal was resolved, so the expression nowreads
upper("a"b"). OpenTofu reports "Missing argument separator"; thislibrary re-reads it as a different expression without error.
Reproduction, reading
The quotes inside the interpolation were escaped as though they were literal
text. OpenTofu rejects that source with "Invalid character"; this library
re-reads it as
${upper(a)}-- the quotes gone andanow a bare reference.Suggested shape of the fix
Not by splitting the text first.
$${means a literal${, so a splitter runahead of unescaping sees the
${inside it and opens an interpolation that isnot there.
_serialize_part_as_valueavoids that by never splitting text at all: it workson the parts the grammar already separated, and tests each one's terminal --
The two paths above take the joined string instead. Both have the parts
available where they escape:
StringRule.string_partson the read side, and onthe write side the value arrives before the parts are rebuilt. If a text-level
pass is unavoidable somewhere, it has to recognise
$${and%%{in the sameleft-to-right pass that finds the boundaries, not before it.
This issue, and the investigation behind it, were produced by an AI assistant (Claude) working on behalf of the author. Please review with that provenance in mind.