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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 30 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
20 changes: 16 additions & 4 deletions src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
* - `</#` cross-reference -> heading ids in the document
* - `[^` footnote reference -> defined footnote labels
* - `][` reference link -> defined link reference labels
Expand All @@ -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) =>
Expand Down
93 changes: 93 additions & 0 deletions src/composite-figure.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
3 changes: 3 additions & 0 deletions src/folding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const FOLDABLE = new Set<BlockNode['type']>([
'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',
])

/**
Expand Down
10 changes: 10 additions & 0 deletions src/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions src/semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down