Skip to content
Closed
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## \[Unreleased\]

- Nothing yet.
### Fixed

- Parse blocks whose type or unquoted label is an HCL keyword, such as the `in` block of the Snowflake provider's `snowflake_schemas` data source. HCL does not reserve its keywords, so `if`, `in`, `for`, `for_each`, `else`, `endif`, `endfor`, `true`, `false`, and `null` are now accepted in every block label position and normalized to identifiers — matching the existing behaviour for keyword attribute names.
- Parse keyword-named *object* keys reliably, fixing a regression of [#148](https://github.com/amplify-education/python-hcl2/issues/148). `object_elem_key` did not accept the keyword terminals, so a key such as `in` parsed only in states where the contextual lexer happened to fall back to `NAME` — which made the key's position inside the object decide whether the file parsed. `{ in = "header", name = "n" }` worked while `{ name = "n", in = "header" }` failed, so the `jsonencode` OpenAPI body from the original report still raised. Keys such as `for` failed in every position.

## \[8.1.3\] - 2026-08-26

Expand Down
14 changes: 12 additions & 2 deletions hcl2/hcl2.lark
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ start : body
body : (new_line_or_comment? (attribute | block))* new_line_or_comment?
attribute : _attribute_name EQ expression
_attribute_name : identifier | keyword | literal_value
block : identifier (identifier | string)* new_line_or_comment? LBRACE body RBRACE
// HCL does not reserve its keywords, so a block type or an unquoted label may
// be spelled `in`, `for`, `true`, etc. (e.g. the `in` block in the Snowflake
// provider). The transformer normalizes those back to identifiers.
block : _block_label (_block_label | string)* new_line_or_comment? LBRACE body RBRACE
_block_label : identifier | keyword | literal_value

// Whitespace and comments
new_line_or_comment: ( NL_OR_COMMENT )+
Expand Down Expand Up @@ -225,7 +229,13 @@ template_string : TEMPLATE_STRING
tuple : LSQB new_line_or_comment? (expression new_line_or_comment? COMMA new_line_or_comment?)* (expression new_line_or_comment? COMMA? new_line_or_comment?)? RSQB
object : LBRACE new_line_or_comment? ((object_elem | (object_elem new_line_or_comment? COMMA)) new_line_or_comment?)* RBRACE
object_elem : object_elem_key ( EQ | COLON ) expression
object_elem_key : expression
// `keyword` is listed explicitly because it is not reachable through
// `expression`: `in`, `for`, etc. lex as their own terminals, never as NAME.
// Without this, `{ type = "apiKey", in = "header" }` fails — the contextual
// lexer only falls back to NAME in states that do not accept the keyword
// terminal, which made the key's position in the object decide whether it
// parsed. The transformer normalizes these to identifiers.
object_elem_key : expression | keyword

// Heredocs
heredoc_template : HEREDOC_TEMPLATE
Expand Down
14 changes: 14 additions & 0 deletions hcl2/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ def body(self, meta: Meta, args) -> BodyRule:

@v_args(meta=True)
def block(self, meta: Meta, args) -> BlockRule:
# _block_label is flattened, so a label may be a KeywordRule or a
# LiteralValueRule (HCL keywords are not reserved, so `in {}` is a
# legal block). Normalize them so labels are always identifiers.
args = [
IdentifierRule([NAME(arg.token.value)], meta)
if isinstance(arg, (KeywordRule, LiteralValueRule))
else arg
for arg in args
]
return BlockRule(args, meta)

@v_args(meta=True)
Expand Down Expand Up @@ -331,6 +340,11 @@ def object_elem(self, meta: Meta, args) -> ObjectElemRule:
@v_args(meta=True)
def object_elem_key(self, meta: Meta, args):
expr = args[0]
# A bare keyword key (`in = "header"`) arrives unwrapped, since
# `keyword` is its own alternative in the grammar rather than being
# reachable through `expression`. Treat it as an identifier key.
if isinstance(expr, KeywordRule):
return ObjectElemKeyRule([IdentifierRule([NAME(expr.token.value)], meta)], meta)
# Simple literals (identifier, string, int, float) wrapped in ExprTermRule
if isinstance(expr, ExprTermRule) and len(expr.children) == 5:
inner = expr.children[2] # position 2 in [None, None, inner, None, None]
Expand Down
32 changes: 32 additions & 0 deletions test/integration/hcl2_original/object_keyword_keys.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
resource "aws_api_gateway_rest_api" "example" {
body = jsonencode({
security_definitions = {
sigv4 = {
type = "apiKey"
name = "Authorization"
in = "header"
x-amazon-apigateway-authtype = "awsSigv4"
}
}
})
}

keywords_in_every_position = {
leading = 0
if = 1
in = 2
for = 3
for_each = 4
else = 5
endif = 6
endfor = 7
true = 8
false = 9
null = 10
trailing = 11
}

colon_separated = {
a : 0,
in : "header"
}
43 changes: 43 additions & 0 deletions test/integration/hcl2_original/resource_keyword_block.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
data "snowflake_schemas" "in" {
in {
database = "database"
}
}

resource "custom_provider_resource" "resource_name" {
if {
name = "if_block"
}
for {
name = "for_block"
}
for_each {
name = "for_each_block"
}
else {
name = "else_block"
}
endif {
name = "endif_block"
}
endfor {
name = "endfor_block"
}
true {
name = "true_block"
}
false {
name = "false_block"
}
null {
name = "null_block"
}
}

in "quoted_label" {
attribute = "value"
}

block in {
attribute = "value"
}
31 changes: 31 additions & 0 deletions test/integration/hcl2_reconstructed/object_keyword_keys.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
resource "aws_api_gateway_rest_api" "example" {
body = jsonencode({
security_definitions = {
sigv4 = {
type = "apiKey",
name = "Authorization",
in = "header",
x-amazon-apigateway-authtype = "awsSigv4"
}
}
})
}

keywords_in_every_position = {
leading = 0,
if = 1,
in = 2,
for = 3,
for_each = 4,
else = 5,
endif = 6,
endfor = 7,
true = 8,
false = 9,
null = 10,
trailing = 11,
}
colon_separated = {
a = 0,
in = "header",
}
62 changes: 62 additions & 0 deletions test/integration/hcl2_reconstructed/resource_keyword_block.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
data "snowflake_schemas" "in" {
in {
database = "database"
}
}


resource "custom_provider_resource" "resource_name" {
if {
name = "if_block"
}


for {
name = "for_block"
}


for_each {
name = "for_each_block"
}


else {
name = "else_block"
}


endif {
name = "endif_block"
}


endfor {
name = "endfor_block"
}


true {
name = "true_block"
}


false {
name = "false_block"
}


null {
name = "null_block"
}
}


in "quoted_label" {
attribute = "value"
}


block in {
attribute = "value"
}
30 changes: 30 additions & 0 deletions test/integration/json_reserialized/object_keyword_keys.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"resource": [
{
"\"aws_api_gateway_rest_api\"": {
"\"example\"": {
"body": "${jsonencode({security_definitions = {sigv4 = {type = \"apiKey\", name = \"Authorization\", in = \"header\", x-amazon-apigateway-authtype = \"awsSigv4\"}}})}",
"__is_block__": true
}
}
}
],
"keywords_in_every_position": {
"leading": 0,
"if": 1,
"in": 2,
"for": 3,
"for_each": 4,
"else": 5,
"endif": 6,
"endfor": 7,
"true": 8,
"false": 9,
"null": 10,
"trailing": 11
},
"colon_separated": {
"a": 0,
"in": "\"header\""
}
}
96 changes: 96 additions & 0 deletions test/integration/json_reserialized/resource_keyword_block.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{
"data": [
{
"\"snowflake_schemas\"": {
"\"in\"": {
"in": [
{
"database": "\"database\"",
"__is_block__": true
}
],
"__is_block__": true
}
}
}
],
"resource": [
{
"\"custom_provider_resource\"": {
"\"resource_name\"": {
"if": [
{
"name": "\"if_block\"",
"__is_block__": true
}
],
"for": [
{
"name": "\"for_block\"",
"__is_block__": true
}
],
"for_each": [
{
"name": "\"for_each_block\"",
"__is_block__": true
}
],
"else": [
{
"name": "\"else_block\"",
"__is_block__": true
}
],
"endif": [
{
"name": "\"endif_block\"",
"__is_block__": true
}
],
"endfor": [
{
"name": "\"endfor_block\"",
"__is_block__": true
}
],
"true": [
{
"name": "\"true_block\"",
"__is_block__": true
}
],
"false": [
{
"name": "\"false_block\"",
"__is_block__": true
}
],
"null": [
{
"name": "\"null_block\"",
"__is_block__": true
}
],
"__is_block__": true
}
}
}
],
"in": [
{
"\"quoted_label\"": {
"attribute": "\"value\"",
"__is_block__": true
}
}
],
"block": [
{
"in": {
"attribute": "\"value\"",
"__is_block__": true
}
}
]
}
30 changes: 30 additions & 0 deletions test/integration/json_serialized/object_keyword_keys.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"resource": [
{
"\"aws_api_gateway_rest_api\"": {
"\"example\"": {
"body": "${jsonencode({security_definitions = {sigv4 = {type = \"apiKey\", name = \"Authorization\", in = \"header\", x-amazon-apigateway-authtype = \"awsSigv4\"}}})}",
"__is_block__": true
}
}
}
],
"keywords_in_every_position": {
"leading": 0,
"if": 1,
"in": 2,
"for": 3,
"for_each": 4,
"else": 5,
"endif": 6,
"endfor": 7,
"true": 8,
"false": 9,
"null": 10,
"trailing": 11
},
"colon_separated": {
"a": 0,
"in": "\"header\""
}
}
Loading
Loading