From a0bb09b80dcfa5772f24f23613ebebc571c47582 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 12 Oct 2026 18:03:58 +0200 Subject: [PATCH] Answer architecture round 4: the forecast and the indent leave, a writer refuses a document whose text alone passes the bound, and the joins are the one bound left --- docs/comprehension-history.md | 31 ++++++++ docs/decisions.md | 15 ++-- src/adf/document.ts | 6 +- src/markdown/directive-syntax.ts | 13 +--- src/markdown/emit/adf-to-markdown.test.ts | 31 +++----- src/markdown/emit/adf-to-markdown.ts | 76 +++++++------------ src/markdown/emit/block-directive-spelling.ts | 7 +- src/markdown/emit/inline-line.ts | 4 +- src/markdown/emit/pipe-table.ts | 6 +- src/markdown/opaque-carry.ts | 4 +- src/markdown/parse/markdown-to-adf.test.ts | 5 -- src/markdown/parse/markdown-to-adf.ts | 3 +- .../portable/adf-to-portable-markdown.test.ts | 13 +--- .../portable/adf-to-portable-markdown.ts | 22 ++++-- src/markdown/portable/editor-normal.ts | 6 +- src/markdown/portable/inline-reduction.ts | 4 +- todo.md | 5 +- 17 files changed, 110 insertions(+), 141 deletions(-) diff --git a/docs/comprehension-history.md b/docs/comprehension-history.md index 499f455..1f52385 100644 --- a/docs/comprehension-history.md +++ b/docs/comprehension-history.md @@ -571,3 +571,34 @@ Inherited architect, decided by: - Self-sufficiency: `README.md:241` "The lossless flavour": two tables map ADF types to their spellings, so the 3am case reads without the spec open - Shape: `src/result.ts:55` `withinEngineLimits`: a length failure is reported under `unsupported-node-shape` at an empty path, and the README cell explaining it holds four cases - Shape: `src/result.ts:45` `engineLimitThrows`: matching exact engine message text rots silently when an engine rewords, and JavaScriptCore's `Out of memory` blurs a heap shortage with length + +## 2026-10-12T15:56:53Z, PR #36 at 584b214, against 7a27f6b + +Ruling: worse + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Mid A | better | worse | worse | same | worse | +| Inherited architect | better | worse | worse | same | worse | + +Mid A, decided by: + +- Locality, Shape, Overall: `src/markdown/emit/adf-to-markdown.ts:61` `walkBlocks`: a running forecast depends on `Writing.indent`, which each container must pass correctly, `tryList` deliberately not indenting +- Shape, Locality: `src/markdown/emit/adf-to-markdown.ts:398` `spelledList`: template strings became `joinedWithin` piped through nested `boundedThen` callbacks, and `joinedItems` repeats the counting loop +- Locality: `src/markdown/emit/adf-to-markdown.ts:147` `readableBlock`: the memo is filled in `commonMarkSpelling` and emptied on read, so when an entry is present depends on call order across two functions +- Shape: `src/markdown/directive-syntax.ts:114` `spellAttributes`: each spelling exists twice, as a string and as a generator of pieces +- Locality: `src/markdown/portable/editor-normal.ts:26` `toEditorNormal`: editor normalization imports the markdown length bound +- Locality: `src/markdown/portable/adf-to-portable-markdown.ts:126` `portableSequence`: each block carries its source path, rebuilt by hand for a replacement +- Navigation: `src/markdown/length-bound.ts:1` `withinEngineLimits`: the bound, its refusal and the catch share one module named for them + +Inherited architect, decided by: + +- Locality, Shape: `src/markdown/emit/adf-to-markdown.ts:61` `walkBlocks`: the forecast is a second length bound beside `joinedWithin`, whose never-overcount rule fixes how every container walks +- Locality: `src/markdown/emit/adf-to-markdown.ts:377` `tryList`: whether items walk indented depends on what `spelledOrDirectiveList` may decide later +- Locality: `src/markdown/parse/markdown-to-adf.ts:266` `directiveNode`: the emitter's `indent` leaks into the parser and the portable reduction +- Locality: `src/markdown/emit/adf-to-markdown.ts:147` `readableBlock`: filling and emptying the memo split across two functions +- Shape: `src/markdown/emit/adf-to-markdown.ts:194` `quotedBlock`: nested `boundedThen` reads as continuation passing where a template stood +- Shape, Locality: `src/markdown/emit/pipe-table.ts:9` `tryPipeTable`: the sum-then-check rule appears in seven shapes, and a reader cannot tell which is the real bound +- Navigation, Shape: `src/markdown/length-bound.ts:41` `withinEngineLimits`: a too-long symptom leads straight to the module named for it +- Shape: `src/markdown/directive-syntax.ts:133` `spellDirectiveOpener`: each spelling is a string function and a pieces generator +- Shape: `src/markdown/opaque-carry.ts:64` `carriedJson`: the lambda parameter `spelled` shadows the function's own diff --git a/docs/decisions.md b/docs/decisions.md index 4b111e1..496cbaa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -666,15 +666,12 @@ is the shortest of the engines the library claims. Every writer, and the reader's CommonMark ask, refuses markdown longer than 2^29 − 24 characters (`longestMarkdown`) as `unsupported-node-shape` at the document's path, so the same document -converts alike everywhere. Each builder that joins pieces — blocks, quoted and list lines, directive -pairs and openers, fences, pipe rows, the carry's JSON — joins them through `joinedWithin`, which -stops once they pass the bound, so none builds a string no engine was asked for. A walk of blocks -also forecasts each block's length with the indent its containers surely prefix every line that is -not blank with, so a subtree deep in quotes or task lists refuses before any container copies it. -The forecast never overcounts, so it refuses only markdown past the bound: a plain list's marker -indent is left out, since the list may give way to the directive form, which prefixes nothing. A -deep list refuses once a level's text passes the bound, after the levels below it re-indented theirs -(`todo.md` item 109). +converts alike everywhere. Each builder joins its pieces — blocks, quoted and list lines, directive +pairs and openers, fences, pipe rows, the carry's JSON — through `joinedWithin`, which stops once +they pass the bound, so none builds a string no engine was asked for. A writer first refuses a +document whose text alone passes the bound, since every character of it is spelled at least once. A +deep list or quote re-indents its whole text at each level until the level that passes the bound +refuses (`todo.md` item 109). A line is held to its text's length alone; what its spelling adds — escapes, link targets, attributes — passes V8's longest string only by throwing, and `withinEngineLimits` refuses that diff --git a/src/adf/document.ts b/src/adf/document.ts index 315a5d5..d7e9223 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -82,16 +82,12 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark return marksKey(left) === marksKey(right) } -export function textLength(node: AdfNode): number { - return node.text?.length ?? 0 -} - // The length of the text the nodes hold, the nodes below them included. export function heldTextLength(nodes: readonly AdfNode[]): number { let length = 0 const pending = [...nodes] for (let node = pending.pop(); node !== undefined; node = pending.pop()) { - length += textLength(node) + length += node.text?.length ?? 0 for (const child of nodeContent(node)) pending.push(child) } return length diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index edd800c..a46dec6 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -131,18 +131,7 @@ export function spellDirectiveCloser(name: string): string { } export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string { - return [...directiveOpenerPieces(name, argument, attributes === '' ? [] : [attributes])].join('') -} - -export function* directiveOpenerPieces(name: string, argument: string | undefined, attributes: Iterable): Generator { - yield `${directivePrefix}${name}` - if (argument !== undefined) yield ` ${argument}` - let first = true - for (const piece of attributes) { - if (first) yield ' ' - first = false - yield piece - } + return `${directivePrefix}${name}${argument === undefined ? '' : ` ${argument}`}${attributes === '' ? '' : ` ${attributes}`}` } export function spellJsonAttribute(value: JsonValue): string { diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index d3c3152..65b282d 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -46,6 +46,11 @@ test('refuses a value that is not an ADF document, naming the check it failed', const tooLong = 'unsupported-node-shape: spelled as markdown, this would be longer than 536870888 characters, the longest string V8 holds' +// A code block of 300 lines, two of them some 1000 characters short of V8's longest string, so only what their spelling adds passes it. +function nearlyHalfTheBound(): AdfNode { + return { attrs: {}, content: [{ text: `${'a'.repeat(894_782)}\n`.repeat(300), type: 'text' }], type: 'codeBlock' } +} + // About 556 million characters of carry JSON: past V8's longest string, short of JavaScriptCore's and SpiderMonkey's. test('refuses a carry whose JSON would run past V8\'s longest string, on every engine', () => { let value: JsonValue = new Array(560_000).fill(0) @@ -55,10 +60,8 @@ test('refuses a carry whose JSON would run past V8\'s longest string, on every e }) test('refuses lines their quotes would prefix past V8\'s longest string', () => { - let quote: AdfNode = { attrs: {}, content: [{ text: 'a\n'.repeat(1_100_000), type: 'text' }], type: 'codeBlock' } - for (let level = 0; level < 245; level += 1) quote = { content: [quote], type: 'blockquote' } - const result = adfToLosslessMarkdown(document(quote)) - assert.equal(markdown(result), tooLong) + const code = nearlyHalfTheBound() + assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong) }) test('refuses a directive whose opener and body together would pass V8\'s longest string', () => { @@ -67,23 +70,9 @@ test('refuses a directive whose opener and body together would pass V8\'s longes assert.equal(markdown(adfToLosslessMarkdown(document(expand))), tooLong) }) -test('refuses a list whose items together would pass V8\'s longest string', () => { - const item: AdfNode = { content: [{ attrs: {}, content: [{ text: 'a'.repeat(2 ** 28), type: 'text' }], type: 'codeBlock' }], type: 'listItem' } - const result = adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' })) - assert.equal(markdown(result), tooLong) -}) - -test('refuses a pipe table whose rows their containers would prefix past V8\'s longest string', () => { - const row: AdfNode = { content: [{ content: [{ type: 'paragraph' }], type: 'tableCell' }], type: 'tableRow' } - const header: AdfNode = { content: [{ content: [{ type: 'paragraph' }], type: 'tableHeader' }], type: 'tableRow' } - let quote: AdfNode = { content: [header, ...Array.from({ length: 1_100_000 }, () => row)], type: 'table' } - for (let level = 0; level < 245; level += 1) quote = { content: [quote], type: 'blockquote' } - assert.equal(markdown(adfToLosslessMarkdown(document(quote))), tooLong) -}) - -test('refuses a code block whose fences together would pass V8\'s longest string', () => { - const long: AdfNode = { text: 'a'.repeat(2 ** 28), type: 'text' } - assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: {}, content: [long, long, long], type: 'codeBlock' }))), tooLong) +test('refuses a list whose item markers would carry its lines past V8\'s longest string', () => { + const item: AdfNode = { content: [nearlyHalfTheBound()], type: 'listItem' } + assert.equal(markdown(adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' }))), tooLong) }) test('refuses a directive opener whose attributes together would pass V8\'s longest string', () => { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 2e30e36..0449581 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -1,7 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { WrittenFlavour } from '../portable/conventions.ts' -import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { adfDocumentFault, heldTextLength, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts' import { alertMarker, flavourClaims, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts' import { blockDirectiveForm, documentSpelling } from '../block-directive.ts' import { blockNodeModel, blockNodes, isTaskItem } from '../../adf/block-nodes.ts' @@ -27,21 +27,17 @@ type PlacedBlock = SpelledBlock & { node: AdfNode } type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number } // CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`. type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string } -// joined: each item's text; start: the first item's number. -type SpelledItems = { joined: readonly string[]; start: number; walked: readonly WalkedItem[] } // Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node. export type SpellingMemo = Map -type WalkedItem = { node: AdfNode; walk: PlacedBlocks } -// indent: the characters the containers around a block surely prefix each of its lines with. -export type Writing = { flavour: WrittenFlavour; indent: number; memo: SpellingMemo | undefined } +// inner: the item's blocks joined, for the list form; walk: the blocks themselves, for the directive form. +type WalkedItem = { inner: string; node: AdfNode; walk: PlacedBlocks } +export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined } const bulletDelimiters: Delimiters = ['-', '*'] -const bulletWidth = '- '.length export const largestListMarker = 999999999 // Bare because tryList admits no item carrying attributes, marks or text. const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const orderedDelimiters: Delimiters = ['.', ')'] -const quoteWidth = '> '.length export function adfToLosslessMarkdown(document: AdfDocument): Result { return withinEngineLimits(() => writeMarkdown(document, 'lossless')) @@ -52,7 +48,9 @@ export function writeMarkdown(document: AdfDocument, flavour: WrittenFlavour): R 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.content === undefined) return success(`${documentSpelling}\n`) - const walk = walkBlocks(document.content, [], 0, { flavour, indent: 0, memo: undefined }) + // Every character of text is spelled at least once, so this much text cannot fit, and none of it is spelled. + if (heldTextLength(document.content) > longestMarkdown) return tooLong() + const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined }) if (!walk.ok) return walk const text = joinedWithin(walk.value.blocks.length === 0 ? [] : [...blockPieces(walk.value.blocks, 'document'), '\n']) return boundedThen(text, success) @@ -62,35 +60,15 @@ function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: nu let headroom = largestNesting - depth if (headroom < 0) return tooDeep(path) const blocks: PlacedBlock[] = [] - // A forecast that never overcounts, so a deep subtree refuses where its containers would prefix its lines past the bound, before any of them copies those lines. - let forecast = 0 for (const [index, node] of nodes.entries()) { const block = emitBlock(node, [...path, 'content', index], depth, writing) if (!block.ok) return block - forecast += prefixedLength(block.value.text, writing.indent) - if (forecast > longestMarkdown) return tooLong() headroom = Math.min(headroom, block.value.headroom) blocks.push({ ...block.value, node }) } return success({ blocks, headroom }) } -// A blank line takes no indent in a list item and one `>` in a quote, so only the others are charged it. -function prefixedLength(text: string, indent: number): number { - let length = text.length - for (let start = 0; start <= text.length; ) { - const end = text.indexOf('\n', start) - const stop = end === -1 ? text.length : end - if (stop > start) length += indent - start = stop + 1 - } - return length -} - -function indented(writing: Writing, width: number): Writing { - return { ...writing, indent: writing.indent + width } -} - function tooDeep(path: ConvertErrorPath): Result { return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) } @@ -185,18 +163,17 @@ function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number } function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result { - const inner = walkBlocks(nodeContent(node), path, depth + 1, indented(writing, quoteWidth)) + const inner = walkBlocks(nodeContent(node), path, depth + 1, writing) if (!inner.ok) return inner const text = joinedWithin(inner.value.blocks.length === 0 ? [head] : [head, '\n\n', ...blockPieces(inner.value.blocks, 'document')]) return quotedBlock(text, inner.value.headroom) } function quotedBlock(text: string | undefined, headroom: number): Result { - const quoted = (whole: string): Result => boundedThen(joinedWithin(quotedPieces(whole)), (quotedText) => success(commonMarkText(quotedText, headroom))) - return boundedThen(text, quoted) + const quoted = text === undefined ? undefined : joinedWithin(quotedPieces(text)) + return boundedThen(quoted, (spelled) => success(commonMarkText(spelled, headroom))) } - function* quotedPieces(text: string): Generator { for (let start = 0; ; ) { const end = text.indexOf('\n', start) @@ -215,8 +192,7 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi // A child other than a task nests in the task before it. for (const [index, child] of nodeContent(node).entries()) { const task = isTaskItem(child) - const itemWriting = indented(writing, bulletWidth) - const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, itemWriting) : placedBlock(child, [...path, 'content', index], depth + 1, itemWriting) + const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing) if (!walk.ok) return walk headroom = Math.min(headroom, walk.value.headroom) const previous = items.at(-1) @@ -229,15 +205,12 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi return boundedThen(inners, (spelled) => spelledList(spelled, () => '', bulletDelimiters, headroom) ?? unspellable) } -// Each item's text, or `undefined` where one, or all of them together, would pass the bound: every item is joined before the list is. +// Each item's text, or `undefined` where one would pass the bound. function joinedItems(items: readonly Iterable[]): string[] | undefined { const inners: string[] = [] - let length = 0 for (const pieces of items) { const inner = joinedWithin(pieces) if (inner === undefined) return undefined - length += inner.length - if (length > longestMarkdown) return undefined inners.push(inner) } return inners @@ -311,7 +284,7 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result | undefined { if (!holdsOnlyAttributes(node, [])) return undefined - const inner = walkBlocks(nodeContent(node), path, depth + 1, indented(writing, quoteWidth)) + const inner = walkBlocks(nodeContent(node), path, depth + 1, writing) if (!inner.ok) return inner const text = joinBlocks(inner.value.blocks, 'document') const alert = text !== undefined && flavourClaims[writing.flavour].alerts && leadingMarker(text, readAlertMarker) !== undefined @@ -374,33 +347,36 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: const walked: WalkedItem[] = [] let headroom = Number.POSITIVE_INFINITY for (const [offset, item] of items.entries()) { - // Unindented: the list may give way to the directive form, which prefixes nothing, and the forecast must not overcount. const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing) if (!walk.ok) return walk + const inner = joinBlocks(walk.value.blocks, 'list-item') + if (inner === undefined) return tooLong() headroom = Math.min(headroom, walk.value.headroom) - walked.push({ node: item, walk: walk.value }) + walked.push({ inner, node: item, walk: walk.value }) } - return boundedThen(joinedItems(walked.map((item) => blockPieces(item.walk.blocks, 'list-item'))), (joined) => spelledOrDirectiveList(node, { joined, start, walked }, path, depth, headroom, writing)) + const number = ordered ? (offset: number): string => `${start + offset}` : (): string => '' + return spelledOrDirectiveList(node, walked, number, { depth, headroom, path, writing }) } // The list spelling where every item holds lines a list item spells, else the directive form. -function spelledOrDirectiveList(node: AdfNode, { joined, start, walked }: SpelledItems, path: ConvertErrorPath, depth: number, headroom: number, writing: Writing): Result { +function spelledOrDirectiveList(node: AdfNode, walked: readonly WalkedItem[], number: (offset: number) => string, at: { depth: number; headroom: number; path: ConvertErrorPath; writing: Writing }): Result { const ordered = node.type === 'orderedList' // GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold. - const inners = joined.map((inner) => (flavourClaims[writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner)) - const list = ordered ? spelledList(inners, (offset) => `${start + offset}`, orderedDelimiters, headroom) : spelledList(inners, () => '', bulletDelimiters, headroom) + const escapesMarker = (inner: string): boolean => flavourClaims[at.writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined + const inners = walked.map(({ inner }) => (escapesMarker(inner) ? `\\${inner}` : inner)) + const list = spelledList(inners, number, ordered ? orderedDelimiters : bulletDelimiters, at.headroom) if (list !== undefined) return list // The directive form spends a level the walk did not count. - if (headroom < 1) return tooDeep(path) - return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => directiveItems(walked, headroom - 1)) + if (at.headroom < 1) return tooDeep(at.path) + return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, at.path, at.depth, () => directiveItems(walked, at.headroom - 1)) } function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): Result | undefined { const pieces = listLines(inners, number, usual) const alternatePieces = listLines(inners, number, alternate) if (pieces === undefined || alternatePieces === undefined) return undefined - const spelled = (text: string, alternateText: string): Result => success({ alternateText, headroom, spelling: 'list', text }) - return boundedThen(joinedWithin(pieces), (text) => boundedThen(joinedWithin(alternatePieces), (alternateText) => spelled(text, alternateText))) + const withAlternate = (text: string): Result => boundedThen(joinedWithin(alternatePieces), (alternateText) => success({ alternateText, headroom, spelling: 'list', text })) + return boundedThen(joinedWithin(pieces), withAlternate) } // `undefined` where an item holds a line no list item spells. diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index e80e895..4886211 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,10 +1,10 @@ import type { AdfNode } from '../../adf/document.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts' -import { attributePieces, directiveOpenerPieces, isBareToken, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' +import { attributePieces, isBareToken, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { blockArgument, markValues, marksAttribute } from '../block-directive.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { boundedThen, joinedWithin } from '../length-bound.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { overNested } from '../../json-value.ts' import { spellEmptyKeys } from '../empty-keys.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' @@ -23,7 +23,8 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, if (overNested(values)) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path) spelledPairs.push([marksAttribute, spellJsonAttribute(values)]) } - const opener = joinedWithin(directiveOpenerPieces(node.type, slot.argument, attributePieces(spelledPairs))) + const head = spellDirectiveOpener(node.type, slot.argument, '') + const opener = joinedWithin(spelledPairs.length === 0 ? [head] : [head, ' ', ...attributePieces(spelledPairs)]) return boundedThen(opener, success) } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index da1be37..512c75d 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -9,13 +9,12 @@ import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../ import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts' -import { heldTextLength, identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' +import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts' -import { longestMarkdown, tooLong } from '../length-bound.ts' import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -81,7 +80,6 @@ export function tryImageLine(alt: string | undefined, url: string, link?: LinkDe } function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { - if (heldTextLength(nodes) > longestMarkdown) return tooLong() const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false } // Terminates because takeFallback refuses a pass that took no new fallback. for (;;) { diff --git a/src/markdown/emit/pipe-table.ts b/src/markdown/emit/pipe-table.ts index 0a1be57..cdf6ef9 100644 --- a/src/markdown/emit/pipe-table.ts +++ b/src/markdown/emit/pipe-table.ts @@ -1,6 +1,6 @@ import type { AdfNode } from '../../adf/document.ts' import type { WrittenFlavour } from '../portable/conventions.ts' -import { boundedThen, joinedWithin, longestMarkdown, separatedBy, tooLong } from '../length-bound.ts' +import { boundedThen, joinedWithin, separatedBy } from '../length-bound.ts' import { delimiterCells, pipeRowPieces } from '../pipe-table-syntax.ts' import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts' import { success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -10,16 +10,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Wri const rows = pipeRows(node) if (rows === undefined) return undefined const spelled: string[][] = [] - // Every cell is spelled before the rows join, so the cells together are held to the bound as they come. - let length = 0 for (const [rowIndex, row] of rows.entries()) { const cells: string[] = [] for (const [cellIndex, paragraph] of row.entries()) { const content = nodeContent(paragraph) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour) if (line === undefined) return undefined - length += line.length - if (length > longestMarkdown) return tooLong() cells.push(line) } spelled.push(cells) diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 7a69f36..cd91d4b 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -3,6 +3,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import type { JsonValue } from '../json-value.ts' import { boundedThen, joinedWithin } from './length-bound.ts' +import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { fencePieces } from './commonmark/backtick-runs.ts' @@ -11,7 +12,6 @@ import { isAdfNode } from '../adf/document.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' import { parseJsonText } from '../json-text.ts' -import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts' export const carryFencePrefix = 'adf:' @@ -61,7 +61,7 @@ function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, pat return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) } const json = joinedWithin(canonicalJsonPieces(spelled, spelling)) - return boundedThen(json, (spelled) => success({ headroom, json: spelled })) + return boundedThen(json, (text) => success({ headroom, json: text })) } // `type` is the one the fence's info string names, `undefined` for the inline carry. diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 616b77b..dc03e7e 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -456,11 +456,6 @@ test('refuses compact JSON in the carry fence without building its two-space spe ) }) -test('refuses a directive whose CommonMark spelling would pass V8\'s longest string', () => { - const markdown = `!adf:blockquote\n${'>'.repeat(490)} a\\\n${'b\\\n'.repeat(560_000)}c\n!adf:/blockquote\n` - assert.equal(content(losslessMarkdownToAdf(markdown)), 'unsupported-node-shape: spelled as markdown, this would be longer than 536870888 characters, the longest string V8 holds') -}) - test('names the node JSON an opaque carry restores alone', () => { const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; " assert.equal(content(losslessMarkdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index e981388..339c7c1 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -51,7 +51,6 @@ function readDocument(markdown: string, claims: Claims): Result { if (typeof markdown !== 'string') return failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []) const parsed = parseBlocks(markdown, claims) @@ -263,7 +262,7 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr if (!read.ok) return read const built = directiveBody(read.value, block.blocks, reading, path, depth) if (!built.ok) return built - const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', indent: 0, memo: reading.memo }) + const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo }) if (readable === undefined) return built if (!readable.ok) return readable return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index fb92f11..e9a09e8 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -76,15 +76,10 @@ test('refuses a paragraph whose text would pass V8\'s longest string, joining no assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /') }) -test('refuses an expand whose title and body together would pass V8\'s longest string', () => { - let quote: AdfNode = node('expand', { title: 'a'.repeat(1_000_000) }, { attrs: {}, content: [text('a\n'.repeat(1_108_000))], type: 'codeBlock' }) - for (let level = 0; level < 240; level += 1) quote = { content: [quote], type: 'blockquote' } - assert.equal(portableDocument(document(quote)), 'unsupported-node-shape at /') -}) - -test('refuses a code block whose text nodes together would pass V8\'s longest string, joining none of them', () => { - const long = text('a'.repeat(2 ** 28)) - assert.equal(portableDocument(document({ content: [long, long, long], type: 'codeBlock' })), 'unsupported-node-shape at /') +// Two code blocks of 300 lines, some 1000 characters short of V8's longest string, so only the quote's prefixes pass it. +test('refuses an expand whose quoted lines would pass V8\'s longest string', () => { + const code: AdfNode = { content: [text(`${'a'.repeat(894_782)}\n`.repeat(300))], type: 'codeBlock' } + assert.equal(portableDocument(document(node('expand', { title: 'x' }, code, code))), 'unsupported-node-shape at /') }) test('refuses nesting past 500 levels wherever the reduction walks', () => { diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index b07a496..65c39da 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -1,7 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' -import { adfDocumentFault, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' -import { boundedThen, joinedWithin, withinEngineLimits } from '../length-bound.ts' +import { boundedThen, joinedWithin, longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts' import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -60,6 +60,8 @@ export function reduceToPortable(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}`, []) + // The reduction drops form, never text, so this much text cannot fit, and none of it is joined. + if (heldTextLength(nodeContent(document)) > longestMarkdown) return tooLong() // Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep). const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) if (!blocks.ok) { @@ -129,20 +131,28 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res for (let next = pending.pop(); next !== undefined; next = pending.pop()) { const block = numberedPastMarkers(next.block) const listed = block.type === 'bulletList' || block.type === 'orderedList' - const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', indent: 0, memo: reduction.memo }) : undefined + const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', memo: reduction.memo }) : undefined if (spelled !== undefined && !spelled.ok) return spelled if (!listed || spelled !== undefined) { sequence.push(block) continue } - // Back on `pending`: a replacement may take the directive form itself. Its offsets are the reduced item's, the caller's while the item's blocks reduce one to one. - const { path } = next - const replacement = block === next.block ? nodeContent(block).flatMap((item, index) => nodeContent(item).map((node, offset) => ({ block: node, path: [...path, 'content', index, 'content', offset] }))) : [{ block, path }] + // Back on `pending`: a replacement may take the directive form itself. + const replacement = block === next.block ? itemBlocks(block, next.path) : [{ block, path: next.path }] for (const node of replacement.reverse()) pending.push(node) } return success(sequence) } +// Offsets are the reduced item's, which are the caller's while the item's blocks reduce one to one. +function itemBlocks(list: AdfNode, path: ConvertErrorPath): Sourced[] { + const blocks: Sourced[] = [] + for (const [index, item] of nodeContent(list).entries()) { + for (const [offset, block] of nodeContent(item).entries()) blocks.push({ block, path: [...path, 'content', index, 'content', offset] }) + } + return blocks +} + // A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list. function numberedPastMarkers(list: AdfNode): AdfNode { const order = nodeAttrs(list)['order'] diff --git a/src/markdown/portable/editor-normal.ts b/src/markdown/portable/editor-normal.ts index ee63a0e..f9ad390 100644 --- a/src/markdown/portable/editor-normal.ts +++ b/src/markdown/portable/editor-normal.ts @@ -1,8 +1,7 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { JsonValue } from '../../json-value.ts' -import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks, textLength } from '../../adf/document.ts' +import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { joinsWhenRead } from '../adjacent-text.ts' -import { longestMarkdown } from '../length-bound.ts' type JsonContainer = JsonValue[] | { [key: string]: JsonValue } @@ -22,8 +21,7 @@ export function toEditorNormal(document: AdfDocument): AdfDocument { const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version } const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }] for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) { - // Text past the bound is refused as markdown either way; joining it first would build the string. - const content = mergeAdjacentText(nodeContent(entry.source), (previous, node) => joinsWhenEditorNormal(previous, node) && textLength(previous) + textLength(node) <= longestMarkdown) + const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal) if (content.length === 0) continue entry.holder.content = content.map((source) => { const holder = normalNode(source) diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index ededbae..d0b1b3a 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -3,10 +3,9 @@ import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { heldTextLength, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' -import { longestMarkdown, tooLong } from '../length-bound.ts' +import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { portableLineFallback, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' @@ -18,7 +17,6 @@ const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { const leaves = inlineLeaves(nodes, container, path, depth) if (!leaves.ok) return leaves - if (heldTextLength(leaves.value) > longestMarkdown) return tooLong() return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path) } diff --git a/todo.md b/todo.md index 5a4f7aa..c2976fc 100644 --- a/todo.md +++ b/todo.md @@ -214,8 +214,9 @@ the reason the project read worse. Each level of a nested list or quote re-indents its whole text, so the work is the depth times the text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside `!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a -list nested 480 levels took 11 minutes to refuse on Node 24, every item fitting the length bound -alone. The writers do the same on such a document. Found while shipping item 106, 2026-10-11. +list nested 480 levels took 11 minutes to refuse on Node 24. A deep subtree past the length bound +refuses only at the level that passes it, after every level below re-indented its text. The writers +do the same on such a document. Found while shipping item 106, 2026-10-11. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.