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

Answer stability round 1: 1 🔴, 1 🟡 (0 misread); nested directive contents share their paragraph's memo, and a title after <destination> needs whitespace, pinned

This commit is contained in:
2026-10-28 12:44:27 +01:00
committed by Lillem4n
parent 273c08ac08
commit 935012d475
5 changed files with 49 additions and 26 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ 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)
const target = readInlineTarget(memo, offset)
read.push(target === undefined ? undefined : { ...target.definition(), length: target.length })
}
return read
@@ -34,4 +34,5 @@ test('a target reads whitespace and a title around its destination, bracketed, u
{ destination: 'd e', length: 11, title: 'f' },
{ destination: '', length: 2 },
])
assert.deepEqual(targets('[a](<b>"t")'), [undefined])
})
+19 -12
View File
@@ -10,8 +10,8 @@ export type LinkPart = { length: number; value: string }
// Every destination ending at one index shares what follows it: the title and the closing `)`.
type Tail = { end: number; title: string | undefined }
// `destinationEnds[start]` is where an unbracketed destination starting there stops, for every start a target can have: the index just after a `(`, a space or a control character.
export type TargetMemo = { destinationEnds: Int32Array; tails: Map<number, Tail | undefined> }
// What the targets read in `text` share; `destinationEnds` is walked at the first one.
export type TargetMemo = { destinationEnds: Int32Array | undefined; tails: Map<number, Tail | undefined>; text: string }
// 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'
@@ -47,14 +47,15 @@ export function readDestination(text: string, offset: number): LinkPart | undefi
destinationRun.lastIndex = offset
destinationRun.test(text)
const run = text.slice(offset, destinationRun.lastIndex)
const length = targetMemo(run).destinationEnds[0] ?? 0
const length = destinationEnds(run)[0] ?? 0
return length === 0 ? undefined : { length, value: decodeTextEscapes(run.slice(0, length)) }
}
// `offset` sits on the `(` a link text closes into. The definition is built only when called, so a link the reader then refuses never decodes its destination.
export function readInlineTarget(text: string, offset: number, memo: TargetMemo): InlineTarget | undefined {
export function readInlineTarget(memo: TargetMemo, offset: number): InlineTarget | undefined {
const { text } = memo
const start = skipLinkWhitespace(text, offset + 1)
const end = destinationEnd(text, start, memo)
const end = destinationEnd(memo, start)
if (end === undefined) return undefined
const tail = memo.tails.has(end) ? memo.tails.get(end) : readTail(text, end)
memo.tails.set(end, tail)
@@ -67,30 +68,36 @@ export function readInlineTarget(text: string, offset: number, memo: TargetMemo)
return { definition, length: tail.end - offset }
}
// Every unbracketed destination's end, in one pass. Index 0 is a start too, for `readDestination`, which reads from its text's first character.
export function targetMemo(text: string): TargetMemo {
const destinationEnds = new Int32Array(text.length + 1)
return { destinationEnds: undefined, tails: new Map(), text }
}
// Where an unbracketed destination starting at each index stops, in one pass, for every start a target can have: the index just after a `(`, a space or a control character. Index 0 is a start too, for `readDestination`, which reads from its text's first character.
function destinationEnds(text: string): Int32Array {
const ends = 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 (start !== undefined) ends[start] = index
if (character !== ' ' && !holdsControlCharacter(character)) continue
for (const stopped of open.splice(0)) destinationEnds[stopped] = index
for (const stopped of open.splice(0)) ends[stopped] = index
open.push(index + 1)
}
for (const stopped of open) destinationEnds[stopped] = text.length
return { destinationEnds, tails: new Map() }
for (const stopped of open) ends[stopped] = text.length
return ends
}
function destinationEnd(text: string, start: number, memo: TargetMemo): number | undefined {
function destinationEnd(memo: TargetMemo, start: number): number | undefined {
const { text } = memo
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
}
memo.destinationEnds ??= destinationEnds(text)
const end = memo.destinationEnds[start] ?? start
return end > start ? end : undefined
}
+5
View File
@@ -83,6 +83,11 @@ export function claimDirectivePrefix(text: string, index: number): ClaimedPrefix
return claimsDirectivePrefix(text, index) ? { [claimedPrefix]: true, index } : undefined
}
// Where a span's [content] starts, counted from its `!adf:`.
export function contentOffset(span: DirectiveSpan): number {
return directivePrefix.length + span.name.length + 1
}
export function holdsDirectivePrefix(text: string): boolean {
return text.includes(directivePrefix)
}
+16 -13
View File
@@ -17,7 +17,7 @@ import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.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 { contentOffset, noSpans, readInlineDirective } from '../directive/syntax.ts'
import { openingLinkTakesDirective } from '../emit/spelling-questions.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from '../directive/mark-reading.ts'
@@ -81,17 +81,19 @@ type Scan = {
inLink: boolean
// Link openers below this index of `brackets` were open when a link closed, so they stay literal: no link holds another.
linkOpenersFrom: number
// Where `source` starts in the outermost content, the text `targets` reads.
offset: number
openingSpellableLink: boolean
path: ConvertErrorPath
pending: string
pieces: Piece[]
source: string
spans: NestedSpans
targets: TargetMemo | undefined
targets: TargetMemo
}
// What a directive's content slot inherits from the content holding it.
type SharedScan = Pick<Scan, 'definitions' | 'path'>
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'targets'>
const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
@@ -103,7 +105,7 @@ const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const scan = freshScan(source, { definitions, path }, { claims: flavourClaims[flavour], container, inLink: false, spans: noSpans })
const scan = freshScan(source, { definitions, path, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans })
const scanned = scanInline(scan)
if (!scanned.ok) return scanned
const nodes = partText(contentNodes(scanned.value.pieces), scan)
@@ -117,8 +119,8 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value })
}
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'spans'>): Scan {
return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source, targets: undefined }
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source }
}
function scanInline(scan: Scan): Result<Scanned> {
@@ -212,7 +214,7 @@ function directivePieces(scan: Scan, span: DirectiveSpan, index: number): Result
if (text !== undefined) return success([{ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }])
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
// A node's slot is the node's own content, never the text of a link around the node.
const slot = slotContent(scan, span, mark !== undefined && (scan.inLink || span.name === 'link'))
const slot = slotContent(scan, span, index, mark !== undefined && (scan.inLink || span.name === 'link'))
if (!slot.ok) return slot
if (mark !== undefined) return mark.ok ? directiveMarkPieces(scan, span.name, mark.value, slot.value, index) : mark
const content = slot.value === undefined ? undefined : contentNodes(slot.value)
@@ -251,10 +253,11 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
return undefined
}
function slotContent(scan: Scan, span: DirectiveSpan, inLink: boolean): Result<Resolved[] | undefined> {
function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result<Resolved[] | undefined> {
if (span.content === undefined) return success(undefined)
const { claims, definitions, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, path }, { claims, container: undefined, inLink, spans: span.spans }))
const { claims, definitions, path, targets } = scan
const offset = scan.offset + index + contentOffset(span)
const parsed = scanInline(freshScan(span.content, { definitions, path, targets }, { claims, container: undefined, inLink, offset, spans: span.spans }))
if (!parsed.ok) return parsed
return success(parsed.value.pieces)
}
@@ -429,9 +432,9 @@ function bracketNodes(bracket: Bracket | OpenBracket): AdfNode[] {
function resolveTarget(scan: Scan, start: number, index: number): InlineTarget | undefined {
const after = index + 1
if (scan.source.charAt(after) === '(') {
scan.targets ??= targetMemo(scan.source)
const inline = readInlineTarget(scan.source, after, scan.targets)
if (inline !== undefined) return inline
const inline = readInlineTarget(scan.targets, scan.offset + after)
// A target read in the outermost content holds in a directive's content exactly when it ends inside it.
if (inline !== undefined && after + inline.length <= scan.source.length) return inline
}
const label = scan.source.charAt(after) === '[' ? readLabel(scan.source, after) : undefined
const labelIsText = label === undefined || label.value === ''
@@ -1038,6 +1038,13 @@ test('leaves the bracket pair no link parses as the text it holds', () => {
])
})
test('leaves a link literal whose target runs past the directive content holding it', () => {
assert.deepEqual(firstContent('!adf:underline[[a](b]c)\n'), [marked('[a](b', underline), text('c)')])
assert.deepEqual(firstContent('!adf:underline[[a](<b]>)\n'), [marked('[a](<b', underline), text('>)')])
assert.deepEqual(firstContent('!adf:underline[[a](b "t]")\n'), [marked('[a](b "t', underline), text('")')])
assert.deepEqual(firstContent('!adf:underline[[a](b]) [c](d)\n'), [marked('[a](b', underline), text(') '), marked('c', link('d'))])
})
test('leaves the brackets of a link whose text already holds one the text they are', () => {
const held: AdfMark = { attrs: { collection: 'c', href: '/u' }, type: 'link' }
assert.deepEqual(content(losslessMarkdownToAdf('[<http://x/>](/v)\n')), [