4b - the block walk spells a list from one walk, reporting its headroom so the list limit stays 500 #96
@@ -520,6 +520,7 @@ test('refuses a document nested deeper than the emitter carries', () => {
|
|||||||
const deep = document(listed(directiveLists, [{ type: 'rule' }]))
|
const deep = document(listed(directiveLists, [{ type: 'rule' }]))
|
||||||
assert.deepEqual(markdownToAdf(markdown(adfToMarkdown(deep))), { ok: true, value: deep })
|
assert.deepEqual(markdownToAdf(markdown(adfToMarkdown(deep))), { ok: true, value: deep })
|
||||||
assert.equal(code(adfToMarkdown(document(listed(directiveLists + 1, [{ type: 'rule' }])))), 'unsupported-nesting-depth')
|
assert.equal(code(adfToMarkdown(document(listed(directiveLists + 1, [{ type: 'rule' }])))), 'unsupported-nesting-depth')
|
||||||
|
assert.equal(code(adfToMarkdown(document({ content: [listed(directiveLists, [{ type: 'rule' }])], type: 'panel' }))), 'unsupported-nesting-depth')
|
||||||
const chain = (levels: number): AdfNode => {
|
const chain = (levels: number): AdfNode => {
|
||||||
let card: AdfNode = { type: 'blockCard' }
|
let card: AdfNode = { type: 'blockCard' }
|
||||||
for (let level = 0; level < levels; level += 1) card = { content: [card], type: 'blockCard' }
|
for (let level = 0; level < levels; level += 1) card = { content: [card], type: 'blockCard' }
|
||||||
|
|||||||
@@ -31,18 +31,13 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
|
|||||||
const fault = adfDocumentFault(document)
|
const fault = adfDocumentFault(document)
|
||||||
if (fault !== undefined) return faulted(fault, [])
|
if (fault !== undefined) return faulted(fault, [])
|
||||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||||
const blocks = emitBlocks(nodeContent(document), 'document', [], 0)
|
const walk = walkBlocks(nodeContent(document), [], 0)
|
||||||
if (!blocks.ok) return blocks
|
|
||||||
return success(blocks.value === '' ? '' : `${blocks.value}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result<string> {
|
|
||||||
const walk = walkBlocks(nodes, path, depth)
|
|
||||||
if (!walk.ok) return walk
|
if (!walk.ok) return walk
|
||||||
return success(joinBlocks(walk.value.blocks, container))
|
const text = joinBlocks(walk.value.blocks, 'document')
|
||||||
|
return success(text === '' ? '' : `${text}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The walk's headroom is the least slack any depth guard below it has, so a spelling that sinks the walked blocks a level can refuse rather than walk again.
|
// headroom: the least slack any depth guard below the walk has.
|
||||||
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number): Result<Walk> {
|
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number): Result<Walk> {
|
||||||
let headroom = largestNesting - depth
|
let headroom = largestNesting - depth
|
||||||
if (headroom < 0) return tooDeep(path)
|
if (headroom < 0) return tooDeep(path)
|
||||||
@@ -224,25 +219,28 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
|
|||||||
if (start === undefined || items.length === 0) return undefined
|
if (start === undefined || items.length === 0) return undefined
|
||||||
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
||||||
const walked: WalkedItem[] = []
|
const walked: WalkedItem[] = []
|
||||||
|
let headroom = Number.POSITIVE_INFINITY
|
||||||
for (const [offset, item] of items.entries()) {
|
for (const [offset, item] of items.entries()) {
|
||||||
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1)
|
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1)
|
||||||
if (!walk.ok) return walk
|
if (!walk.ok) return walk
|
||||||
|
headroom = Math.min(headroom, walk.value.headroom)
|
||||||
walked.push({ node: item, walk: walk.value })
|
walked.push({ node: item, walk: walk.value })
|
||||||
}
|
}
|
||||||
const headroom = Math.min(...walked.map((item) => item.walk.headroom))
|
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
for (const [offset, item] of walked.entries()) {
|
for (const [offset, item] of walked.entries()) {
|
||||||
const line = listItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ')
|
const line = listItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ')
|
||||||
if (line === undefined) return headroom < 1 ? tooDeep(path) : emitDirectiveBlock(node, ordered ? blockDirectives.orderedList : blockDirectives.bulletList, path, depth, () => success(directiveItems(walked)))
|
if (line === undefined) {
|
||||||
|
if (headroom < 1) return tooDeep(path)
|
||||||
|
return emitDirectiveBlock(node, ordered ? blockDirectives.orderedList : blockDirectives.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
|
||||||
|
}
|
||||||
lines.push(line)
|
lines.push(line)
|
||||||
}
|
}
|
||||||
return success({ headroom, spelling: 'list', text: lines.join('\n') })
|
return success({ headroom, spelling: 'list', text: lines.join('\n') })
|
||||||
}
|
}
|
||||||
|
|
||||||
// The directive form sinks each item's blocks a level below where the walk read them.
|
// The directive form sinks each item's blocks a level below where the walk read them.
|
||||||
function directiveItems(items: readonly WalkedItem[]): Walk {
|
function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
|
||||||
const blocks = items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive'), item.walk.headroom - 1), node: item.node }))
|
return items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive'), item.walk.headroom - 1), node: item.node }))
|
||||||
return { blocks, headroom: Math.min(...blocks.map((block) => block.headroom)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function listStart(node: AdfNode, items: number): number | undefined {
|
function listStart(node: AdfNode, items: number): number | undefined {
|
||||||
|
|||||||
@@ -64,11 +64,9 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
|
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
|
||||||
constant factor rather than 4b's class change, and the parting is what gives depth its own
|
constant factor rather than 4b's class change, and the parting is what gives depth its own
|
||||||
code (§8) — measure before joining them back.
|
code (§8) — measure before joining them back.
|
||||||
**Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists. The one walk
|
**Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists, the walk
|
||||||
counts items one below the list, as the readable form does, and reports its headroom; a
|
reporting its headroom (§11). Counting every list twice was rejected for halving the limit,
|
||||||
list falling back to the directive form refuses when that form's extra level no longer
|
counting the directive form once for doubling the parser's frames per level.
|
||||||
fits. Counting every list twice was rejected for halving the limit, counting the directive
|
|
||||||
form once for doubling the parser's frames per level.
|
|
||||||
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
||||||
for the emit, so its two walks stay parted.
|
for the emit, so its two walks stay parted.
|
||||||
- [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
- [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
||||||
@@ -90,9 +88,10 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
|
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
|
||||||
complexity (the maintainer, 2026-09-16). A sixth 4b leaves behind: the parser asks
|
complexity (the maintainer, 2026-09-16). A sixth 4b leaves behind: the parser asks
|
||||||
`commonMarkSpelling` at every directive-spelled list it reads, and the answer spells the whole
|
`commonMarkSpelling` at every directive-spelled list it reads, and the answer spells the whole
|
||||||
subtree below, so nested directive lists cost O(depth × subtree) — 250 rule-first levels parse
|
subtree below, itself quadratic in the depth left, so nested directive lists cost about the
|
||||||
in 1.3 s at 16.5 kB, 1.5 MB of that shape at 250 levels in 0.6 s — bounded by the depth guard like
|
cube of their depth — 250 rule-first levels parse in 1.3 s at 16.5 kB, 1.5 MB of that shape
|
||||||
`readNestedDirective` (the maintainer, 2026-09-18).
|
at 250 levels in 0.6 s — bounded by the depth guard like `readNestedDirective` (the
|
||||||
|
maintainer, 2026-09-18).
|
||||||
- [ ] **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
|
||||||
|
|||||||
Reference in New Issue
Block a user