From c7a7a4333b0fc3e353cfac2c1d8cb1dc8ddebb0a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 18 Sep 2026 00:43:56 +0200 Subject: [PATCH] 4b - review nits: the list's headroom folds into its item loop, emitBlocks inlined, panel-wrapped boundary pinned, 4c note says cubic --- src/markdown/emit/adf-to-markdown.test.ts | 1 + src/markdown/emit/adf-to-markdown.ts | 26 +++++++++++------------ todo.md | 15 ++++++------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index e3ecae1..ca07e0a 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -520,6 +520,7 @@ test('refuses a document nested deeper than the emitter carries', () => { const deep = document(listed(directiveLists, [{ type: 'rule' }])) 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({ content: [listed(directiveLists, [{ type: 'rule' }])], type: 'panel' }))), 'unsupported-nesting-depth') const chain = (levels: number): AdfNode => { let card: AdfNode = { type: 'blockCard' } for (let level = 0; level < levels; level += 1) card = { content: [card], type: 'blockCard' } diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 7e2027c..0484fd1 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -31,18 +31,13 @@ export function adfToMarkdown(document: AdfDocument): Result { const fault = adfDocumentFault(document) if (fault !== undefined) return faulted(fault, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) - const blocks = emitBlocks(nodeContent(document), '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 { - const walk = walkBlocks(nodes, path, depth) + const walk = walkBlocks(nodeContent(document), [], 0) 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 { let headroom = largestNesting - depth 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 (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined const walked: WalkedItem[] = [] + let headroom = Number.POSITIVE_INFINITY for (const [offset, item] of items.entries()) { const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1) if (!walk.ok) return walk + headroom = Math.min(headroom, walk.value.headroom) walked.push({ node: item, walk: walk.value }) } - const headroom = Math.min(...walked.map((item) => item.walk.headroom)) const lines: string[] = [] for (const [offset, item] of walked.entries()) { 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) } 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. -function directiveItems(items: readonly WalkedItem[]): Walk { - const blocks = 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 directiveItems(items: readonly WalkedItem[]): PlacedBlock[] { + return items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive'), item.walk.headroom - 1), node: item.node })) } function listStart(node: AdfNode, items: number): number | undefined { diff --git a/todo.md b/todo.md index 67b8383..168a070 100644 --- a/todo.md +++ b/todo.md @@ -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 constant factor rather than 4b's class change, and the parting is what gives depth its own code (§8) — measure before joining them back. - **Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists. The one walk - counts items one below the list, as the readable form does, and reports its headroom; a - list falling back to the directive form refuses when that form's extra level no longer - fits. Counting every list twice was rejected for halving the limit, counting the directive - form once for doubling the parser's frames per level. + **Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists, the walk + reporting its headroom (§11). 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 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 @@ -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 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 - subtree below, so nested directive lists cost O(depth × subtree) — 250 rule-first levels parse - 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 - `readNestedDirective` (the maintainer, 2026-09-18). + subtree below, itself quadratic in the depth left, so nested directive lists cost about the + cube of their depth — 250 rule-first levels parse 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 `readNestedDirective` (the + maintainer, 2026-09-18). - [ ] **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 a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its