diff --git a/src/markdown/emit/inline-line.test.ts b/src/markdown/emit/inline-line.test.ts index 321ea4e..78577cc 100644 --- a/src/markdown/emit/inline-line.test.ts +++ b/src/markdown/emit/inline-line.test.ts @@ -2,13 +2,16 @@ import assert from 'node:assert/strict' import fc from 'fast-check' import test from 'node:test' -import { emitInlineLine } from './inline-line.ts' +import type { AdfNode } from '../../adf/document.ts' +import type { LineContainer } from '../line-container.ts' +import { emitInlineLine, linePass, takeFallback, withheldMarkFallbacks } from './inline-line.ts' import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.ts' +import { success, type Result } from '../../result.ts' test('spells a line alike taking every fallback a pass finds or the first alone', () => { fc.assert( fc.property(markedLine, lineContainer, (nodes, container) => { - assert.deepEqual(emitInlineLine(nodes, container, [], 'lossless'), emitInlineLine(nodes, container, [], 'lossless', 'first')) + assert.deepEqual(emitInlineLine(nodes, container, [], 'lossless'), firstFallbackLine(nodes, container)) }), // Lines the fixed seed rarely generates: carrying one run lets a neighbour or its holder read as written, or one pass carries every copy of a repeated mark run. { @@ -27,3 +30,16 @@ test('spells a line alike taking every fallback a pass finds or the first alone' }, ) }) + +// Takes the first fallback a pass finds alone, and no cap. +function firstFallbackLine(nodes: readonly AdfNode[], container: LineContainer): Result { + const fallbacks = withheldMarkFallbacks(nodes, 'lossless') + for (;;) { + const attempt = linePass(nodes, container, [], fallbacks) + if (!attempt.ok) return attempt + const { fallback } = attempt.value + if (fallback === undefined) return success(attempt.value.line) + const taken = takeFallback(fallbacks, fallback === 'opening-link' ? fallback : [fallback[0]], []) + if (!taken.ok) return taken + } +} diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 2ff9550..8a07358 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -27,9 +27,6 @@ type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } | type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link' -// How many fallbacks one pass applies: `every`, or the `first` alone and with no cap; a property checks that both write the same line. -export type FallbacksPerPass = 'every' | 'first' - type InlineContext = { atBlockEnd: boolean bracketed: boolean @@ -43,7 +40,7 @@ type InlineContext = { type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } -type LineAttempt = { fallback: Fallback; line?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } +type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } @@ -52,28 +49,28 @@ export const readBackPasses = 32 export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } -export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass = 'every'): Result { - const emitted = emitLine(nodes, container, path, flavour, perPass) +export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { + const emitted = emitLine(nodes, container, path, flavour) if (!emitted.ok) return emitted return success(emitted.value.line) } export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result { - const emitted = emitLine(nodes, 'paragraph', path, 'lossless', 'every') + const emitted = emitLine(nodes, 'paragraph', path, 'lossless') if (!emitted.ok) return emitted return success(emitted.value.openingLinkAsDirective) } -export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, alone: (run: MarkRun) => boolean): Result { +export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const emission = lineSegments(nodes, container, path, withheldMarkFallbacks(nodes, 'portable')) if (!emission.ok) return emission if (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', { alone, kind: 'drop' }) + const verdict = lineVerdict(emission.value.segments, container, 'portable', { kind: 'drop', nodes }) return success(verdict.kind === 'line' ? undefined : verdict) } export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined { - const emitted = emitLine(nodes, 'table-cell', path, flavour, 'every') + const emitted = emitLine(nodes, 'table-cell', path, flavour) if (!emitted.ok) return undefined // spec/flavour.md, Tables; the portable reduction leaves a `|` in syntax only escaped, in a link title. if (flavour === 'lossless' && emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined @@ -92,31 +89,33 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ return attemptLine([syntax('[!['), ...description, syntax(']('), bracketedLinkTarget(destination), syntax(`)](${target})`)], 'paragraph', 'lossless').line } -function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass): Result { +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. for (let pass = 1; ; pass += 1) { - const emission = lineSegments(nodes, container, path, fallbacks) - if (!emission.ok) return emission - const { carries, segments } = emission.value - const attempt = segments === undefined ? { fallback: carries } : attemptLine(segments, container, flavour) - if (attempt.fallback === undefined) return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: attempt.segments }) - const taken = takeFallback(fallbacks, takenThisPass(attempt.fallback, perPass), path) + const attempt = linePass(nodes, container, path, fallbacks) + if (!attempt.ok) return attempt + const { fallback, line, segments } = attempt.value + if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments }) + const taken = takeFallback(fallbacks, fallback, path) if (!taken.ok) return taken - if (perPass === 'every' && pass === readBackPasses) carryEveryEmphasis(fallbacks, nodes) + if (pass === readBackPasses) carryEveryEmphasis(fallbacks, nodes) } } +export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { + const emission = lineSegments(nodes, container, path, fallbacks) + if (!emission.ok) return emission + const { carries, segments } = emission.value + return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour)) +} + function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): void { for (const [index, node] of nodes.entries()) if (nodeMarks(node).some(isEmphasis)) fallbacks.carried.add(index) } -function takenThisPass(fallback: Fallback, perPass: FallbacksPerPass): Fallback { - return fallback === 'opening-link' || perPass === 'every' ? fallback : [fallback[0]] -} - // Seeds the withheld-mark carries once per line, so the run at each mark depth does not rescan every node's marks. -function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: WrittenFlavour): LineFallbacks { +export function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: WrittenFlavour): LineFallbacks { const carried = new Set() for (const [index, node] of nodes.entries()) { const taken = takenMarkTypes(node) @@ -125,7 +124,7 @@ function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: WrittenFlavou return { carried, flavour, openingLinkAsDirective: false } } -function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result { +export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result { 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 diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index ae2f6a9..6064776 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,12 +1,14 @@ +import type { AdfNode } from '../../adf/document.ts' import type { Claims, WrittenFlavour } from '../flavour.ts' import type { InlineReading } from '../inline-content.ts' import type { InlineSource, InlineToken } from '../inline-tokens.ts' import type { LineContainer } from '../line-container.ts' +import { delimiterFlags, isWordCharacter, runLength } from '../commonmark/emphasis-matching.ts' import { delimiterRunToken, highlightFlanking, inlineSource, readInlineToken } from '../inline-tokens.ts' import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts' import { flavourClaims } from '../flavour.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts' -import { delimiterFlags, isWordCharacter, runLength } from '../commonmark/emphasis-matching.ts' +import { nodeMarks } from '../../adf/document.ts' import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts' import { readLineBack } from '../inline-content.ts' @@ -28,8 +30,8 @@ export type InlineSegment = export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] } -// How the caller takes a run back: a carry swaps its nodes for one carry directive, splitting every run holding it; a drop removes its mark, moving no other delimiter where `alone` holds. -export type RunFallback = { kind: 'carry' } | { alone: (run: MarkRun) => boolean; kind: 'drop' } +// How the caller takes a run back: a carry swaps its nodes for one carry directive, splitting every run holding it; a drop removes its mark from the line's `nodes`. +export type RunFallback = { kind: 'carry' } | { kind: 'drop'; nodes: readonly AdfNode[] } type ScanLine = { position: LinePosition; start: number; text: string } @@ -297,7 +299,7 @@ function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReadin const firstPair = pairs[first] if (firstPair === undefined) return misflankedHighlights(placed, output) const merging = new Set() - if (fallback.kind === 'drop') for (const { nodes } of pairs) if (!fallback.alone(nodes)) merging.add(nodes.first).add(nodes.last + 1) + if (fallback.kind === 'drop') for (const { nodes } of pairs) if (!dropsAlone(fallback.nodes, nodes)) merging.add(nodes.first).add(nodes.last + 1) const sweep: PairSweep = { fallback, fallen: new Uint8Array(pairs.length), first, merging, output, pairAt, pairs, placed } sweep.fallen[first] = 1 // Each verdict reads the verdicts settled before it, so the sweep settles outward from the first pair. @@ -308,6 +310,18 @@ function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReadin return runs } +// Dropping the run merges no runs and moves no run's end, whatever else drops: at each end, the node outside holds no mark type the end node holds inside the run's mark. +function dropsAlone(nodes: readonly AdfNode[], run: MarkRun): boolean { + return endsAlone(nodes[run.first], nodes[run.first - 1], run.depth) && endsAlone(nodes[run.last], nodes[run.last + 1], run.depth) +} + +function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, depth: number): boolean { + const besideTypes = new Set(nodeMarks(beside ?? {}).map((mark) => mark.type)) + return nodeMarks(edge ?? {}) + .slice(depth + 1) + .every((mark) => !besideTypes.has(mark.type)) +} + // The pairs in closing order. function emphasisPairs(placed: readonly PlacedSegment[]): { pairAt: Int32Array; pairs: EmphasisPair[] } { const pairAt = new Int32Array(placed.length).fill(-1) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 750e7bd..d547154 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -412,11 +412,11 @@ 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 = [{ depth: 0, first: 1, last: 1 }, { depth: 0, first: 3, last: 3 }, { depth: 0, first: 5, last: 5 }] - assert.deepEqual(portableLineFallback(line, 'paragraph', [], () => false), { ok: true, value: { kind: 'unspellable-run', runs } }) + assert.deepEqual(portableLineFallback(line, 'paragraph', []), { ok: true, value: { kind: 'unspellable-run', runs } }) }) test('refuses a portable line holding a node only the carry spells, at the line', () => { - const refused = portableLineFallback([text('x', { type: 'odd' })], 'paragraph', ['content', 0], () => false) + const refused = portableLineFallback([text('x', { type: 'odd' })], 'paragraph', ['content', 0]) 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 020e71d..7452459 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -2,13 +2,19 @@ import assert from 'node:assert/strict' import fc from 'fast-check' import test from 'node:test' +import type { AdfNode } from '../../adf/document.ts' +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 { reduceInline } from './inline-reduction.ts' +import { nodeMarks } from '../../adf/document.ts' +import { portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts' +import { portableLineFallback } from '../emit/inline-line.ts' +import { success, type Result } from '../../result.ts' test('reduces a line alike dropping every run a pass may or the first alone', () => { fc.assert( fc.property(markedLine, lineContainer, (nodes, container) => { - assert.deepEqual(reduceInline(nodes, container, [], 0), reduceInline(nodes, container, [], 0, 'first')) + assert.deepEqual(reduceInline(nodes, container, [], 0), firstRunLine(nodes, container)) }), // Lines the fixed seed rarely generates: a later run must not drop with the first, or one pass drops every copy of a repeated mark run. { @@ -29,3 +35,23 @@ test('reduces a line alike dropping every run a pass may or the first alone', () }, ) }) + +// Drops the first run a pass finds alone, with every misflanked highlight, and takes no cap. +function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Result { + const leaves = portableLeaves(nodes, container, [], 0) + if (!leaves.ok) return leaves + for (let current = leaves.value; ; ) { + const fallback = portableLineFallback(current, container, []) + if (!fallback.ok) return fallback + if (fallback.value === undefined) return success(current) + const fixed = withoutFallback(current, firstRun(current, fallback.value), []) + if (!fixed.ok) return fixed + current = trimmedEdges(fixed.value) + } +} + +function firstRun(leaves: readonly AdfNode[], fallback: PortableLineFallback): PortableLineFallback { + if (fallback.kind === 'opening-link') return fallback + const [first, ...others] = fallback.runs + return { kind: 'unspellable-run', runs: [first, ...others.filter((run) => nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === 'backgroundColor')] } +} diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index ca08eb5..3c9842c 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -9,7 +9,7 @@ import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal import { largestNesting } from '../../nesting.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts' -import { portableLineFallback, readBackPasses, type FallbacksPerPass, type PortableLineFallback } from '../emit/inline-line.ts' +import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' const highlight = 'backgroundColor' @@ -17,10 +17,16 @@ const highlightMark: AdfMark = { type: highlight } const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong'] const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] -export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number, perPass: FallbacksPerPass = 'every'): Result { +export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { + const leaves = portableLeaves(nodes, container, path, depth) + if (!leaves.ok) return leaves + return spellableLine(leaves.value, container, path) +} + +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 spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path, perPass) + return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value)))) } export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { @@ -155,7 +161,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode { return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] } } -function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { +export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { for (let current = leaves; ; ) { const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal)) let changed = false @@ -219,19 +225,23 @@ function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index return mark !== undefined && other !== undefined && sameMarkWhenEditorNormal(mark, other) } -function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath, perPass: FallbacksPerPass): Result { +function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { for (let current = leaves, pass = 1; ; pass += 1) { - const fallback = portableLineFallback(current, container, path, (run) => dropsAlone(current, run)) + const fallback = portableLineFallback(current, container, path) if (!fallback.ok) return fallback if (fallback.value === undefined) return success(current) - const fixed = withoutFallback(current, fallback.value, perPass) - if (fixed === undefined) return failure('unsupported-node-shape', 'a portable line keeps a spelling that dropping a mark does not change', path) - current = trimmedEdges(perPass === 'every' && pass === readBackPasses ? withoutEmphasis(fixed) : fixed) + const fixed = withoutFallback(current, fallback.value, path) + if (!fixed.ok) return fixed + current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value) } } -function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, perPass: FallbacksPerPass): AdfNode[] | undefined { - if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, droppedRuns(leaves, fallback.runs, perPass)) +export function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, path: ConvertErrorPath): Result { + const fixed = fallback.kind === 'unspellable-run' ? withoutMarks(leaves, fallback.runs) : withoutOpeningLinkCode(leaves) + return fixed === undefined ? failure('unsupported-node-shape', 'a portable line keeps a spelling that dropping a mark does not change', path) : success(fixed) +} + +function withoutOpeningLinkCode(leaves: readonly AdfNode[]): AdfNode[] | undefined { const mark = nodeMarks(leaves[0] ?? {})[0] if (mark === undefined || mark.type !== 'link') return undefined let last = 0 @@ -245,7 +255,7 @@ function withoutEmphasis(leaves: readonly AdfNode[]): AdfNode[] { return leaves.map((leaf) => withMarks(leaf, nodeMarks(leaf).filter((mark) => !isEmphasis(mark)))) } -// The runs may nest, so a leaf drops the mark at every depth a run holds it. +// Pairs that fall together may hold one another, so a leaf drops its mark at each depth a run over it names. function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] { const depths = new Map() for (const run of runs) { @@ -260,20 +270,3 @@ function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): Adf return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => !dropped.includes(held))) }) } - -// Every misflanked highlight drops with the first run; with `every`, so does every emphasis run that falls with it. -function droppedRuns(leaves: readonly AdfNode[], runs: readonly [MarkRun, ...MarkRun[]], perPass: FallbacksPerPass): readonly MarkRun[] { - return perPass === 'every' ? runs : runs.filter((run, index) => index === 0 || nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === highlight) -} - -// Dropping the run merges no runs and moves no run's end, whatever else drops: at each end, the leaf outside holds no mark type the end leaf holds inside the run's mark. -function dropsAlone(leaves: readonly AdfNode[], run: MarkRun): boolean { - return endsAlone(leaves[run.first], leaves[run.first - 1], run.depth) && endsAlone(leaves[run.last], leaves[run.last + 1], run.depth) -} - -function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, depth: number): boolean { - const besideTypes = new Set(nodeMarks(beside ?? {}).map((mark) => mark.type)) - return nodeMarks(edge ?? {}) - .slice(depth + 1) - .every((mark) => !besideTypes.has(mark.type)) -}