diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index e6bd866..78fbbd8 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -67,7 +67,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A const scan = segments.map((segment) => segment.text).join('') const escapings: InlineEscaping[] = [] for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping) - const escaped = escapedIndexes(scan, escapings, container) + const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container)) const placements: number[] = [] let output = '' for (let index = 0; index < scan.length; index += 1) { @@ -82,31 +82,30 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A return { line: output, unspellableRun: unspellableRun(segments, output, placements) } } -function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): Set { +function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): ReadonlySet { const escaped = new Set() const linkClose = lastLinkClose(scan, escapings) let line = scanLine(scan, 0) + let afterEscape = false for (let index = 0; index < scan.length; index += 1) { if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1) const escaping = escapings[index] const escapable = escaping === 'backslash' || escaping === 'bracketed' - if ( + afterEscape = (escapable && (claimsLineStart(line, index, container) || mergesWithSyntax(scan, escapings, index) || - opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || + opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) || (escaping === 'bracketed-link-target' && - ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || claimsDirectivePrefix(scan, index))) - ) { - escaped.add(index) - } + ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index))) + if (afterEscape) escaped.add(index) } - escapeClosedRuns(scan, escapings, escaped) return escaped } // CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens. -function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], escaped: Set): void { +function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], claimed: ReadonlySet): ReadonlySet { + const escaped = new Set(claimed) const formed = new Set() let end = scan.length - 1 while (end >= 0) { @@ -118,17 +117,18 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], es while (scan.charAt(start - 1) === '`') start -= 1 let segmentEnd = end for (let index = end; index > start; index -= 1) { - if (!escaped.has(index)) continue + if (!claimed.has(index)) continue formed.add(segmentEnd - index + 1) segmentEnd = index - 1 } - if (segmentEnd !== end || escaped.has(start)) formed.add(segmentEnd - start + 1) + if (segmentEnd !== end || claimed.has(start)) formed.add(segmentEnd - start + 1) else if (escapings[start] !== 'none' && formed.has(end - start + 1)) { for (let index = start; index <= end; index += 1) escaped.add(index) formed.add(1) } end = start - 1 } + return escaped } function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined { @@ -231,10 +231,10 @@ function opensConstruct( index: number, inBrackets: boolean, container: LineContainer, - escaped: ReadonlySet, + afterEscape: boolean, ): boolean { if (container === 'heading' && closesHeading(scan, index)) return true - return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped) + return claimsCharacter(scan, linkClose, index, inBrackets, container, afterEscape) } // A hard break is the one spelling that puts a delimiter row under a row of its own, so only a later line claims. @@ -260,7 +260,7 @@ function claimsCharacter( index: number, inBrackets: boolean, container: LineContainer, - escaped: ReadonlySet, + afterEscape: boolean, ): boolean { const character = scan.charAt(index) if (inBrackets && (character === '[' || character === ']')) return true @@ -270,8 +270,8 @@ function claimsCharacter( if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined if (character === '!') return claimsDirectivePrefix(scan, index) if (character === '[') return index < linkClose - if (character === '`') return opensCodeSpan(scan, index, escaped) - if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped) + if (character === '`') return opensCodeSpan(scan, index, afterEscape) + if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, afterEscape) return false } @@ -284,16 +284,16 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef return -1 } -function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet): boolean { +function opensCodeSpan(scan: string, index: number, afterEscape: boolean): boolean { // A run escapes whole: a rest left bare would be a raw run of another length for a closer. - if (scan.charAt(index - 1) === '`' && escaped.has(index - 1)) return true - if (!startsRun(scan, index, escaped)) return false + if (afterEscape && scan.charAt(index - 1) === '`') return true + if (!startsRun(scan, index, afterEscape)) return false const opener = backtickRun(scan, index) return closingBacktickRun(scan, index + opener, opener) !== undefined } -function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet): boolean { - if (!startsRun(scan, index, escaped)) return false +function claimsEmphasis(scan: string, index: number, afterEscape: boolean): boolean { + if (!startsRun(scan, index, afterEscape)) return false const character = scan.charAt(index) const length = runLength(scan, index) if (character === '~' && length !== 2) return false @@ -301,8 +301,8 @@ function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet): boolean { - if (index === 0 || escaped.has(index - 1)) return true +function startsRun(scan: string, index: number, afterEscape: boolean): boolean { + if (index === 0 || afterEscape) return true return scan.charAt(index - 1) !== scan.charAt(index) } diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index df1a13b..e6af3e6 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -64,13 +64,19 @@ type Line = { column: number; text: string } type LeafOpener = { index: number; position: SourcePosition } +type ContainerStack = { + drop: (depth: number) => OpenContainer[] + edges: readonly { container: EdgeContainer; depth: number }[] + open: readonly OpenContainer[] + push: (container: OpenContainer) => void +} + type Walk = ParsedBlocks & { directiveDepths: Map - edges: { container: EdgeContainer; depth: number }[] leaf: OpenLeaf | undefined leafOpeners: Map> position: SourcePosition - stack: OpenContainer[] + stack: ContainerStack } const indentedCodeColumns = 4 @@ -82,11 +88,10 @@ export function parseBlocks(markdown: string): ParsedBlocks { blocks: [], definitions: new Map(), directiveDepths: new Map(), - edges: [], leaf: undefined, leafOpeners: new Map(), position: { line: 1, offset: 0 }, - stack: [], + stack: containerStack(), } for (const line of sourceLines(markdown)) { walk.position = line.position @@ -96,16 +101,38 @@ export function parseBlocks(markdown: string): ParsedBlocks { return { blocks: walk.blocks, definitions: walk.definitions } } +function containerStack(): ContainerStack { + const edges: { container: EdgeContainer; depth: number }[] = [] + const open: OpenContainer[] = [] + return { + drop: (depth) => { + const dropped = open.splice(depth) + for (const container of dropped) { + if (container.kind === 'directive') container.depths.pop() + else edges.pop() + } + return dropped + }, + edges, + open, + push: (container) => { + const depth = open.push(container) - 1 + if (container.kind === 'directive') container.depths.push(depth) + else edges.push({ container, depth }) + }, + } +} + function readLine(walk: Walk, line: Line): void { const matched = matchContainers(walk, line) // CommonMark: no container opens inside an open code or HTML block. - if (matched.depth === walk.stack.length && swallowsLines(walk.leaf)) { + if (matched.depth === walk.stack.open.length && swallowsLines(walk.leaf)) { readBlockLine(walk, matched.rest) return } - const paragraphOpen = matched.depth === walk.stack.length && walk.leaf?.kind === 'paragraph' + const paragraphOpen = matched.depth === walk.stack.open.length && walk.leaf?.kind === 'paragraph' const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth) - if (!opened.opened && matched.depth < walk.stack.length) { + if (!opened.opened && matched.depth < walk.stack.open.length) { if (continuesLazily(walk, opened.rest)) { appendParagraph(walk, opened.rest.text) return @@ -122,12 +149,12 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean { // A directive container has no continuation marker, so every line continues it. function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } { let rest = line - for (const { container, depth } of walk.edges) { + for (const { container, depth } of walk.stack.edges) { const next = continuesContainer(walk, container, rest) if (next === undefined) return { depth, rest } rest = next } - return { depth: walk.stack.length, rest } + return { depth: walk.stack.open.length, rest } } function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined { @@ -145,7 +172,7 @@ function blockquoteRest(opener: Line): Line | undefined { } function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } { - const unmatched = walk.stack[depth] + const unmatched = walk.stack.open[depth] const tail = thematicBreakTail(line.text) let opened = false let rest = line @@ -202,16 +229,12 @@ function openContainer(walk: Walk, start: ContainerStart): void { if (start.kind === 'blockquote') { const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position } currentBlocks(walk).push(blockquote) - pushEdge(walk, blockquote) + walk.stack.push(blockquote) return } const list = openedList(walk, start) list.items.push(blocks) - 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 }) + walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list }) } // Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md). @@ -226,7 +249,7 @@ function openedList(walk: Walk, start: Extract function closeContainers(walk: Walk, depth: number): void { closeLeaf(walk) - for (const container of dropContainers(walk, depth)) { + for (const container of walk.stack.drop(depth)) { if (container.kind !== 'directive') continue container.parent[container.index] = { fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`), @@ -236,15 +259,6 @@ function closeContainers(walk: Walk, depth: number): void { } } -function dropContainers(walk: Walk, depth: number): OpenContainer[] { - 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 { if (directive.kind === 'closer') closeDirective(walk, directive.name) else openDirective(walk, directive) @@ -268,7 +282,7 @@ function openDirective(walk: Walk, directive: Extract []) - depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1) + walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) } function closeDirective(walk: Walk, name: string): void { @@ -283,13 +297,13 @@ function closeDirective(walk: Walk, name: string): void { return } closeContainers(walk, depth + 1) - dropContainers(walk, depth) + walk.stack.drop(depth) } // A closer crosses no list item or blockquote edge. function openDirectiveDepth(walk: Walk, name: string): number | undefined { const depth = walk.directiveDepths.get(name)?.at(-1) - return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth + return depth === undefined || depth < (walk.stack.edges.at(-1)?.depth ?? -1) ? undefined : depth } function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void { @@ -497,7 +511,7 @@ function takeParagraph(walk: Walk): Extract | unde } function currentBlocks(walk: Walk): Block[] { - return walk.stack.at(-1)?.blocks ?? walk.blocks + return walk.stack.open.at(-1)?.blocks ?? walk.blocks } function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> { diff --git a/todo-history.md b/todo-history.md index 96a7121..bc87b97 100644 --- a/todo-history.md +++ b/todo-history.md @@ -1054,6 +1054,18 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li layout. What `src/result.ts`, `README.md` §The errors and `ls src` already say went, and the level's definition, `matchEmphasis`'s transcription and `emitLine`'s termination moved to one line at the code each defines. +- [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).** `escapedIndexes` fills + the `escaped` set left to right while the predicates it calls read the half-built set, then + `escapeClosedRuns` walks the same set right to left and adds to it; the order is load-bearing + and asserted nowhere, and a refactor to `filter`/`map` breaks it silently. Separately, + `walk.edges` is the blockquote and list-item subsequence of `walk.stack` with each entry's + stack index, maintained by hand in six places and stated in none. Put each invariant where it + cannot be got wrong — a type, a derived value, a named phase — rather than in a comment. The + panel's first and second hardest places. `escapeClaims` hands each predicate the one fact + it reads — whether the character before was escaped — and `escapeClosedRuns` takes that + pass's set read-only and returns the whole. `containerStack` is the one writer of the stack, + `edges` and each directive's `depths`, all read-only elsewhere; deriving `edges` from the + stack instead makes every line walk the open directives, quadratic in their nesting. ## 5 — Ship `0.1.0` diff --git a/todo.md b/todo.md index c1769ca..6487747 100644 --- a/todo.md +++ b/todo.md @@ -58,14 +58,7 @@ chunk clearing a §11 seam. the measured one. - [x] **24 — The conformance gates have a directory (`0.2.0`).** - [x] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).** -- [ ] **26 — The two mutable structures say what they guarantee (`0.2.0`).** `escapedIndexes` fills - the `escaped` set left to right while the predicates it calls read the half-built set, then - `escapeClosedRuns` walks the same set right to left and adds to it; the order is load-bearing - and asserted nowhere, and a refactor to `filter`/`map` breaks it silently. Separately, - `walk.edges` is the blockquote and list-item subsequence of `walk.stack` with each entry's - stack index, maintained by hand in six places and stated in none. Put each invariant where it - cannot be got wrong — a type, a derived value, a named phase — rather than in a comment. The - panel's first and second hardest places. +- [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).** - [ ] **27 — The dead `headroom` write goes (`0.2.0`).** `directiveItems` (`emit/adf-to-markdown.ts:258`) writes `item.walk.headroom - 1` onto each `PlacedBlock`, and nothing on that path reads a block's `headroom`: `joinBlocks` and `separationBetween` read