Skip to content

Escape handling ignores ${...} boundaries in both directions, corrupting the expression inside #339

Description

@livingstaccato

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions