diff --git a/CHANGELOG.md b/CHANGELOG.md index 0938eb3..fd1766f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). separately because it is not a ninth one. An opener carrying a title or a `[label]` is unchanged and still an admonition. +- **A cross-reference reaches a captioned host, not only a heading** (spec PART + 9R R4, markup-carve/carve-lsp#79). `` naming a figure, a table, a + composite figure or one of its panels now completes, jumps to its host, finds + its usages, and hovers with the text it resolves to - "Figure 2" for a group, + "Figure 2a" for its first panel. Every crossref feature walked headings only + before this, so a reference to a plain captioned figure - a construct that + predates composite figures entirely - offered no completion and jumped + nowhere. Hovering one reported it as a heading, because no case existed for + the reference and the lexical fallback matched the `#` inside it. + + The number is the engine's own resolved `caption_number`; only the panel + letter (a..z, then aa) is derived here, and the tests pin it against the + anchor text the engine renders for the same id. An unnumbered group's panels + stay anchors without crossref text, which is what PART 9 §4c says they are, + and completion leaves such an id out rather than offering a reference that + renders as literal text. Find-references answers from the declaration too - + the block-attribute line above a captioned host, which is where its id is + actually written - and not only from a usage. + +- **The outline carries a composite figure and nests its panels.** The group is + named by its caption, says how many panels it holds, and hangs under the + section it appears in - a group takes no heading level, so it never closes + one. A panel is named by its own caption, falling back to the letter a + crossref would use for it. + ### Changed - The `@markup-carve/carve` dependency tracks a carve-js commit rather than the diff --git a/src/analyze.ts b/src/analyze.ts index 314d567..575f660 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -19,6 +19,7 @@ import { import path from 'node:path' import { pathToFileURL } from 'node:url' import { smartPunctuationText } from './inline-text.js' +import { panelLetter } from './captions.js' import { resolveIncludes, type IncludeDependency, type IncludeOptions } from './includes.js' import type { IncludeParseCache } from './include-cache.js' @@ -148,8 +149,12 @@ function includedSymbols( if (child.version !== undefined) cache?.set(child.id, child.version, document) } const uri = pathToFileURL(child.id).toString() - for (const heading of walkHeadings(document.children)) { - const symbol = headingSymbol(heading) + for (const entry of walkOutline(document.children)) { + // An INCLUDED file contributes its headings, flat. A composite figure is + // a landmark inside its own file rather than a navigable entry in + // another one, so it stays out of this list. + if (entry.type !== 'heading') continue + const symbol = headingSymbol(entry) symbols.push({ name: symbol.name, kind: symbol.kind, @@ -193,11 +198,7 @@ function documentSymbols(doc: Document): DocumentSymbol[] { const stack: Array<{ level: number; symbol: DocumentSymbol }> = [] const roots: DocumentSymbol[] = [] - for (const heading of walkHeadings(doc.children)) { - const symbol = headingSymbol(heading) - while (stack.length && stack[stack.length - 1]!.level >= heading.level) { - stack.pop() - } + const place = (symbol: DocumentSymbol): void => { const parent = stack[stack.length - 1] if (parent) { parent.symbol.children ??= [] @@ -205,26 +206,106 @@ function documentSymbols(doc: Document): DocumentSymbol[] { } else { roots.push(symbol) } - stack.push({ level: heading.level, symbol }) + } + + for (const entry of walkOutline(doc.children)) { + if (entry.type === 'figure_group') { + // A composite figure is a structural landmark: one figure holding + // ordered panels (PART 9 §4c). It takes NO level, so it never pops the + // heading stack - it hangs under the section it appears in, the way a + // heading's own children do. + place(figureGroupSymbol(entry)) + continue + } + while (stack.length && stack[stack.length - 1]!.level >= entry.level) { + stack.pop() + } + const symbol = headingSymbol(entry) + place(symbol) + stack.push({ level: entry.level, symbol }) } return roots } -function* walkHeadings(nodes: BlockNode[]): Iterable { +type OutlineEntry = Heading | Extract + +/** + * The outline's entries in source order: headings, and composite figures. + * + * A group is worth an entry where a plain figure is not, because it is a + * CONTAINER an author folds, navigates and loses their place inside - the same + * reason it folds. Its panels come with it, so the outline says how many there + * are without scrolling the fence. + */ +function* walkOutline(nodes: BlockNode[]): Iterable { for (const node of nodes) { if (node.type === 'heading') yield node + if (node.type === 'figure_group') { + yield node + // Its panels ride on the group's own symbol - a `figure` or `table` + // yields no entry of its own here - but the walk still DESCENDS through + // them. A panel can wrap a quote holding headings, and skipping the panel + // node dropped those headings out of the outline entirely. + yield* walkOutline(node.children) + continue + } if ('children' in node && Array.isArray(node.children)) { - yield* walkHeadings(node.children.filter(isBlockNode)) + yield* walkOutline(node.children.filter(isBlockNode)) } if (node.type === 'figure') { if ('children' in node.target && Array.isArray(node.target.children)) { - yield* walkHeadings(node.target.children.filter(isBlockNode)) + yield* walkOutline(node.target.children.filter(isBlockNode)) } } } } +/** A group's panels are its direct `figure` and `table` children (§4c). */ +function isPanel(node: BlockNode): boolean { + return node.type === 'figure' || node.type === 'table' +} + +function figureGroupSymbol(group: Extract): DocumentSymbol { + const range = blockRange(group) + const panels = group.children.filter(isPanel) + return { + name: (group.caption ? plainText(group.caption) : '') || 'Composite figure', + detail: panels.length === 1 ? '1 panel' : `${panels.length} panels`, + kind: SymbolKind.Struct, + range, + selectionRange: range, + children: panels.map((panel, index) => panelSymbol(panel, index)), + } +} + +/** + * A panel is named by its own caption, and falls back to the LETTER a crossref + * would use for it (§4c) - not to a number, which would read as a figure number + * the panel does not have. + */ +function panelSymbol(panel: BlockNode, index: number): DocumentSymbol { + const caption = (panel as { caption?: InlineNode[] }).caption + const range = blockRange(panel) + return { + name: (caption ? plainText(caption) : '') || `Panel ${panelLetter(index)}`, + kind: panel.type === 'table' ? SymbolKind.Array : SymbolKind.Object, + range, + selectionRange: range, + children: [], + } +} + +function blockRange(node: BlockNode): Range { + if (!node.pos) { + return { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } + } + return { + start: { line: node.pos.startLine - 1, character: 0 }, + end: { line: node.pos.endLine - 1, character: 200 }, + } +} + function isBlockNode(node: unknown): node is BlockNode { return Boolean(node && typeof node === 'object' && 'type' in node) } @@ -258,6 +339,10 @@ function plainText(nodes: InlineNode[]): string { // An inline literal (§27) renders as visible prose, so it contributes its // verbatim content to the outline symbol name just as a code span does. else if (node.type === 'literal_inline') out += node.content + // A caption's resolved number. Without it a composite figure appeared in + // the outline as "Figure : Group caption", with the gap where the number + // the reader is looking for should be. + else if (node.type === 'caption_number') out += String(node.n) else if (node.type === 'symbol') out += `:${node.name}:` else if (node.type === 'mention') out += `@${node.user}` else if (node.type === 'tag') out += `#${node.name}` diff --git a/src/captions.ts b/src/captions.ts new file mode 100644 index 0000000..9413823 --- /dev/null +++ b/src/captions.ts @@ -0,0 +1,180 @@ +import { + type BlockNode, + type Document, + type InlineNode, + type Position as SourcePosition, +} from '@markup-carve/carve' +import { smartPunctuationText } from './inline-text.js' + +/** + * The captioned hosts a `` can name, and the text each one resolves to. + * + * The server used to walk HEADINGS for every crossref feature, so a `` + * naming a captioned figure - a construct that predates composite figures - + * offered no completion, jumped nowhere and hovered as if the `#` inside it + * opened a section. This is the other half of the crossref target set (PART 9R + * R4): `figure`, `table`, `figure_group`, and a group's PANELS. + * + * WHERE THE NUMBER COMES FROM. Not from here. A numbered caption carries a + * `caption_number` node whose `n` the engine already resolved, so the text is + * assembled from the caption the author wrote up to and including that node - + * "Figure 2" out of `^ Figure #: Group caption`. Re-deriving the sequence would + * be a second copy of PART 9R R5, and the two would drift the first time a + * label sequence changed. + * + * The PANEL LETTER is the one thing derived here, because no engine API exposes + * it: panel order among the panels only, a..z then aa, ab (PART 9 §4c). The + * tests pin it against the anchor text `carveToHtml` writes for the same id, so + * the derivation is measured against the engine rather than against a reading + * of the clause. + */ +export interface CaptionTarget { + /** The authored id, as written. */ + id: string + /** What the host is, for a completion label and a hover heading. */ + kind: 'figure' | 'table' | 'composite figure' | 'panel' + /** + * The text a `` resolves to - "Figure 2", "Figure 2a", "Table 1" - or + * null when the host drew no number. §4c: an unnumbered group's panels are + * anchors but not caption crossref targets, and the same has always been true + * of an id on an uncaptioned figure. + */ + text: string | null + /** The host's own span, for go-to-definition and find-references. */ + pos: SourcePosition | undefined +} + +/** Every captioned host in the document that carries an id, in source order. */ +export function captionTargets(doc: Document): CaptionTarget[] { + const targets: CaptionTarget[] = [] + collect(doc.children, targets) + return targets +} + +/** The target an id names, or null. Ids match case-insensitively, as elsewhere. */ +export function captionTargetById(doc: Document, id: string): CaptionTarget | null { + const wanted = id.toLowerCase() + return captionTargets(doc).find((target) => target.id.toLowerCase() === wanted) ?? null +} + +/** + * The letter a panel takes in crossref text: a..z, then aa, ab, ... (§4c). + * + * Bijective base-26, not base-26 with a zero digit: the panel after `z` is `aa`, + * so 26 must not carry as `ba`. + */ +export function panelLetter(index: number): string { + let out = '' + let n = index + do { + out = String.fromCharCode(97 + (n % 26)) + out + n = Math.floor(n / 26) - 1 + } while (n >= 0) + return out +} + +function collect(nodes: readonly BlockNode[], targets: CaptionTarget[]): void { + for (const node of nodes) { + if (node.type === 'figure_group') { + const groupText = captionRefText(node.caption) + pushTarget(targets, node, 'composite figure', groupText) + collectPanels(node, groupText, targets) + continue + } + if (node.type === 'figure') { + pushTarget(targets, node, 'figure', captionRefText(node.caption)) + } else if (node.type === 'table') { + pushTarget(targets, node, 'table', captionRefText(node.caption)) + } + // The walk continues into every container, because a captioned figure in a + // block quote or a list item is a crossref target like any other. A group's + // children are walked by collectPanels above instead, which needs the panel + // ORDER that this loop does not carry. + if ('children' in node && Array.isArray(node.children)) { + collect(node.children.filter(isBlockNode), targets) + } + if (node.type === 'figure' && isBlockNode(node.target)) { + collect([node.target], targets) + } + } +} + +/** + * A group's panels are its DIRECT `figure` and `table` children, in source + * order (§4c). Everything else in the body is plain group content: it can still + * hold crossref targets of its own, but it draws no letter and does not shift + * the letters of the panels around it. + */ +function collectPanels( + group: Extract, + groupText: string | null, + targets: CaptionTarget[], +): void { + let panelIndex = 0 + for (const child of group.children) { + const isPanel = child.type === 'figure' || child.type === 'table' + if (isPanel) { + pushTarget( + targets, + child, + 'panel', + groupText === null ? null : `${groupText}${panelLetter(panelIndex)}`, + ) + panelIndex++ + if (child.type === 'figure' && isBlockNode(child.target)) collect([child.target], targets) + if ('children' in child && Array.isArray(child.children)) { + collect(child.children.filter(isBlockNode), targets) + } + continue + } + collect([child], targets) + } +} + +function pushTarget( + targets: CaptionTarget[], + node: BlockNode, + kind: CaptionTarget['kind'], + text: string | null, +): void { + const id = (node as { attrs?: { id?: string } }).attrs?.id + if (!id) return + targets.push({ id, kind, text, pos: node.pos }) +} + +/** + * The crossref text a caption yields: everything up to and including its + * `caption_number`, trimmed. A caption with no number yields null - there is + * nothing for `` to render, and the engine leaves such a reference as + * literal text. + */ +function captionRefText(caption: readonly InlineNode[] | undefined): string | null { + if (!caption) return null + let out = '' + for (const node of caption) { + if (node.type === 'caption_number') { + const n = (node as { n?: number }).n + if (typeof n !== 'number') return null + return `${out}${n}`.trim() + } + out += plainText([node]) + } + return null +} + +function plainText(nodes: readonly InlineNode[]): string { + let out = '' + for (const node of nodes) { + if (node.type === 'text') out += node.value + else if ('children' in node && Array.isArray(node.children)) { + out += plainText(node.children as InlineNode[]) + } else if (node.type === 'code') out += node.value + else if (node.type === 'literal_inline') out += node.content + else out += smartPunctuationText(node) + } + return out +} + +function isBlockNode(node: unknown): node is BlockNode { + return Boolean(node && typeof node === 'object' && 'type' in node) +} diff --git a/src/completion.ts b/src/completion.ts index 4f052a6..5c5b872 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -4,6 +4,7 @@ import { type Position, } from 'vscode-languageserver/node.js' import { parse, resolve, type BlockNode, type Document } from '@markup-carve/carve' +import { captionTargets, type CaptionTarget } from './captions.js' /** The eight canonical admonition kinds (grammar PART 9 §12, Tier 1). */ const ADMONITIONS = ['note', 'tip', 'warning', 'danger', 'info', 'success', 'example', 'quote'] @@ -38,9 +39,17 @@ export function completionAt(source: string, position: Position): CompletionItem ] } if ((match = /<\/#([\w-]*)$/.exec(prefix))) { - return headingIds(source).map((id) => - completion(id, CompletionItemKind.Reference, match![1], position, 'Heading id'), - ) + // A crossref reaches a captioned host as well as a heading (PART 9R R4). + // Offering only heading ids said the others were not targets, which is the + // reading that made a `` naming a figure look like a typo. + return [ + ...headingIds(source).map((id) => + completion(id, CompletionItemKind.Reference, match![1], position, 'Heading id'), + ), + ...resolvableCaptionIds(source).map(({ id, detail }) => + completion(id, CompletionItemKind.Reference, match![1], position, detail), + ), + ] } if ((match = /\[\^([\w-]*)$/.exec(prefix))) { return footnoteLabels(source).map((label) => @@ -91,6 +100,33 @@ function headingIds(source: string): string[] { return [...new Set(ids)] } +/** + * The caption ids a `` can actually RESOLVE, each with what it resolves to + * - "Figure 2" beside "Figure 2a", so the list distinguishes a group from its + * panels without the author having to remember which is which. + * + * A host that drew NO number is left out. Its id is a real anchor, and a + * `[text](#id)` fragment link reaches it, but a CROSSREF to it renders as + * literal text (PART 9 §4c: an unnumbered group's panels are anchors, not + * caption crossref targets). Every heading id this list offers resolves, and a + * caption id that did not would be the one entry that quietly does not work. + */ +function resolvableCaptionIds(source: string): Array<{ id: string; detail: string }> { + let targets: CaptionTarget[] + try { + targets = captionTargets(resolve(parse(source, { positions: true }))) + } catch { + // Parsing may fail mid-edit; offer no ids rather than throwing. + return [] + } + const resolvable: Array<{ id: string; detail: string }> = [] + for (const target of targets) { + if (target.text === null) continue + resolvable.push({ id: target.id, detail: `${target.text} (${target.kind})` }) + } + return resolvable +} + function footnoteLabels(source: string): string[] { try { const doc: Document = resolve(parse(source)) diff --git a/src/composite-figure-crossref.test.ts b/src/composite-figure-crossref.test.ts new file mode 100644 index 0000000..98e14d9 --- /dev/null +++ b/src/composite-figure-crossref.test.ts @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { carveToHtml, lintCarve, parse, resolve } from '@markup-carve/carve' +import { analyzeCarve } from './analyze.js' +import { captionTargets, panelLetter } from './captions.js' +import { completionAt } from './completion.js' +import { definitionAt } from './definition.js' +import { hoverAt } from './hover.js' +import { referencesAt } from './references.js' + +/* + * A composite figure across the features that resolve an id (PART 9 §4c, + * markup-carve/carve#1122), plus the diagnostics the clause names. + * + * The crossref half of this was NOT a figure_group gap. The server resolved no + * caption id at all - not a group's, not a panel's, and not the plain captioned + * figure's that predates the feature - because every crossref feature walked + * headings only. The `{#fig}` figure below is here for exactly that reason: it + * is the case that has been broken the longest and is the control for the group + * cases, so a fix that only taught the walk about `figure_group` fails it. + * + * WHERE THE EXPECTED TEXT COMES FROM. The engine, not this file's reading of + * §4c. `carveToHtml` writes the resolved text into the anchor for each id, and + * the assertions below compare the server's answer against THAT rather than + * against a literal - so a letter scheme or a numbering rule that changes + * upstream fails here instead of diverging quietly in an editor. + */ + +const DOC = `# Section + +{#fig} +![plain](p.png) +^ Figure #: Plain + +{#g} +::: figure +{#one} +![one](a.png) +^ (a) One + +A stray paragraph between the panels. + +{#two} +![two](b.png) +^ (b) Two +::: +^ Figure #: Group caption + +See , , and . +` + +/** + * The line the references sit on, and the column of each one. Derived from the + * document rather than written down, so editing DOC cannot silently move an + * assertion onto a blank line where every feature correctly answers nothing. + */ +const REF_LINE = DOC.split('\n').findIndex((line) => line.startsWith('See ')) +const refColumn = (id: string): number => DOC.split('\n')[REF_LINE]!.indexOf(``) + 3 + +/** The text the ENGINE renders for ``, read off the anchor it writes. */ +const engineRefText = (source: string, id: string): string | null => { + const match = new RegExp(`([^<]*)`).exec(carveToHtml(source)) + return match ? match[1]! : null +} + +const targetFor = (source: string, id: string) => + captionTargets(resolve(parse(source, { positions: true }))).find((t) => t.id === id) + +test('every caption id resolves to the text the engine renders for it', () => { + for (const id of ['fig', 'g', 'one', 'two']) { + assert.equal( + targetFor(DOC, id)?.text, + engineRefText(DOC, id), + `crossref text for #${id} disagrees with the rendered anchor`, + ) + } + // Not merely "equal to each other": the group's own reference carries no + // letter and its panels do, which is the rule under test. + assert.equal(engineRefText(DOC, 'g'), 'Figure 2') + assert.equal(engineRefText(DOC, 'one'), 'Figure 2a') + assert.equal(engineRefText(DOC, 'two'), 'Figure 2b') +}) + +test('a stray block between panels does not take a letter', () => { + // The paragraph sits between the two panels. If the walk counted children + // rather than PANELS, the second panel would be "Figure 2c". + assert.equal(targetFor(DOC, 'two')?.text, 'Figure 2b') +}) + +test('panel letters run a..z then aa', () => { + assert.deepEqual([0, 1, 25, 26, 27, 51, 52].map(panelLetter), [ + 'a', + 'b', + 'z', + 'aa', + 'ab', + 'az', + 'ba', + ]) +}) + +test('an unnumbered group registers anchors but no crossref text', () => { + const source = `{#ug}\n::: figure\n{#u1}\n![u](u.png)\n^ (a) U\n:::\n^ No number here\n\nSee and .\n` + assert.equal(targetFor(source, 'ug')?.text, null) + assert.equal(targetFor(source, 'u1')?.text, null) + // The engine agrees: it leaves both references as literal text rather than + // linking them, which is what "not a caption crossref target" means (§4c). + assert.equal(engineRefText(source, 'ug'), null) + assert.equal(engineRefText(source, 'u1'), null) +}) + +test('go-to-definition on a crossref reaches the host, panel or not', () => { + // `` is the control: a captioned figure, no group involved, and it + // jumped nowhere before this. + const hostLine = (id: string): number | undefined => + definitionAt('file:///d.crv', DOC, { line: REF_LINE, character: refColumn(id) })?.range.start.line + assert.equal(hostLine('fig'), 3) + assert.equal(hostLine('g'), 7) + assert.equal(hostLine('one'), 9) +}) + +test('a crossref hover names what the id resolves to', () => { + const text = (id: string): string => { + const hover = hoverAt(DOC, { line: REF_LINE, character: refColumn(id) }) + return typeof hover?.contents === 'object' && 'value' in hover.contents ? hover.contents.value : '' + } + assert.match(text('one'), /Figure 2a/) + assert.match(text('one'), /panel/) + assert.match(text('g'), /Figure 2/) + // CONTROL. Before this, no `heading_ref` case existed and the lexical rules + // took the `#` inside the reference, so every crossref hovered as a heading. + assert.doesNotMatch(text('one'), /create section headings/) +}) + +test('find-references pairs a panel id with its usages', () => { + const locations = referencesAt( + 'file:///d.crv', + DOC, + { line: REF_LINE, character: refColumn('one') }, + { includeDeclaration: true }, + ) + assert.deepEqual(locations?.map((location) => location.range.start.line), [9, REF_LINE]) +}) + +test('crossref completion offers caption ids beside heading ids', () => { + const source = DOC.replace('See , , and .', 'See items.find((i) => i.label === label)?.detail + + assert.equal(detail('Section'), 'Heading id') + assert.equal(detail('fig'), 'Figure 1 (figure)') + assert.equal(detail('g'), 'Figure 2 (composite figure)') + assert.equal(detail('one'), 'Figure 2a (panel)') + assert.equal(detail('two'), 'Figure 2b (panel)') +}) + +test('an unnumbered host is not offered as a crossref target', () => { + // Every id ` item.label) + assert.deepEqual(labels, []) +}) + +test('find-references works from the declaration as well as from a usage', () => { + // A heading declares its id on its own line; a captioned host declares it on + // the block-attribute line ABOVE itself. Both are where an author puts the + // cursor to ask "what points at this?". + const fromAttributeLine = referencesAt('file:///d.crv', DOC, { line: 6, character: 2 }, { + includeDeclaration: false, + }) + const fromHostLine = referencesAt('file:///d.crv', DOC, { line: 7, character: 2 }, { + includeDeclaration: false, + }) + assert.deepEqual(fromAttributeLine?.map((location) => location.range.start.line), [REF_LINE]) + assert.deepEqual(fromHostLine?.map((location) => location.range.start.line), [REF_LINE]) +}) + +test('a declaration inside a container still declares', () => { + // The id's line is not always a bare `{#id}` at column 0. A shape test on it + // reads like a safety check and is not one - a host only has an id because a + // block-attribute line gave it one - so it can only reject a right answer. + const quoted = '> {#fig}\n> ![a](a.png)\n> ^ Figure #: A\n\nSee .\n' + assert.deepEqual( + referencesAt('file:///d.crv', quoted, { line: 0, character: 3 }, { includeDeclaration: false }) + ?.map((location) => location.range.start.line), + [4], + ) +}) + +test('a line that declares no id declares nothing', () => { + // CONTROL. The lookup keys on hosts that CARRY an id, not on hosts. An + // uncaptioned-id figure has no entry, so the prose above it is just prose. + assert.equal( + referencesAt('file:///d.crv', 'Prose.\n![a](a.png)\n^ Figure #: A\n', { line: 0, character: 2 }, { + includeDeclaration: false, + }), + null, + ) + // And a line nowhere near a host answers nothing either. + assert.equal( + referencesAt('file:///d.crv', DOC, { line: 12, character: 2 }, { includeDeclaration: false }), + null, + ) +}) + +test('a construct on the declaration line still wins', () => { + // CONTROL for the declaration lookup, which is matched by LINE and so + // answers for every column on it. A host line can carry a reference image, + // and asking on its label must reach the link-reference definition rather + // than the figure that happens to wrap it. + const source = '{#fig}\n![alt][img]\n^ Figure #: C\n\n[img]: pic.png\n\nAgain [x][img].\n' + const onLabel = referencesAt('file:///d.crv', source, { line: 1, character: 8 }, { + includeDeclaration: true, + }) + assert.deepEqual(onLabel?.map((location) => location.range.start.line), [1, 4, 6]) + + // The same line still declares the figure where nothing else claims the + // cursor - here, on the `!` that opens the image. + const onHost = referencesAt('file:///d.crv', source, { line: 0, character: 2 }, { + includeDeclaration: false, + }) + assert.deepEqual(onHost?.map((location) => location.range.start.line), []) +}) + +test('a collapsed reference is not a usage of a caption id', () => { + // `[foo][]` falls back to the implicit HEADING target and to nothing else + // (PART 9R R1): beside a figure `{#foo}` it renders as literal text, so + // reporting it as a usage would be a reference the author cannot follow. + const source = '{#foo}\n![a](a.png)\n^ Figure #: Foo\n\nSee [foo][] and .\n' + const locations = referencesAt('file:///d.crv', source, { line: 4, character: 22 }, { + includeDeclaration: false, + }) + assert.deepEqual(locations?.map((location) => location.range.start.character), [16]) +}) + +test('a collapsed reference IS a usage of a heading id', () => { + // CONTROL. The scan is not removed, it is scoped: the same spelling still + // reaches a heading, which is the target it actually resolves to. + const source = '# Foo\n\nSee [foo][] and .\n' + const locations = referencesAt('file:///d.crv', source, { line: 2, character: 20 }, { + includeDeclaration: false, + }) + assert.deepEqual(locations?.map((location) => location.range.start.character), [16, 4]) +}) + +test('the outline carries the group and nests its panels', () => { + const symbols = analyzeCarve(DOC).symbols + assert.equal(symbols.length, 1, 'the heading is still the only root') + + const group = symbols[0]!.children?.find((child) => child.name.startsWith('Figure 2')) + assert.ok(group, `no group symbol under the heading: ${JSON.stringify(symbols[0]!.children)}`) + assert.equal(group.name, 'Figure 2: Group caption') + assert.equal(group.detail, '2 panels') + assert.deepEqual(group.children?.map((panel) => panel.name), ['(a) One', '(b) Two']) +}) + +test('a heading inside a panel stays in the outline', () => { + // CONTROL for the group's own entry. A panel yields no outline entry of its + // own, but the walk still descends through it: a panel can wrap a quote + // holding headings, and those belong to the section the group sits in - which + // is where they were before the group had an entry at all. + const symbols = analyzeCarve( + '# Section\n\n::: figure\n> ## Inside a panel\n>\n> Quoted.\n^ (a) A\n:::\n^ Figure #: g\n', + ).symbols + assert.deepEqual( + symbols[0]!.children?.map((child) => child.name), + ['Figure 1: g', 'Inside a panel'], + ) +}) + +test('a group with no caption is still an outline entry, named for what it is', () => { + const symbols = analyzeCarve('::: figure\n![a](a.png)\n^ (a) A\n:::\n').symbols + assert.deepEqual(symbols.map((symbol) => symbol.name), ['Composite figure']) + assert.equal(symbols[0]!.detail, '1 panel') +}) + +test('an uncaptioned panel falls back to the letter a crossref would use', () => { + const symbols = analyzeCarve('::: figure\n| a |\n\n| b |\n:::\n^ Figure #: g\n').symbols + assert.deepEqual(symbols[0]!.children?.map((panel) => panel.name), ['Panel a', 'Panel b']) +}) + +test('a heading is still a heading, wherever it sits relative to a group', () => { + // CONTROL for the outline change. A group takes no heading level, so it must + // not pop the stack: the section after it stays a sibling of the one before. + const symbols = analyzeCarve( + '# One\n\n::: figure\n![a](a.png)\n^ (a) A\n:::\n\n## Under one\n\n# Two\n', + ).symbols + assert.deepEqual(symbols.map((symbol) => symbol.name), ['One', 'Two']) + assert.deepEqual(symbols[0]!.children?.map((child) => child.name), ['Composite figure', 'Under one']) +}) + +/* + * The five §4c findings. The server derives none of them - it publishes what + * `lintCarve` reports - so what is pinned here is that the passthrough works + * and that the engine still emits each id under the pinned build. Nothing + * asserted it before, so a passthrough that stopped would have been silent. + */ +const LINT_CASES: Array<[string, string]> = [ + ['figure-group-nested', '::: figure\n::: figure\nBody.\n:::\n:::\n^ Figure #: outer\n'], + ['figure-group-opener-metadata', '::: figure "T"\n![a](a.png)\n^ (a) A\n:::\n'], + ['figure-group-panel-number', '::: figure\n![a](a.png)\n^ Figure #: panel\n:::\n^ Figure #: group\n'], + ['figure-group-empty', '::: figure\nJust text.\n:::\n^ Figure #: g\n'], + ['figure-group-single-panel', '::: figure\n![a](a.png)\n^ (a) A\n:::\n^ Figure #: g\n'], +] + +for (const [code, source] of LINT_CASES) { + test(`${code} reaches the server's diagnostics`, () => { + assert.ok( + lintCarve(source).some((warning) => warning.rule === code), + `the pinned engine no longer emits ${code}`, + ) + assert.ok( + analyzeCarve(source).diagnostics.some((diagnostic) => diagnostic.code === code), + `${code} did not reach the server: ${JSON.stringify( + analyzeCarve(source).diagnostics.map((d) => d.code), + )}`, + ) + }) +} + +test('a well-formed group reports none of them', () => { + // CONTROL. Every case above differs from this document in one line, so a + // passthrough that published a fixed list would look identical up there. + const clean = '::: figure\n![a](a.png)\n^ (a) A\n\n![b](b.png)\n^ (b) B\n:::\n^ Figure #: g\n' + const codes = analyzeCarve(clean).diagnostics.map((diagnostic) => diagnostic.code) + assert.deepEqual(codes.filter((code) => String(code).startsWith('figure-group-')), []) +}) diff --git a/src/definition.ts b/src/definition.ts index 7c5bf00..e87df71 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -2,13 +2,15 @@ import { type Location, type Position } from 'vscode-languageserver/node.js' import { parse, resolve, type BlockNode, type Document, type InlineNode } from '@markup-carve/carve' import { astColumnToCharacter, sourceLines } from './position.js' import { smartPunctuationText } from './inline-text.js' +import { captionTargetById } from './captions.js' import { includeDefinitionAt } from './include-definition.js' import type { IncludeOptions } from './includes.js' /** * Go-to-definition for Carve constructs: * - * - Cross-reference `` -> the heading whose generated id is `id` + * - Cross-reference `` -> the heading whose generated id is `id`, + * or the captioned host carrying it * - Fragment link `[text](#id)` -> same (href starts with `#`) * - Footnote ref `[^name]` -> the `[^name]:` definition line * - Link reference `[text][ref]` -> the `[ref]:` definition line @@ -115,13 +117,19 @@ function resolveCrossrefAt(uri: string, source: string, line: string, position: function findHeadingById(uri: string, source: string, targetId: string): Location | null { let doc: Document try { - doc = resolve(parse(source)) + doc = resolve(parse(source, { positions: true })) } catch { return null } const heading = findHeadingWithId(doc.children, targetId.toLowerCase()) - if (!heading || !heading.pos) return null + if (!heading || !heading.pos) { + // A crossref reaches a CAPTIONED HOST as well - a figure, a table, a + // composite figure or one of its panels (PART 9R R4). Only headings were + // searched here, so `` jumped nowhere on a construct that predates + // composite figures entirely. + return findCaptionById(uri, doc, targetId) + } const line = heading.pos.startLine - 1 return { @@ -142,6 +150,18 @@ function findHeadingById(uri: string, source: string, targetId: string): Locatio } } +/** + * The host's own first line. A captioned host has no single "definition line" + * the way a heading does - its id sits on a block-attribute line above it, its + * caption below it - so the jump lands on the host itself, which is the line an + * author is looking for. + */ +function findCaptionById(uri: string, doc: Document, targetId: string): Location | null { + const target = captionTargetById(doc, targetId) + if (!target?.pos) return null + return locationAtLine(uri, target.pos.startLine - 1) +} + function findHeadingWithId( nodes: BlockNode[], targetId: string, diff --git a/src/hover.ts b/src/hover.ts index d0db5ec..4033471 100644 --- a/src/hover.ts +++ b/src/hover.ts @@ -8,6 +8,7 @@ import { type Position as SourcePosition, } from '@markup-carve/carve' import { astColumnToCharacter, characterToAstColumn, sourceLines } from './position.js' +import { captionTargetById, type CaptionTarget } from './captions.js' interface HoverRule { pattern: RegExp @@ -97,7 +98,11 @@ function astHoverAt(doc: Document, position: Position, lines: string[]): Hover | character: characterToAstColumn(lines[position.line] ?? '', position.character) - 1, } const matches: Array<{ pos: SourcePosition; contents: string }> = [] - for (const node of doc.children) collectBlock(matches, node, cursor) + // A crossref's help depends on what the id NAMES, so the whole document is in + // scope for it while every other rule reads one node. It is looked up through + // this resolver rather than from inside the inline walk. + const resolveRef: RefResolver = (id) => captionTargetById(doc, id) + for (const node of doc.children) collectBlock(matches, node, cursor, resolveRef) matches.sort((a, b) => spanSize(a.pos) - spanSize(b.pos)) const match = matches[0] if (!match) return null @@ -114,45 +119,46 @@ function collectBlock( matches: Array<{ pos: SourcePosition; contents: string }>, node: BlockNode, position: Position, + resolveRef: RefResolver, ): void { addMatch(matches, node.pos, position, blockContents(node)) switch (node.type) { case 'heading': - collectInline(matches, node.children, position) + collectInline(matches, node.children, position, resolveRef) break case 'paragraph': - collectInline(matches, node.children, position) + collectInline(matches, node.children, position, resolveRef) break case 'block_quote': - node.children.forEach((child) => collectBlock(matches, child, position)) + node.children.forEach((child) => collectBlock(matches, child, position, resolveRef)) break case 'list': - node.items.forEach((item) => item.children.forEach((child) => collectBlock(matches, child, position))) + node.items.forEach((item) => item.children.forEach((child) => collectBlock(matches, child, position, resolveRef))) break case 'admonition': case 'div': - node.children.forEach((child) => collectBlock(matches, child, position)) - if (node.type === 'admonition' && node.title) collectInline(matches, node.title, position) + node.children.forEach((child) => collectBlock(matches, child, position, resolveRef)) + if (node.type === 'admonition' && node.title) collectInline(matches, node.title, position, resolveRef) break case 'definition_list': node.items.forEach((item) => { - item.terms.forEach((term) => collectInline(matches, term, position)) + item.terms.forEach((term) => collectInline(matches, term, position, resolveRef)) item.definitions.forEach((definition) => - definition.forEach((child) => collectBlock(matches, child, position)), + definition.forEach((child) => collectBlock(matches, child, position, resolveRef)), ) }) break case 'figure': - collectBlock(matches, node.target, position) - collectInline(matches, node.caption, position) + collectBlock(matches, node.target, position, resolveRef) + collectInline(matches, node.caption, position, resolveRef) break case 'figure_group': - node.children.forEach((child) => collectBlock(matches, child, position)) - if (node.caption) collectInline(matches, node.caption, position) + node.children.forEach((child) => collectBlock(matches, child, position, resolveRef)) + if (node.caption) collectInline(matches, node.caption, position, resolveRef) break case 'table': - if (node.caption) collectInline(matches, node.caption, position) - node.rows.forEach((row) => row.cells.forEach((cell) => collectInline(matches, cell.children, position))) + if (node.caption) collectInline(matches, node.caption, position, resolveRef) + node.rows.forEach((row) => row.cells.forEach((cell) => collectInline(matches, cell.children, position, resolveRef))) break } } @@ -161,13 +167,14 @@ function collectInline( matches: Array<{ pos: SourcePosition; contents: string }>, nodes: InlineNode[], position: Position, + resolveRef: RefResolver, ): void { for (const node of nodes) { - addMatch(matches, node.pos, position, inlineContents(node)) + addMatch(matches, node.pos, position, inlineContents(node, resolveRef)) const children = (node as { children?: InlineNode[] }).children - if (Array.isArray(children)) collectInline(matches, children, position) + if (Array.isArray(children)) collectInline(matches, children, position, resolveRef) const content = (node as { content?: InlineNode[] }).content - if (Array.isArray(content)) collectInline(matches, content, position) + if (Array.isArray(content)) collectInline(matches, content, position, resolveRef) } } @@ -210,7 +217,7 @@ function blockContents(node: BlockNode): string | null { } } -function inlineContents(node: InlineNode): string | null { +function inlineContents(node: InlineNode, resolveRef: RefResolver): string | null { switch (node.type) { case 'strong': return '**Bold**\n\nCarve uses single asterisks for bold text: `*bold*`.' @@ -240,11 +247,40 @@ function inlineContents(node: InlineNode): string | null { return '**Tag**\n\nTags use `#name`.' case 'span': return '**Span**\n\nInline spans use `[text]{attrs}`.' + case 'heading_ref': + return crossrefContents(node, resolveRef) default: return null } } +type RefResolver = (id: string) => CaptionTarget | null + +/** + * A `` used to fall through this switch entirely, so the LEXICAL rules + * above took it and reported the `#` inside the reference as a heading marker. + * It now says what the reference resolves to, which for a composite figure's + * panel is the group's number plus its letter (PART 9 §4c). + * + * A heading target keeps the generic wording: the heading's own text is what + * the reference renders, and it is already on screen. + */ +function crossrefContents(node: InlineNode, resolveRef: RefResolver): string { + const generic = + '**Cross-reference**\n\nA `` reference links to the heading or captioned host carrying that id.' + const id = (node as { target?: unknown }).target + if (typeof id !== 'string') return generic + const target = resolveRef(id) + if (!target) return generic + if (target.text === null) { + return ( + '**Cross-reference**\n\nAn unnumbered ' + target.kind + '. It is an anchor, but it drew no ' + + 'number, so this reference has no caption text to render.' + ) + } + return '**Cross-reference**\n\nResolves to **' + target.text + '** (' + target.kind + ').' +} + function contains(pos: SourcePosition, position: Position): boolean { if ( pos.startColumn === undefined || diff --git a/src/references.ts b/src/references.ts index ead29e1..ff032f2 100644 --- a/src/references.ts +++ b/src/references.ts @@ -1,6 +1,7 @@ import { type Location, type Position, type ReferenceContext } from 'vscode-languageserver/node.js' import { parse, resolve, type BlockNode, type Document } from '@markup-carve/carve' import { smartPunctuationText } from './inline-text.js' +import { captionTargetById, captionTargets } from './captions.js' /** * Find-references for Carve constructs (same-document scope). @@ -10,6 +11,8 @@ import { smartPunctuationText } from './inline-text.js' * * Supported families, mirroring definition.ts: * - Heading id (on `#` heading line or on a `` / `[text](#id)` usage) + * - Caption id (on a `` usage, or on a captioned host's own declaration + * line - matched LAST, so a construct on that line still wins) * - Footnote `[^name]` references and `[^name]:` definition * - Link-ref `[text][ref]` / `[ref][]` usages and `[ref]:` definition * - Citation `[@key]` usages and `[@key]:` definition @@ -46,6 +49,12 @@ export function referencesAt( const wikilinkResult = resolveWikilinkGroup(uri, source, lines, line, position, context) if (wikilinkResult) return wikilinkResult + // 6. A captioned host's own declaration line. LAST, because it is the only + // family matched by line rather than by a construct under the cursor - see + // resolveCaptionDeclarationGroup. + const captionResult = resolveCaptionDeclarationGroup(uri, source, lines, position, context) + if (captionResult) return captionResult + return null } @@ -65,7 +74,9 @@ function resolveHeadingGroup( const headingMatch = /^(#{1,6})\s+/.exec(line) if (headingMatch) { const id = getHeadingId(source, position.line) - if (id) return collectHeadingRefs(uri, source, lines, id, position.line, context) + if (id) { + return collectRefs(uri, source, lines, id, { line: position.line, kind: 'heading' }, context) + } } // Cursor on @@ -75,9 +86,9 @@ function resolveHeadingGroup( const end = start + m[0].length if (position.character < start || position.character >= end) continue const id = m[1]! - const defLine = findHeadingLineById(source, id) - if (defLine === null) return [] - return collectHeadingRefs(uri, source, lines, id, defLine, context) + const declaration = findDeclarationById(source, id) + if (declaration === null) return [] + return collectRefs(uri, source, lines, id, declaration, context) } // Cursor on [text](#id) @@ -87,14 +98,71 @@ function resolveHeadingGroup( const end = start + m[0].length if (position.character < start || position.character >= end) continue const id = m[1]! - const defLine = findHeadingLineById(source, id) - if (defLine === null) return [] - return collectHeadingRefs(uri, source, lines, id, defLine, context) + const declaration = findDeclarationById(source, id) + if (declaration === null) return [] + return collectRefs(uri, source, lines, id, declaration, context) } return null } +/** + * Cursor on the DECLARATION of a captioned host. A heading declares its id on + * its own line, and asking there works; a captioned host declares it on the + * block-attribute line ABOVE itself, and asking there answered nothing - so the + * feature was reachable from a usage only, which is half of what this module + * documents. + * + * IT RUNS LAST, and that ordering is the rule rather than an accident. This is + * the only family matched by LINE rather than by a construct under the cursor, + * so it answers for every column on that line - including a column holding + * something else. A host line can carry a reference image (`![alt][img]`), and + * running this before the link-reference family answered the FIGURE's + * references for a cursor sitting on `img`. + */ +function resolveCaptionDeclarationGroup( + uri: string, + source: string, + lines: string[], + position: Position, + context: ReferenceContext, +): Location[] | null { + const declared = captionIdDeclaredAt(source, position.line) + if (declared === null) return null + const declaration = findDeclarationById(source, declared) + if (declaration === null) return [] + return collectRefs(uri, source, lines, declared, declaration, context) +} + +/** + * The caption id a source line DECLARES: the host's own first line, or the line + * immediately above it, which is where the id is written. The attribute line + * sits OUTSIDE the host's span, so it is matched by adjacency rather than by + * containment. + * + * NO SHAPE TEST ON THE LINE ABOVE, deliberately. A `/^\s*\{.*\}\s*$/` guard + * reads like a safety check and is not one: a host only HAS an id because a + * block-attribute line gave it one, so the line above a host in this list is + * that line by construction, and the guard can only ever reject a spelling of + * it - `> {#fig}` inside a block quote, which the anchored brace does not + * match. A check that cannot reject a wrong answer and can reject a right one + * is worse than no check. + */ +function captionIdDeclaredAt(source: string, lineIndex: number): string | null { + let doc: Document + try { + doc = resolve(parse(source, { positions: true })) + } catch { + return null + } + for (const target of captionTargets(doc)) { + if (!target.pos) continue + const hostLine = target.pos.startLine - 1 + if (lineIndex === hostLine || lineIndex === hostLine - 1) return target.id + } + return null +} + function getHeadingId(source: string, lineIndex: number): string | null { let doc: Document try { @@ -120,16 +188,32 @@ function findHeadingAtLine( return null } -function findHeadingLineById(source: string, targetId: string): number | null { +/** + * Where a crossref id is declared, and WHAT declares it: a heading's own line, + * or a captioned host's first line. Headings alone were searched here, so + * asking for the usages of a figure id found the group and then returned + * nothing. + * + * The kind is not decoration. A COLLAPSED reference `[text][]` falls back to + * the implicit HEADING target (PART 9R R1) and to nothing else - `[foo][]` + * beside a figure `{#foo}` renders as literal text - so the usage scan that + * looks for it must run for one kind and not the other. + */ +function findDeclarationById( + source: string, + targetId: string, +): { line: number; kind: 'heading' | 'caption' } | null { let doc: Document try { - doc = resolve(parse(source)) + doc = resolve(parse(source, { positions: true })) } catch { return null } const heading = findHeadingWithId(doc.children, targetId.toLowerCase()) - if (!heading || !heading.pos) return null - return heading.pos.startLine - 1 + if (heading?.pos) return { line: heading.pos.startLine - 1, kind: 'heading' } + const caption = captionTargetById(doc, targetId) + if (caption?.pos) return { line: caption.pos.startLine - 1, kind: 'caption' } + return null } function findHeadingWithId( @@ -149,17 +233,28 @@ function findHeadingWithId( return null } -function collectHeadingRefs( +/** + * Every usage of an id, for a declaration that may be a heading or a captioned + * host. + * + * The families differ in exactly one usage form. A COLLAPSED reference + * `[text][]` falls back to the implicit HEADING target (PART 9R R1) and to + * nothing else, so `[foo][]` beside a figure `{#foo}` renders as literal text + * rather than reaching the figure. Reporting it as a usage of the figure would + * be a reference the author cannot follow and did not write. + */ +function collectRefs( uri: string, source: string, lines: string[], id: string, - defLine: number, + declaration: { line: number; kind: 'heading' | 'caption' }, context: ReferenceContext, ): Location[] { const locs: Location[] = [] + const defLine = declaration.line - // Optionally include the definition (the heading line itself) + // Optionally include the definition (the declaring line itself) if (context.includeDeclaration) { locs.push(lineLocation(uri, defLine, 0, lines[defLine]?.length ?? 0)) } @@ -190,12 +285,15 @@ function collectHeadingRefs( // These are resolved to `href` in the AST, so we check the link pool from // semantic analysis. For simplicity we scan for [text][] where text lowercased // matches the heading id (djot implicit ref: whitespace-collapsed, lowercase). - const implicitRefRe = /\[([^\]\n]+)\]\[\]/g - for (let i = 0; i < lines.length; i++) { - for (const m of lines[i]!.matchAll(implicitRefRe)) { - const slug = m[1]!.trim().toLowerCase().replace(/\s+/g, '-') - if (slug === idLower) { - locs.push(lineLocation(uri, i, m.index!, m.index! + m[0].length)) + // HEADINGS ONLY - see the note on this function. + if (declaration.kind === 'heading') { + const implicitRefRe = /\[([^\]\n]+)\]\[\]/g + for (let i = 0; i < lines.length; i++) { + for (const m of lines[i]!.matchAll(implicitRefRe)) { + const slug = m[1]!.trim().toLowerCase().replace(/\s+/g, '-') + if (slug === idLower) { + locs.push(lineLocation(uri, i, m.index!, m.index! + m[0].length)) + } } } }