Skip to content

fix: parse heredocs with an empty body (#309) - #312

Merged
kkozik-amplify merged 2 commits into
amplify-education:mainfrom
livingstaccato:fix/empty-heredoc
Aug 24, 2026
Merged

fix: parse heredocs with an empty body (#309)#312
kkozik-amplify merged 2 commits into
amplify-education:mainfrom
livingstaccato:fix/empty-heredoc

Conversation

@livingstaccato

Copy link
Copy Markdown
Contributor

Fixes #309.

A heredoc with an empty body failed to parse. The v8 rewrite turned the optional newline after the opening marker into a mandatory one, so a marker immediately followed by its closing delimiter no longer matched HEREDOC_TEMPLATE.

The louder half of the bug

The parse error is the rare case. Having failed to match at that position, the lexer scans on and matches a later delimiter, silently absorbing everything in between:

import hcl2

hcl2.loads('''locals {
  a = <<EOF
EOF

  b = <<EOF
real body
EOF
}
''')

8.1.2 returns a single attribute whose value runs to the end of b, with b absent from the output. No exception. Only a file whose empty heredoc has no later delimiter to latch onto surfaces as UnexpectedToken.

The fix

Make the body and its trailing newline one lazy optional group:

HEREDOC_TEMPLATE : /<<(?P<heredoc>[a-zA-Z][a-zA-Z0-9._-]+)\n(?:(?:.|\n)*?\n)??\s*(?P=heredoc)\n/

Two simpler-looking patches are both wrong, which seems worth recording:

  • Restoring the bare \n? that the rewrite dropped fixes the empty case but keeps the run-on, because the body can still stretch to a later delimiter.
  • Dropping the \n before \s* fixes both of those, but then \s* can match nothing and a word merely ending in the delimiter terminates the body: <<EOF\nsayEOF\nEOF truncates at sayEOF.

The laziness is load-bearing for the same reason: a greedy ? prefers to match a body. The delimiter still has to begin its own line, so the rule from #194 is preserved.

Tests

test/helpers/terraform-config/ carried an empty-heredoc fixture, added with #117 when this was fixed the first time. The v8 test reorganisation replaced that layout, and no zero-line heredoc appears anywhere in the current suite — which is why nothing caught this.

The specialized heredoc fixture now covers empty, empty-trimmed and blank-line-only heredocs, plus an attribute after them that proves the lexer stops where it should. Without the grammar change, 6 of the new tests fail. nose2 --config tox.ini: 1400 tests, OK. ruff clean.


This pull request, and the investigation behind it, were produced by an AI assistant (Claude) working on behalf of the author. Every reproduction, test run and benchmark cited was executed rather than inferred, but please review with that provenance in mind.

`a = <<EOF\nEOF` failed to parse. The v8 rewrite turned the optional
newline after the opening marker into a mandatory one, so a marker
immediately followed by its closing delimiter no longer matched the
HEREDOC_TEMPLATE terminal.

The failure is not confined to the empty heredoc. Having failed to match
at that position, the lexer scans on and matches a *later* delimiter, so
an empty heredoc followed by more attributes silently swallows them:

    locals {
      a = <<EOF
    EOF

      b = <<EOF
    real body
    EOF
    }

serialized to a single attribute `a` whose value ran to the end of `b`,
with `b` absent from the output. Only a file whose empty heredoc had no
later delimiter to latch onto surfaced as a parse error.

Make the body and its trailing newline a single optional group rather
than restoring the bare `\n?` the rewrite dropped. The group has to be
lazy: a greedy one prefers to match a body, which reintroduces the
run-on above. The delimiter still has to begin its own line, so amplify-education#194's
rule holds and a word merely ending in the delimiter does not terminate
the body.

Cover empty, empty-trimmed and blank-line-only heredocs in the
specialized suite, along with an attribute after them that proves the
lexer stops where it should. `test/helpers/terraform-config/` carried an
empty-heredoc fixture for this before the v8 test reorganisation; nothing
in the current suite replaced it. Without the fix, 6 of the new tests
fail.
@livingstaccato
livingstaccato requested a review from a team as a code owner August 18, 2026 18:29
livingstaccato added a commit to provide-io/pyvider-hcl that referenced this pull request Aug 18, 2026
`<<EOF` immediately followed by its closing marker leaked the raw markers
as the value: `_unwrap_heredoc` split the remainder on its last newline,
found none, and gave up, so the string fell through to escape processing
untouched.

The case is unreachable with python-hcl2 8.1.2, whose grammar cannot
parse a body-less heredoc at all, so nothing exercised it. Running the
suite against a build carrying the fix for that (submitted upstream as
amplify-education/python-hcl2#312) surfaced it: with the parser repaired,
`x = <<EOF\nEOF` reached the normalizer and came back as the two-line
string `<<EOF\nEOF` rather than the empty string HCL specifies.

Distinguish having no body lines, which holds "", from having one empty
body line, which holds a newline. The new tests drive the normalizer
directly rather than through the parser, so they run on the released
python-hcl2 as well.
The grammar change fixes more than the existing tests pin down. Two
consecutive empty heredocs are the tightest form of the run-on and were
silent data loss before: main returned one attribute spanning both, with
the second absent and no exception. The run-on also latched onto a
non-matching later delimiter.

Add container contexts too — tuple element, object value, function
argument — which all raised before the fix, showing the repair is not
limited to top-level attributes. Tab-indented closing delimiter covers
the whitespace class in `\s*`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kkozik-amplify
kkozik-amplify merged commit c41a977 into amplify-education:main Aug 24, 2026
8 checks passed
kkozik-amplify added a commit to livingstaccato/python-hcl2 that referenced this pull request Aug 24, 2026
amplify-education#312 landed the empty-heredoc fix, which touched the same two append-only
spots as this branch. Both conflicts keep both sides:

  CHANGELOG.md      - both entries, amplify-education#309's landed one first so this
                      branch's diff is a pure append
  test_api.py       - TestEmptyHeredocs from main, then this branch's
                      TestNegativeIntegerLiterals and TestNegatedKeywords

No source overlap: this branch only touches hcl2/rules/expressions.py,
amplify-education#312 only touched hcl2/hcl2.lark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kkozik-amplify added a commit to livingstaccato/python-hcl2 that referenced this pull request Aug 24, 2026
amplify-education#312 and amplify-education#311 have landed since this branch was opened. Both conflicts
are append-only and keep both sides:

  CHANGELOG.md      - landed entries first, so this branch's two lines
                      are a pure append
  test_api.py       - TestEmptyHeredocs, TestNegativeIntegerLiterals and
                      TestNegatedKeywords from main, then this branch's
                      TestStripStringQuotes

No source overlap: this branch touches hcl2/rules/strings.py and
hcl2/utils.py, which neither amplify-education#311 (expressions.py) nor amplify-education#312 (hcl2.lark)
went near.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kkozik-amplify added a commit to agu2347/python-hcl2 that referenced this pull request Aug 24, 2026
amplify-education#312, amplify-education#311 and amplify-education#313 have landed. Two conflicts:

  hcl2/hcl2.lark  - real overlap with amplify-education#312, both editing the heredoc
                    terminals. Resolved as the union: amplify-education#312's lazy optional
                    body group plus this branch's \r?, giving
                    /<<MARKER\r?\n(?:(?:.|\n)*?\r?\n)??\s*MARKER\r?\n/.
                    Verified an empty heredoc in a CRLF file parses, two
                    consecutive empty CRLF heredocs stay separate, and a
                    line merely ending in the marker still does not
                    terminate the body.
  CHANGELOG.md    - landed entries first, this branch's appended.

hcl2/rules/strings.py and hcl2/utils.py auto-merged against amplify-education#313, which
touched both. Checked rather than assumed: strip_string_quotes on CRLF
source, escapes resolved through CRLF line endings, a literal CR inside a
string surviving, and amplify-education#313's out-of-range and lone-surrogate escape
guards all still behave.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kkozik-amplify added a commit to agu2347/python-hcl2 that referenced this pull request Aug 24, 2026
amplify-education#312, amplify-education#311 and amplify-education#313 have landed. Only CHANGELOG.md conflicted, resolved
with the landed entries first and this branch's appended.

hcl2/rules/strings.py auto-merged against amplify-education#313, which rewrote the same
file's string-serialization path. Verified the two coexist: amplify-education#313's
process_escape_sequences and lark_name() dispatch sit alongside this
branch's _strip_closing_marker_line, and the full suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression in 8.x: empty heredoc fails to parse (regression of #101)

2 participants