From 47503fe3c793ca044aabd366c2dfd861b4c30970 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 3 Nov 2026 16:55:24 +0100 Subject: [PATCH] Group mark runs by one rule, insideRun, that both writers and the portable reducer ask --- src/markdown/emit/inline-line.ts | 65 ++++++++++++----------- src/markdown/mark-spellings.ts | 11 +++- src/markdown/portable/inline-reduction.ts | 28 +++++----- 3 files changed, 59 insertions(+), 45 deletions(-) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 3c33740..5b02f79 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -6,14 +6,14 @@ 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, spellMarkAttributes, type RunNode } from '../mark-spellings.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 } 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' @@ -34,12 +34,13 @@ type InlineContext = { carried: ReadonlySet container: LineContainer flavour: WrittenFlavour + lineNodes: readonly AdfNode[] openingLinkAsDirective: boolean path: ConvertErrorPath spansLines: boolean } -type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { 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[] } @@ -139,8 +140,8 @@ export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: } function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, path, spansLines: container === 'paragraph' } - const emission = emitRun(nodes, 0, 0, context) + const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, 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 return success({ segments: spellEdgeWhitespace(emission.value.segments) }) @@ -209,11 +210,11 @@ function heldContentOrText(node: AdfNode): 'content' | 'text' | undefined { return node.text === undefined ? undefined : 'text' } -function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result { +function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, context: InlineContext): Result { 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.carried) const carries: NodeRange[] = [] const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { @@ -228,19 +229,19 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c return success(carry === undefined ? { segments } : { carries: [carry, ...others] }) } -function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet): InlineRun[] { +function inlineRuns(nodes: readonly RunNode[], firstIndex: number, carried: ReadonlySet): InlineRun[] { const runs: InlineRun[] = [] - for (const [offset, node] of nodes.entries()) { + for (const [offset, runNode] of nodes.entries()) { 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] - if (mark === undefined) { - runs.push({ index, kind: 'plain', node }) - continue - } + const { node, outer } = runNode 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] }) + // A carried node spells its marks inside the carry. + const carriedNode = carries(node, carried, index) + const inside = carriedNode || previous?.kind !== 'marked' ? undefined : insideRun(runNode, previous.mark) + const mark = carriedNode ? undefined : nodeMarks(node)[outer] + if (previous?.kind === 'marked' && inside !== undefined) previous.nodes.push(inside) + else if (mark === undefined) runs.push({ index, kind: 'plain', node }) + else runs.push({ index, kind: 'marked', mark, nodes: [{ node, outer: outer + 1 }] }) } return runs } @@ -278,12 +279,12 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result< const held = model === undefined ? undefined : heldContentOrText(node) if (held !== undefined) return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, nodePath(context, index)) const segments = model === undefined ? textSegments(node, context) : node.type === 'hardBreak' ? hardBreakSegments(node, model, context) : inlineDirectiveSegments(node, model) - return segments === undefined ? carriedRun([node], { first: index, last: index }, context) : success({ segments }) + return segments === undefined ? carriedRun({ first: index, last: index }, context) : success({ segments }) } // Builds the carry's JSON where the walk makes the carry, so its refusal comes ahead of any later node's. -function carriedRun(nodes: readonly AdfNode[], range: NodeRange, context: InlineContext): Result { - for (const [offset, node] of nodes.entries()) { +function carriedRun(range: NodeRange, context: InlineContext): Result { + for (const [offset, node] of context.lineNodes.slice(range.first, range.last + 1).entries()) { const carried = carriedInline(node, nodePath(context, range.first + offset)) if (!carried.ok) return carried } @@ -315,15 +316,15 @@ function textSegments(node: AdfNode, context: InlineContext): InlineSegment[] | return parts.map((part) => (part === '\r' ? syntax(' ') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) } -function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { +function emitMarkedRun(nodes: readonly RunNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { const range: NodeRange = { first: index, last: index + nodes.length - 1 } if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, range, context) const spelling = markSpelling(mark.type) const attributes = spelling === undefined ? undefined : spellMarkAttributes(mark, spelling) - if (spelling === undefined || attributes === undefined) return carriedRun(nodes, range, context) + if (spelling === undefined || attributes === undefined) return carriedRun(range, context) if (spelling.kind === 'code') { - const spans = codeSpans(nodes, depth) - return spans === undefined ? carriedRun(nodes, range, context) : success({ segments: [syntax(spans)] }) + const spans = codeSpans(nodes) + return spans === undefined ? carriedRun(range, context) : success({ segments: [syntax(spans)] }) } if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined @@ -334,7 +335,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] }) } -function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result { +function emitEmphasis(nodes: readonly RunNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result { const inner = emitRun(nodes, depth + 1, range.first, context) if (!inner.ok) return inner if (inner.value.carries !== undefined) return inner @@ -348,7 +349,7 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number }) } -function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result { +function emitHighlight(nodes: readonly RunNode[], depth: number, range: NodeRange, context: InlineContext): Result { const inner = emitRun(nodes, depth + 1, range.first, context) if (!inner.ok || inner.value.carries !== undefined) return inner const run = { ...range, depth } @@ -358,11 +359,11 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang } // Each node its own span: CommonMark reads two adjacent text nodes in one span back as one. -function codeSpans(nodes: readonly AdfNode[], depth: number): string | undefined { +function codeSpans(nodes: readonly RunNode[]): string | undefined { const spans: string[] = [] - for (const node of nodes) { + for (const { node, outer } of nodes) { const { text } = node - if (!isSpellableText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return undefined + if (!isSpellableText(node) || nodeMarks(node).length !== outer || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return undefined const fence = '`'.repeat(longestBacktickRun(text) + 1) spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`) } @@ -374,11 +375,11 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) } -function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { +function tryLink(nodes: readonly RunNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { const href = linkHref(nodeAttrs(mark)) - if (href === undefined) return carriedRun(nodes, range, context) + 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, depth + 1, context.bracketed, titleEscapes(context.flavour, context.container)) + const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), depth + 1, 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 }) diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 49fbe80..ca42526 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -2,7 +2,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { LinkDefinition, TitleEscapes } from './commonmark/link-syntax.ts' import type { MarkType } from '../adf/mark-attributes.ts' -import { emptyKeys, nodeAttrs, nodeMarks } from '../adf/document.ts' +import { emptyKeys, identicalMark, nodeAttrs, nodeMarks } from '../adf/document.ts' import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts' import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive/syntax.ts' import { holdsEntityReference } from './commonmark/entity-references.ts' @@ -17,6 +17,9 @@ export type MarkSpelling = Spelling & { attributes: AttributeVocabulary } export type CommonMarkLink = { form: 'autolink'; target?: undefined } | { form: 'inline'; target: string } +// An inline node inside the runs of its first `outer` marks. +export type RunNode = { node: AdfNode; outer: number } + const markSpellings: Readonly> = { border: { kind: 'directive' }, code: { kind: 'code' }, @@ -41,6 +44,12 @@ export function isEmphasis(mark: AdfMark): boolean { return markSpelling(mark.type)?.kind === 'emphasis' } +// spec/flavour.md, Marks. The writers and the portable reducer group runs alike only while both ask this. +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 +} + export function linkMark(definition: LinkDefinition): AdfMark { return linkMarkFrom(toLinkAttrs(definition)) } diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 5f697d2..055f51f 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -4,7 +4,7 @@ import type { MarkRun } from '../emit/line-escaping.ts' import { codeTextMarkTypes } from '../../adf/inline-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { imageAside } from '../image-aside.ts' -import { isEmphasis } from '../mark-spellings.ts' +import { insideRun, isEmphasis, type RunNode } from '../mark-spellings.ts' import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' @@ -162,6 +162,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode { } export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { + // Terminates: a pass that changes a leaf drops its edge whitespace or takes a mark off that whitespace, and merging adds neither. for (let current = leaves; ; ) { const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal)) let changed = false @@ -211,21 +212,24 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined { if (whitespace === '') return marks.length const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak' - const neighbourMarks = lineEdge ? [] : nodeMarks(neighbour) - let shared = 0 - while (shared < marks.length && sameMarkAt(marks, neighbourMarks, shared)) shared += 1 + const shared = lineEdge ? 0 : sharedRuns(marks, neighbour) const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type)) const kept = stripping === -1 ? marks.length : stripping return kept === 0 && lineEdge ? undefined : kept } -function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean { - const mark = marks[index] - const other = others[index] - return mark !== undefined && other !== undefined && sameMarkWhenEditorNormal(mark, other) +// How many of the runs around a leaf holding `marks`, outermost first, hold `neighbour` too. +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 } function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { + // Terminates: each fallback takes a mark off some leaf, and trimmedEdges adds none to any character. for (let current = leaves, pass = 1; ; pass += 1) { const fallback = portableLineFallback(current, container, path) if (!fallback.ok) return fallback @@ -244,11 +248,11 @@ export function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLi function withoutOpeningLinkCode(leaves: readonly AdfNode[]): AdfNode[] | undefined { const mark = nodeMarks(leaves[0] ?? {})[0] if (mark === undefined || mark.type !== 'link') return undefined - let last = 0 - while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1 + const past = leaves.findIndex((leaf) => insideRun({ node: leaf, outer: 0 }, mark) === undefined) + const end = past === -1 ? leaves.length : past // A code span is what binds the `]` a link definition reads, and dropping it keeps the link target. - const spans = leaves.slice(0, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code') - return spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined + const spans = leaves.slice(0, end).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code') + return spans ? leaves.map((leaf, index) => (index >= end ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined } function withoutEmphasis(leaves: readonly AdfNode[]): AdfNode[] {