From eb92f945f7fb75ef22e704634a6cb336f11fcd91 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 4 Nov 2026 13:08:41 +0100 Subject: [PATCH] Ask once whether a line spans, keep base's fallback loop, and decide carries once per pass --- docs/decisions.md | 8 +- src/markdown/emit/inline-line.test.ts | 13 ++- src/markdown/emit/inline-line.ts | 86 +++++++++++-------- src/markdown/emit/inline-runs.test.ts | 20 +++++ src/markdown/emit/inline-runs.ts | 65 +++++++------- .../portable/adf-to-portable-markdown.test.ts | 5 +- .../portable/inline-reduction.test.ts | 3 +- src/markdown/portable/inline-reduction.ts | 34 ++------ 8 files changed, 133 insertions(+), 101 deletions(-) create mode 100644 src/markdown/emit/inline-runs.test.ts diff --git a/docs/decisions.md b/docs/decisions.md index bbfce6a..757d11d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -721,9 +721,11 @@ the maintainer decides. After the fifth ruling they gave the restructure to a su current design for a better reading rather than answer the ruling's places one by one (2026-11-04). A ruling judges the code, never the behaviour: item 91's spanning stays, since the paragraphs it spells, such as bold around a code span, occur in documents all the time (2026-11-03). The maintainer's call on how: refactor first, ruled alone (item 166), then make spanning part of -grouping, with the span rule in one home. `inlineRuns` in `emit/inline-runs.ts` groups a line, `runCrosses` says which -node a run passes, and a run ends at its last holder. Parting is a line's first fallback, so each pass walks once. The reducer -judges the spanned line, then the parted line, each trimmed by its own runs through that same grouping. `withoutOpeningLinkCode` +grouping, with the span rule in one home. `inlineRuns` in `emit/inline-runs.ts` groups a line spanned or parted, `runCrosses` +says which node a run passes, and a run ends at its last holder. The writer tries the spanned line once and, where it needs any +fallback, runs its fallback loop on the parted line, so `takeFallback` takes what it is handed; whether a node is carried is +decided once per pass, on its `RunNode` (the restructure of 2026-11-04). The reducer judges the same two lines, each trimmed by +its own runs, which `runsSharedWithNext` counts beside `inlineRuns`. `withoutOpeningLinkCode` stays on `insideRun` alone, since it answers the parted line. A refactor that merges as its own chunk took its own ruling, so the chunk's later rulings read against that merge, the commit `larv-review ruling-base` prints. A restructure past a pass's round cap still goes unreviewed, so the PR summary names it. diff --git a/src/markdown/emit/inline-line.test.ts b/src/markdown/emit/inline-line.test.ts index d13714b..9fa1ce2 100644 --- a/src/markdown/emit/inline-line.test.ts +++ b/src/markdown/emit/inline-line.test.ts @@ -31,11 +31,20 @@ test('spells a line alike taking every fallback a pass finds or the first alone' ) }) -// Takes only the first fallback each pass finds, with no cap. +test('carries the range it is handed on its first call', () => { + const fallbacks = withheldMarkFallbacks([{ text: 'a', type: 'text' }], 'lossless') + assert.deepEqual(takeFallback(fallbacks, [{ first: 0, last: 0 }], []), success(null)) + assert.deepEqual([...fallbacks.carried], [0]) +}) + +// Writes the spanned line where it reads back, else the parted line taking only the first fallback each pass finds, with no cap. function firstFallbackLine(nodes: readonly AdfNode[], container: LineContainer): Result { const fallbacks = withheldMarkFallbacks(nodes, 'lossless') + const spanned = linePass(nodes, container, [], fallbacks, 'spanned') + if (!spanned.ok) return spanned + if (spanned.value.fallback === undefined) return success(spanned.value.line) for (;;) { - const attempt = linePass(nodes, container, [], fallbacks) + const attempt = linePass(nodes, container, [], fallbacks, 'parted') if (!attempt.ok) return attempt const { fallback } = attempt.value if (fallback === undefined) return success(attempt.value.line) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 0fa7ee2..a7b3700 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -1,13 +1,12 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts' -import type { InlineRun, RunGrouping, RunNode } from './inline-runs.ts' +import type { InlineRun, PlainRun, RunGrouping, RunNode } from './inline-runs.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinition, TitleEscapes } from '../commonmark/link-syntax.ts' 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 { carries, inlineRuns } from './inline-runs.ts' import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -15,6 +14,7 @@ import { flavourClaims } from '../flavour.ts' import { highlightDelimiter } from '../inline-tokens.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts' +import { inlineRuns } from './inline-runs.ts' import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts' import { largestNesting } from '../../nesting.ts' @@ -30,11 +30,12 @@ type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } | type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link' -type InlineContext = RunGrouping & { +type InlineContext = { atBlockEnd: boolean bracketed: boolean container: LineContainer flavour: WrittenFlavour + grouping: RunGrouping lineNodes: readonly AdfNode[] openingLinkAsDirective: boolean path: ConvertErrorPath @@ -43,7 +44,7 @@ type InlineContext = RunGrouping & { type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } -type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: boolean } +type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } // docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark. export const readBackPasses = 32 @@ -62,9 +63,9 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve return success(emitted.value.openingLinkAsDirective) } -export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, { parted }: Pick): Result { - const fallbacks = { ...withheldMarkFallbacks(nodes, 'portable'), parted } - const emission = lineSegments(nodes, container, path, fallbacks) +export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, grouping: RunGrouping): Result { + const fallbacks = withheldMarkFallbacks(nodes, 'portable') + const emission = lineSegments(nodes, container, path, fallbacks, grouping) 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 }) @@ -93,9 +94,18 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { const fallbacks = withheldMarkFallbacks(nodes, flavour) - // Terminates because takeFallback refuses a pass that took no new fallback. Pass 0 only parts the line, so `readBackPasses` counts what a parted line takes. - for (let pass = 0; ; pass += 1) { - const attempt = linePass(nodes, container, path, fallbacks) + // spec/flavour.md, Marks: the spanned line where it reads back, else the parted line with every fallback it needs. + const spanned = linePass(nodes, container, path, fallbacks, 'spanned') + if (!spanned.ok) return spanned + const { fallback, line, segments } = spanned.value + if (fallback === undefined) return success({ line, openingLinkAsDirective: false, segments }) + return partedLine(nodes, container, path, fallbacks) +} + +function partedLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { + // Terminates because takeFallback refuses a pass that took no new fallback. + for (let pass = 1; ; pass += 1) { + const attempt = linePass(nodes, container, path, fallbacks, 'parted') if (!attempt.ok) return attempt const { fallback, line, segments } = attempt.value if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments }) @@ -105,8 +115,8 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } } -export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const emission = lineSegments(nodes, container, path, fallbacks) +export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, grouping: RunGrouping): Result { + const emission = lineSegments(nodes, container, path, fallbacks, grouping) if (!emission.ok) return emission const { carries, segments } = emission.value return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour)) @@ -123,15 +133,20 @@ export function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: Writte const taken = takenMarkTypes(node) if (taken !== undefined && nodeMarks(node).some((mark) => !taken.includes(mark.type))) carried.add(index) } - return { carried, flavour, openingLinkAsDirective: false, parted: false } + return { carried, flavour, openingLinkAsDirective: false } +} + +// Decides the text-node and slot-fault carries up front, `withheldMarkFallbacks` the withheld-mark ones, so a line holding many of them is emitted once. +function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { + if (carried.has(index)) return true + if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' + const model = inlineNodeModel(node.type) + if (model === undefined) return true + const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] + return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined } export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result { - // spec/flavour.md, Marks: a line needing a fallback first parts its runs, and takes what the parted line still needs. - if (!fallbacks.parted) { - fallbacks.parted = true - return success(null) - } if (fallback === 'opening-link') { if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path) fallbacks.openingLinkAsDirective = true @@ -143,12 +158,12 @@ export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: return success(null) } -function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - 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) +function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, { carried, flavour, openingLinkAsDirective }: LineFallbacks, grouping: RunGrouping): Result { + const context: InlineContext = { atBlockEnd: true, bracketed: false, container, flavour, grouping, lineNodes: nodes, openingLinkAsDirective, path, spansLines: container === 'paragraph' } + const emission = emitRun(nodes.map((node, index) => ({ carried: carries(node, carried, index), 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, fallbacks.flavour) }) + return success({ segments: spellEdgeWhitespace(emission.value.segments, flavour) }) } function attemptLine(segments: InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt { @@ -219,13 +234,13 @@ 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, firstIndex, context) + const runs = inlineRuns(nodes, firstIndex, context.grouping) const carries: NodeRange[] = [] const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { - if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling)) + if (takesTextBreak(runs[offset - 1], run)) segments.push(syntax(textBreakSpelling)) const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 } - const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) + const emitted = run.kind === 'plain' ? emitLeaf(run, runContext) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) if (!emitted.ok) return emitted if (emitted.value.carries !== undefined) for (const carry of emitted.value.carries) carries.push(carry) else for (const segment of emitted.value.segments) segments.push(segment) @@ -234,9 +249,8 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c return success(carry === undefined ? { segments } : { carries: [carry, ...others] }) } -function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): 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 +function takesTextBreak(previous: InlineRun | undefined, run: InlineRun): boolean { + if (previous?.kind !== 'plain' || run.kind !== 'plain' || previous.carried || run.carried) return false return joinsWhenRead(previous.node, run.node) } @@ -245,11 +259,11 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath { } // Builds the node's path only to refuse it: a path copies every level above, and a line may hold a million leaves. -function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result { - if (carries(node, context.carried, index)) { - const carried = carriedInline(node, nodePath(context, index)) - if (!carried.ok) return carried - return success({ segments: [syntax(carried.value)] }) +function emitLeaf({ carried, index, node }: PlainRun, context: InlineContext): Result { + if (carried) { + const carry = carriedInline(node, nodePath(context, index)) + if (!carry.ok) return carry + return success({ segments: [syntax(carry.value)] }) } const types = nodeMarks(node).map((mark) => mark.type) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, nodePath(context, index)) @@ -294,7 +308,7 @@ 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 RunNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { +function emitMarkedRun(nodes: readonly [RunNode, ...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, mark }, context) const spelling = markSpelling(mark.type) @@ -348,11 +362,11 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) } -function tryLink(nodes: readonly RunNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { +function tryLink(nodes: readonly [RunNode, ...RunNode[]], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { 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), nodes[0]?.outer ?? 0, context.bracketed, titleEscapes(context.flavour, context.container)) + const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), nodes[0].outer, 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/emit/inline-runs.test.ts b/src/markdown/emit/inline-runs.test.ts new file mode 100644 index 0000000..a73bb96 --- /dev/null +++ b/src/markdown/emit/inline-runs.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { AdfMark, AdfNode } from '../../adf/document.ts' +import { runsSharedWithNext } from './inline-runs.ts' + +const em: AdfMark = { type: 'em' } +const strong: AdfMark = { type: 'strong' } +const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + +test('counts a run crossing a code span to its next holder where the line is spanned, and none where it is parted', () => { + const line = [marked('Run ', strong), marked('npm ci', { type: 'code' }), marked(' first', strong)] + assert.deepEqual(runsSharedWithNext(line, 'spanned'), [1, 1, 0]) + assert.deepEqual(runsSharedWithNext(line, 'parted'), [0, 0, 0]) +}) + +test('counts every nested run crossing a hard break, and none crossing a hard break holding an empty marks', () => { + assert.deepEqual(runsSharedWithNext([marked('a', em, strong), { type: 'hardBreak' }, marked('b', em, strong)], 'spanned'), [2, 2, 0]) + assert.deepEqual(runsSharedWithNext([marked('a', em), { marks: [], type: 'hardBreak' }, marked('b', em)], 'spanned'), [0, 0, 0]) +}) diff --git a/src/markdown/emit/inline-runs.ts b/src/markdown/emit/inline-runs.ts index 39245e6..cc72e38 100644 --- a/src/markdown/emit/inline-runs.ts +++ b/src/markdown/emit/inline-runs.ts @@ -1,54 +1,53 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' -import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' -import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' +import { identicalMark, nodeMarks } from '../../adf/document.ts' import { markSpelling } from '../mark-spellings.ts' -import { slotFault } from '../directive/syntax.ts' +import { takesMark } from '../../adf/inline-nodes.ts' -export type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { index: number; kind: 'plain'; node: AdfNode } +export type InlineRun = MarkedRun | PlainRun -// `parted` keeps a run from crossing any node. -export type RunGrouping = { carried: ReadonlySet; parted: boolean } +type MarkedRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: [RunNode, ...RunNode[]] } -// An inline node inside the runs of its first `outer` marks. A node a run crosses holds no mark of that run, so its `outer` stays. -export type RunNode = { node: AdfNode; outer: number } +export type PlainRun = { carried: boolean; index: number; kind: 'plain'; node: AdfNode } -export const partedLine: RunGrouping = { carried: new Set(), parted: true } -export const spannedLine: RunGrouping = { carried: new Set(), parted: false } +// spec/flavour.md, Marks: spanned, an em, strike, strong or link run crosses a node that cannot hold its mark; parted, as a line needing a fallback is, every run breaks there. +export type RunGrouping = 'parted' | 'spanned' + +// An inline node inside the runs of its first `outer` marks, `carried` where the carry spells it. A node a run crosses holds no mark of that run, so its `outer` stays. +export type RunNode = { carried: boolean; node: AdfNode; outer: number } -// spec/flavour.md, Marks. The portable reducer must ask this too, or it trims edge whitespace by runs the writers do not spell. export function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] { const runs: InlineRun[] = [] let end = 0 - for (const [offset, { node, outer }] of nodes.entries()) { + for (const [offset, { carried, node, outer }] of nodes.entries()) { if (offset < end) continue const index = firstIndex + offset // A carried node spells its marks inside the carry. - const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer] + const mark = carried ? undefined : nodeMarks(node)[outer] if (mark === undefined) { - runs.push({ index, kind: 'plain', node }) + runs.push({ carried, index, kind: 'plain', node }) continue } - end = runEnd(nodes, offset, mark, firstIndex, grouping) - runs.push({ index, kind: 'marked', mark, nodes: nodes.slice(offset, end).map((runNode) => insideRun(runNode, mark) ?? runNode) }) + end = runEnd(nodes, offset, mark, grouping) + runs.push({ index, kind: 'marked', mark, nodes: [{ carried, node, outer: outer + 1 }, ...nodes.slice(offset + 1, end).map((runNode) => insideRun(runNode, mark) ?? runNode)] }) } return runs } // A run opens and closes at a node holding its mark, so a node it would cross past the last such node stays outside. -function runEnd(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, parted }: RunGrouping): number { +function runEnd(nodes: readonly RunNode[], first: number, mark: AdfMark, grouping: RunGrouping): number { let end = first + 1 for (let offset = end; offset < nodes.length; offset += 1) { const runNode = nodes[offset] - if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break + if (runNode === undefined || runNode.carried) break if (insideRun(runNode, mark) !== undefined) end = offset + 1 - else if (parted || !runCrosses(runNode.node, mark)) break + else if (grouping === 'parted' || !runCrosses(runNode.node, mark)) break } return end } -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 insideRun(runNode: RunNode, mark: AdfMark): RunNode | undefined { + const held = nodeMarks(runNode.node)[runNode.outer] + return held !== undefined && identicalMark(held, mark) ? { ...runNode, outer: runNode.outer + 1 } : undefined } // The readers refuse `marks=empty` inside a mark's spelling. @@ -57,12 +56,18 @@ function runCrosses(node: AdfNode, mark: AdfMark): boolean { return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0 } -// Decides the text-node and slot-fault carries up front, `withheldMarkFallbacks` the withheld-mark ones, so a line holding many of them is emitted once. -export function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { - if (carried.has(index)) return true - if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' - const model = inlineNodeModel(node.type) - if (model === undefined) return true - const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] - return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined +// For each leaf but the last, how many runs hold both it and the next leaf, grouped as the writers group them, so the reducer trims edge whitespace by runs they spell. +export function runsSharedWithNext(leaves: readonly AdfNode[], grouping: RunGrouping): number[] { + const shared = leaves.map(() => 0) + countSharedRuns(leaves.map((node) => ({ carried: false, node, outer: 0 })), 0, grouping, shared) + return shared +} + +// Recurses once per mark of a leaf, and the reducer's leaves hold one mark of each kept type. +function countSharedRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping, shared: number[]): void { + for (const run of inlineRuns(nodes, firstIndex, grouping)) { + 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, run.index, grouping, shared) + } } diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 96d7bb7..e43b53c 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -5,7 +5,6 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/doc import { adfToPortableMarkdown, reduceToPortable } from './adf-to-portable-markdown.ts' import { largestNesting } from '../../nesting.ts' import { portableLineFallback } from '../emit/inline-line.ts' -import { spannedLine } from '../emit/inline-runs.ts' import { portableMarkdownToAdf } from './portable-markdown-to-adf.ts' const code: AdfMark = { type: 'code' } @@ -413,12 +412,12 @@ test('gives up every highlight run whose own delimiter cannot flank in one fallb const highlight: AdfMark = { type: 'backgroundColor' } const line = [text('a '), text('x', highlight), text('a '), text('x', highlight), text('a '), text('x', highlight), text('a')] const runs = [{ first: 1, last: 1, mark: highlight }, { first: 3, last: 3, mark: highlight }, { first: 5, last: 5, mark: highlight }] - assert.deepEqual(portableLineFallback(line, 'paragraph', [], spannedLine), { ok: true, value: { kind: 'unspellable-run', runs } }) + assert.deepEqual(portableLineFallback(line, 'paragraph', [], 'spanned'), { ok: true, value: { kind: 'unspellable-run', runs } }) }) test('refuses a portable line holding a node only the carry spells, at the line', () => { for (const mark of [{ attrs: { a: 1 }, type: 'em' }, { type: 'odd' }]) { - const refused = portableLineFallback([text('x', mark)], 'paragraph', ['content', 0], spannedLine) + const refused = portableLineFallback([text('x', mark)], 'paragraph', ['content', 0], 'spanned') assert.deepEqual(refused.ok ? refused : [refused.error.code, refused.error.path], ['unsupported-node-shape', ['content', 0]]) } }) diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts index 3451e17..e60aca4 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -7,7 +7,6 @@ import type { LineContainer } from '../line-container.ts' import type { PortableLineFallback } from '../emit/inline-line.ts' import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.ts' import { judgedLine, portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts' -import { spannedLine } from '../emit/inline-runs.ts' import { success, type Result } from '../../result.ts' test('reduces a line alike dropping every run a pass may or the first alone', () => { @@ -70,7 +69,7 @@ function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Resu if (fallback === undefined) return success(line) const fixed = withoutFallback(line, firstRun(fallback), []) if (!fixed.ok) return fixed - current = trimmedEdges(fixed.value, spannedLine) + current = trimmedEdges(fixed.value, 'spanned') } } diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 201b31a..7d016f9 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -1,12 +1,12 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' -import type { RunGrouping, RunNode } from '../emit/inline-runs.ts' +import type { RunGrouping } from '../emit/inline-runs.ts' 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 { inlineRuns, insideRun, partedLine, spannedLine } from '../emit/inline-runs.ts' +import { insideRun, runsSharedWithNext } from '../emit/inline-runs.ts' import { isEmphasis } from '../mark-spellings.ts' import { joinsWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' @@ -34,7 +34,7 @@ export function reduceInline(nodes: readonly AdfNode[], container: LineContainer export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { const leaves = inlineLeaves(nodes, container, path, depth) if (!leaves.ok) return leaves - return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value, spannedLine)), spannedLine)) + return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value, 'spanned')), 'spanned')) } export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { @@ -198,22 +198,6 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] { return leaves.slice(first, last + 1) } -// For each leaf but the last, how many runs of the line hold both it and the next leaf. -function runsSharedWithNext(leaves: readonly AdfNode[], grouping: RunGrouping): number[] { - const shared = leaves.map(() => 0) - countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, grouping, shared) - return shared -} - -// Recurses once per mark, and a portable leaf holds one mark of each kept type. -function countSharedRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping, shared: number[]): void { - for (const run of inlineRuns(nodes, firstIndex, grouping)) { - 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, run.index, grouping, shared) - } -} - function beside(neighbour: AdfNode | undefined, sharedRuns = 0): Beside { return { lineEdge: neighbour === undefined || neighbour.type === 'hardBreak', sharedRuns } } @@ -256,17 +240,17 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver if (fallback === undefined) return success(line) const fixed = withoutFallback(line, fallback, path) if (!fixed.ok) return fixed - current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value, spannedLine) + current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value, 'spanned') } } -// spec/flavour.md, Marks: the lines in the order the writer tries them, each trimmed by its own runs. +// Judged as the writer writes a line: spanned where that reads back, else parted and trimmed by the parted runs, the line a fallback's indexes name. export function judgedLine(spanned: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - const spannedFallback = portableLineFallback(spanned, container, path, spannedLine) + const spannedFallback = portableLineFallback(spanned, container, path, 'spanned') if (!spannedFallback.ok) return spannedFallback if (spannedFallback.value === undefined) return success({ fallback: undefined, leaves: [...spanned] }) - const parted = trimmedEdges(spanned, partedLine) - const partedFallback = portableLineFallback(parted, container, path, partedLine) + const parted = trimmedEdges(spanned, 'parted') + const partedFallback = portableLineFallback(parted, container, path, 'parted') if (!partedFallback.ok) return partedFallback return success({ fallback: partedFallback.value, leaves: parted }) } @@ -279,7 +263,7 @@ 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 - const past = leaves.findIndex((leaf) => insideRun({ node: leaf, outer: 0 }, mark) === undefined) + const past = leaves.findIndex((leaf) => insideRun({ carried: false, 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, end).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')