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..8eb8ebd 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+https://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)