Skip to content

Commit b8f1dfe

Browse files
Add .[] as jq-compatible alias for [*] and document jq interop (#283)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 51b3861 commit b8f1dfe

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

docs/04_hq.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type_filter := IDENTIFIER
4444
- `name` matches block types and attribute names
4545
- `type:name` matches only nodes of the given type (e.g. `function_call:length`)
4646
- `name~` skips all block labels, going straight to the body (see below)
47-
- `[*]` selects all matches at that level
47+
- `[*]` or `.[]` selects all matches at that level (`.[]` is a jq-compatible alias)
4848
- `[N]` selects the Nth match (zero-based)
4949
- `[select(PRED)]` filters matches using a predicate (see below)
5050

@@ -612,6 +612,53 @@ hq 'resource[*] | {type: .block_type, name: .name_labels}' main.tf --json
612612
hq 'variable[select(.default)]::name_labels[0] + " = " + str(_.attribute("default").value)' variables.tf --value
613613
```
614614

615+
## Piping to jq
616+
617+
hq handles structural HCL navigation (blocks, labels, type filters, predicates). For data transforms on the results, pipe `--json` or `--ndjson` output to jq:
618+
619+
```sh
620+
# Defaults — fill missing values
621+
hq 'resource~[*] | {name: .name_labels, tags}' main.tf --json | jq '.tags // "none"'
622+
623+
# Reshaping — extract specific fields into arrays
624+
hq 'resource[*]' main.tf --json | jq '[.block_type, .name]'
625+
626+
# Mapping — transform each result
627+
hq 'module~[*] | .source' dir/ --ndjson | jq -r 'ascii_downcase'
628+
629+
# Filtering on JSON values
630+
hq 'resource~[*]' main.tf --ndjson | jq 'select(.count > 3)'
631+
632+
# Aggregation — group or sort across results
633+
hq 'resource[*]' dir/ --ndjson | jq -s 'group_by(.block_type)'
634+
```
635+
636+
**Mental model:** hq navigates HCL structure (block types, labels, attributes, nesting). Once you have `--json` output, you're in jq's world — use jq for arithmetic, string transforms, reshaping, defaults, and aggregation.
637+
638+
## Coming from jq
639+
640+
| jq | hq equivalent | Notes |
641+
|---|---|---|
642+
| `.key` | `.key` | Same syntax |
643+
| `.[]` | `.[]` or `[*]` | `.[]` is an alias for `[*]` |
644+
| `.[N]` | `[N]` | Zero-based index |
645+
| `select(.x == "y")` | `[select(.x == "y")]` | Bracket or pipe syntax |
646+
| `keys` | `\| keys` | Pipe stage |
647+
| `length` | `\| length` | Pipe stage |
648+
| `has("key")` | `[select(has("key"))]` | Inside select predicates |
649+
| `contains("str")` | `[select(.field \| contains("str"))]` | Inside select predicates |
650+
| `test("regex")` | `[select(.field \| test("regex"))]` | Inside select predicates |
651+
| `{a, b}` | `{a, b}` | Object construction |
652+
| `{newkey: .old}` | `{newkey: .old}` | Renamed keys |
653+
| `map(.f)` || `--json \| jq 'map(.f)'` |
654+
| `.x // "default"` || `--json \| jq '.x // "default"'` |
655+
| `[.x, .y]` || `--json \| jq '[.x, .y]'` |
656+
| `group_by(.f)` || `--ndjson \| jq -s 'group_by(.f)'` |
657+
| `sort_by(.f)` || `--ndjson \| jq -s 'sort_by(.f)'` |
658+
| `if-then-else` || `--json \| jq 'if ...'` or hybrid (`::`) mode |
659+
660+
Features unique to hq (no jq equivalent): block type navigation (`resource.aws_instance`), label traversal, skip labels (`~`), type qualifiers (`function_call:name`), recursive descent (`..`), `--describe` / `--schema` introspection.
661+
615662
## See Also
616663

617664
- [hq Examples](05_hq_examples.md) — validated real-world queries by use case

hcl2/query/path.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def parse_path(path_str: str) -> List[PathSegment]: # pylint: disable=too-many-
4141
if not path_str or not path_str.strip():
4242
raise QuerySyntaxError("Empty path")
4343

44+
# jq compat: .[] is an alias for [*]
45+
path_str = path_str.replace(".[]", "[*]")
46+
4447
segments: List[PathSegment] = []
4548
parts = _split_path(path_str)
4649

test/unit/query/test_path.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,25 @@ def test_escaped_quote_in_string(self):
189189
segments = parse_path('*[select(.name == "a\\"b")]')
190190
self.assertEqual(len(segments), 1)
191191
self.assertIsNotNone(segments[0].predicate)
192+
193+
# jq compat: .[] as alias for [*]
194+
195+
def test_jq_iterate_alias(self):
196+
"""resource.[] is equivalent to resource[*]"""
197+
segments = parse_path("resource.[]")
198+
expected = parse_path("resource[*]")
199+
self.assertEqual(segments, expected)
200+
201+
def test_jq_iterate_alias_chained(self):
202+
"""a.b.[] normalizes to a.b[*]"""
203+
segments = parse_path("a.b.[]")
204+
self.assertEqual(len(segments), 2)
205+
self.assertEqual(segments[0].name, "a")
206+
self.assertEqual(segments[1].name, "b")
207+
self.assertTrue(segments[1].select_all)
208+
209+
def test_jq_iterate_alias_with_continuation(self):
210+
"""resource.[].tags normalizes to resource[*].tags"""
211+
segments = parse_path("resource.[].tags")
212+
expected = parse_path("resource[*].tags")
213+
self.assertEqual(segments, expected)

0 commit comments

Comments
 (0)