Skip to content

Commit 68ad804

Browse files
Add adjacent comment support to hq BlockView and AttributeView queries (#282)
* Fix comment serialization: multi-token NL_OR_COMMENT and classification (#134) NewLineOrCommentRule.serialize() now concatenates all child tokens instead of only reading the first, fixing silently dropped comments in multi-token new_line_or_comment nodes. BinaryOpRule distinguishes its trailing new_line_or_comment (which can absorb body-level comments from the grammar) from expression-internal comments, routing absorbed standalone comments to __comments__ and keeping expression-internal ones in __inline_comments__. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add adjacent comment support to hq BlockView and AttributeView queries Collect leading comments from NewLineOrCommentRule siblings in BodyView and attach them to BlockView/AttributeView via to_dict(with_comments=True). Remove the now-inaccurate --with-comments warning from hq CLI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e9be26b commit 68ad804

7 files changed

Lines changed: 242 additions & 11 deletions

File tree

cli/hq.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -667,12 +667,6 @@ def main(): # pylint: disable=too-many-branches,too-many-statements
667667
serialization_options = None
668668
if args.with_comments:
669669
serialization_options = SerializationOptions(with_comments=True)
670-
print(
671-
"Warning: --with-comments only includes comments for top-level body "
672-
"queries (e.g. 'resource[*]' on a single file). Comments adjacent to "
673-
"individual blocks are not yet captured by sub-block queries.",
674-
file=sys.stderr,
675-
)
676670

677671
# --schema: dump schema and exit
678672
if args.schema:

hcl2/query/attributes.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
"""AttributeView facade."""
22

3-
from typing import Any
3+
from typing import Any, List, Optional
44

55
from hcl2.query._base import NodeView, register_view, view_for
6+
from hcl2.rules.abstract import LarkElement
67
from hcl2.rules.base import AttributeRule
8+
from hcl2.utils import SerializationOptions
79

810

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

15+
def __init__(
16+
self,
17+
node: LarkElement,
18+
adjacent_comments: Optional[List[dict]] = None,
19+
):
20+
super().__init__(node)
21+
self._adjacent_comments = adjacent_comments
22+
1323
@property
1424
def name(self) -> str:
1525
"""Return the attribute name as a plain string."""
@@ -27,3 +37,15 @@ def value_node(self) -> "NodeView":
2737
"""Return a view over the expression node."""
2838
node: AttributeRule = self._node # type: ignore[assignment]
2939
return view_for(node.expression)
40+
41+
def to_dict(self, options: Optional[SerializationOptions] = None) -> Any:
42+
"""Serialize, merging adjacent comments from the parent body."""
43+
result = super().to_dict(options=options)
44+
if (
45+
self._adjacent_comments
46+
and options is not None
47+
and options.with_comments
48+
and isinstance(result, dict)
49+
):
50+
result["__comments__"] = self._adjacent_comments
51+
return result

hcl2/query/blocks.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""BlockView facade."""
22

3-
from typing import List, Optional
3+
from typing import Any, List, Optional
44

5+
from hcl2.const import COMMENTS_KEY
56
from hcl2.query._base import NodeView, register_view
7+
from hcl2.rules.abstract import LarkElement
68
from hcl2.rules.base import BlockRule
79
from hcl2.rules.literal_rules import IdentifierRule
810
from hcl2.rules.strings import StringRule
11+
from hcl2.utils import SerializationOptions
912

1013

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

31+
def __init__(
32+
self,
33+
node: LarkElement,
34+
adjacent_comments: Optional[List[dict]] = None,
35+
):
36+
super().__init__(node)
37+
self._adjacent_comments = adjacent_comments
38+
2839
@property
2940
def block_type(self) -> str:
3041
"""Return the block type (first label) as a plain string."""
@@ -50,6 +61,21 @@ def body(self) -> "NodeView":
5061
node: BlockRule = self._node # type: ignore[assignment]
5162
return BodyView(node.body)
5263

64+
def to_dict(self, options: Optional[SerializationOptions] = None) -> Any:
65+
"""Serialize, merging adjacent comments from the parent body."""
66+
result = super().to_dict(options=options)
67+
if (
68+
self._adjacent_comments
69+
and options is not None
70+
and options.with_comments
71+
and isinstance(result, dict)
72+
):
73+
# Place adjacent comments at the outer level of the block dict,
74+
# alongside the label keys — not drilled into the body dict.
75+
existing = result.get(COMMENTS_KEY, [])
76+
result[COMMENTS_KEY] = self._adjacent_comments + existing
77+
return result
78+
5379
def blocks(
5480
self, block_type: Optional[str] = None, *labels: str
5581
) -> List["NodeView"]:

hcl2/query/body.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@
44

55
from hcl2.query._base import NodeView, register_view
66
from hcl2.rules.base import AttributeRule, BlockRule, BodyRule, StartRule
7+
from hcl2.rules.whitespace import NewLineOrCommentRule
8+
9+
10+
def _collect_leading_comments(body: BodyRule, child_index: int) -> List[dict]:
11+
"""Collect comments from NewLineOrCommentRule siblings preceding *child_index*.
12+
13+
Walks backward through ``body.children`` from ``child_index - 1``,
14+
collecting comment dicts via ``to_list()``, stopping at the first
15+
``BlockRule`` or ``AttributeRule`` (the previous semantic sibling) or
16+
the start of the children list.
17+
"""
18+
chunks: List[List[dict]] = []
19+
for i in range(child_index - 1, -1, -1):
20+
sibling = body.children[i]
21+
if isinstance(sibling, (BlockRule, AttributeRule)):
22+
break
23+
if isinstance(sibling, NewLineOrCommentRule):
24+
comments = sibling.to_list()
25+
if comments:
26+
chunks.append(comments)
27+
# Reverse node order (walked backward) but keep each node's comments in order
28+
chunks.reverse()
29+
result: List[dict] = []
30+
for chunk in chunks:
31+
result.extend(chunk)
32+
return result
733

834

935
@register_view(StartRule)
@@ -63,7 +89,8 @@ def blocks(
6389
for child in node.children:
6490
if not isinstance(child, BlockRule):
6591
continue
66-
block_view = BlockView(child)
92+
adjacent = _collect_leading_comments(node, child.index) or None
93+
block_view = BlockView(child, adjacent_comments=adjacent)
6794
if block_type is not None and block_view.block_type != block_type:
6895
continue
6996
if labels:
@@ -84,7 +111,8 @@ def attributes(self, name: Optional[str] = None) -> List["NodeView"]:
84111
for child in node.children:
85112
if not isinstance(child, AttributeRule):
86113
continue
87-
attr_view = AttributeView(child)
114+
adjacent = _collect_leading_comments(node, child.index) or None
115+
attr_view = AttributeView(child, adjacent_comments=adjacent)
88116
if name is not None and attr_view.name != name:
89117
continue
90118
results.append(attr_view)

test/unit/query/test_attributes.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import TestCase
33

44
from hcl2.query.body import DocumentView
5+
from hcl2.utils import SerializationOptions
56

67

78
class TestAttributeView(TestCase):
@@ -38,3 +39,27 @@ def test_to_dict(self):
3839
attr = doc.attribute("x")
3940
result = attr.to_dict()
4041
self.assertEqual(result, {"x": 42})
42+
43+
44+
class TestAttributeViewAdjacentComments(TestCase):
45+
"""Tests for adjacent comment merging in AttributeView.to_dict()."""
46+
47+
_OPTS = SerializationOptions(with_comments=True)
48+
49+
def test_adjacent_comment(self):
50+
doc = DocumentView.parse("# about x\nx = 1\n")
51+
attr = doc.body.attributes("x")[0]
52+
result = attr.to_dict(options=self._OPTS)
53+
self.assertEqual(result["__comments__"], [{"value": "about x"}])
54+
55+
def test_no_comments_without_option(self):
56+
doc = DocumentView.parse("# about x\nx = 1\n")
57+
attr = doc.body.attributes("x")[0]
58+
result = attr.to_dict()
59+
self.assertNotIn("__comments__", result)
60+
61+
def test_no_adjacent_comments(self):
62+
doc = DocumentView.parse("x = 1\n")
63+
attr = doc.body.attributes("x")[0]
64+
result = attr.to_dict(options=self._OPTS)
65+
self.assertNotIn("__comments__", result)

test/unit/query/test_blocks.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import TestCase
33

44
from hcl2.query.body import DocumentView
5+
from hcl2.utils import SerializationOptions
56

67

78
class TestBlockView(TestCase):
@@ -65,3 +66,59 @@ def test_attributes_filtered(self):
6566
attrs = block.attributes("a")
6667
self.assertEqual(len(attrs), 1)
6768
self.assertEqual(attrs[0].name, "a")
69+
70+
71+
class TestBlockViewAdjacentComments(TestCase):
72+
"""Tests for adjacent comment merging in BlockView.to_dict()."""
73+
74+
_OPTS = SerializationOptions(with_comments=True)
75+
76+
def test_adjacent_comments_at_outer_level(self):
77+
doc = DocumentView.parse(
78+
'# about resource\nresource "type" "name" {\n x = 1\n}\n'
79+
)
80+
block = doc.blocks("resource")[0]
81+
result = block.to_dict(options=self._OPTS)
82+
# Adjacent comments go at outer level, alongside the label key
83+
self.assertEqual(result["__comments__"], [{"value": "about resource"}])
84+
self.assertNotIn("__comments__", result['"type"']['"name"'])
85+
86+
def test_adjacent_separate_from_inner_comments(self):
87+
doc = DocumentView.parse(
88+
'# adjacent\nresource "type" "name" {\n # inner\n x = 1\n}\n'
89+
)
90+
block = doc.blocks("resource")[0]
91+
result = block.to_dict(options=self._OPTS)
92+
# Adjacent at outer level
93+
self.assertEqual(result["__comments__"], [{"value": "adjacent"}])
94+
# Inner stays in body dict under __comments__
95+
body = result['"type"']['"name"']
96+
self.assertEqual(body["__comments__"], [{"value": "inner"}])
97+
98+
def test_no_comments_without_option(self):
99+
doc = DocumentView.parse('# about\nresource "type" "name" {}\n')
100+
block = doc.blocks("resource")[0]
101+
result = block.to_dict()
102+
self.assertNotIn("__comments__", result)
103+
104+
def test_no_labels_block_merges_adjacent_and_inner(self):
105+
doc = DocumentView.parse("# about locals\nlocals {\n # inner\n x = 1\n}\n")
106+
block = doc.blocks("locals")[0]
107+
result = block.to_dict(options=self._OPTS)
108+
# No name labels -> body dict IS the top level, so they merge
109+
self.assertEqual(
110+
result["__comments__"],
111+
[{"value": "about locals"}, {"value": "inner"}],
112+
)
113+
114+
def test_single_label_block(self):
115+
doc = DocumentView.parse('# about var\nvariable "name" {\n default = 1\n}\n')
116+
block = doc.blocks("variable")[0]
117+
result = block.to_dict(options=self._OPTS)
118+
self.assertEqual(result["__comments__"], [{"value": "about var"}])
119+
120+
def test_no_adjacent_comments(self):
121+
doc = DocumentView.parse('resource "type" "name" {\n x = 1\n}\n')
122+
block = doc.blocks("resource")[0]
123+
result = block.to_dict(options=self._OPTS)
124+
self.assertNotIn("__comments__", result)

test/unit/query/test_body.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# pylint: disable=C0103,C0114,C0115,C0116
22
from unittest import TestCase
33

4-
from hcl2.query.body import DocumentView, BodyView
4+
from hcl2.query.body import DocumentView, BodyView, _collect_leading_comments
55

66

77
class TestDocumentView(TestCase):
@@ -94,3 +94,82 @@ def test_attributes(self):
9494
body = doc.body
9595
attrs = body.attributes()
9696
self.assertEqual(len(attrs), 2)
97+
98+
99+
class TestCollectLeadingComments(TestCase):
100+
"""Tests for _collect_leading_comments helper."""
101+
102+
def _body(self, hcl: str):
103+
doc = DocumentView.parse(hcl)
104+
return doc.body.raw # BodyRule
105+
106+
def test_comment_before_block(self):
107+
body = self._body('# about resource\nresource "a" "b" {}\n')
108+
# Find the BlockRule child
109+
from hcl2.rules.base import BlockRule
110+
111+
for child in body.children:
112+
if isinstance(child, BlockRule):
113+
result = _collect_leading_comments(body, child.index)
114+
self.assertEqual(result, [{"value": "about resource"}])
115+
return
116+
self.fail("No BlockRule found")
117+
118+
def test_comment_before_attribute(self):
119+
body = self._body("# about x\nx = 1\n")
120+
from hcl2.rules.base import AttributeRule
121+
122+
for child in body.children:
123+
if isinstance(child, AttributeRule):
124+
result = _collect_leading_comments(body, child.index)
125+
self.assertEqual(result, [{"value": "about x"}])
126+
return
127+
self.fail("No AttributeRule found")
128+
129+
def test_stops_at_previous_semantic_sibling(self):
130+
body = self._body("x = 1\n# about y\ny = 2\n")
131+
from hcl2.rules.base import AttributeRule
132+
133+
attrs = [c for c in body.children if isinstance(c, AttributeRule)]
134+
# First attribute (x) — comment before it is empty (only bare newlines)
135+
result_x = _collect_leading_comments(body, attrs[0].index)
136+
self.assertEqual(result_x, [])
137+
# Second attribute (y) — has "about y" above it
138+
result_y = _collect_leading_comments(body, attrs[1].index)
139+
self.assertEqual(result_y, [{"value": "about y"}])
140+
141+
def test_bare_newlines_not_collected(self):
142+
body = self._body("\n\nx = 1\n")
143+
from hcl2.rules.base import AttributeRule
144+
145+
for child in body.children:
146+
if isinstance(child, AttributeRule):
147+
result = _collect_leading_comments(body, child.index)
148+
self.assertEqual(result, [])
149+
return
150+
self.fail("No AttributeRule found")
151+
152+
def test_multiple_comments_in_order(self):
153+
body = self._body("# first\n# second\nx = 1\n")
154+
from hcl2.rules.base import AttributeRule
155+
156+
for child in body.children:
157+
if isinstance(child, AttributeRule):
158+
result = _collect_leading_comments(body, child.index)
159+
self.assertEqual(result, [{"value": "first"}, {"value": "second"}])
160+
return
161+
self.fail("No AttributeRule found")
162+
163+
def test_comment_between_two_blocks(self):
164+
body = self._body('resource "a" "b" {}\n# about variable\nvariable "c" {}\n')
165+
from hcl2.rules.base import BlockRule
166+
167+
blocks = [c for c in body.children if isinstance(c, BlockRule)]
168+
self.assertEqual(len(blocks), 2)
169+
# First block: no leading comments
170+
self.assertEqual(_collect_leading_comments(body, blocks[0].index), [])
171+
# Second block: "about variable"
172+
self.assertEqual(
173+
_collect_leading_comments(body, blocks[1].index),
174+
[{"value": "about variable"}],
175+
)

0 commit comments

Comments
 (0)