diff --git a/CHANGELOG.md b/CHANGELOG.md index ab268aa..c50c94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ holds many unclosed backtick runs: 640000 characters of them took one second, now 100 ms. - Every reader takes time linear in the inline markdown when emphasis spans many links, or many closing brackets follow a link: 8000 links inside 8000 `*` took two seconds, now about 100 ms. +- Every reader takes time linear in the inline markdown when it holds many `](` that open no link, + as in `[a](b` repeated: 16000 of them took 25 seconds, now 80 ms. - Every reader leaves `[text]`, `[text][]`, `[text][label]` and a definition `[label]: url` literal when the label runs over 999 characters, where it read them as a link or a definition. CommonMark caps a label at 999 characters, an escape counting as two. diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 28628a7..57048cf 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -490,3 +490,7 @@ Read most (opens and searches): ## Reading cost, chunk 62dfe3cc2720, finished 2026-10-26T20:21:53Z 90793 new input tokens before the first commit. + +## Reading cost, chunk 6c9ac781b7a6, finished 2026-10-28T10:49:16Z + +306528 new input tokens before the first commit. diff --git a/src/markdown/commonmark/link-syntax.test.ts b/src/markdown/commonmark/link-syntax.test.ts new file mode 100644 index 0000000..04492bd --- /dev/null +++ b/src/markdown/commonmark/link-syntax.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { readInlineTarget, targetMemo } from './link-syntax.ts' + +// Every `](` of the text, read in order through one memo, as a reader closes its brackets. +function targets(text: string): unknown[] { + const memo = targetMemo(text) + const read: unknown[] = [] + for (let offset = text.indexOf('](') + 1; offset > 0; offset = text.indexOf('](', offset) + 1) { + const target = readInlineTarget(text, offset, memo) + read.push(target === undefined ? undefined : { ...target.definition(), length: target.length }) + } + return read +} + +test('a target nothing closes reads as none, at every `](`', () => { + assert.deepEqual(targets('[a](b'.repeat(3)), [undefined, undefined, undefined]) +}) + +test('targets sharing a destination end share its title and its failure', () => { + assert.deepEqual(targets('[a](b[c](d "t" x)'), [undefined, undefined]) + assert.deepEqual(targets('[a](b[c](d "t" x'), [undefined, undefined]) +}) + +test('an unbracketed destination ends at its first unmatched `)`', () => { + assert.deepEqual(targets('[a](b(c)d) [e](f)'), [{ destination: 'b(c)d', length: 7 }, { destination: 'f', length: 3 }]) + assert.deepEqual(targets('[a](b\\)c)'), [{ destination: 'b)c', length: 6 }]) +}) + +test('a target reads its whitespace, a bracketed destination and a title', () => { + assert.deepEqual(targets('[a]( b\n "t" ) [c]( \'f\') [g]()'), [ + { destination: 'b', length: 10, title: 't' }, + { destination: 'd e', length: 11, title: 'f' }, + { destination: '', length: 2 }, + ]) +}) diff --git a/src/markdown/commonmark/link-syntax.ts b/src/markdown/commonmark/link-syntax.ts index d6ec45e..5252dcf 100644 --- a/src/markdown/commonmark/link-syntax.ts +++ b/src/markdown/commonmark/link-syntax.ts @@ -5,6 +5,12 @@ export type LinkDefinition = { destination: string; title?: string } export type LinkPart = { length: number; value: string } +// What follows a destination ending at an index, the same for every destination ending there: the index after the `)`, and the title. +type Tail = { end: number; title: string | undefined } + +// `destinationEnds[start]` is where an unbracketed destination starting there stops, for every start a target reads from: one after a `(`, a space or a control character. +export type TargetMemo = { destinationEnds: Int32Array; tails: Map } + // The lossless flavour escapes a quote alone, writing a title holding a backslash or an entity reference as a directive (spec/flavour.md, Marks); the portable flavour writes no directive, so it escapes those too, and a table cell's `|` (README, Portable markdown). export type TitleEscapes = 'lossless' | 'portable' | 'portable-table-cell' @@ -12,6 +18,7 @@ const bracketedDestination = /<((?:[^\n<>\\]|\\[^\n])*)>/y // CommonMark: a link label holds at most this many characters between its brackets, an escape counting as two. export const labelLimit = 999 const linkLabel = new RegExp(String.raw`\[((?:[^[\]\\]|\\[\s\S]){0,${labelLimit}})\]`, 'y') +const linkWhitespace = /[ \t]*\n?[ \t]*/y const titleClosers: Readonly> = { '"': '"', "'": "'", '(': ')' } // The label is matched by its normalized raw text, escapes and all. @@ -52,22 +59,55 @@ export function readDestination(text: string, offset: number): LinkPart | undefi return index <= offset ? undefined : { length: index - offset, value: decodeTextEscapes(text.slice(offset, index)) } } -// `offset` sits on the `(` a link text closes into. -export function readInlineTarget(text: string, offset: number): { definition: LinkDefinition; length: number } | undefined { - let index = skipLinkWhitespace(text, offset + 1) - let destination = '' - if (text.charAt(index) !== ')') { - const read = readDestination(text, index) - if (read === undefined) return undefined - destination = read.value - index += read.length +// `offset` sits on the `(` a link text closes into. The definition is read on demand, so a target its link refuses costs no copy. +export function readInlineTarget(text: string, offset: number, memo: TargetMemo): { definition: () => LinkDefinition; length: number } | undefined { + const start = skipLinkWhitespace(text, offset + 1) + const end = destinationEnd(text, start, memo) + if (end === undefined) return undefined + const tail = memo.tails.has(end) ? memo.tails.get(end) : readTail(text, end) + memo.tails.set(end, tail) + if (tail === undefined) return undefined + const definition = (): LinkDefinition => { + const edge = text.charAt(start) === '<' ? 1 : 0 + const destination = decodeTextEscapes(text.slice(start + edge, end - edge)) + return tail.title === undefined ? { destination } : { destination, title: tail.title } } - const afterDestination = index - index = skipLinkWhitespace(text, index) - const title = index > afterDestination ? readTitle(text, index) : undefined + return { definition, length: tail.end - offset } +} + +// `readDestination`'s loop run for every start at once, with a stack of the starts still open. +export function targetMemo(text: string): TargetMemo { + const destinationEnds = new Int32Array(text.length + 1) + const open = [0] + for (let index = 0; index < text.length; index += backslashEscape(text, index) === undefined ? 1 : 2) { + const character = text.charAt(index) + if (character === '(') open.push(index + 1) + const start = character === ')' ? open.pop() : undefined + if (start !== undefined) destinationEnds[start] = index + if (character !== ' ' && !holdsControlCharacter(character)) continue + for (const stopped of open.splice(0)) destinationEnds[stopped] = index + open.push(index + 1) + } + for (const stopped of open) destinationEnds[stopped] = text.length + return { destinationEnds, tails: new Map() } +} + +function destinationEnd(text: string, start: number, memo: TargetMemo): number | undefined { + if (text.charAt(start) === ')') return start + if (text.charAt(start) === '<') { + bracketedDestination.lastIndex = start + const bracketed = bracketedDestination.exec(text)?.[0] + return bracketed === undefined ? undefined : start + bracketed.length + } + const end = memo.destinationEnds[start] ?? start + return end > start ? end : undefined +} + +function readTail(text: string, end: number): Tail | undefined { + let index = skipLinkWhitespace(text, end) + const title = index > end ? readTitle(text, index) : undefined if (title !== undefined) index = skipLinkWhitespace(text, index + title.length) - if (text.charAt(index) !== ')') return undefined - return { definition: title === undefined ? { destination } : { destination, title: title.value }, length: index + 1 - offset } + return text.charAt(index) === ')' ? { end: index + 1, title: title?.value } : undefined } export function readTitle(text: string, offset: number): LinkPart | undefined { @@ -90,8 +130,10 @@ export function readTitle(text: string, offset: number): LinkPart | undefined { // The label, the destination and the title each take at most one line ending with them. export function skipLinkWhitespace(text: string, offset: number): number { - const rest = text.slice(offset) - return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length + linkWhitespace.lastIndex = offset + // The pattern matches empty, so the test always moves `lastIndex` past the whitespace. + linkWhitespace.test(text) + return linkWhitespace.lastIndex } export function spellDestination(href: string): string | undefined { diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index df97265..0b92642 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -4,7 +4,7 @@ import type { Claims, Flavour } from '../flavour.ts' import type { EmphasisPairing } from '../commonmark/emphasis-matching.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 type { LinkDefinition, TargetMemo } from '../commonmark/link-syntax.ts' import { centeredImage, externalMedia } from '../../adf/plain-image.ts' import { commonMarkLink, linkDefinition, linkHref, linkMark, toLinkAttrs } from '../mark-spellings.ts' import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts' @@ -15,7 +15,7 @@ import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../a import { imageAside } from '../image-aside.ts' import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' -import { labelLimit, normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' +import { labelLimit, normalizeLabel, readInlineTarget, readLabel, targetMemo } from '../commonmark/link-syntax.ts' import { matchEmphasis } from '../commonmark/emphasis-matching.ts' import { noSpans, readInlineDirective } from '../directive/syntax.ts' import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' @@ -87,6 +87,8 @@ type Scan = { pieces: Piece[] source: string spans: NestedSpans + // Built at the first inline target the scan reads. + targets: TargetMemo | undefined } // What a directive's content slot inherits from the content holding it. @@ -117,7 +119,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions, } function freshScan(source: string, shared: SharedScan, own: Pick): Scan { - return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source } + return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source, targets: undefined } } function scanInline(scan: Scan): Result { @@ -395,7 +397,7 @@ function closeBracket(scan: Scan, index: number): Result { if (!open.image) scan.linkOpenersFrom = scan.brackets.length // Before `linkPieces`' own-marks guard: brackets that go literal mark nothing, so a carry inside them is no error. if (!open.image && holdsLink(inner)) return success(unopened(scan, open, index)) - const closed = open.image ? imagePieces(scan, inner, target.definition) : linkPieces(scan, inner, target.definition) + const closed = open.image ? imagePieces(scan, inner, target.definition()) : linkPieces(scan, inner, target.definition()) if (!closed.ok) return closed scan.pieces.length = open.index for (const piece of closed.value) scan.pieces.push(piece) @@ -425,11 +427,12 @@ function bracketNodes(bracket: Bracket | OpenBracket): AdfNode[] { return [{ text: bracket.image ? '![' : '[', type: 'text' }] } -function resolveTarget(scan: Scan, start: number, index: number): { definition: LinkDefinition; length: number } | undefined { +function resolveTarget(scan: Scan, start: number, index: number): { definition: () => LinkDefinition; length: number } | undefined { const after = index + 1 if (scan.source.charAt(after) === '(') { - const inline = readInlineTarget(scan.source, after) - if (inline !== undefined) return { definition: inline.definition, length: inline.length } + scan.targets ??= targetMemo(scan.source) + const inline = readInlineTarget(scan.source, after, scan.targets) + if (inline !== undefined) return inline } const label = scan.source.charAt(after) === '[' ? readLabel(scan.source, after) : undefined const labelIsText = label === undefined || label.value === '' @@ -437,7 +440,7 @@ function resolveTarget(scan: Scan, start: number, index: number): { definition: const name = labelIsText ? scan.source.slice(start, index) : label.value const definition = scan.definitions.get(normalizeLabel(name)) if (definition === undefined) return undefined - return { definition, length: label?.length ?? 0 } + return { definition: () => definition, length: label?.length ?? 0 } } // An image alone in the link text keeps its piece, so a paragraph holding nothing else builds the linked image. diff --git a/todo.md b/todo.md index 579da9a..21495da 100644 --- a/todo.md +++ b/todo.md @@ -6,13 +6,13 @@ `Bar = 0.1` -`Next ID = 151` +`Next ID = 152` ## Items | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 140 | 0.3.0 | | defect | **Read an inline link's target in time linear in the inline markdown, in every reader.** | 3 | 1 | 2 | 4 | 3 | 9 by 3 | 19.6 | +| 151 | 0.3.0 | | defect | **Read a link destination holding an unbalanced unescaped parenthesis as no destination, in every markdown reader.** | 2 | 2 | 2 | 4 | 2 | 5 by 2 | 17.4 | | 95 | 0.3.0 | | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 4 | 1 | 3 | 6 | 6 | code health by 7 | 10.2 | | 146 | 0.3.0 | | | **Open the README with what an app developer choosing an ADF converter needs: what the package converts, which call to take, and that it runs without React, Atlaskit or any runtime dependency, in Node, Forge, Deno, Bun and browsers.** | 2 | 2 | 1 | 3 | 3 | 3 by 6 | 9.5 | | 33 | 0.3.0 | | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 | @@ -23,8 +23,8 @@ | 91 | 0.3.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 | | 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 | | 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | -| 73 | 0.3.0 | 33, 91, 95, 109, 115, 132, 133, 140, 145, 146, 150 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | -| 72 | 0.3.0 | 33, 91, 95, 109, 115, 132, 133, 140, 145, 146, 150 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | +| 73 | 0.3.0 | 33, 91, 95, 109, 115, 132, 133, 145, 146, 150 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 72 | 0.3.0 | 33, 91, 95, 109, 115, 132, 133, 145, 146, 150 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | | 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | @@ -69,14 +69,13 @@ ## Details -### 140. Read an inline link's target in time linear in the inline markdown, in every reader. +### 151. Read a link destination holding an unbalanced unescaped parenthesis as no destination, in every markdown reader. -`'[a](b'.repeat(n)` reads in time quadratic in n: each `](` after an open bracket reads its -destination to the end of the line through `readDestination` (`src/markdown/commonmark/link-syntax.ts`), -finds no `)` and fails. `skipLinkWhitespace` also copies the rest of the text on every call. -`losslessMarkdownToAdf` takes 1.6 s at n = 4000 and 25 s at n = 16 000 on Node 24, 4× per doubling. -cmark's pathological tests name the shape "unclosed links B". Found while shipping item 129, -2026-10-23. +CommonMark lets an unbracketed destination hold a parenthesis only escaped or in a balanced pair, +but `[a](b(c "t")` reads as a link to `b(c` titled `t`, where cmark and commonmark.js read text. +`readDestination` and `targetMemo` (`src/markdown/commonmark/link-syntax.ts`) stop at a space with +parentheses still open and keep the destination. The writers escape an unbalanced parenthesis, so +no written markdown changes. Found while shipping item 140, 2026-10-28. ### 95. Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.