From 6bdee0efb78f2a84f393e6c355260a4908ea390a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 14:48:15 +0200 Subject: [PATCH 1/2] feat: composite figures are a container the server knows PART 9 section 4c makes a BARE `::: figure` fence - the fence, its separator, the kind word, and nothing else - ONE figure of ordered panels, normalized to a `figure_group` node. An opener carrying a quoted title or a `[label]` is not that production and stays an admonition. The server switches on `node.type` in four places, each with a default that does nothing, so a node type the engine grew and the server never learned is INVISIBLE rather than a type error: the container simply stopped folding, hovering and tokenizing, and all 238 tests stayed green. That is the shape of this change - four cases, not a mechanism. - folding: `figure_group` joins FOLDABLE, so a long group collapses like every other fenced container. - semantic tokens: the opener's reserved kind word is scoped `type`, the same as an admonition's, because the two are the same shape on the line and a client colouring one should colour the other. The caption is collected HERE beside the children rather than from any one of them, because the group's caption sits after the CLOSING fence - the one placement section 4c adds. - hover: its own description, naming what separates it from the generic container it looks like. - completion: `::: ` offers `figure`, listed separately from the eight admonition kinds and labelled "Composite figure". Folding it into that list would say it is a ninth admonition, which is the confusion the clause exists to prevent. THE DEPENDENCY MOVES TO A GIT PIN. `@markup-carve/carve` was pinned to the published `0.1.3`, which predates the node - `figure_group` is not in its type union, so a case for it would not have compiled. Mid-development a git pin is the correct pin, and it moves back to a version range at the next release. Tests assert the engine's shape first, so a dependency that stops producing `figure_group` reports that plainly instead of four unrelated feature failures. The titled and labelled openers are the control throughout: they differ from the bare one only in the tail of one line, so a reading that fires on the kind word alone would call them groups and nothing else would notice. --- CHANGELOG.md | 18 +++++++ package-lock.json | 33 +++++++++++-- package.json | 2 +- src/completion.ts | 20 ++++++-- src/composite-figure.test.ts | 93 ++++++++++++++++++++++++++++++++++++ src/folding.ts | 3 ++ src/hover.ts | 10 ++++ src/semantic.ts | 12 +++++ 8 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/composite-figure.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ceedf..0938eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Composite figures are a container the server knows** (spec PART 9 §4c, + markup-carve/carve#1215). A bare `::: figure` fence parses to a `figure_group` + node, which the server had no case for, so the container silently stopped + folding, hovering and producing semantic tokens. It now folds like any other + fenced container, hovers with its own description, and its opener carries the + reserved kind word as a `type` token - including the `^ ` caption below the + CLOSING fence, which belongs to the group rather than to anything inside it. + `::: ` completion offers `figure` alongside the eight admonition kinds, listed + separately because it is not a ninth one. An opener carrying a title or a + `[label]` is unchanged and still an admonition. + ### Changed +- The `@markup-carve/carve` dependency tracks a carve-js commit rather than the + published `0.1.3`, which predates the composite-figure node. Mid-development a + git pin is the correct pin; it moves back to a version range at the next + release. + - Diagnostics are coalesced per document instead of running on every keystroke (markup-carve/carve-lsp#68). Analysis is whole-document - a full parse and resolve plus the migration and lint passes - so one run per edit multiplies diff --git a/package-lock.json b/package-lock.json index efffe8b..747c2e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.2", "license": "MIT", "dependencies": { - "@markup-carve/carve": "0.1.3", + "@markup-carve/carve": "git+https://github.com/markup-carve/carve-js.git#4b15193e3c25b62e68b65a57a27447bf7daf9c69", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12" }, @@ -23,9 +23,12 @@ }, "node_modules/@markup-carve/carve": { "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@markup-carve/carve/-/carve-0.1.3.tgz", - "integrity": "sha512-o/43mp5+PS/TNDVz5Mgo92ECuXZ7gFBjVkj8dD3mN2Frr9BgYlXNEZzMtrmYRQCrvB8vXFCP4cIUkofpq1YYYQ==", + "resolved": "git+ssh://git@github.com/markup-carve/carve-js.git#4b15193e3c25b62e68b65a57a27447bf7daf9c69", + "integrity": "sha512-0gv7mpG+ZNyLw3sfUiF3ll1XHASVFrgFWNGxvriRN9cU+RPA4v1lcD3ug/sSwgxdRxY9DKvgwWeZy8b0CHtu/w==", "license": "MIT", + "dependencies": { + "parse5": "^7.3.0" + }, "bin": { "carve": "dist/cli.js" }, @@ -51,6 +54,30 @@ "undici-types": "~6.21.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index fe6e58a..108300e 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@markup-carve/carve": "0.1.3", + "@markup-carve/carve": "git+https://github.com/markup-carve/carve-js.git#4b15193e3c25b62e68b65a57a27447bf7daf9c69", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12" }, diff --git a/src/completion.ts b/src/completion.ts index 69266fc..4f052a6 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -8,9 +8,18 @@ import { parse, resolve, type BlockNode, type Document } from '@markup-carve/car /** The eight canonical admonition kinds (grammar PART 9 §12, Tier 1). */ const ADMONITIONS = ['note', 'tip', 'warning', 'danger', 'info', 'success', 'example', 'quote'] +/** + * `figure` is not one of them. It is RESERVED among the `:::` types (PART 9 + * §4c): a BARE `::: figure` opener is one figure of ordered panels, and the same + * word with a title or a `[label]` is an ordinary container. Offering it beside + * the eight would say it is a ninth admonition, so it is offered separately and + * labelled for what it opens. + */ +const FIGURE_GROUP = 'figure' + /** * Context-aware completions driven by the text immediately before the cursor: - * - `::: ` opens an admonition -> canonical kinds + * - `::: ` opens a container -> canonical admonition kinds, and `figure` * - ` heading ids in the document * - `[^` footnote reference -> defined footnote labels * - `][` reference link -> defined link reference labels @@ -21,9 +30,12 @@ export function completionAt(source: string, position: Position): CompletionItem let match: RegExpExecArray | null if ((match = /:::\s*([\w-]*)$/.exec(prefix))) { - return ADMONITIONS.map((kind) => - completion(kind, CompletionItemKind.Keyword, match![1], position, 'Admonition kind'), - ) + return [ + ...ADMONITIONS.map((kind) => + completion(kind, CompletionItemKind.Keyword, match![1], position, 'Admonition kind'), + ), + completion(FIGURE_GROUP, CompletionItemKind.Struct, match![1], position, 'Composite figure'), + ] } if ((match = /<\/#([\w-]*)$/.exec(prefix))) { return headingIds(source).map((id) => diff --git a/src/composite-figure.test.ts b/src/composite-figure.test.ts new file mode 100644 index 0000000..41de969 --- /dev/null +++ b/src/composite-figure.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parse, resolve } from '@markup-carve/carve' +import { completionAt } from './completion.js' +import { foldingRanges } from './folding.js' +import { hoverAt } from './hover.js' +import { semanticTokens } from './semantic.js' + +/* + * Composite figures across the four features that read block types (PART 9 §4c, + * markup-carve/carve#1215). + * + * A BARE `::: figure` opener - the fence, its separator, the kind word, and + * NOTHING else - is ONE figure of ordered panels, and the engine normalizes it + * to a `figure_group` node. An opener carrying a quoted title or a `[label]` is + * not that production at all and stays a generic container. + * + * Every feature here switches on `node.type` with a default that does nothing, + * so a node type the engine grew and the server never learned is INVISIBLE + * rather than a type error: the container simply stops folding, hovering and + * tokenizing, and every existing test stays green. That is the failure this file + * is here to catch, which is why the first test asserts the engine's shape + * directly - if the dependency stops producing `figure_group`, this file should + * say so plainly rather than reporting four unrelated feature failures. + */ + +const GROUP = '::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: Group caption\n' +const TITLED = '::: figure "A titled figure div"\n![one](a.png)\n^ (a) One\n:::\n' +const LABELLED = '::: figure [g]\nBody.\n:::\n' + +const topLevelTypes = (source: string): string[] => + resolve(parse(source, { positions: true })).children.map((node) => node.type) + +test('the pinned engine normalizes a bare figure fence to a figure_group', () => { + assert.deepEqual(topLevelTypes(GROUP), ['figure_group']) +}) + +test('a title or a label leaves it a generic container', () => { + // The control for every case below. These two differ from GROUP only in the + // tail of one line, so a reading that fires on the kind word alone would + // report them as groups and nothing else here would notice. + assert.deepEqual(topLevelTypes(TITLED), ['admonition']) + assert.deepEqual(topLevelTypes(LABELLED), ['admonition']) +}) + +test('a composite figure folds', () => { + const ranges = foldingRanges(GROUP) + assert.ok( + ranges.some((range) => range.startLine === 0 && range.endLine >= 3), + `no fold for the group: ${JSON.stringify(ranges)}`, + ) +}) + +test('the opener carries the reserved kind word as a type token', () => { + const opener = semanticTokens(GROUP).filter((token) => token.line === 0) + assert.ok(opener.length > 0, 'the opener line produced no token at all') + assert.equal(opener[0].type, 'type') + // `::: figure` - the whole reserved opener, not just the fence run. + assert.equal(opener[0].character, 0) + assert.equal(opener[0].length, 10) +}) + +test('the group caption after the closing fence is tokenized', () => { + // Line 4, the `^ ` line BELOW the closer. It is the group's caption and it + // sits outside the container, which is the one placement §4c adds: read from + // any child instead of from the group, and this line has no token at all. + assert.ok( + semanticTokens(GROUP).some((token) => token.line === 4), + 'the caption line below the closing fence produced no token', + ) +}) + +test('hovering a composite figure describes the group, not an admonition', () => { + const hover = hoverAt(GROUP, { line: 0, character: 4 }) + const text = typeof hover?.contents === 'object' && 'value' in hover.contents ? hover.contents.value : '' + assert.match(text, /Composite Figure/) +}) + +test('a titled figure opener still hovers as an admonition', () => { + const hover = hoverAt(TITLED, { line: 0, character: 4 }) + const text = typeof hover?.contents === 'object' && 'value' in hover.contents ? hover.contents.value : '' + assert.match(text, /Admonition/) +}) + +test('a colon fence offers figure, and not as a ninth admonition kind', () => { + const items = completionAt(':::', { line: 0, character: 3 }) + const figure = items.find((item) => item.label === 'figure') + assert.ok(figure, `figure is not offered: ${items.map((i) => i.label).join(',')}`) + assert.equal(figure.detail, 'Composite figure') + // The eight are still there and still say what they are. + const note = items.find((item) => item.label === 'note') + assert.equal(note?.detail, 'Admonition kind') +}) diff --git a/src/folding.ts b/src/folding.ts index cd00b93..8a2a4ec 100644 --- a/src/folding.ts +++ b/src/folding.ts @@ -13,6 +13,9 @@ const FOLDABLE = new Set([ 'div', 'definition_list', 'figure', + // A composite figure is a fenced container like any other, and a long one is + // exactly what a reader wants to collapse (PART 9 §4c). + 'figure_group', ]) /** diff --git a/src/hover.ts b/src/hover.ts index 2ffad41..d0db5ec 100644 --- a/src/hover.ts +++ b/src/hover.ts @@ -146,6 +146,10 @@ function collectBlock( collectBlock(matches, node.target, position) collectInline(matches, node.caption, position) break + case 'figure_group': + node.children.forEach((child) => collectBlock(matches, child, position)) + if (node.caption) collectInline(matches, node.caption, position) + break case 'table': if (node.caption) collectInline(matches, node.caption, position) node.rows.forEach((row) => row.cells.forEach((cell) => collectInline(matches, cell.children, position))) @@ -195,6 +199,12 @@ function blockContents(node: BlockNode): string | null { return '**Admonition**\n\nTyped `:::` fences create admonition blocks.' case 'div': return '**Div**\n\nBare `:::` fences create generic container blocks.' + case 'figure_group': + return ( + '**Composite Figure**\n\nA bare `::: figure` fence is one figure of ordered panels. ' + + 'The `^ ` line after the closing fence captions the whole group. ' + + 'An opener carrying a title or a `[label]` stays a generic container instead.' + ) default: return null } diff --git a/src/semantic.ts b/src/semantic.ts index 8576dbb..a3810af 100644 --- a/src/semantic.ts +++ b/src/semantic.ts @@ -138,6 +138,18 @@ function collectBlock(tokens: Token[], lines: string[], node: BlockNode): void { collectFigureTarget(tokens, lines, node.target) collectInline(tokens, lines, node.caption) break + // A composite figure (PART 9 §4c): one figure of ordered panels, opened by a + // BARE `::: figure` fence. Its opener carries the reserved kind word, so the + // prefix is scoped `type` exactly as an admonition's is - the two are the + // same shape on the line and a client colouring one should colour the other. + // The caption is the group's, and it sits AFTER the closing fence rather than + // inside the container, which is why it is collected here beside the children + // and not from any one of them. + case 'figure_group': + pushLinePrefix(tokens, lines, node.pos, /^\s*:{3,}\s*figure/, 'type') + for (const child of node.children) collectBlock(tokens, lines, child) + if (node.caption) collectInline(tokens, lines, node.caption) + break case 'table': pushPosition(tokens, lines, node.pos, 'string') if (node.caption) collectInline(tokens, lines, node.caption) From 286b8da59770fec7af2aff4a7bc111e17cb063c0 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 14:50:58 +0200 Subject: [PATCH 2/2] fix: the locked git dependency stays on HTTPS `package.json` declares the carve-js pin as `git+https://`, and the lockfile recorded `git+ssh://git@github.com/` for the same dependency. That is not a disagreement npm invented: this machine's git config rewrites the HTTPS form to SSH via `insteadOf`, so the install that generated the lock resolved over SSH and wrote what it used. `npm ci` follows the LOCKED url, not the declared one. Anywhere without a GitHub SSH key - CI first among them - it would have failed with `Permission denied (publickey)` before a line compiled, on a public repository, and nothing in the diff would have suggested why. Rewritten to the HTTPS form, which is also what tree-sitter-carve's lockfile carries for the same dependency. Verified by removing node_modules entirely and running `npm ci` from the lockfile, then building and running the suite. --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 747c2e5..8eb8ebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ }, "node_modules/@markup-carve/carve": { "version": "0.1.3", - "resolved": "git+ssh://git@github.com/markup-carve/carve-js.git#4b15193e3c25b62e68b65a57a27447bf7daf9c69", + "resolved": "git+https://github.com/markup-carve/carve-js.git#4b15193e3c25b62e68b65a57a27447bf7daf9c69", "integrity": "sha512-0gv7mpG+ZNyLw3sfUiF3ll1XHASVFrgFWNGxvriRN9cU+RPA4v1lcD3ug/sSwgxdRxY9DKvgwWeZy8b0CHtu/w==", "license": "MIT", "dependencies": {