3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 12:18:28 +01:00

Read deep list items' blank lines and a directive's unclosed backtick runs in time linear in the markdown

This commit is contained in:
2026-10-18 11:15:30 +02:00
committed by Lillem4n
parent a9cdd70365
commit d82b7abb3f
11 changed files with 119 additions and 71 deletions
+28
View File
@@ -122,3 +122,31 @@ test('closes the containers a closer names past as unclosed, and crosses no list
test("releases an indented code block's held blank lines as a walk rather than as one call's arguments", () => {
assert.deepEqual(kinds(` a\n${'\n'.repeat(200000)} b\n`), ['code'])
})
// A level is one list item. The gate times nothing; a quadratic walk outlasts Bun's 5 s test timeout.
function deepestItem(markdown: string): { depth: number; kinds: string[] } {
let blocks = parseBlocks(markdown, flavourClaims.lossless).blocks
let depth = 0
for (let list = blocks[0]; list?.kind === 'bulletList'; list = blocks[0]) {
blocks = list.items.at(-1) ?? []
depth += 1
}
return { depth, kinds: blocks.map((block) => block.kind) }
}
test('continues a blank line through a deep run of list items in time linear in the lines', () => {
const depth = 20000
assert.deepEqual(deepestItem(`${'- '.repeat(depth)}a\n${'\n'.repeat(depth)}`), { depth, kinds: ['paragraph'] })
assert.deepEqual(deepestItem(`${'- '.repeat(depth)}a\n${'\n'.repeat(depth)}${' '.repeat(depth)}b\n`), { depth, kinds: ['paragraph', 'paragraph'] })
})
test('stops a blank line at the first blockquote and at an empty item', () => {
assert.deepEqual(deepestItem('- > a\n\n b\n'), { depth: 1, kinds: ['blockquote', 'paragraph'] })
assert.deepEqual(deepestItem('- - \n\n a\n'), { depth: 2, kinds: [] })
assert.deepEqual(deepestItem('- > - a\n\n > b\n'), { depth: 1, kinds: ['blockquote', 'blockquote'] })
})
test('continues a line through a deep run of list items in time linear in the line', () => {
const depth = 20000
assert.deepEqual(deepestItem(`${'- '.repeat(depth)}a\n${`${' '.repeat(depth)}b\n`.repeat(3)}`), { depth, kinds: ['paragraph'] })
})
+35 -11
View File
@@ -73,6 +73,8 @@ type ContainerStack = {
edges: readonly { container: EdgeContainer; depth: number }[]
open: readonly OpenContainer[]
push: (container: OpenContainer) => void
// The index in `edges` of each blockquote.
quotes: readonly number[]
}
type Walk = ParsedBlocks & {
@@ -112,13 +114,17 @@ function containerStack(): ContainerStack {
const depthsOf = (name: string): number[] => entryOf(directiveDepths, name, () => [])
const edges: { container: EdgeContainer; depth: number }[] = []
const open: OpenContainer[] = []
const quotes: number[] = []
return {
directiveDepth: (name) => directiveDepths.get(name)?.at(-1),
drop: (depth) => {
const dropped = open.splice(depth)
for (const container of dropped) {
if (container.kind === 'directive') depthsOf(container.name).pop()
else edges.pop()
else {
edges.pop()
if (container.kind === 'blockquote') quotes.pop()
}
}
return dropped
},
@@ -127,8 +133,12 @@ function containerStack(): ContainerStack {
push: (container) => {
const depth = open.push(container) - 1
if (container.kind === 'directive') depthsOf(container.name).push(depth)
else edges.push({ container, depth })
else {
if (container.kind === 'blockquote') quotes.push(edges.length)
edges.push({ container, depth })
}
},
quotes,
}
}
@@ -157,22 +167,34 @@ 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 } {
if (isBlankLine(line.text)) return matchBlankLine(walk, line, 0, 0)
let quotes = 0
let rest = line
for (const { container, depth } of walk.stack.edges) {
const next = continuesContainer(walk, container, rest)
for (const [index, { container, depth }] of walk.stack.edges.entries()) {
const next = continuesContainer(container, rest)
if (next === undefined) return { depth, rest }
rest = next
if (container.kind !== 'blockquote') continue
quotes += 1
if (isBlankLine(rest.text)) return matchBlankLine(walk, rest, index + 1, quotes)
}
return { depth: walk.stack.open.length, rest }
}
function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
// A blank line continues every list item up to the next blockquote, so it is matched in one step rather than one per item.
// A list item begins with at most one blank line: an empty one gives the second up. Only the innermost item is empty:
// opening a container inside an item gives it a block.
function matchBlankLine(walk: Walk, line: Line, from: number, quote: number): { depth: number; rest: Line } {
const { edges, quotes } = walk.stack
const last = edges.length - 1
const emptyItem = last >= from && edges[last]?.container.blocks.length === 0 && walk.leaf === undefined
const stop = Math.min(quotes[quote] ?? edges.length, emptyItem ? last : edges.length)
return { depth: edges[stop]?.depth ?? walk.stack.open.length, rest: stop === from ? line : { column: line.column, text: '' } }
}
function continuesContainer(container: EdgeContainer, line: Line): Line | undefined {
if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation))
// A list item begins with at most one blank line: an empty one gives the second up.
if (isBlankLine(line.text)) {
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
}
return leadingColumns(line) < container.indentation ? undefined : removeColumns(line, container.indentation)
return leadingColumns(line, container.indentation) < container.indentation ? undefined : removeColumns(line, container.indentation)
}
function blockquoteRest(opener: Line): Line | undefined {
@@ -583,9 +605,11 @@ function sourceLine(markdown: string, line: number, start: number, end: number):
return { position: { line, offset: start }, text: replaceNullCharacters(markdown.slice(start, end)) }
}
function leadingColumns(line: Line): number {
// Counting stops at `upTo` columns, so a test against a width costs the width, not the line's whole indentation.
function leadingColumns(line: Line, upTo = Infinity): number {
let columns = 0
for (const character of line.text) {
if (columns >= upTo) break
if (character === ' ') columns += 1
else if (character === '\t') columns += tabStop - ((line.column + columns) % tabStop)
else break
+6 -6
View File
@@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { ClaimedPrefix, DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
import type { Claims } from '../portable/conventions.ts'
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { InlineToken } from '../inline-tokens.ts'
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
@@ -112,7 +112,7 @@ function scanInline(scan: Scan): Result<Scanned> {
const inline = inlineSource(scan.source)
let index = 0
while (index < scan.source.length) {
const read = readToken(scan, index, readInlineToken(inline, index, scan.claims))
const read = readToken(scan, inline, index, readInlineToken(inline, index, scan.claims))
if (!read.ok) return read
index = read.value
}
@@ -120,8 +120,8 @@ function scanInline(scan: Scan): Result<Scanned> {
return assemble(scan)
}
function readToken(scan: Scan, index: number, token: InlineToken): Result<number> {
if (token.kind === 'directive') return readDirective(scan, token.prefix)
function readToken(scan: Scan, inline: InlineSource, index: number, token: InlineToken): Result<number> {
if (token.kind === 'directive') return readDirective(scan, inline, token.prefix)
if (token.kind === 'html') return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.path)
const end = index + token.width
switch (token.kind) {
@@ -170,10 +170,10 @@ function readLineEnding(scan: Scan, index: number): number {
return index + 1
}
function readDirective(scan: Scan, prefix: ClaimedPrefix): Result<number> {
function readDirective(scan: Scan, inline: InlineSource, prefix: ClaimedPrefix): Result<number> {
const held = scan.spans.get(prefix.index)
if (held !== undefined) return pushDirective(scan, held, prefix.index)
const directive = readInlineDirective(scan.source, prefix)
const directive = readInlineDirective(scan.source, prefix, inline.codeSpanCloser)
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
return pushDirective(scan, directive.value, prefix.index)
}