12c - The block directive forms read and spell !adf: openers and closers #86

Merged
lilleman merged 6 commits from 12c into main 2026-09-16 21:48:08 +02:00
3 changed files with 66 additions and 51 deletions
Showing only changes of commit 582594a6d6 - Show all commits
+55 -41
View File
@@ -17,7 +17,6 @@ import {
} from '../commonmark-grammar.ts' } from '../commonmark-grammar.ts'
import { blockDirectiveForm } from '../block-directive-forms.ts' import { blockDirectiveForm } from '../block-directive-forms.ts'
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts' import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
import { largestNesting } from '../../nesting.ts'
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { readLinkDefinitions } from '../link-reference-definitions.ts' import { readLinkDefinitions } from '../link-reference-definitions.ts'
@@ -41,12 +40,11 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
type ListBlock = Extract<Block, { items: Block[][] }> type ListBlock = Extract<Block, { items: Block[][] }>
type OpenDirective = { blocks: Block[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition } type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
type OpenContainer = type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
| Extract<Block, { kind: 'blockquote' }>
| OpenDirective type OpenContainer = EdgeContainer | OpenDirective
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
type OpenLeaf = { position: SourcePosition } & ( type OpenLeaf = { position: SourcePosition } & (
| { closer: RegExp | undefined; construct: string; kind: 'html' } | { closer: RegExp | undefined; construct: string; kind: 'html' }
@@ -63,14 +61,30 @@ type Line = { column: number; text: string }
type LeafOpener = { index: number; position: SourcePosition } type LeafOpener = { index: number; position: SourcePosition }
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; leafOpeners: Map<Block[], Map<string, LeafOpener>>; position: SourcePosition; stack: OpenContainer[] } type Walk = ParsedBlocks & {
directiveDepths: Map<string, number[]>
edges: { container: EdgeContainer; depth: number }[]
leaf: OpenLeaf | undefined
leafOpeners: Map<Block[], Map<string, LeafOpener>>
position: SourcePosition
stack: OpenContainer[]
}
const indentedCodeColumns = 4 const indentedCodeColumns = 4
const largestOpenerIndentation = 3 const largestOpenerIndentation = 3
const tabStop = 4 const tabStop = 4
export function parseBlocks(markdown: string): ParsedBlocks { export function parseBlocks(markdown: string): ParsedBlocks {
const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, leafOpeners: new Map(), position: { line: 1, offset: 0 }, stack: [] } const walk: Walk = {
blocks: [],
definitions: new Map(),
directiveDepths: new Map(),
edges: [],
leaf: undefined,
leafOpeners: new Map(),
position: { line: 1, offset: 0 },
stack: [],
}
for (const line of sourceLines(markdown)) { for (const line of sourceLines(markdown)) {
walk.position = line.position walk.position = line.position
readLine(walk, { column: 0, text: line.text }) readLine(walk, { column: 0, text: line.text })
@@ -102,22 +116,19 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean {
return leaf?.kind === 'fenced-code' || leaf?.kind === 'html' return leaf?.kind === 'fenced-code' || leaf?.kind === 'html'
} }
// A directive container has no continuation marker, so every line continues it.
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } { function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
let depth = 0
let rest = line let rest = line
for (const container of walk.stack) { for (const { container, depth } of walk.edges) {
const next = continuesContainer(walk, container, rest) const next = continuesContainer(walk, container, rest)
if (next === undefined) break if (next === undefined) return { depth, rest }
depth += 1
rest = next rest = next
} }
return { depth, rest } return { depth: walk.stack.length, rest }
} }
function continuesContainer(walk: Walk, container: OpenContainer, line: Line): Line | undefined { function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation)) if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation))
// A directive container has no continuation marker.
if (container.kind === 'directive') return line
// A list item begins with at most one blank line: an empty one gives the second up. // A list item begins with at most one blank line: an empty one gives the second up.
if (isBlankLine(line.text)) { if (isBlankLine(line.text)) {
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' } return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
@@ -179,14 +190,18 @@ function openList(start: number | undefined, position: SourcePosition): ListBloc
function openContainer(walk: Walk, start: ContainerStart): void { function openContainer(walk: Walk, start: ContainerStart): void {
const blocks: Block[] = [] const blocks: Block[] = []
if (start.kind === 'blockquote') { if (start.kind === 'blockquote') {
const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position } const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
currentBlocks(walk).push(blockquote) currentBlocks(walk).push(blockquote)
walk.stack.push(blockquote) pushEdge(walk, blockquote)
return return
} }
const list = openedList(walk, start) const list = openedList(walk, start)
list.items.push(blocks) list.items.push(blocks)
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list }) pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list })
}
function pushEdge(walk: Walk, container: EdgeContainer): void {
walk.edges.push({ container, depth: walk.stack.push(container) - 1 })
} }
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md). // Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
@@ -201,7 +216,7 @@ function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>
function closeContainers(walk: Walk, depth: number): void { function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk) closeLeaf(walk)
for (const container of walk.stack.slice(depth)) { for (const container of dropContainers(walk, depth)) {
if (container.kind !== 'directive') continue if (container.kind !== 'directive') continue
container.parent[container.index] = { container.parent[container.index] = {
fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`), fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
@@ -209,11 +224,15 @@ function closeContainers(walk: Walk, depth: number): void {
position: container.position, position: container.position,
} }
} }
dropContainers(walk, depth)
} }
function dropContainers(walk: Walk, depth: number): void { function dropContainers(walk: Walk, depth: number): OpenContainer[] {
walk.stack.length = depth const dropped = walk.stack.splice(depth)
for (const container of dropped) {
if (container.kind === 'directive') container.depths.pop()
else walk.edges.pop()
}
return dropped
} }
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void { function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
@@ -223,11 +242,6 @@ function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'opener' }>): void { function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'opener' }>): void {
const { name } = directive const { name } = directive
// The node walk refuses this depth anyway; a deeper stack only makes every later line walk it.
if (walk.stack.length > largestNesting && blockDirectiveForm(name) === 'container') {
pushFault(walk, { code: 'unsupported-nesting-depth', message: `the input nests directive containers deeper than the ${largestNesting} levels the parser carries` })
return
}
const block: DirectiveBlock = { const block: DirectiveBlock = {
argument: directive.argument, argument: directive.argument,
attributes: directive.attributes, attributes: directive.attributes,
@@ -239,8 +253,12 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'op
const parent = currentBlocks(walk) const parent = currentBlocks(walk)
const index = parent.push(block) - 1 const index = parent.push(block) - 1
const { position } = block const { position } = block
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, index, kind: 'directive', name, parent, position }) if (block.blocks === undefined) {
else leafOpenersIn(walk, parent).set(name, { index, position }) entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position })
return
}
const depths = entryOf(walk.directiveDepths, name, (): number[] => [])
depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1)
} }
function closeDirective(walk: Walk, name: string): void { function closeDirective(walk: Walk, name: string): void {
@@ -260,12 +278,8 @@ function closeDirective(walk: Walk, name: string): void {
// A closer crosses no list item or blockquote edge. // A closer crosses no list item or blockquote edge.
function openDirectiveDepth(walk: Walk, name: string): number | undefined { function openDirectiveDepth(walk: Walk, name: string): number | undefined {
for (let depth = walk.stack.length - 1; depth >= 0; depth -= 1) { const depth = walk.directiveDepths.get(name)?.at(-1)
const container = walk.stack[depth] return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth
if (container?.kind !== 'directive') return undefined
if (container.name === name) return depth
}
return undefined
} }
function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void { function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
@@ -280,12 +294,12 @@ function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
blocks[opener.index] = { fault, kind: 'fault', position: opener.position } blocks[opener.index] = { fault, kind: 'fault', position: opener.position }
} }
function leafOpenersIn(walk: Walk, blocks: Block[]): Map<string, LeafOpener> { function entryOf<K, V>(map: Map<K, V>, key: K, create: () => V): V {
const known = walk.leafOpeners.get(blocks) const known = map.get(key)
if (known !== undefined) return known if (known !== undefined) return known
const openers = new Map<string, LeafOpener>() const created = create()
walk.leafOpeners.set(blocks, openers) map.set(key, created)
return openers return created
} }
function pushFault(walk: Walk, fault: ConvertFault): void { function pushFault(walk: Walk, fault: ConvertFault): void {
+5 -7
View File
@@ -668,13 +668,11 @@ test('refuses input nested deeper than the parser carries', () => {
const marks = (levels: number): string => `${'!adf:underline['.repeat(levels)}a${']'.repeat(levels)}\n` const marks = (levels: number): string => `${'!adf:underline['.repeat(levels)}a${']'.repeat(levels)}\n`
assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth')
assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }])
const panels = (levels: number, body: string): string => `${'!adf:panel\n'.repeat(levels)}${body}${'!adf:/panel\n'.repeat(levels)}` const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('')
assert.equal(code(markdownToAdf(panels(1000, ''))), 'unsupported-nesting-depth') const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name)
assert.ok(markdownToAdf(panels(largestNesting, '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok) assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok)
assert.equal( assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 })
content(markdownToAdf(panels(largestNesting, '!adf:paragraph {localId=a-1}\n!adf:panel\n!adf:/paragraph\n'))), assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth')
`unsupported-nesting-depth: the input nests directive containers deeper than the ${largestNesting} levels the parser carries`,
)
}) })
test('decodes the backslash escapes CommonMark spells, and keeps the rest literal', () => { test('decodes the backslash escapes CommonMark spells, and keeps the rest literal', () => {
+6 -3
View File
@@ -74,9 +74,12 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
them: `readNestedDirective` restarts its depth counter per level, so each parse level them: `readNestedDirective` restarts its depth counter per level, so each parse level
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents guard. A fifth predates 12c: a blank line continues every open list item without consuming
nobody typed. `readDirectiveContent`'s scan splits into named steps with that fix rather input, so the block walk visits the whole item stack per line — `d` nested items and `4d`
than keeping its complexity (the maintainer, 2026-09-16). blank lines take 11s at 59 KB (the stability-reviewer, 2026-09-16). §11's scanning rule is
the whole argument; the pipeline persona feeds documents nobody typed.
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
complexity (the maintainer, 2026-09-16).
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces - [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its