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

Answer architecture round 2: the escape walk owns its escapes and names every token kind, a directive read takes the claimed prefix, the reader computes one end, imports sort, and item 96 covers unclosed backtick runs

This commit is contained in:
2026-10-09 16:55:35 +02:00
committed by Lillem4n
parent 652ae60179
commit af9b0d082c
7 changed files with 116 additions and 76 deletions
+24
View File
@@ -335,3 +335,27 @@ Inherited architect, decided by:
- Locality: `src/markdown/emit/adf-to-markdown.ts:72` `joinBlocks`: a block's spelling depends on the run before it, where the base's separation was a pure function of a pair
- Shape: `src/markdown/emit/adf-to-markdown.ts:334` `spelledList`: one exit to the directive form, though bullets pass `() => ''` as the number
- Self-sufficiency: `src/markdown/emit/adf-to-markdown.ts:369` `tryListItemLines`: the `*` branch needs the spec open, cancelling the spec pointers removed
## 2026-10-09T14:50:52Z, PR #27 at 2378782, against a9fedb3
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | better | better |
| Inherited architect | better | better | better | same | better |
Mid A, decided by:
- Locality, Self-sufficiency: `src/markdown/inline-tokens.ts:20` `readInlineToken`: the parser and the escaper ask one function what the reader takes at a character, where each held its own copy of the rules
- Locality, Shape: `src/markdown/emit/line-escaping.ts:102` `escapeClaims`: the order-dependent `afterEscape` and `pairsEquals` flags are gone; runs go whole to `escapeRun` and fixed inputs ride one `EscapeWalk` record
- Shape, Navigation: `src/markdown/parse/inline-content.ts:110` `scanInline`: a two-level character dispatch and eight helpers became one switch over token kinds
- Shape: `src/markdown/emit/line-escaping.ts:158` `claimsCharacter`: switches on `token.kind` in place of a chain of character tests
Inherited architect, decided by:
- Navigation, Shape: `src/markdown/inline-tokens.ts:20` `readInlineToken`: one tokenizer answers what the reader sees at an index, where two parallel implementations had to agree
- Locality: `src/markdown/emit/line-escaping.ts:112` `escapeClaims`: each step reads on its own, no flag carried from the previous character
- Shape: `src/markdown/emit/line-escaping.ts:158` `claimsCharacter`: switches on the token kind and takes the shared record; the heading closer is worked out once in `closingHashes`
- Shape, Navigation: `src/markdown/parse/inline-content.ts:121` `readToken`: one exhaustive switch over `InlineToken['kind']` replaces a two-level dispatch
- Locality: `src/markdown/emit/line-escaping.ts:173` `claimsCharacter`: `default: return true` claims every kind it does not name, so a new kind is escaped without its author deciding
+4 -3
View File
@@ -2,8 +2,8 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import type { DirectiveAttributes, DirectiveLine } from './directive-syntax.ts'
import { claimDirectivePrefix, readDirectiveLine, readInlineDirective } from './directive-syntax.ts'
import { largestNesting } from '../nesting.ts'
import { claimsDirectivePrefix, readDirectiveLine, readInlineDirective } from './directive-syntax.ts'
// A pair the input spells bare decodes to itself; a quoted one names its spelling beside the decoding.
type Pair = [string, string, string?]
@@ -22,8 +22,9 @@ function fault(line: string): string {
}
function inline(text: string): unknown {
if (!claimsDirectivePrefix(text, 0)) return 'unclaimed'
const read = readInlineDirective(text, 0)
const prefix = claimDirectivePrefix(text, 0)
if (prefix === undefined) return 'unclaimed'
const read = readInlineDirective(text, prefix)
if (read.fault !== undefined) return read.fault.message
return { attributes: read.value.attributes, content: read.value.content, length: read.value.length, name: read.value.name }
}
+10 -3
View File
@@ -10,6 +10,9 @@ import { serializeCanonicalJson } from '../canonical-json.ts'
export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue }
// An `!adf:` standing at `index`, which only `claimDirectivePrefix` builds.
export type ClaimedPrefix = { readonly [claimedPrefix]: true; readonly index: number }
export type DirectiveValue = { decoded: string; spelling: string }
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
@@ -35,6 +38,7 @@ type DirectiveContent = { end: number; spans: NestedSpans }
export const directivePrefix = '!adf:'
const claimedPrefix: unique symbol = Symbol('claimed prefix')
const bareTokenSource = '[A-Za-z0-9_-]+'
const bareRun = new RegExp(bareTokenSource, 'y')
const bareToken = new RegExp(`^${bareTokenSource}$`)
@@ -67,6 +71,10 @@ export function attributeValue(text: string, kind: AttributeKind): AttributeRead
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
}
export function claimDirectivePrefix(text: string, index: number): ClaimedPrefix | undefined {
return claimsDirectivePrefix(text, index) ? { [claimedPrefix]: true, index } : undefined
}
export function claimsDirectivePrefix(text: string, index: number): boolean {
return text.startsWith(directivePrefix, index)
}
@@ -88,9 +96,8 @@ export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined
return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line)
}
// The caller has checked `claimsDirectivePrefix` at `index`.
export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> {
return readClaimedDirective(text, index, 1)
export function readInlineDirective(text: string, prefix: ClaimedPrefix): Read<DirectiveSpan> {
return readClaimedDirective(text, prefix.index, 1)
}
export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<string> {
+46 -38
View File
@@ -32,15 +32,14 @@ type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width:
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
// What stays fixed while one line's escapes are decided; `escaped` collects them.
// What stays fixed while one line's escapes are decided.
type EscapeWalk = {
claims: Claims
container: LineContainer
escaped: Set<number>
escapings: readonly InlineEscaping[]
headingCloser: number | undefined
linkClose: number
scan: string
readonly claims: Claims
readonly container: LineContainer
readonly escapings: readonly InlineEscaping[]
readonly headingCloser: number | undefined
readonly linkClose: number
readonly scan: string
}
const followsLinkText = /[([]/
@@ -73,7 +72,7 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
const openOffset = offsets[openerIndex] ?? 0
const closeOffset = offsets[index] ?? 0
if (opener.text !== '_') continue
if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue
if (!isWordCharacter(scan.charAt(openOffset - 1)) && !isWordCharacter(scan.charAt(closeOffset + 1))) continue
opener.text = '*'
segment.text = '*'
}
@@ -103,47 +102,47 @@ function escapeClaims(scan: string, escapings: readonly InlineEscaping[], contai
const walk: EscapeWalk = {
claims,
container,
escaped: new Set(),
escapings,
headingCloser: container === 'heading' ? closingHashes(scan) : undefined,
linkClose: lastLinkClose(scan, escapings),
scan,
}
const escaped = new Set<number>()
let line = scanLine(scan, 0)
let index = 0
while (index < scan.length) {
while (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
if (runCharacters.includes(scan.charAt(index))) {
index = escapeRun(walk, index, line)
const end = index + runLength(scan, index)
for (const claimed of runEscapes(walk, index, end, line)) escaped.add(claimed)
index = end
continue
}
const escaping = escapings[index]
const token = readInlineToken(scan, index, claims)
const claimed = isEscapable(escaping)
? index === walk.headingCloser || claimsLineStart(line, index, container) || claimsCharacter(walk, index, token)
: escaping === 'bracketed-link-target' && token.kind === 'directive'
if (claimed) walk.escaped.add(index)
const claimed = claimsCharacter(walk, index, token, line)
if (claimed) escaped.add(index)
// The reader takes a `==` whole, so its second `=` starts nothing unless the first is escaped.
index += !claimed && token.kind === 'highlight' ? token.width : 1
}
return walk.escaped
return escaped
}
// A run joining the emitter's own delimiter escapes its text; a run of text escapes from its start while the reader claims what the escapes leave of it.
function escapeRun(walk: EscapeWalk, start: number, line: ScanLine): number {
const { escaped, escapings, scan } = walk
const end = start + runLength(scan, start)
function runEscapes(walk: EscapeWalk, start: number, end: number, line: ScanLine): number[] {
const { escapings, scan } = walk
const indexes: number[] = []
const run = escapings.slice(start, end)
if (run.some(isSyntax) && run.some(isEscapable)) {
for (let index = start; index < end; index += 1) if (isEscapable(escapings[index])) escaped.add(index)
return end
for (let index = start; index < end; index += 1) if (isEscapable(escapings[index])) indexes.push(index)
return indexes
}
for (let index = start; index < end; index += 1) {
// A backtick run escapes whole: a rest left bare would be a raw run of another length for a closer.
if (!(index > start && scan.charAt(index) === '`') && !claimsRunRest(walk, index, end, line)) break
escaped.add(index)
const claimed = (index > start && scan.charAt(index) === '`') || claimsRunRest(walk, index, end, line)
if (!claimed) break
indexes.push(index)
}
return end
return indexes
}
function claimsRunRest({ claims, container, escapings, scan }: EscapeWalk, index: number, end: number, line: ScanLine): boolean {
@@ -155,22 +154,35 @@ function claimsRunRest({ claims, container, escapings, scan }: EscapeWalk, index
return token.kind !== 'text'
}
function claimsCharacter({ container, escapings, linkClose, scan }: EscapeWalk, index: number, token: InlineToken): boolean {
const character = scan.charAt(index)
const inBrackets = escapings[index] === 'bracketed'
function claimsCharacter({ container, escapings, headingCloser, linkClose, scan }: 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
if (index === headingCloser || claimsLineStart(line, index, container)) return true
switch (token.kind) {
case 'autolink':
case 'code-span':
case 'delimiter-run':
case 'directive':
case 'entity':
case 'escape':
case 'hard-break':
case 'html':
return true
case 'bracket':
if (token.image) return isSyntax(escapings[index + 1])
return inBrackets || index < linkClose
return escaping === 'bracketed' || index < linkClose
case 'bracket-close':
return inBrackets
return escaping === 'bracketed'
case 'highlight':
return token.opens || token.closes
case 'text':
// A backslash before a line ending reads as a hard break, so no line ending escapes.
case 'line-ending':
case 'text': {
const character = scan.charAt(index)
if (character === '|') return container === 'table-cell'
return character === '{' && scan.charAt(index - 1) === ']' && isSyntax(escapings[index - 1])
default:
return true
}
}
}
@@ -289,7 +301,7 @@ function emittedRuns(segments: readonly InlineSegment[], placements: readonly nu
})
}
for (const run of runs) {
const flags = delimiterFlags(run.character, charAt(output, run.start - 1), output.charAt(run.start + run.length))
const flags = delimiterFlags(run.character, output.charAt(run.start - 1), output.charAt(run.start + run.length))
run.canClose = flags.canClose
run.canOpen = flags.canOpen
}
@@ -332,7 +344,3 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef
}
return -1
}
function charAt(text: string, index: number): string {
return index < 0 ? '' : text.charAt(index)
}
+7 -4
View File
@@ -1,7 +1,8 @@
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, closingBacktickRun } from './commonmark/backtick-runs.ts'
import { claimsDirectivePrefix } from './directive-syntax.ts'
import { claimDirectivePrefix } from './directive-syntax.ts'
import { delimiterFlags, runLength } from './commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from './portable/conventions.ts'
import { readEntityReference } from './commonmark/entity-references.ts'
@@ -13,7 +14,7 @@ export type InlineToken =
| { kind: 'bracket-close' | 'entity' | 'escape' | 'hard-break' | 'line-ending' | 'text'; width: number }
| { kind: 'code-span'; opener: number; width: number }
| { canClose: boolean; canOpen: boolean; kind: 'delimiter-run'; width: number }
| { kind: 'directive' }
| { kind: 'directive'; prefix: ClaimedPrefix }
| { closes: boolean; kind: 'highlight'; opens: boolean; width: number }
| { construct: string; kind: 'html' }
@@ -21,9 +22,11 @@ export function readInlineToken(source: string, index: number, claims: Claims):
switch (source.charAt(index)) {
case '<':
return angleToken(source, index)
case '!':
if (claims.directives && claimsDirectivePrefix(source, index)) return { kind: 'directive' }
case '!': {
const prefix = claims.directives ? claimDirectivePrefix(source, index) : undefined
if (prefix !== undefined) return { kind: 'directive', prefix }
return source.charAt(index + 1) === '[' ? { image: true, kind: 'bracket', width: 2 } : text(1)
}
case '[':
return { image: false, kind: 'bracket', width: 1 }
case ']':
+18 -23
View File
@@ -1,21 +1,21 @@
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 { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { InlineToken } from '../inline-tokens.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../portable/conventions.ts'
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts'
@@ -119,47 +119,42 @@ function scanInline(scan: Scan): Result<Scanned> {
}
function readToken(scan: Scan, index: number, token: InlineToken): Result<number> {
if (token.kind === 'directive') return readDirective(scan, index, 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) {
case 'autolink': {
const end = index + token.width
case 'autolink':
flush(scan, false)
pushNode(scan, linkedText(scan.source.slice(index + 1, end - 1), token.email ? 'mailto:' : ''))
return success(end)
}
case 'bracket':
flush(scan, false)
scan.pieces.push({ active: true, image: token.image, kind: 'open', start: index + token.width })
return success(index + token.width)
scan.pieces.push({ active: true, image: token.image, kind: 'open', start: end })
return success(end)
case 'bracket-close':
return closeBracket(scan, index)
case 'code-span': {
const end = index + token.width
case 'code-span':
flush(scan, false)
pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' })
return success(end)
}
case 'delimiter-run':
flush(scan, false)
scan.pieces.push({ canClose: token.canClose, canOpen: token.canOpen, character: scan.source.charAt(index), kind: 'run', length: token.width })
return success(index + token.width)
case 'directive':
return readDirective(scan, index)
return success(end)
case 'entity':
case 'escape':
case 'text':
scan.pending += scan.source.slice(index, index + token.width)
return success(index + token.width)
scan.pending += scan.source.slice(index, end)
return success(end)
case 'hard-break':
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
flush(scan, false)
pushNode(scan, { type: 'hardBreak' })
return success(index + token.width)
return success(end)
case 'highlight':
flush(scan, false)
scan.pieces.push({ closes: token.closes, kind: 'highlight', opens: token.opens })
return success(index + token.width)
case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.path)
return success(end)
case 'line-ending':
return success(readLineEnding(scan, index))
}
@@ -173,10 +168,10 @@ function readLineEnding(scan: Scan, index: number): number {
return index + 1
}
function readDirective(scan: Scan, index: number): Result<number> {
function readDirective(scan: Scan, index: number, prefix: ClaimedPrefix): Result<number> {
const held = scan.spans.get(index)
if (held !== undefined) return pushDirective(scan, held, index)
const directive = readInlineDirective(scan.source, index)
const directive = readInlineDirective(scan.source, prefix)
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
return pushDirective(scan, directive.value, index)
}
+7 -5
View File
@@ -34,7 +34,7 @@
| 62 | 0.2.0 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
| 92 | 0.2.0 | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 4 | 2 | 4 | 1 | 9.0 |
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 |
| 96 | 0.2.1 | defect | **Read and write a text holding many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator in time linear in the text.** | 2 | 3 | 5 | 9 | 9 | 9.2 |
| 96 | 0.2.1 | defect | **Read and write a text holding many unclosed backtick runs, or many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, in time linear in the text.** | 2 | 3 | 5 | 9 | 9 | 9.2 |
| 33 | 0.2.1 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
| 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
@@ -135,11 +135,13 @@ them 68188. Read most over the last five, opens and searches: `README.md` 32, `d
may reject the document. Replace `InlineNodeModel.takesMarks` (`src/adf/inline-nodes.ts`) with the
mark types a node takes, and have the emitter carry a node holding another.
### 96. Read and write a text holding many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator in time linear in the text.
### 96. Read and write a text holding many unclosed backtick runs, or many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, in time linear in the text.
`inlineHtmlConstruct` (`src/markdown/commonmark/grammar.ts`) searches the rest of the text for the
opener's terminator at every opener, so `<!--` repeated with no `-->` is quadratic in both
directions: the reader and the emitter's escape walk both ask it through `readInlineToken`.
`readInlineToken` (`src/markdown/inline-tokens.ts`) searches the rest of the text at every opener:
`closingBacktickRun` for a run of the same length, and `inlineHtmlConstruct` for the terminator. So
backtick runs of lengths 1 to k with no closers cost about k·n, and `<!--` repeated with no `-->`
is quadratic, in the reader and the emitter's escape walk alike. commonmark.js keeps a cache of
backtick runs by length for the first.
### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.