Skip to content

fix: return heredoc bodies that match what Terraform evaluates (#326) - #335

Open
livingstaccato wants to merge 8 commits into
amplify-education:mainfrom
livingstaccato:fix/heredoc-body-values
Open

fix: return heredoc bodies that match what Terraform evaluates (#326)#335
livingstaccato wants to merge 8 commits into
amplify-education:mainfrom
livingstaccato:fix/heredoc-body-values

Conversation

@livingstaccato

@livingstaccato livingstaccato commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #326.
Fixes #330.

What

preserve_heredocs=False returned heredoc bodies that differ from the values Terraform and OpenTofu evaluate the same source to. Three separate causes, all checked against OpenTofu v1.12.5 rather than read off the spec:

  1. The newline before the closing marker was dropped. The spec ends the template where the delimiter "subsequently appears again on a line of its own", so every content line, the last one included, is terminated by its own newline: <<EOT\nline\nEOT is "line\n". The code stripped that newline along with the marker's indentation.
  2. <<- measured its indent in spaces only, so a tab-indented body was not dedented at all. OpenTofu dedents it by one tab per level; measuring whitespace characters matches it, and is identical to counting spaces on space-indented input.
  3. A whitespace-only line was excluded from the margin measurement but dedented anyway.
  4. The closing marker's own indent was stripped as spaces and tabs only, so a body indented with a non-breaking space, a vertical tab, a form feed or an ideographic space came back with that character appended to the value. The dedent already measured whitespace, matching OpenTofu; the marker strip now uses the same rule.

This is not a regression -- 7.2.1 returned the same values -- so it changes long-standing behaviour rather than restoring anything.

Write side

strings_to_heredocs added a newline before the closing marker that the value already carried, emitting a body one line longer than the string it came from. The two errors cancelled inside this library's own round trip, which is why no test caught either. They do not cancel against Terraform: running test/integration/specialized/heredocs.tf through flatten -> strings_to_heredocs -> restore, 5 of the 11 locals came back with different values. A value that does not end in a newline cannot be written as a heredoc without gaining one, so it is now left quoted.

Carriage returns

The flattened form is quoted-string source, and a quoted string cannot hold a literal carriage return -- OpenTofu rejects one with "No closing marker was found for the string". A heredoc read out of a CRLF file therefore flattened to source that would not parse again. \r is now escaped like \n; both process_escape_sequences and OpenTofu resolve it back, so the value survives. The value form (strip_string_quotes=True) still hands back real characters.

A carriage return that does not end a line is a different case, and cannot be written as a heredoc at all. OpenTofu rejects <<EOF\nx\ry\nEOF with "No closing marker was found for the string", while the quoted "x\ry\n" it came from is valid and evaluates to that character. Such a value therefore stays quoted, for the same reason a value that does not end in a newline does — writing the heredoc would trade a wrong value for a file the reference implementation cannot read.

The delimiter search counts CRLF lines too. The body is split on \n, so a CRLF line carries its own \r, and OpenTofu ends a heredoc on EOF\r as readily as on EOF .

The writer had to learn the same escape. _unescape_heredoc_body resolved \n, \" and \\ but not \r, so flattening a CRLF heredoc and restoring it produced a body holding a literal backslash and an r -- a heredoc interprets no escape, so that is a different value. Both halves are now inverses, and a test runs the whole path rather than each half separately: OpenTofu evaluates a CRLF file containing <<EOF\r\nx\r\ny\r\nEOF\r\n to "x\r\ny\r\n", and that is what survives the round trip.

Tests

test/unit/test_heredoc_matches_terraform.py carries a 16-case table whose expectations come from OpenTofu, not from this implementation. bin/heredoc_ground_truth re-derives that table by evaluating every case with tofu console and exits non-zero on any mismatch, so the expectations can be rechecked against a future Terraform rather than trusted.

Delimiter selection

strings_to_heredocs wrote <<EOF over every value without looking at it, so a string holding a line reading EOF closed its own heredoc there and produced a file that no longer parsed -- and log excerpts, shell scripts and embedded configs, the payloads heredocs exist for, are exactly the values that contain the word. The delimiter is now chosen against the body: EOF when nothing in it could close the heredoc, a numbered variant otherwise, so ordinary output is byte-for-byte what it was.

Which lines count as markers is Terraform's rule rather than this grammar's, which is stricter. OpenTofu v1.12.5 ends a heredoc on EOF and evaluates <<EOF\nbody\nEOF \n to "body\n"; HEREDOC_TEMPLATE here requires the newline to follow the word and rejects that file outright. Choosing against the looser reading is what keeps the written file readable by both. That parser-side divergence is a separate defect and is not addressed here.

Not fixed here

Escapes other than \n, \r, \" and \\ are still not decoded when a value is written as a heredoc -- pre-existing, filed as #329.

Merging

It touches the same code as #350 (hcl2/rules/strings.py). Whichever of those lands first, this one needs a rebase rather than a merge — the overlaps are real edits to the same methods, not adjacent lines, so resolving them by hand risks losing one of the two fixes. Say the word and I will rebase and re-run the suite.


This pull request, 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.

Three things about a flattened heredoc body differed from the value
Terraform and OpenTofu evaluate the same source to. Every expectation
added here was produced by running the source through OpenTofu v1.12.5
rather than read off the spec.

- The newline terminating the last content line was dropped, so
  `<<EOT\nline\nEOT` came back as "line" rather than "line\n". The spec
  ends the template where the delimiter "subsequently appears again on a
  line of its own", so every content line, the last included, is
  terminated by its own newline. Only the closing marker's indentation
  is not content, and that is still removed.
- `<<-` measured its indent with `lstrip(" ")`. A tab-indented body
  measured zero on every line, so it was not dedented at all. The spec
  says "spaces", but the reference implementation does not read it that
  narrowly, and measuring whitespace characters is identical to counting
  spaces on space-indented input.
- A whitespace-only line was correctly excluded from the measurement and
  then trimmed anyway. OpenTofu leaves such a line as written: a
  six-space line inside a four-space heredoc stays six spaces.

Fixing the read exposed the matching bug in the write. With
`strings_to_heredocs`, the emitter appended a newline before the closing
marker that the value already carried, so the body came out one line
longer. The two errors cancelled inside this library's own round trip
but not against Terraform: five of the eleven values in the round-trip
fixture changed when OpenTofu evaluated the restored file. They no
longer do. A value that does not end in a newline is now left as a
quoted string, since no heredoc can express it.

This is not a regression. 7.2.1 returns the same values as 8.1.3 on all
four inputs, so nothing here arrived with the v8 rewrite and no fix is
restoring anything -- it changes long-standing behaviour to match the
reference implementation.
…aform

`test_heredoc_matches_terraform.py` asserts values that came from running
each source through OpenTofu rather than from this library or from the
spec. That provenance was a docstring: a reader had to take it on trust,
and nothing re-checked it if the reference implementation moved.

`bin/heredoc_ground_truth` reads the `CASES` table out of the test
module, evaluates every source with `tofu console` (or `terraform
console`), and reports any disagreement, exiting non-zero. `--print`
emits the evaluated table as Python for pasting.

It is not wired into the test run on purpose. The suite must pass without
a Terraform binary present, and these values move about as often as the
HCL spec does -- this is an audit tool for a reviewer who would rather
check than trust, not a gate.

Both paths are exercised: all 16 cases agree with OpenTofu v1.12.5, and
feeding it the pre-fix value for a case makes it report the mismatch and
exit 1.
`preserve_heredocs=False` without `strip_string_quotes` returns the body
as quoted-string source -- the text a parser has to read back. Newlines
were escaped for that; carriage returns were not. A heredoc from a CRLF
file flattened to `"x<CR>\ny<CR>\n"`, which OpenTofu rejects with "No
closing marker was found for the string", so the form documented as
reconstructable was not.

`\r` is an escape both this package's `process_escape_sequences` and
OpenTofu resolve back to a carriage return, so the value survives the
round trip unchanged. The trimmed form had the same gap and gets the
same treatment. The value form keeps handing back real characters.

Two existing CRLF tests asserted the raw-carriage-return output; they
now assert the escaped source and say why.
@livingstaccato

livingstaccato commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Please hold off on merging this one for now — I want to do another review pass over it before it goes in. Opened as a draft for that reason; I will mark it ready and say so here once I am done.

Escaping carriage returns in the flattened form left the writer half a
step behind: `_unescape_heredoc_body` resolved `\n`, `\"` and `\\` but
not `\r`, so a heredoc read out of a CRLF file and written back came out
holding a literal backslash and an `r`. A heredoc interprets no escape --
its body is the characters themselves -- so that is a different value,
and OpenTofu reads it as one.

The two halves have to be inverses. Flatten writes `\r` because a quoted
string cannot hold a raw carriage return; the writer therefore has to
resolve it, exactly as it already resolved `\n` for the same reason.

Each half was covered on its own -- flattening a CRLF heredoc, restoring
an LF string -- which is why the combination could break with the suite
green. The new tests run the whole path: CRLF source, flatten, write,
read the value back, against the string OpenTofu evaluates the original
file to.

Escapes other than these four are still not resolved when writing a
heredoc, which is a separate pre-existing defect (amplify-education#329).
…ion#330)

`strings_to_heredocs` wrote `<<EOF` over every value without looking at
it. A string holding a line reading `EOF` therefore closed its own
heredoc at that line, and everything after became stray tokens: the file
this library had just written no longer parsed, here or in Terraform.
The values people put in heredocs -- log excerpts, shell scripts,
embedded configs -- are exactly the ones that contain the word.

The delimiter is now chosen against the body: `EOF` when no line could
end the heredoc there, a numbered variant otherwise, so ordinary output
is byte-for-byte what it was.

Which lines count is Terraform's rule rather than this grammar's, which
is stricter. OpenTofu v1.12.5 ends a heredoc on `EOF  ` and evaluates
`<<EOF\nbody\nEOF  \n` to `"body\n"`; `HEREDOC_TEMPLATE` here requires
the newline to follow the word, and rejects that file outright. Choosing
against the looser reading is what keeps the written file readable by
both -- the stricter one would emit a body Terraform treats as closed.
`strings_to_heredocs` leaves a value that does not end in a newline
quoted, and the comments said a heredoc body always ends in one. An
empty heredoc does not: `<<EOF\nEOF` evaluates to "" in Terraform and
here, so the empty string is a value the rule excludes that a heredoc
could express.

The behaviour is unchanged -- `x = ""` says it in one line rather than
three -- but the reason stated was wrong, and someone reading it would
have concluded the exclusion was forced.
The dedent measures whitespace rather than spaces and tabs, because that
is what OpenTofu does -- it dedents a body indented with a non-breaking
space, a vertical tab, a form feed or an ideographic space exactly as it
dedents a space-indented one. The closing marker's own indentation was
still stripped as `[ \t]*`, so those bodies came back with the marker's
indent character appended to the value: `'a\nb\n\xa0'` where OpenTofu
evaluates `'a\nb\n'`.

It is now any whitespace but a newline, which is the same rule the
dedent uses. Trailing spaces on a content line still survive, for the
reason they always did: such a line ends with its own newline, and the
match cannot cross one.

The four cases are in `CASES`, so `bin/heredoc_ground_truth` re-derives
them from Terraform along with the rest rather than trusting this
reading of the spec. All 20 agree.
Two cases where writing one produced a file Terraform cannot read.

A lone carriage return is not expressible. A heredoc body is read
literally, so a `\r` may only appear where one ends a line: OpenTofu
rejects `<<EOF\nx\ry\nEOF` with "No closing marker was found for the
string", while the quoted `"x\ry\n"` it came from is valid and evaluates
to that character. Resolving `\r` into the body therefore turned a wrong
value into an unreadable file. Such a value now stays quoted, for the
same reason a value that does not end in a newline does -- and the test
that pinned the old output was asserting a file OpenTofu rejects, which
passed only because this parser is more permissive than its scanner.

The delimiter search was blind to CRLF. The body is split on `\n`, so a
CRLF line hands back its own `\r`, and a marker check allowing only
spaces and tabs never matched `EOF\r`. OpenTofu ends a heredoc there as
readily as on `EOF `, so a CRLF body carrying the delimiter was written
under `<<EOF` and closed at its own line.

Both verified against OpenTofu v1.12.5 in both directions: the three
forms it accepts are now the three this library writes, and it reads all
three back to the same values.
@livingstaccato

livingstaccato commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Review pass done, so the hold above no longer applies — this is ready for review now.

Rebased on current main; GitHub reports it mergeable as it stands.

🤖 Drafted with Claude Code.

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

Labels

None yet

Projects

None yet

1 participant