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

Span a run inside inlineRuns, with the reducer asking the same grouping

This commit is contained in:
2026-11-03 22:34:53 +01:00
committed by Lillem4n
parent a8d9595a1f
commit d76d4c9440
8 changed files with 122 additions and 117 deletions
@@ -936,6 +936,7 @@ test('spells an emphasis run once across the code spans, mentions, dates and inl
assert.equal(readsBack(document(paragraph(marked('a ', strong), { attrs: { timestamp: '0' }, type: 'date' }, marked(' b', strong)))), '**a !adf:date{timestamp=0} b**\n')
assert.equal(readsBack(document(paragraph(marked('a ', strong), { attrs: { id: 'm' }, type: 'mediaInline' }, marked(' b', strong)))), '**a !adf:mediaInline{id=m} b**\n')
assert.equal(readsBack(document(paragraph(marked('a ', strong), marked('c', { type: 'code' }), mention, { type: 'hardBreak' }, marked(' b', strong)))), '**a `c`!adf:mention[@A]{id=x}\\\n!adf:text{text=" "}b**\n')
assert.equal(readsBack(document(paragraph(marked('a', strong), marked('https://x/', { attrs: { href: 'https://x/' }, type: 'link' }, { type: 'code' }), marked('b', strong)))), '**a[`https://x/`](https://x/)b**\n')
})
test('spells a link run once across the mentions and inline cards between two nodes holding it, and parts it at code and inline media that do not hold it', () => {
+55 -88
View File
@@ -6,15 +6,15 @@ import type { RunFallback } from './unspellable-runs.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { commonMarkLink, insideRun, isEmphasis, linkHref, markSpelling, runSpans, spellMarkAttributes, type RunNode } from '../mark-spellings.ts'
import { directivePrefix, slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts'
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightDelimiter } from '../inline-tokens.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel, takenMarkTypes, takesMark } from '../../adf/inline-nodes.ts'
import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts'
import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
@@ -37,18 +37,17 @@ type InlineContext = {
lineNodes: readonly AdfNode[]
openingLinkAsDirective: boolean
path: ConvertErrorPath
spans: boolean
spansLines: boolean
}
type InlineRun = MarkedRun | { index: number; kind: 'plain'; node: AdfNode }
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
type MarkedRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] }
type SpannedNodes = { nodes: AdfNode[]; spans: boolean }
type RunGrouping = { carried: ReadonlySet<number>; spans: boolean }
// docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark.
export const readBackPasses = 32
@@ -69,7 +68,7 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
const fallbacks = withheldMarkFallbacks(nodes, 'portable')
const emission = lineSegments(nodes, container, path, fallbacks)
const emission = lineSegments(nodes, container, path, fallbacks, false)
if (!emission.ok) return emission
if (fallbacks.carried.size > 0 || emission.value.carries !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path)
const verdict = lineVerdict(emission.value.segments, container, 'portable', { kind: 'drop', nodes })
@@ -110,25 +109,20 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
}
}
// spec/flavour.md, Marks: a pass writes the spanned line where a run spans a node and the line needs no fallback; otherwise it writes the parted line and takes that line's fallbacks.
// spec/flavour.md, Marks: a pass writes the spanned line where it needs no fallback; otherwise it writes the parted line and takes that line's fallbacks.
export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<LineAttempt> {
const spanned = spannedNodes(nodes, fallbacks.carried)
if (spanned.spans) {
const attempt = attemptNodes(spanned.nodes, container, path, fallbacks)
if (spannedLineRefusal(attempt, fallbacks.flavour) === undefined) return attempt
}
return attemptNodes(nodes, container, path, fallbacks)
const spanned = lineAttempt(nodes, container, path, fallbacks, true)
return spanned.ok && writesSpanned(spanned.value, fallbacks.flavour) ? spanned : lineAttempt(nodes, container, path, fallbacks, false)
}
function spannedLineRefusal(attempt: Result<LineAttempt>, flavour: WrittenFlavour): 'directive' | 'error' | 'fallback' | undefined {
if (!attempt.ok) return 'error'
if (attempt.value.fallback !== undefined) return 'fallback'
function writesSpanned({ fallback, segments }: LineAttempt, flavour: WrittenFlavour): boolean {
if (fallback !== undefined) return false
// A span can leave whitespace at a line edge, which only `!adf:text` spells.
return !flavourClaims[flavour].directives && attempt.value.segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix)) ? 'directive' : undefined
return flavourClaims[flavour].directives || !segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix))
}
function attemptNodes(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<LineAttempt> {
const emission = lineSegments(nodes, container, path, fallbacks)
function lineAttempt(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result<LineAttempt> {
const emission = lineSegments(nodes, container, path, fallbacks, spans)
if (!emission.ok) return emission
const { carries, segments } = emission.value
return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour))
@@ -160,8 +154,8 @@ export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path:
return success(null)
}
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spansLines: container === 'paragraph' }
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spans, spansLines: container === 'paragraph' }
const emission = emitRun(nodes.map((node) => ({ node, outer: 0 })), 0, 0, context)
if (!emission.ok) return emission
if (emission.value.carries !== undefined) return emission
@@ -235,7 +229,7 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c
if (depth > largestNesting) {
return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
}
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const runs = inlineRuns(nodes, firstIndex, context)
const carries: NodeRange[] = []
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
@@ -250,83 +244,56 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c
return success(carry === undefined ? { segments } : { carries: [carry, ...others] })
}
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] {
const runs: InlineRun[] = []
for (const [offset, node] of nodes.entries()) {
let next = 0
for (const [offset, { node, outer }] of nodes.entries()) {
if (offset < next) continue
const index = firstIndex + offset
// `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry.
const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth]
// A carried node spells its marks inside the carry.
const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer]
if (mark === undefined) {
runs.push({ index, kind: 'plain', node })
continue
}
const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
const inside = nodesInRun(nodes, offset, mark, firstIndex, grouping)
runs.push({ index, kind: 'marked', mark, nodes: inside })
next = offset + inside.length
}
return runs
}
// spec/flavour.md, Marks: the nodes as the spanned line writes them; `carried` names the nodes the line carries, which no run spans.
export function spannedNodes(nodes: readonly AdfNode[], carried: ReadonlySet<number>): SpannedNodes {
const spanned: SpannedNodes = { nodes: [], spans: false }
lendMarks(nodes, 0, 0, carried, spanned)
return spanned
}
function lendMarks(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>, spanned: SpannedNodes): void {
// Past this depth the spanned line is refused as the parted one is.
if (depth > largestNesting) {
for (const node of nodes) spanned.nodes.push(node)
return
// The run of `mark` opening at `first` ends at the last node holding the mark, so the nodes it spans past that one stay outside.
function nodesInRun(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, spans }: RunGrouping): RunNode[] {
const inside: RunNode[] = []
let holders = 0
for (let offset = first; offset < nodes.length; offset += 1) {
const runNode = nodes[offset]
if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break
const held = insideRun(runNode, mark)
if (held === undefined && !(spans && runSpans(runNode.node, mark))) break
inside.push(held ?? runNode)
if (held !== undefined) holders = inside.length
}
for (const run of spannedRuns(inlineRuns(nodes, depth, firstIndex, carried), depth, carried, spanned)) {
if (run.kind === 'plain') spanned.nodes.push(run.node)
else lendMarks(run.nodes, depth + 1, run.index, carried, spanned)
return inside.slice(0, holders)
}
// For each leaf but the last, how many runs of the spanned line hold both it and the next leaf.
export function runsSharedWithNext(leaves: readonly AdfNode[]): number[] {
const shared = leaves.map(() => 0)
countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, 0, shared)
return shared
}
function countSharedRuns(nodes: readonly RunNode[], depth: number, firstIndex: number, shared: number[]): void {
if (depth > largestNesting) return
for (const run of inlineRuns(nodes, firstIndex, { carried: new Set(), spans: true })) {
if (run.kind === 'plain') continue
for (let index = run.index; index < run.index + run.nodes.length - 1; index += 1) shared[index] = (shared[index] ?? 0) + 1
countSharedRuns(run.nodes, depth + 1, run.index, shared)
}
}
function spannedRuns(runs: readonly InlineRun[], depth: number, carried: ReadonlySet<number>, spanned: SpannedNodes): InlineRun[] {
const joined: InlineRun[] = []
for (const run of runs) {
const open = run.kind === 'marked' ? openRun(joined, run.mark, carried) : undefined
if (open === undefined) {
joined.push(run)
continue
}
for (const between of joined.splice(joined.lastIndexOf(open) + 1)) for (const node of runNodes(between)) open.nodes.push(withMark(node, open.mark, depth))
for (const node of runNodes(run)) open.nodes.push(node)
spanned.spans = true
}
return joined
}
// The last run of `mark`, an emphasis or link mark, where `mark` spans every node after that run, `undefined` where none.
function openRun(runs: readonly InlineRun[], mark: AdfMark, carried: ReadonlySet<number>): MarkedRun | undefined {
const kind = markSpelling(mark.type)?.kind
if (kind !== 'emphasis' && kind !== 'link') return undefined
for (let position = runs.length - 1; position >= 0; position -= 1) {
const run = runs[position]
if (run?.kind === 'marked' && identicalMark(run.mark, mark)) return run
if (run === undefined || !runNodes(run).every((node, offset) => spansNode(node, run.index + offset, mark, carried))) return undefined
}
return undefined
}
// spec/flavour.md, Marks: a run breaks at a carried node, at a node a reader would give its mark, and at an empty marks array, whose `marks=empty` a lent mark would erase.
function spansNode(node: AdfNode, index: number, mark: AdfMark, carried: ReadonlySet<number>): boolean {
return !carries(node, carried, index) && !takesMark(node, mark.type) && node.marks?.length !== 0
}
function runNodes(run: InlineRun): readonly AdfNode[] {
return run.kind === 'plain' ? [run.node] : run.nodes
}
function withMark(node: AdfNode, mark: AdfMark, depth: number): AdfNode {
const marks = nodeMarks(node)
return { ...node, marks: [...marks.slice(0, depth), mark, ...marks.slice(depth)] }
}
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
@@ -460,7 +427,7 @@ function tryLink(nodes: readonly RunNode[], mark: AdfMark, depth: number, range:
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return carriedRun(range, context)
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), depth + 1, context.bracketed, titleEscapes(context.flavour, context.container))
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), nodes[0]?.outer ?? 0, context.bracketed, titleEscapes(context.flavour, context.container))
if (commonMark === undefined) return undefined
if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
+1 -1
View File
@@ -19,7 +19,7 @@ export type InlineEscaping = (typeof escapingCodes)[number]
export type NodeRange = { first: number; last: number }
// `depth` is the index into each node's marks array that the run spells.
// `depth` is the run's nesting level. On a parted line, the only line a fallback reads, that is the index into each node's marks array that the run spells.
export type MarkRun = NodeRange & { depth: number }
export type InlineSegment =
+9 -2
View File
@@ -9,6 +9,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, linkAttrs, linkMarkFrom, markAttributes, type LinkAttrs } from '../adf/mark-attributes.ts'
import { spellEmptyKeys } from './directive/empty-keys.ts'
import { takesMark } from '../adf/inline-nodes.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
@@ -44,12 +45,18 @@ export function isEmphasis(mark: AdfMark): boolean {
return markSpelling(mark.type)?.kind === 'emphasis'
}
// spec/flavour.md, Marks. The portable reducer must ask this too, or it trims edge whitespace by runs the writers do not spell.
// spec/flavour.md, Marks.
export function insideRun({ node, outer }: RunNode, mark: AdfMark): RunNode | undefined {
const held = nodeMarks(node)[outer]
return held !== undefined && identicalMark(held, mark) ? { node, outer: outer + 1 } : undefined
}
// spec/flavour.md, Marks. The readers refuse `marks=empty` inside a mark's spelling.
export function runSpans(node: AdfNode, mark: AdfMark): boolean {
const kind = markSpelling(mark.type)?.kind
return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0
}
export function linkMark(definition: LinkDefinition): AdfMark {
return linkMarkFrom(toLinkAttrs(definition))
}
@@ -72,7 +79,7 @@ export function linkHref(attrs: AdfAttributes): string | undefined {
return typeof href === 'string' ? href : undefined
}
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0.
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's `outer`, the parser's 0.
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean, escapes: TitleEscapes): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
const title = attrs['title']
+15 -22
View File
@@ -5,11 +5,11 @@ import { codeTextMarkTypes } from '../../adf/inline-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { imageAside } from '../image-aside.ts'
import { insideRun, isEmphasis, type RunNode } from '../mark-spellings.ts'
import { insideRun, isEmphasis } from '../mark-spellings.ts'
import { joinsWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts'
import { portableLineFallback, readBackPasses, spannedNodes, type PortableLineFallback } from '../emit/inline-line.ts'
import { portableLineFallback, readBackPasses, runsSharedWithNext, type PortableLineFallback } from '../emit/inline-line.ts'
import { spellDestination } from '../commonmark/link-syntax.ts'
const highlight = 'backgroundColor'
@@ -165,12 +165,11 @@ export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
// Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark.
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal))
// Judged beside the nodes as the spanned line writes them, a portable line carrying none, so whitespace beside a code span inside a run keeps the run's mark.
const spanned = spannedNodes(merged, new Set()).nodes
const shared = runsSharedWithNext(merged)
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {
const edges = leafEdges(leaf, spanned[index - 1], spanned[index + 1])
const edges = leafEdges(leaf, runsBeside(merged[index - 1], shared[index - 1]), runsBeside(merged[index + 1], shared[index]))
if (edges === undefined) {
trimmed.push(leaf)
continue
@@ -191,15 +190,20 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
return leaves.slice(first, last + 1)
}
// How many runs a leaf shares with its neighbour, `undefined` at a line edge.
function runsBeside(neighbour: AdfNode | undefined, shared: number | undefined): number | undefined {
return neighbour === undefined || neighbour.type === 'hardBreak' ? undefined : shared
}
// spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge.
function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | undefined): AdfNode[] | undefined {
function leafEdges(leaf: AdfNode, runsBefore: number | undefined, runsAfter: number | undefined): AdfNode[] | undefined {
const marks = nodeMarks(leaf)
const text = leaf.text
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
const lead = text.slice(0, text.search(/[^ \t]|$/))
const trail = text.slice(text.search(/[ \t]*$/))
const leadDepth = edgeDepth(marks, previous, lead)
const trailDepth = edgeDepth(marks, next, trail)
const leadDepth = edgeDepth(marks, runsBefore, lead)
const trailDepth = edgeDepth(marks, runsAfter, trail)
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
const edges: AdfNode[] = []
@@ -211,22 +215,11 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode |
}
// How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge.
function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined {
function edgeDepth(marks: readonly AdfMark[], runsBeside: number | undefined, whitespace: string): number | undefined {
if (whitespace === '') return marks.length
const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak'
const shared = lineEdge ? 0 : sharedRuns(marks, neighbour)
const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type))
const stripping = marks.findIndex((mark, index) => index >= (runsBeside ?? 0) && edgeStrippingMarks.includes(mark.type))
const kept = stripping === -1 ? marks.length : stripping
return kept === 0 && lineEdge ? undefined : kept
}
function sharedRuns(marks: readonly AdfMark[], neighbour: AdfNode): number {
let inside: RunNode | undefined = { node: neighbour, outer: 0 }
for (const [depth, mark] of marks.entries()) {
inside = insideRun(inside, mark)
if (inside === undefined) return depth
}
return marks.length
return kept === 0 && runsBeside === undefined ? undefined : kept
}
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {