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

Answer principles round 1: 0 🔴, 1 🟡; scope the changelog to outside directives, and keep the backtick cursor inside a closure

This commit is contained in:
2026-10-18 10:46:49 +02:00
committed by Lillem4n
parent e12249e593
commit a674df9b75
8 changed files with 64 additions and 47 deletions
+3 -2
View File
@@ -17,8 +17,9 @@
- `losslessMarkdownToAdf` names the CommonMark spelling to write when it refuses a directive whose
node CommonMark spells, such as `[![alt](url)](href)` for a linked `!adf:mediaSingle`.
- Every reader and writer takes time linear in a text holding many unclosed backtick runs, or many
`<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, where such a text took time
quadratic in its length: 160k characters of `<?` took seven seconds each way, now 50 ms.
`<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, outside a directive's `[content]`,
where such a text took time quadratic in its length: 160k characters of `<?` took seven seconds
each way, now 50 ms.
- The README compares the package with seven other ADF converters, each checked against its
published version.
+12 -12
View File
@@ -1,24 +1,24 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { backtickRuns, closingRun } from './backtick-runs.ts'
import { backtickCloser } from './backtick-runs.ts'
test('a code span closes on the first later run of exactly its opener length', () => {
const runs = backtickRuns('`a``b```c`')
assert.equal(closingRun(runs, 1, 1), 9)
assert.equal(closingRun(runs, 1, 2), 2)
assert.equal(closingRun(runs, 1, 3), 5)
assert.equal(closingRun(runs, 1, 4), undefined)
const closer = backtickCloser('`a``b```c`')
assert.equal(closer(1, 1), 9)
assert.equal(closer(1, 2), 2)
assert.equal(closer(1, 3), 5)
assert.equal(closer(1, 4), undefined)
})
test('a run before the opener closes nothing', () => {
const runs = backtickRuns('``a``b')
assert.equal(closingRun(runs, 2, 2), 3)
assert.equal(closingRun(runs, 5, 2), undefined)
const closer = backtickCloser('``a``b')
assert.equal(closer(2, 2), 3)
assert.equal(closer(5, 2), undefined)
})
test('an ask from earlier in the text than the last finds the earlier closer', () => {
const runs = backtickRuns('`a`b`c`')
assert.equal(closingRun(runs, 5, 1), 6)
assert.equal(closingRun(runs, 1, 1), 2)
const closer = backtickCloser('`a`b`c`')
assert.equal(closer(5, 1), 6)
assert.equal(closer(1, 1), 2)
})
+14 -20
View File
@@ -1,16 +1,14 @@
import { runLength } from './emphasis-matching.ts'
// Each run's start by its length, read once, so asking for a closer at every opener stays linear in the text.
export interface BacktickRuns {
readonly asked: Map<number, number>
readonly starts: ReadonlyMap<number, readonly number[]>
}
export function backtickRun(text: string, index: number): number {
return text.charAt(index) === '`' ? runLength(text, index) : 0
}
export function backtickRuns(text: string): BacktickRuns {
// As `closingBacktickRun`, for `from` past the end of an opener's run, with each run's start read once by its length.
// Readers ask with `from` rising, which keeps each length's cursor moving forward, so asking at every opener stays
// linear in the text; an ask behind the cursor restarts that length from its first run.
export function backtickCloser(text: string): (from: number, opener: number) => number | undefined {
const asked = new Map<number, number>()
const starts = new Map<number, number[]>()
for (let start = text.indexOf('`'); start !== -1; ) {
const length = runLength(text, start)
@@ -19,19 +17,15 @@ export function backtickRuns(text: string): BacktickRuns {
else known.push(start)
start = text.indexOf('`', start + length)
}
return { asked: new Map(), starts }
}
// As `closingBacktickRun`, for `from` past the end of an opener's run. Readers ask with `from` rising, which keeps
// each length's cursor moving forward; an ask behind the cursor restarts that length from its first run.
export function closingRun(runs: BacktickRuns, from: number, opener: number): number | undefined {
const starts = runs.starts.get(opener)
if (starts === undefined) return undefined
let at = runs.asked.get(opener) ?? 0
if ((starts[at - 1] ?? -1) >= from) at = 0
while ((starts[at] ?? Infinity) < from) at += 1
runs.asked.set(opener, at)
return starts[at]
return (from, opener) => {
const runs = starts.get(opener)
if (runs === undefined) return undefined
let at = asked.get(opener) ?? 0
if ((runs[at - 1] ?? -1) >= from) at = 0
while ((runs[at] ?? Infinity) < from) at += 1
asked.set(opener, at)
return runs[at]
}
}
// Where the run of exactly `opener` backticks closing a code span begins, `undefined` where none does.
+1 -1
View File
@@ -146,7 +146,7 @@ function htmlTagName(text: string): string {
return text.replace(tagName, '<$1>')
}
// `terminated` says whether a terminator starts at or after `from`, which a caller asking at every opener answers from what it kept.
// `terminated` says whether `terminator` starts at or after `from` in `text`.
export function inlineHtmlConstruct(text: string, index: number, terminated: (terminator: string, from: number) => boolean): string | undefined {
for (const construct of inlineHtmlConstructs) {
construct.opener.lastIndex = index
+4 -5
View File
@@ -47,7 +47,6 @@ type EscapeWalk = {
readonly headingCloser: number | undefined
readonly inline: InlineSource
readonly linkClose: number
readonly scan: string
}
const escapingCodes: readonly ['backslash', 'bracketed', 'bracketed-link-target', 'none'] = ['backslash', 'bracketed', 'bracketed-link-target', 'none']
@@ -148,7 +147,6 @@ function escapeClaims(scan: string, escapings: Escapings, container: LineContain
headingCloser: container === 'heading' ? closingHashes(scan) : undefined,
inline: inlineSource(scan),
linkClose: lastLinkClose(scan, escapings),
scan,
}
const escaped = new Uint8Array(scan.length)
let line = scanLine(scan, 0)
@@ -174,7 +172,7 @@ function escapeClaims(scan: string, escapings: Escapings, container: LineContain
// A text character beside one of the emitter's delimiters would lengthen that delimiter's run, so every text character in such a run escapes.
function runEscapes(walk: EscapeWalk, start: number, end: number, line: ScanLine): RunEscapes {
const { escapings, scan } = walk
const { escapings, inline: { text: scan } } = walk
if (runHolds(escapings, start, end, isSyntax) && runHolds(escapings, start, end, isEscapable)) return { escapable: true }
let index = start
// A backtick run escapes whole: a bare rest would be a shorter backtick string, which can close a span an earlier run opens.
@@ -187,7 +185,8 @@ function runHolds(escapings: Escapings, start: number, end: number, kind: (escap
return false
}
function claimsRunRest({ claims, container, escapings, inline, scan }: EscapeWalk, index: number, end: number, line: ScanLine): boolean {
function claimsRunRest({ claims, container, escapings, inline }: EscapeWalk, index: number, end: number, line: ScanLine): boolean {
const scan = inline.text
const escaping = escapings(index)
if (escaping === 'bracketed-link-target') return readInlineToken(inline, index, claims).kind === 'code-span'
if (!isEscapable(escaping)) return false
@@ -196,7 +195,7 @@ function claimsRunRest({ claims, container, escapings, inline, scan }: EscapeWal
return token.kind !== 'text'
}
function claimsCharacter({ container, escapings, headingCloser, linkClose, scan }: EscapeWalk, index: number, token: InlineToken, line: ScanLine): boolean {
function claimsCharacter({ container, escapings, headingCloser, inline: { text: scan }, linkClose }: EscapeWalk, index: number, token: InlineToken, line: ScanLine): boolean {
const escaping = escapings(index)
if (escaping === 'bracketed-link-target') return token.kind === 'directive'
if (!isEscapable(escaping)) return false
+23
View File
@@ -0,0 +1,23 @@
import type { AdfDocument } from '../adf/document.ts'
import assert from 'node:assert/strict'
import test from 'node:test'
import { adfToLosslessMarkdown } from './emit/adf-to-markdown.ts'
import { flavourClaims } from './portable/conventions.ts'
import { inlineSource, readInlineToken } from './inline-tokens.ts'
import { losslessMarkdownToAdf } from './parse/markdown-to-adf.ts'
const constructs = ['<!A>', '<??>', '<![CDATA[]]>']
test('an HTML construct whose terminator starts right after its opener reads as HTML', () => {
for (const construct of constructs) assert.equal(readInlineToken(inlineSource(`a ${construct}`), 2, flavourClaims.commonmark).kind, 'html', construct)
})
test('text spelling such a construct round-trips', () => {
for (const construct of constructs) {
const document: AdfDocument = { content: [{ content: [{ text: `a ${construct}`, type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const written = adfToLosslessMarkdown(document)
assert.ok(written.ok)
assert.deepEqual(losslessMarkdownToAdf(written.value), { ok: true, value: document }, construct)
}
})
+4 -5
View File
@@ -1,8 +1,7 @@
import type { BacktickRuns } from './commonmark/backtick-runs.ts'
import type { ClaimedPrefix } from './directive-syntax.ts'
import type { Claims } from './portable/conventions.ts'
import { backslashEscape, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from './commonmark/grammar.ts'
import { backtickRun, backtickRuns, closingRun } from './commonmark/backtick-runs.ts'
import { backtickCloser, backtickRun } from './commonmark/backtick-runs.ts'
import { claimDirectivePrefix } from './directive-syntax.ts'
import { delimiterFlags, runLength } from './commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from './portable/conventions.ts'
@@ -21,14 +20,14 @@ export type InlineToken =
// A text and what its openers look ahead for, read once, so a walk reading a token at every index stays linear in it.
export type InlineSource = {
readonly backticks: BacktickRuns
readonly closingBacktickRun: (from: number, opener: number) => number | undefined
// Each HTML construct terminator's last start in the text, -1 for none, read the first time an opener asks.
readonly terminators: Map<string, number>
readonly text: string
}
export function inlineSource(text: string): InlineSource {
return { backticks: backtickRuns(text), terminators: new Map(), text }
return { closingBacktickRun: backtickCloser(text), terminators: new Map(), text }
}
export function readInlineToken(inline: InlineSource, index: number, claims: Claims): InlineToken {
@@ -87,7 +86,7 @@ function angleToken(inline: InlineSource, index: number): InlineToken {
function backtickToken(inline: InlineSource, index: number): InlineToken {
const opener = backtickRun(inline.text, index)
const closer = closingRun(inline.backticks, index + opener, opener)
const closer = inline.closingBacktickRun(index + opener, opener)
return closer === undefined ? text(opener) : { kind: 'code-span', opener, width: closer + opener - index }
}
+3 -2
View File
@@ -173,9 +173,10 @@ maintainer posts them once `0.3.0` is published. Found while planning discoverab
the rest of the text at every opener, so backtick runs of lengths 1 to k with no closers inside an
inline directive's `[content]` cost about k·n. Measured on 2026-10-18: 24 ms at 40k characters,
126 ms at 160k and 1004 ms at 640k, where the same runs outside a directive take 6, 18 and 63 ms.
The inline reader reads such runs through `backtickRuns` and `closingRun`
The inline reader reads such runs through `backtickCloser`
(`src/markdown/commonmark/backtick-runs.ts`), kept once per text; the directive reader would need
them kept for the source it reads from.
them kept for the source it reads from. Delete `closingBacktickRun` once nothing calls it, so one
reader finds a closer.
### 81. Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.