Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). `</#id>` 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
Expand Down
107 changes: 96 additions & 11 deletions src/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -193,38 +198,114 @@ 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 ??= []
parent.symbol.children.push(symbol)
} 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<Heading> {
type OutlineEntry = Heading | Extract<BlockNode, { type: 'figure_group' }>

/**
* 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<OutlineEntry> {
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<BlockNode, { type: 'figure_group' }>): 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)
}
Expand Down Expand Up @@ -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}`
Expand Down
180 changes: 180 additions & 0 deletions src/captions.ts
Original file line number Diff line number Diff line change
@@ -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 `</#id>` can name, and the text each one resolves to.
*
* The server used to walk HEADINGS for every crossref feature, so a `</#fig>`
* 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 `</#id>` 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<BlockNode, { type: 'figure_group' }>,
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 `</#id>` 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)
}
Loading