Skip to content
Merged
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
6 changes: 0 additions & 6 deletions cli/hq.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,12 +667,6 @@ def main(): # pylint: disable=too-many-branches,too-many-statements
serialization_options = None
if args.with_comments:
serialization_options = SerializationOptions(with_comments=True)
print(
"Warning: --with-comments only includes comments for top-level body "
"queries (e.g. 'resource[*]' on a single file). Comments adjacent to "
"individual blocks are not yet captured by sub-block queries.",
file=sys.stderr,
)

# --schema: dump schema and exit
if args.schema:
Expand Down
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