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:
+3
-2
@@ -17,8 +17,9 @@
|
||||
- `losslessMarkdownToAdf` names the CommonMark spelling to write when it refuses a directive whose
|
||||
node CommonMark spells, such as `[](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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user