Answer the stability review: the link close hoists out of the character walk
CI / gate (push) Successful in 4s
CI / gate (push) Successful in 4s
This commit is contained in:
@@ -138,9 +138,10 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
|
||||
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
|
||||
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
|
||||
`Result` rather than the stack overflow that waits near 2000.
|
||||
- A reader takes the text and an index — a sticky regex, `indexOf` — never a fresh slice per
|
||||
character, and a per-character walk hoists what does not vary with the character. The pipeline
|
||||
persona feeds documents nobody typed, so an ordinary megabyte stays milliseconds.
|
||||
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
|
||||
line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
|
||||
hoists the scan that does not vary with the character. The pipeline persona feeds documents
|
||||
nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond.
|
||||
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
|
||||
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
|
||||
unrepresentable.
|
||||
@@ -180,7 +181,7 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
|
||||
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
|
||||
validation or exported validator — a refusal that keeps the round-trip is not schema validation,
|
||||
so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no
|
||||
streaming APIs, no performance budget past §11's linear scan — nothing here is tuned, and no
|
||||
streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no
|
||||
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal.
|
||||
|
||||
## 15. The working loop
|
||||
|
||||
@@ -69,6 +69,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
|
||||
const escaped = new Set<number>()
|
||||
const placements: number[] = []
|
||||
let output = ''
|
||||
const linkClose = lastLinkClose(scan, escapings)
|
||||
let line = scanLine(scan, 0)
|
||||
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)
|
||||
@@ -78,7 +79,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
|
||||
escapable &&
|
||||
(claimsLineStart(line, index, container) ||
|
||||
mergesWithSyntax(scan, escapings, index) ||
|
||||
opensConstruct(scan, escapings, index, escaping === 'bracketed', container, escaped))
|
||||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))
|
||||
) {
|
||||
output += '\\'
|
||||
escaped.add(index)
|
||||
@@ -185,14 +186,14 @@ function isSyntax(escaping: InlineEscaping | undefined): boolean {
|
||||
|
||||
function opensConstruct(
|
||||
scan: string,
|
||||
escapings: readonly (InlineEscaping | undefined)[],
|
||||
linkClose: number,
|
||||
index: number,
|
||||
inBrackets: boolean,
|
||||
container: LineContainer,
|
||||
escaped: ReadonlySet<number>,
|
||||
): boolean {
|
||||
if (container === 'heading' && closesHeading(scan, index)) return true
|
||||
return claimsCharacter(scan, escapings, index, inBrackets, container, escaped)
|
||||
return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped)
|
||||
}
|
||||
|
||||
function claimsLineStart(line: ScanLine, index: number, container: LineContainer): boolean {
|
||||
@@ -211,7 +212,7 @@ function closesHeading(scan: string, index: number): boolean {
|
||||
|
||||
function claimsCharacter(
|
||||
scan: string,
|
||||
escapings: readonly (InlineEscaping | undefined)[],
|
||||
linkClose: number,
|
||||
index: number,
|
||||
inBrackets: boolean,
|
||||
container: LineContainer,
|
||||
@@ -224,19 +225,20 @@ function claimsCharacter(
|
||||
if (character === '&') return readEntityReference(scan, index) !== undefined
|
||||
if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined
|
||||
if (character === ':') return opensInlineDirective(scan, index)
|
||||
if (character === '[') return opensLink(scan, escapings, index)
|
||||
if (character === '[') return index < linkClose
|
||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
||||
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
|
||||
return false
|
||||
}
|
||||
|
||||
// The last `]` link text could close on, so a `[` ahead of it opens one and a `[` past it cannot.
|
||||
// A `]` the emitter spelled sits inside a construct that binds before link text does.
|
||||
function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
|
||||
for (let cursor = index + 1; cursor < scan.length; cursor += 1) {
|
||||
function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undefined)[]): number {
|
||||
for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) {
|
||||
if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue
|
||||
if (followsLinkText.test(scan.charAt(cursor + 1))) return true
|
||||
if (followsLinkText.test(scan.charAt(cursor + 1))) return cursor
|
||||
}
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
|
||||
function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
|
||||
@@ -4,14 +4,18 @@ import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { readEntityReference } from './entity-references.ts'
|
||||
|
||||
const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'entity-references.ts'), 'utf8')
|
||||
|
||||
// The packing the module rests on, read off the rows rather than trusted: a value holding a space or a
|
||||
// tilde mis-splits into a wrong name and a lost one, at load, with nothing failing.
|
||||
test('every packed reference parts into one name and one value', () => {
|
||||
// A value holding a space or a tilde mis-splits into a wrong name and a lost one, silently, at load.
|
||||
test('every packed reference parts into one name and one value the table decodes', () => {
|
||||
const packed = /const packedReferences =\n {2}'([\s\S]*?)'\n/.exec(source)?.[1]
|
||||
assert.ok(packed !== undefined)
|
||||
const references = packed.split(/(?:\\\n)? +/)
|
||||
assert.equal(references.length, 2125)
|
||||
for (const reference of references) assert.match(reference, /^[A-Za-z][A-Za-z0-9]*~[^ ~]+$/)
|
||||
for (const reference of references) {
|
||||
assert.match(reference, /^[A-Za-z][A-Za-z0-9]*~[^ ~]+$/)
|
||||
assert.ok(readEntityReference(`&${reference.slice(0, reference.indexOf('~'))};`, 0) !== undefined)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user