Skip to content
Draft
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
25 changes: 25 additions & 0 deletions hcl2/deserializer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Deserialize Python dicts (or JSON) into LarkElement trees."""

import json
import re
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -189,6 +190,13 @@ def _deserialize_identifier(self, value: str) -> IdentifierRule:
return IdentifierRule([NAME(value)])

def _deserialize_string(self, value: str) -> StringRule:
# If the string contains template directives, delegate to parser
inner = value[1:-1] if value.startswith('"') and value.endswith('"') else value
# Check for unescaped %{ (i.e. %{ not preceded by another %)
stripped = inner.replace("%%{", "")
if "%{" in stripped:
return self._deserialize_string_via_parser(value)

result = []
# split string into individual parts based on lark grammar
# e.g. 'aaa$${bbb}ccc${"ddd-${eee}"}' -> ['aaa', '$${bbb}', 'ccc', '${"ddd-${eee}"}']
Expand All @@ -210,6 +218,23 @@ def _deserialize_string(self, value: str) -> StringRule:

return StringRule([DBLQUOTE(), *result, DBLQUOTE()])

def _deserialize_string_via_parser(self, value: str) -> StringRule:
"""Deserialize a string containing template directives by parsing it."""
# Ensure the value is quoted
if not (value.startswith('"') and value.endswith('"')):
value = f'"{value}"'
snippet = f"temp = {value}"
parsed_tree = _get_parser().parse(snippet)
rules_tree = self._transformer.transform(parsed_tree)
# Extract the string from: start -> body -> attribute -> expression -> string
expr = rules_tree.body.children[0].expression
# The expression is an ExprTermRule wrapping a StringRule
for child in expr.children:
if isinstance(child, StringRule):
return child
# Fallback: shouldn't happen, but return as-is
return expr # type: ignore[return-value]

def _deserialize_string_part(self, value: str) -> StringPartRule:
if value.startswith("$${") and value.endswith("}"):
return StringPartRule([ESCAPED_INTERPOLATION(value)])
Expand Down
31 changes: 28 additions & 3 deletions hcl2/hcl2.lark
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ IF : "if"
IN : "in"
FOR : "for"
FOR_EACH : "for_each"
ELSE : "else"
ENDIF : "endif"
ENDFOR : "endfor"


// Literals
NAME : /[a-zA-Z_][a-zA-Z0-9_-]*/
ESCAPED_INTERPOLATION.2: /\$\$\{[^}]*\}/
STRING_CHARS.1: /(?:(?!\$\$\{)(?!\$\{)[^"\\]|\\.|(?:\$(?!\$?\{)))+/
ESCAPED_DIRECTIVE.2: /%%\{[^}]*\}/
STRING_CHARS.1: /(?:(?!\$\$\{)(?!\$\{)(?!%%\{)(?!%\{)[^"\\]|\\.|(?:\$(?!\$?\{))|(?:%(?!%?\{)))+/
DECIMAL : "0".."9"
NEGATIVE_DECIMAL : "-" DECIMAL
EXP_MARK : ("e" | "E") ("+" | "-")? DECIMAL+
Expand Down Expand Up @@ -51,11 +55,16 @@ COMMA : ","
DOT : "."
EQ : /[ \t]*=(?!=|>)/
COLON : /[ \t]*:(?!:)/
DBLQUOTE : "\""
DBLQUOTE : /\\?"/
TEMPLATE_STRING.3 : /\\\\"(?:[^"\\\\]|\\\\.)*\\\\"/

// Interpolation
INTERP_START : "${"

// Template Directives
DIRECTIVE_START : "%{"
STRIP_MARKER : "~"

// Splat Operators
ATTR_SPLAT : ".*"
FULL_SPLAT_START : "[*]"
Expand Down Expand Up @@ -90,19 +99,32 @@ new_line_or_comment: ( NL_OR_COMMENT )+

// Basic literals and identifiers
identifier : NAME
keyword: IN | FOR | IF | FOR_EACH
keyword: IN | FOR | IF | FOR_EACH | ELSE | ENDIF | ENDFOR
int_lit: INT_LITERAL
float_lit: FLOAT_LITERAL
string: DBLQUOTE string_part* DBLQUOTE
string_part: STRING_CHARS
| ESCAPED_INTERPOLATION
| ESCAPED_DIRECTIVE
| interpolation
| template_if_start
| template_else
| template_endif
| template_for_start
| template_endfor

// Expressions
?expression : or_expr QMARK new_line_or_comment? expression new_line_or_comment? COLON new_line_or_comment? expression -> conditional
| or_expr
interpolation: INTERP_START expression RBRACE

// Template directives (flat rules — transformer assembles if/for structure)
template_if_start: DIRECTIVE_START STRIP_MARKER? IF expression STRIP_MARKER? RBRACE
template_else: DIRECTIVE_START STRIP_MARKER? ELSE STRIP_MARKER? RBRACE
template_endif: DIRECTIVE_START STRIP_MARKER? ENDIF STRIP_MARKER? RBRACE
template_for_start: DIRECTIVE_START STRIP_MARKER? FOR identifier (COMMA identifier)? IN expression STRIP_MARKER? RBRACE
template_endfor: DIRECTIVE_START STRIP_MARKER? ENDFOR STRIP_MARKER? RBRACE

// Operator precedence ladder (lowest to highest)
// Each level uses left recursion for left-associativity.
// Rule aliases (-> binary_op, -> binary_term, -> binary_operator) maintain
Expand Down Expand Up @@ -160,6 +182,7 @@ expr_term : LPAR new_line_or_comment? expression new_line_or_comment? RPAR
| float_lit
| int_lit
| string
| template_string
| tuple
| object
| identifier
Expand All @@ -173,6 +196,8 @@ expr_term : LPAR new_line_or_comment? expression new_line_or_comment? RPAR
| for_tuple_expr
| for_object_expr

template_string : TEMPLATE_STRING

// Collections
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
Expand Down
54 changes: 54 additions & 0 deletions hcl2/reconstructor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
"""Reconstruct HCL2 text from a Lark Tree AST."""

from typing import List, Optional, Union

from lark import Tree, Token
from hcl2.rules import tokens
from hcl2.rules.base import BlockRule
from hcl2.rules.containers import ObjectElemRule
from hcl2.rules.directives import (
TemplateIfRule,
TemplateForRule,
TemplateIfStartRule,
TemplateElseRule,
TemplateEndifRule,
TemplateForStartRule,
TemplateEndforRule,
)
from hcl2.rules.for_expressions import ForIntroRule, ForTupleExprRule, ForObjectExprRule
from hcl2.rules.literal_rules import IdentifierRule
from hcl2.rules.strings import StringRule
Expand Down Expand Up @@ -100,6 +110,50 @@ def _should_add_space_before(
return False
return True

# Template directive spacing: %{~ keyword ~} patterns
_directive_rules = (
TemplateIfStartRule.lark_name(),
TemplateElseRule.lark_name(),
TemplateEndifRule.lark_name(),
TemplateForStartRule.lark_name(),
TemplateEndforRule.lark_name(),
TemplateIfRule.lark_name(),
TemplateForRule.lark_name(),
)
if parent_rule_name in _directive_rules:
# Space after DIRECTIVE_START (before keyword or strip marker)
if self._last_token_name == tokens.DIRECTIVE_START.lark_name():
# No space before strip marker
if token_type == tokens.STRIP_MARKER.lark_name():
return False
return True
# Space after STRIP_MARKER (before keyword)
if self._last_token_name == tokens.STRIP_MARKER.lark_name():
# After strip marker: space before keyword, no space before RBRACE
if token_type == tokens.RBRACE.lark_name():
return False
return True
# Space after keywords
if self._last_token_name in [
tokens.FOR.lark_name(),
tokens.IN.lark_name(),
tokens.IF.lark_name(),
]:
return True
# Space before IN keyword (after identifier)
if token_type == tokens.IN.lark_name():
return True
# Space before STRIP_MARKER (before closing })
if token_type == tokens.STRIP_MARKER.lark_name():
return True
# Space before RBRACE (closing directive, no strip marker)
if token_type == tokens.RBRACE.lark_name():
return True
# Space after COMMA in for directives
if self._last_token_name == tokens.COMMA.lark_name():
return True
return False

if token_type in [
tokens.FOR.lark_name(),
tokens.IN.lark_name(),
Expand Down
Loading
Loading