diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2026daa..2f8883f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,13 @@ jobs: - name: Line terminators run: npm run test:line-terminators + # The queries are the other half of what this repo ships - vim-carve, + # helix-carve and zed-carve 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. + - name: Highlight captures resolve the way an editor resolves them + run: npm run test:highlights + - name: The vendored battery is still carve-grammars' copy run: npm run test:battery-drift - name: Build WASM grammar diff --git a/CHANGELOG.md b/CHANGELOG.md index 0008a43..9c7893b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **A bare `::: figure` opener highlights as a composite figure** (spec PART 9 + ยง4c, markup-carve/carve#1215). The kind word `figure` is reserved among the + `:::` types: an opener carrying nothing else is one figure of ordered panels, + and its `class_name` now captures `@type.builtin` instead of the generic + `@type`. An opener that carries a quoted title or a `[label]` keeps `@type`, + and a bare opener inside an open group is restored to `@type` as well, because + groups do not nest - through a direct child, an intervening container or quote, + and a list item. The grammar itself is unchanged: + the parse tree already tells the two apart by which fields the opener carries, + and the group caption already parses as a sibling of the container rather than + inside it, which is where the clause puts it. + ## [0.1.2] - 2026-08-10 ### Fixed diff --git a/package.json b/package.json index d8c5fad..fe8bd82 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:battery-drift": "./tools/check-battery-drift.sh", "test:under-acceptance": "node scripts/under-acceptance.mjs", "test:no-error": "node scripts/no-error-sweep.mjs", - "test:line-terminators": "node scripts/line-terminators.mjs" + "test:line-terminators": "node scripts/line-terminators.mjs", + "test:highlights": "node scripts/highlight-captures.mjs" }, "author": "", "license": "MIT", diff --git a/queries/highlights.scm b/queries/highlights.scm index 746f0ed..caa50f3 100644 --- a/queries/highlights.scm +++ b/queries/highlights.scm @@ -267,6 +267,87 @@ (class_name) ] @type +; Composite figures (PART 9 4c, markup-carve/carve#1215). The kind word `figure` +; is RESERVED among the `:::` types: a BARE opener - the fence, its separator, +; the word, and nothing else - is ONE figure of ordered panels, not an +; admonition. `!title !label` IS the distinction, and it is the whole reason this +; belongs in a query rather than in the grammar: the parse tree already tells the +; two apart by which fields the opener carries, so a reserved kind word needs a +; reserved capture rather than a new node. An opener carrying a quoted title or a +; `[label]` matches nothing here and keeps `@type` above, which is the generic +; Tier-2 container the clause says it stays. +; +; The group caption needs no rule: it is an ordinary `^ ` line one line below the +; closing fence, and the parser already places it as a sibling of the container +; rather than inside it, where the existing `(caption)` patterns claim it. +((div + class: (class_name) @type.builtin + !title + !label) + (#eq? @type.builtin "figure") + (#set! priority 105)) + +; GROUPS DO NOT NEST: a bare `::: figure` inside an open group is a generic +; container, not an inner group, at ANY depth. A query has no transitive +; closure - there is no "any descendant" - so the reach is spelled as wildcard +; chains rooted at the group, one per intervening level, each restoring `@type` +; on the inner opener at a higher priority than the pattern above gives it. +; +; Three levels covers every shape the language actually produces: a direct child +; of the group's content; one intervening container (`div` > `content` > `div`, +; and `block_quote` > `content` > `div`); and a list item +; (`list` > `list_item` > `list_item_content` > `div`). The wildcards are +; deliberate - naming the container types would have to be revisited every time +; a new block gains a content field, and the chain LENGTH is the real constraint. +; +; RESIDUAL, written down rather than left to be rediscovered: a bare opener +; reached through MORE than three levels - a quote inside a list item inside the +; group, say - keeps the group capture. The parse tree is right either way; only +; the colour is not. +((div + class: (class_name) @_group.class + !title + !label + content: (content + (div + class: (class_name) @type + !title + !label))) + (#eq? @_group.class "figure") + (#eq? @type "figure") + (#set! priority 110)) + +((div + class: (class_name) @_group.class + !title + !label + content: (content + (_ + (_ + (div + class: (class_name) @type + !title + !label))))) + (#eq? @_group.class "figure") + (#eq? @type "figure") + (#set! priority 110)) + +((div + class: (class_name) @_group.class + !title + !label + content: (content + (_ + (_ + (_ + (div + class: (class_name) @type + !title + !label)))))) + (#eq? @_group.class "figure") + (#eq? @type "figure") + (#set! priority 110)) + (identifier) @tag (key_value diff --git a/scripts/highlight-captures.mjs b/scripts/highlight-captures.mjs new file mode 100644 index 0000000..a7c36d6 --- /dev/null +++ b/scripts/highlight-captures.mjs @@ -0,0 +1,199 @@ +/** + * What `queries/highlights.scm` actually paints, resolved the way a consumer + * resolves it. + * + * Every other check in this repo reads the PARSE TREE. 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 at all. A + * capture could name a node that no longer exists, or two patterns could claim + * the same node with nobody deciding which wins, and every test stayed green. + * + * Resolution matters as much as matching, because a query file is not a list of + * independent facts. Several patterns claim the same node, and what the editor + * shows is the one with the highest `(#set! priority N)`, later patterns winning + * a tie - the rule Neovim and Helix both implement, with 100 as the default. + * Reporting every match instead (which is what `tree-sitter query` prints) would + * call the composite-figure cases below green while the editor painted the + * generic colour over them. + * + * Run: `node scripts/highlight-captures.mjs` + */ +import Parser from 'tree-sitter'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const Carve = require('../bindings/node'); + +const parser = new Parser(); +parser.setLanguage(Carve); + +/* + * `#offset!` adjusts a capture's RANGE and is understood by the editors that + * consume these queries, not by the node binding, which refuses to build a query + * holding a directive it does not know. Stripping it leaves every pattern, every + * capture and every `#set!` intact - only four range adjustments are lost, and + * no case here asserts on a range. The alternative was to check a hand-copied + * subset of the file, which is the shape of check this repo keeps finding + * afterwards: it would have passed while the real file was broken. + */ +const raw = readFileSync(resolve(__dirname, '../queries/highlights.scm'), 'utf8'); +const source = raw.replace(/\(#offset![^)]*\)/g, ''); +const query = new Parser.Query(Carve, source); + +const DEFAULT_PRIORITY = 100; + +/* + * Captures that are not a COLOUR. `spell` and `nospell` mark a range for the + * spell checker, `conceal` hides one, `none` clears an inherited highlight - + * none of them is what an editor paints, and all of them land on the same nodes + * the colour patterns do. Counting them made every generic-container row read + * `nospell`, which is a true statement about the query file and not the question + * being asked. + */ +const NOT_A_COLOUR = new Set(['spell', 'nospell', 'conceal', 'none']); + +/** + * The capture an editor would paint on the node at `row`/`column`. + * + * @param {string} text - the document to parse. + * @param {number} row - zero-based line of the node's start. + * @param {number} column - zero-based column of the node's start. + * @returns {string|null} the winning capture name, or null if nothing claims it. + */ +function effectiveCapture(text, row, column) { + const tree = parser.parse(text); + let winner = null; + let winningPriority = -Infinity; + let winningIndex = -Infinity; + + query.matches(tree.rootNode).forEach((match, index) => { + const priority = Number(query.setProperties?.[match.pattern]?.priority ?? DEFAULT_PRIORITY); + for (const capture of match.captures) { + // `@_name` captures are internal to a predicate and paint nothing. + if (capture.name.startsWith('_')) continue; + if (NOT_A_COLOUR.has(capture.name)) continue; + const { startPosition } = capture.node; + if (startPosition.row !== row || startPosition.column !== column) continue; + if (priority > winningPriority || (priority === winningPriority && index >= winningIndex)) { + winner = capture.name; + winningPriority = priority; + winningIndex = index; + } + } + }); + + return winner; +} + +/* + * Composite figures (PART 9 4c). Each case names the node by where it starts, + * because the point is which of two same-looking kind words gets which colour. + */ +const CASES = [ + { + name: 'a bare figure opener is a composite figure', + source: '::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: Group caption\n', + at: [0, 4], + expect: 'type.builtin', + }, + { + name: 'a quoted title keeps it a generic container', + source: '::: figure "A titled figure div"\nx\n:::\n^ Not a group caption\n', + at: [0, 4], + expect: 'type', + }, + { + name: 'a [label] keeps it a generic container', + source: '::: figure [g]\nx\n:::\n', + at: [0, 4], + expect: 'type', + }, + { + name: 'the outer opener of a nested pair is the group', + source: '::: figure\n:::: figure\nx\n::::\n:::\n', + at: [0, 4], + expect: 'type.builtin', + }, + { + name: 'the inner opener of a nested pair is a generic container', + source: '::: figure\n:::: figure\nx\n::::\n:::\n', + at: [1, 5], + expect: 'type', + }, + { + name: 'a bare opener one container deep inside a group is generic', + source: '::: figure\n:::: note\n::::: figure\nx\n:::::\n::::\n:::\n', + at: [2, 6], + expect: 'type', + }, + { + name: 'a bare opener inside a quote inside a group is generic', + source: '::: figure\n> quoted\n>\n> :::: figure\n> x\n> ::::\n:::\n', + at: [3, 7], + expect: 'type', + }, + { + name: 'a bare opener inside a list item inside a group is generic', + source: '::: figure\n- item\n\n :::: figure\n x\n ::::\n:::\n', + at: [3, 7], + expect: 'type', + }, + { + name: 'the intervening container itself keeps its own capture', + source: '::: figure\n:::: note\n::::: figure\nx\n:::::\n::::\n:::\n', + at: [1, 5], + expect: 'type', + }, + { + name: 'a group inside another container kind is still a group', + source: '::: note\n:::: figure\nx\n::::\n:::\n', + at: [1, 5], + expect: 'type.builtin', + }, + { + name: 'another kind word is a generic container', + source: '::: note\nx\n:::\n', + at: [0, 4], + expect: 'type', + }, + { + name: 'the group caption after the closing fence is a caption', + source: '::: figure\nx\n:::\n^ Figure #: Group caption\n', + at: [3, 2], + expect: 'markup.italic', + }, +]; + +const fails = []; +let pass = 0; + +for (const { name, source: text, at, expect } of CASES) { + const got = effectiveCapture(text, at[0], at[1]); + if (got === expect) pass++; + else fails.push(`FAIL ${name}\n at ${at[0]}:${at[1]} the winning capture is ${got}, expected ${expect}`); +} + +/* + * The resolver has to answer both ways, or every row above passes without + * reading anything: a position nothing claims must come back null, and a + * capture name has to be able to be wrong. + */ +if (effectiveCapture('plain prose\n', 0, 0) !== null) { + fails.push('FAIL control: the resolver claims a capture on plain prose'); +} else pass++; + +if (effectiveCapture('::: note\nx\n:::\n', 0, 4) === 'type.builtin') { + fails.push('FAIL control: a plain `::: note` resolves to the composite-figure capture'); +} else pass++; + +if (fails.length) { + console.log(`highlight captures: ${fails.length} failing`); + for (const f of fails) console.log(f); + process.exit(1); +} + +console.log(`highlight captures: ${pass} checks pass across ${CASES.length} shapes`); diff --git a/test/corpus/carve.txt b/test/corpus/carve.txt index 65b830c..a8a9154 100644 --- a/test/corpus/carve.txt +++ b/test/corpus/carve.txt @@ -4778,3 +4778,101 @@ A fence whose only closer is over-indented does not interrupt (tree-sitter-carve begin_marker: (verbatim_marker_begin) content: (content) end_marker: (verbatim_marker_end))))))) + +=============================================================================== +A bare figure fence is a composite figure, and its caption sits after the closer (PART 9 4c) +=============================================================================== + +::: figure +![one](a.png) +^ (a) One + +![two](b.png) +^ (b) Two +::: +^ Figure #: Group caption + +------------------------------------------------------------------------------- + +(document + (div + (div_marker_begin) + class: (class_name) + content: (content + (paragraph + (inline_image + description: (image_description) + destination: (inline_link_destination))) + (caption + (caption_marker) + content: (caption_content)) + (paragraph + (inline_image + description: (image_description) + destination: (inline_link_destination))) + (caption + (caption_marker) + content: (caption_content))) + (div_marker_end)) + (caption + (caption_marker) + content: (caption_content))) + +=============================================================================== +A title or a label keeps a figure fence a generic container (PART 9 4c) +=============================================================================== + +::: figure "A titled figure div" +x +::: + +::: figure [g] +y +::: + +------------------------------------------------------------------------------- + +(document + (div + (div_marker_begin) + class: (class_name) + title: (div_title) + content: (content + (paragraph)) + (div_marker_end)) + (div + (div_marker_begin) + class: (class_name) + label: (code_block_label) + content: (content + (paragraph)) + (div_marker_end))) + +=============================================================================== +A bare figure fence inside an open group is an ordinary nested container (PART 9 4c) +=============================================================================== + +::: figure +:::: figure +x +:::: +::: +^ Figure #: Outer only + +------------------------------------------------------------------------------- + +(document + (div + (div_marker_begin) + class: (class_name) + content: (content + (div + (div_marker_begin) + class: (class_name) + content: (content + (paragraph)) + (div_marker_end))) + (div_marker_end)) + (caption + (caption_marker) + content: (caption_content)))