Skip to content

feat: a bare figure fence highlights as a composite figure - #197

Merged
dereuromark merged 2 commits into
mainfrom
feat/composite-figures-composite-sat-6436a4ec
Aug 15, 2026
Merged

feat: a bare figure fence highlights as a composite figure#197
dereuromark merged 2 commits into
mainfrom
feat/composite-figures-composite-sat-6436a4ec

Conversation

@dereuromark

Copy link
Copy Markdown
Contributor

Carries the composite-figures vocabulary (PART 9 §4c, landed as markup-carve/carve#1215, design markup-carve/carve#1122) into the queries this repo ships.

The grammar needed nothing, and that is the finding

Measured before writing any rule. The parser already tells the two openers apart by which FIELDS each carries:

Input:

::: figure
![one](a.png)
^ (a) One
:::
^ Figure #: Group caption

Tree:

(document
  (div
    (div_marker_begin)
    class: (class_name)
    content: (content
      (paragraph (inline_image ...))
      (caption (caption_marker) content: (caption_content)))
    (div_marker_end))
  (caption (caption_marker) content: (caption_content)))

Input:

::: figure "A titled figure div"
x
:::

Tree:

(document
  (div
    (div_marker_begin)
    class: (class_name)
    title: (div_title)
    content: (content (paragraph))
    (div_marker_end)))

A bare opener carries neither title: nor label:; a titled one does. And the ^ line after the closing fence already parses as a SIBLING of the container, not inside it, which is exactly where §4c puts the group caption - §4's sixth host. So a reserved kind word wants a reserved CAPTURE, not a new node. Three parse shapes are pinned in test/corpus/carve.txt so a later grammar change cannot quietly take the distinction away.

What changed

File Change
queries/highlights.scm a bare ::: figure opener's class_name captures @type.builtin; four patterns in total, one for the group and three restoring @type inside it
test/corpus/carve.txt three parse tests: the bare group with its caption after the closer, the titled and labelled openers, the nested pair
scripts/highlight-captures.mjs new, resolves what an editor would actually paint
.github/workflows/ci.yml, package.json run it

!title !label is the whole distinction and is why this is expressible as a query at all. A titled or labelled opener matches nothing new and keeps @type.

GROUPS DO NOT NEST is three more patterns at a higher priority, restoring @type on a bare opener inside a group. A tree-sitter query has no transitive closure, so "any descendant" cannot be written; what can is a wildcard chain rooted at the group, one per intervening level. Three levels covers every shape the language produces - the direct child, div/block_quote > content > div, and list > list_item > list_item_content > div. The residual, a bare opener more than three levels down, is stated at the rule.

A check this repo did not have

The queries are the other half of what this package ships - vim-carve, helix-carve and zed-carve pin this repo and load these files - and nothing here read them. A capture naming a node that no longer exists, or two patterns claiming one node with nobody deciding which wins, left every check green.

scripts/highlight-captures.mjs resolves what a consumer resolves: highest (#set! priority N) wins, later patterns break a tie, 100 by default. Reporting every match instead - which is what tree-sitter query prints - would call the nested cases green while the editor painted the generic colour over them.

Two things it has to get right, both found by watching it fail first: spell, nospell, conceal and none are not colours and land on the same nodes the colour patterns do; and #offset! is a directive the node binding refuses to build a query with, so it is stripped rather than checking a hand-copied subset of the file, which would have passed while the real file was broken.

Downstream

vim-carve and helix-carve carry their own copies of these queries and pin this repo, so both need a pin bump plus the same two-file query change once this lands. zed-carve likewise. Those are separate PRs.

PART 9 section 4c reserves the kind word `figure` among the `:::` types: a
BARE opener - the fence, its separator, the word, and nothing else - is ONE
figure of ordered panels, not an admonition.

THE GRAMMAR NEEDED NOTHING, which is the finding rather than the shortcut.
Measured before writing any rule: the parser already distinguishes the two
openers by which FIELDS they carry - a bare one has neither `title:` nor
`label:` - and it already places the `^ ` line after a closing fence as a
SIBLING of the container rather than inside it, which is exactly where the
clause puts the group caption. So a reserved kind word wants a reserved
CAPTURE, not a new node, and the three parse shapes are pinned in
test/corpus/carve.txt so a future grammar change cannot quietly take that
distinction away.

queries/highlights.scm gets it. `!title !label` is the whole distinction and
is why this is expressible as a query at all; a titled or labelled opener
matches nothing new and keeps `@type`. GROUPS DO NOT NEST is a second
pattern at a higher priority, restoring `@type` on a bare opener that is a
direct child of a group's content - a query cannot say "whose ancestor is
not a group", so it says the specific shape instead. The residual is written
at the rule: a bare opener nested deeper keeps the group capture, where the
clause degrades any depth.

And a check the repo did not have. The queries are the other half of what
this package ships - vim-carve, helix-carve and zed-carve pin this repo and
load these files - and nothing read them, so a capture naming a node that no
longer exists would have stayed green. scripts/highlight-captures.mjs
resolves what an editor actually paints: highest `(#set! priority N)` wins,
later patterns break a tie, 100 by default. Reporting every match instead -
which is what `tree-sitter query` prints - would call the nested case green
while the editor painted the generic colour over it.

Two things the resolver has to get right, both found by watching it fail:
`spell`, `nospell`, `conceal` and `none` are not colours and land on the
same nodes the colour patterns do, and `#offset!` is a directive the node
binding refuses to build a query with, so it is stripped rather than
checking a hand-copied subset of the file.
The first pass covered a bare `::: figure` that is a DIRECT child of an open
group's content, and named the rest as a residual. That left the shapes
people actually write over-coloured: a group holding a `::: note` holding a
figure, a group holding a quote holding one, and a group holding a list item
holding one - all three are one intervening block, not an exotic depth.

A tree-sitter query has no transitive closure, so "any descendant" cannot be
written. What can is a wildcard chain rooted at the group, one pattern per
intervening level, and three levels is enough for every shape the language
produces: the direct child; `div`/`block_quote` > `content` > `div`; and
`list` > `list_item` > `list_item_content` > `div`. The wildcards are
deliberate rather than lazy - naming the container types would need
revisiting every time a block gains a content field, while the chain LENGTH
is the real constraint and is now what the comment states.

The residual that remains is a bare opener more than three levels down, and
it is named at the rule.

Each of the three new shapes is checked, and each was watched to fail:
removing the two wildcard patterns fails exactly those three cases and
nothing else.
@dereuromark
dereuromark merged commit 17362de into main Aug 15, 2026
2 checks passed
@dereuromark
dereuromark deleted the feat/composite-figures-composite-sat-6436a4ec branch August 15, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant