Skip to content
Merged
934 changes: 470 additions & 464 deletions cli/hq.py

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion hcl2/query/attributes.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"""AttributeView facade."""

from typing import Any
from typing import Any, List, Optional

from hcl2.query._base import NodeView, register_view, view_for
from hcl2.rules.abstract import LarkElement
from hcl2.rules.base import AttributeRule
from hcl2.utils import SerializationOptions


@register_view(AttributeRule)
class AttributeView(NodeView):
"""View over an HCL2 attribute (AttributeRule)."""

def __init__(
self,
node: LarkElement,
adjacent_comments: Optional[List[dict]] = None,
):
super().__init__(node)
self._adjacent_comments = adjacent_comments

@property
def name(self) -> str:
"""Return the attribute name as a plain string."""
Expand All @@ -27,3 +37,15 @@ def value_node(self) -> "NodeView":
"""Return a view over the expression node."""
node: AttributeRule = self._node # type: ignore[assignment]
return view_for(node.expression)

def to_dict(self, options: Optional[SerializationOptions] = None) -> Any:
"""Serialize, merging adjacent comments from the parent body."""
result = super().to_dict(options=options)
if (
self._adjacent_comments
and options is not None
and options.with_comments
and isinstance(result, dict)
):
result["__comments__"] = self._adjacent_comments
return result
28 changes: 27 additions & 1 deletion hcl2/query/blocks.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""BlockView facade."""

from typing import List, Optional
from typing import Any, List, Optional

from hcl2.const import COMMENTS_KEY
from hcl2.query._base import NodeView, register_view
from hcl2.rules.abstract import LarkElement
from hcl2.rules.base import BlockRule
from hcl2.rules.literal_rules import IdentifierRule
from hcl2.rules.strings import StringRule
from hcl2.utils import SerializationOptions


def _label_to_str(label) -> str:
Expand All @@ -25,6 +28,14 @@ def _label_to_str(label) -> str:
class BlockView(NodeView):
"""View over an HCL2 block (BlockRule)."""

def __init__(
self,
node: LarkElement,
adjacent_comments: Optional[List[dict]] = None,
):
super().__init__(node)
self._adjacent_comments = adjacent_comments

@property
def block_type(self) -> str:
"""Return the block type (first label) as a plain string."""
Expand All @@ -50,6 +61,21 @@ def body(self) -> "NodeView":
node: BlockRule = self._node # type: ignore[assignment]
return BodyView(node.body)

def to_dict(self, options: Optional[SerializationOptions] = None) -> Any:
"""Serialize, merging adjacent comments from the parent body."""
result = super().to_dict(options=options)
if (
self._adjacent_comments
and options is not None
and options.with_comments
and isinstance(result, dict)
):
# Place adjacent comments at the outer level of the block dict,
# alongside the label keys — not drilled into the body dict.
existing = result.get(COMMENTS_KEY, [])
result[COMMENTS_KEY] = self._adjacent_comments + existing
return result

def blocks(
self, block_type: Optional[str] = None, *labels: str
) -> List["NodeView"]:
Expand Down
32 changes: 30 additions & 2 deletions hcl2/query/body.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@

from hcl2.query._base import NodeView, register_view
from hcl2.rules.base import AttributeRule, BlockRule, BodyRule, StartRule
from hcl2.rules.whitespace import NewLineOrCommentRule


def _collect_leading_comments(body: BodyRule, child_index: int) -> List[dict]:
"""Collect comments from NewLineOrCommentRule siblings preceding *child_index*.

Walks backward through ``body.children`` from ``child_index - 1``,
collecting comment dicts via ``to_list()``, stopping at the first
``BlockRule`` or ``AttributeRule`` (the previous semantic sibling) or
the start of the children list.
"""
chunks: List[List[dict]] = []
for i in range(child_index - 1, -1, -1):
sibling = body.children[i]
if isinstance(sibling, (BlockRule, AttributeRule)):
break
if isinstance(sibling, NewLineOrCommentRule):
comments = sibling.to_list()
if comments:
chunks.append(comments)
# Reverse node order (walked backward) but keep each node's comments in order
chunks.reverse()
result: List[dict] = []
for chunk in chunks:
result.extend(chunk)
return result


@register_view(StartRule)
Expand Down Expand Up @@ -63,7 +89,8 @@ def blocks(
for child in node.children:
if not isinstance(child, BlockRule):
continue
block_view = BlockView(child)
adjacent = _collect_leading_comments(node, child.index) or None
block_view = BlockView(child, adjacent_comments=adjacent)
if block_type is not None and block_view.block_type != block_type:
continue
if labels:
Expand All @@ -84,7 +111,8 @@ def attributes(self, name: Optional[str] = None) -> List["NodeView"]:
for child in node.children:
if not isinstance(child, AttributeRule):
continue
attr_view = AttributeView(child)
adjacent = _collect_leading_comments(node, child.index) or None
attr_view = AttributeView(child, adjacent_comments=adjacent)
if name is not None and attr_view.name != name:
continue
results.append(attr_view)
Expand Down
2 changes: 1 addition & 1 deletion hcl2/rules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ def serialize(
attribute_names.add(child.identifier.serialize(options))
result.update(child.serialize(options))
if options.with_comments:
# collect in-line comments from attribute assignments, expressions etc
inline_comments.extend(child.expression.inline_comments())
comments.extend(child.expression.absorbed_comments())

if isinstance(child, NewLineOrCommentRule) and options.with_comments:
child_comments = child.to_list()
Expand Down
32 changes: 32 additions & 0 deletions hcl2/rules/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,38 @@ def binary_term(self) -> BinaryTermRule:
"""Return the binary term (operator + right-hand operand)."""
return self._children[1]

@property
def _trailing_nl(self) -> Optional[NewLineOrCommentRule]:
"""Return the trailing new_line_or_comment child, if present."""
child = self._children[2]
if isinstance(child, NewLineOrCommentRule):
return child
return None

def inline_comments(self):
"""Collect inline comments, excluding absorbed body-level comments."""
trailing = self._trailing_nl
result = []
for child in self._children:
if isinstance(child, NewLineOrCommentRule):
# Trailing NL_OR_COMMENT with a leading newline contains
# body-level comments absorbed by the grammar, not inline ones.
if child is trailing and not child.is_inline:
continue
comments = child.to_list()
if comments is not None:
result.extend(comments)
elif isinstance(child, InlineCommentMixIn):
result.extend(child.inline_comments())
return result

def absorbed_comments(self):
"""Return body-level comments absorbed into the trailing NL_OR_COMMENT."""
trailing = self._trailing_nl
if trailing is not None and not trailing.is_inline:
return trailing.to_list() or []
return []

def serialize(
self, options=SerializationOptions(), context=SerializationContext()
) -> Any:
Expand Down
20 changes: 19 additions & 1 deletion hcl2/rules/whitespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ def serialize(
self, options=SerializationOptions(), context=SerializationContext()
) -> Any:
"""Serialize to the raw comment/newline string."""
return self.token.serialize()
return "".join(child.serialize() for child in self._children)

@property
def is_inline(self) -> bool:
"""True if this comment is on the same line as preceding code.

A raw string starting with ``\\n`` means the comment sits on its own
line (standalone). One starting with ``#``, ``//``, or ``/*`` is
inline — it follows code on the same line.
"""
return not self.serialize().startswith("\n")

def to_list(
self, options: SerializationOptions = SerializationOptions()
Expand Down Expand Up @@ -91,3 +101,11 @@ def inline_comments(self):
result.extend(child.inline_comments())

return result

def absorbed_comments(self):
"""Return body-level comments absorbed by grammar into this expression.

Default: empty. ``BinaryOpRule`` overrides this because its trailing
``new_line_or_comment?`` can swallow the next body-level comment.
"""
return []
57 changes: 57 additions & 0 deletions test/integration/specialized/comments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"resource": [
{
"\"aws_instance\"": {
"\"web\"": {
"ami": "\"abc-123\"",
"instance_type": "\"t2.micro\"",
"count": "${1 + 2}",
"tags": {
"Name": "\"web\"",
"Env": "\"prod\""
},
"enabled": "true",
"nested": [
{
"key": "\"value\"",
"__comments__": [
{
"value": "comment inside nested block"
}
],
"__is_block__": true
}
],
"__comments__": [
{
"value": "standalone comment inside block"
},
{
"value": "hash standalone comment"
},
{
"value": "absorbed standalone after binary_op"
},
{
"value": "multi-line\n block comment"
}
],
"__inline_comments__": [
{
"value": "comment inside object"
},
{
"value": "inline after value"
}
],
"__is_block__": true
}
}
}
],
"__comments__": [
{
"value": "top-level standalone comment"
}
]
}
28 changes: 28 additions & 0 deletions test/integration/specialized/comments.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// top-level standalone comment
resource "aws_instance" "web" {
ami = "abc-123"

// standalone comment inside block
instance_type = "t2.micro"

# hash standalone comment
count = 1 + 2
# absorbed standalone after binary_op

tags = {
Name = "web"
# comment inside object
Env = "prod" # inline after value
}

/*
multi-line
block comment
*/
enabled = true

nested {
// comment inside nested block
key = "value"
}
}
Loading
Loading