Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Fixed

- Restore `py.typed` marker so type checkers recognize `hcl2` (and `cli`) as typed packages. ([#298](https://github.com/amplify-education/python-hcl2/issues/298))
- Parse heredocs with an empty body again. A marker immediately followed by its closing delimiter failed to match, and the lexer then ran on to a later delimiter, silently absorbing the attributes in between. ([#309](https://github.com/amplify-education/python-hcl2/issues/309))

## \[8.1.2\] - 2026-04-10

Expand Down
4 changes: 2 additions & 2 deletions hcl2/hcl2.lark
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ ELLIPSIS : "..."
COLONS: "::"

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

// Ignore whitespace (but not newlines, as they're significant in HCL)
%ignore /[ \t]+/
Expand Down
12 changes: 12 additions & 0 deletions test/integration/specialized/heredocs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,16 @@ EOF
json_content = <<EOF
{"key": "value"}
EOF

empty = <<EOF
EOF

empty_trimmed = <<-EOF
EOF

blank_line_only = <<EOF

EOF

after_empty = "still parsed"
}
4 changes: 4 additions & 0 deletions test/integration/specialized/heredocs_flattened.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"trimmed": "\"indented1\\nindented2\"",
"trimmed_mixed": "\"line1\\n line2\\nline3\"",
"json_content": "\"{\\\"key\\\": \\\"value\\\"}\"",
"empty": "\"\"",
"empty_trimmed": "\"\"",
"blank_line_only": "\"\"",
"after_empty": "\"still parsed\"",
"__is_block__": true
}
]
Expand Down
4 changes: 4 additions & 0 deletions test/integration/specialized/heredocs_preserved.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"trimmed": "\"<<-EOF\n indented1\n indented2\n EOF\"",
"trimmed_mixed": "\"<<-EOF\n line1\n line2\n line3\n EOF\"",
"json_content": "\"<<EOF\n{\"key\": \"value\"}\nEOF\"",
"empty": "\"<<EOF\nEOF\"",
"empty_trimmed": "\"<<-EOF\n EOF\"",
"blank_line_only": "\"<<EOF\n\nEOF\"",
"after_empty": "\"still parsed\"",
"__is_block__": true
}
]
Expand Down
4 changes: 4 additions & 0 deletions test/integration/specialized/heredocs_restored.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ line1
line3
EOF
json_content = "{\"key\": \"value\"}"
empty = ""
empty_trimmed = ""
blank_line_only = ""
after_empty = "still parsed"
}
44 changes: 44 additions & 0 deletions test/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,47 @@ def test_query_file_object(self):
self.assertIsInstance(result, DocumentView)
attr = result.attribute("x")
self.assertIsNotNone(attr)


class TestEmptyHeredocs(TestCase):
"""A heredoc with no body parses, and does not swallow what follows.

The v8 grammar made the newline before the closing delimiter mandatory, so
a marker immediately followed by its delimiter could not match. The lexer
then scanned on to a later delimiter, which silently absorbed the
intervening attributes rather than reporting an error.
"""

def test_empty_heredoc_parses(self):
self.assertEqual(loads("a = <<EOF\nEOF\n"), {"a": '"<<EOF\nEOF"'})

def test_empty_trimmed_heredoc_parses(self):
self.assertEqual(loads("a = <<-EOF\n EOF\n"), {"a": '"<<-EOF\n EOF"'})

def test_empty_heredoc_flattens_to_empty_string(self):
options = SerializationOptions(preserve_heredocs=False)
self.assertEqual(loads("a = <<EOF\nEOF\n", serialization_options=options), {"a": '""'})

def test_empty_heredoc_does_not_swallow_following_attributes(self):
source = "a = <<EOF\nEOF\n\nb = <<EOF\nreal body\nEOF\n\nc = 1\n"
self.assertEqual(
loads(source),
{"a": '"<<EOF\nEOF"', "b": '"<<EOF\nreal body\nEOF"', "c": 1},
)

def test_heredoc_with_a_blank_body_line_is_distinct_from_empty(self):
self.assertEqual(loads("a = <<EOF\n\nEOF\n"), {"a": '"<<EOF\n\nEOF"'})

def test_delimiter_must_start_its_own_line(self):
"""A word merely ending in the delimiter does not terminate the body."""
self.assertEqual(loads("a = <<EOF\nsayEOF\nEOF\n"), {"a": '"<<EOF\nsayEOF\nEOF"'})

def test_body_containing_the_delimiter_as_a_prefix(self):
self.assertEqual(loads("a = <<EOF\nEOF_NOT\nEOF\n"), {"a": '"<<EOF\nEOF_NOT\nEOF"'})

def test_consecutive_heredocs_stay_separate(self):
source = "a = <<EOF\nx\nEOF\nb = <<EOF\ny\nEOF\n"
self.assertEqual(loads(source), {"a": '"<<EOF\nx\nEOF"', "b": '"<<EOF\ny\nEOF"'})

def test_indented_closing_delimiter_still_allowed(self):
self.assertEqual(loads("a = <<EOF\nx\n EOF\n"), {"a": '"<<EOF\nx\n EOF"'})