diff --git a/src/adf/document.ts b/src/adf/document.ts index 478dc0e..7cc084e 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -1,7 +1,7 @@ import { failure, faulted, success, type ConvertFault, type Result } from '../result.ts' import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { largestNesting } from '../nesting.ts' -import { longestOutput, tooLong } from '../length-bound.ts' +import { withinLongest } from '../length-bound.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export type AdfAttributes = { [key: string]: JsonValue } @@ -56,7 +56,7 @@ export function writableContent(document: AdfDocument): Result longestOutput ? tooLong() : success(document.content) + return withinLongest(heldTextLength(document.content), document.content) } export function attributeNestingMessage(key: string, type: string): string { diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 67fa48e..5bc2bcf 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -6,6 +6,7 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../ import type { Arbitrary } from 'fast-check' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { JsonValue } from '../json-value.ts' +import type { LineContainer } from '../markdown/line-container.ts' import type { LinkDefinition } from '../markdown/commonmark/link-syntax.ts' import { blockArgument } from '../markdown/directive/block.ts' import { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts' @@ -204,6 +205,17 @@ const positions = fc.letrec((tie) => { } }) +// Short texts under a few marks beside nodes only the carry spells: the lines where one misread or carried run sits beside another. +const lineMark = fc.constantFrom({ type: 'backgroundColor' }, { type: 'code' }, { type: 'em' }, { attrs: { href: 'u' }, type: 'link' }, { type: 'link' }, { type: 'odd' }, { type: 'strike' }, { type: 'strong' }) +const lineMarks = fc.uniqueArray(lineMark, { maxLength: 3, selector: (held) => held.type }) +const lineLeaf = fc.oneof( + { arbitrary: fc.record({ marks: lineMarks, text: fc.string({ maxLength: 3, minLength: 1, unit: fc.constantFrom(' ', '*', '-', '.', '0', '=', '[', '_', '`', 'a', 'un') }) }).map((held): AdfNode => ({ ...held, type: 'text' })), weight: 8 }, + { arbitrary: fc.constant({ type: 'hardBreak' }), weight: 1 }, + { arbitrary: fc.record({ attrs: fc.constantFrom({ id: 'a' }, { id: 'a', odd: 'b' }), marks: lineMarks }).map((held): AdfNode => ({ ...held, type: 'mention' })), weight: 1 }, +) +export const lineContainer = fc.constantFrom('heading', 'paragraph', 'table-cell') +export const markedLine = fc.array(lineLeaf, { maxLength: 10, minLength: 1 }) + export const adfDocument = fc.oneof( { arbitrary: fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => ({ content, type: 'doc', version: 1 })), weight: 30 }, { arbitrary: fc.constant({ content: [], type: 'doc', version: 1 }), weight: 1 }, diff --git a/src/length-bound.test.ts b/src/length-bound.test.ts index b542b73..e6e4204 100644 --- a/src/length-bound.test.ts +++ b/src/length-bound.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { Result } from './result.ts' -import { joinedWithin, withinEngineLimits, withinLongest } from './length-bound.ts' +import { joinedWithin, longestOutput, withinEngineLimits, withinLongest } from './length-bound.ts' import { success } from './result.ts' // The code at the document's path, and the bound the message names. @@ -24,8 +24,9 @@ test('joins pieces up to the bound, and builds nothing past it', () => { assert.equal(pastTheBound(joinedWithin([half, half])), true) }) -test('passes a text within the bound', () => { - assert.deepEqual(withinLongest('x'), success('x')) +test('passes a value whose length is within the bound, and refuses one past it', () => { + assert.deepEqual(withinLongest(longestOutput, 'x'), success('x')) + assert.equal(pastTheBound(withinLongest(longestOutput + 1, 'x')), true) }) test('refuses a string past the bound as too long on every engine, and any other engine limit as engine-limit', () => { diff --git a/src/length-bound.ts b/src/length-bound.ts index c72c5bf..3138cdb 100644 --- a/src/length-bound.ts +++ b/src/length-bound.ts @@ -18,8 +18,8 @@ export function tooLong(): Result { return failure('unsupported-node-shape', `this conversion would build text longer than ${longestOutput} characters, the longest this library writes`, []) } -export function withinLongest(text: string): Result { - return text.length > longestOutput ? tooLong() : success(text) +export function withinLongest(length: number, value: T): Result { + return length > longestOutput ? tooLong() : success(value) } export function joinedWithin(pieces: Iterable): Result { diff --git a/src/markdown/commonmark/grammar.ts b/src/markdown/commonmark/grammar.ts index 84ae249..27ac718 100644 --- a/src/markdown/commonmark/grammar.ts +++ b/src/markdown/commonmark/grammar.ts @@ -66,6 +66,7 @@ const emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+" const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?' const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y') const orderedListOpener = /^(\d{1,9})([.)])(?:[ \t]|$)/ +const ruleCharacters = /^[-*_ \t]*$/ const setextUnderline = /^(=+|-+)[ \t]*$/ const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u @@ -203,6 +204,11 @@ export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: st return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker } +// True of every line isThematicBreak takes, and kept for a prefixed line from its parts without reading it. +export function holdsOnlyRuleCharacters(text: string): boolean { + return ruleCharacters.test(text) +} + export function isUnicodeWhitespace(character: string): boolean { return unicodeWhitespace.test(character) } diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index ae9f74d..8c0c479 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -13,7 +13,7 @@ import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from ' import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts' import { languageSlot, type LanguageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' -import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts' +import { withinEngineLimits, withinLongest } from '../../length-bound.ts' import { spellBlockDirectiveOpener } from '../directive/block-spelling.ts' import { spellDirectiveCloser, spellDirectiveOpener } from '../directive/syntax.ts' import { tryImage } from './image.ts' @@ -55,10 +55,14 @@ export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Resu if (!content.ok) return content if (content.value === undefined) return success(`${documentSpelling}\n`) const walk = walkBlocks(content.value, [], 0, { ...writtenAs, memo: undefined }) - if (!walk.ok) return walk - const text = joinBlocks(walk.value.blocks, 'document') - if (text.length + 1 > longestOutput) return tooLong() - return success(text.length === 0 ? '' : `${writtenText(text)}\n`) + return walk.ok ? writtenDocument(joinBlocks(walk.value.blocks, 'document')) : walk +} + +// Every line of a document's markdown ends with a line break, its last included. +function writtenDocument(text: BlockText): Result { + if (text.length === 0) return success('') + const fits = withinLongest(text.length + 1, text) + return fits.ok ? success(`${writtenText(fits.value)}\n`) : fits } export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result { @@ -69,7 +73,7 @@ export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, de const block = emitBlock(node, [...path, 'content', index], depth, writing) if (!block.ok) return block // Refused here, so an engine holding longer strings stops at the block where V8 throws. - const fits = withinLongest(block.value) + const fits = withinLongest(block.value.text.length, block.value) if (!fits.ok) return fits headroom = Math.min(headroom, block.value.headroom) blocks.push({ ...block.value, node }) @@ -77,10 +81,6 @@ export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, de return success({ blocks, headroom }) } -function withinLongest(block: EmittedBlock): Result { - return block.text.length > longestOutput ? tooLong() : success(block) -} - function tooDeep(path: ConvertErrorPath): Result { return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) } @@ -120,7 +120,7 @@ export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: const readable = readableBlock(node, path, depth, writing, 'asked') if (readable === undefined) return success(false) if (!readable.ok) return readable - const fits = withinLongest(readable.value) + const fits = withinLongest(readable.value.text.length, readable.value) if (!fits.ok) return fits return success(readable.value.spelling !== 'directive') } diff --git a/src/markdown/emit/block-text.test.ts b/src/markdown/emit/block-text.test.ts new file mode 100644 index 0000000..97cb302 --- /dev/null +++ b/src/markdown/emit/block-text.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import fc from 'fast-check' +import test from 'node:test' + +import { blockText, joinedText, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts' +import { holdsOnlyRuleCharacters, isBlankLine } from '../commonmark/grammar.ts' +import { propertyRuns } from '../../conformance/property-harness.ts' + +const { tree } = fc.letrec<{ tree: BlockText }>((tie) => ({ + tree: fc.oneof( + { depthSize: 'small', maxDepth: 5 }, + fc.string({ maxLength: 6, unit: fc.constantFrom('a', ' ', '\t', '\n', '-', '*', '_', '>') }).map(blockText), + fc.array(tie('tree'), { maxLength: 3 }).map(joinedText), + fc.tuple(fc.constantFrom('- ', '1. ', '*', '\\', '[ ] ', '-'), fc.constantFrom('', ' ', ' '), tie('tree')).map(([head, indent, body]) => prefixedText(head, indent, body)), + tie('tree').map(quotedText), + ), +})) + +test('measures a block text as the text it writes', () => { + fc.assert( + fc.property(tree, (text) => { + const written = writtenText(text) + const [first = '', ...rest] = written.split('\n') + assert.equal(text.length, written.length) + assert.equal(text.lineCount, rest.length + 1) + assert.equal(text.first, first) + assert.equal(text.firstCharacter, first.charAt(0)) + assert.equal(text.firstBlank, first !== '' && isBlankLine(first)) + assert.equal(text.firstRuleCharacters, holdsOnlyRuleCharacters(first)) + assert.equal(text.blankRest, rest.filter((line) => line !== '' && isBlankLine(line)).length) + assert.equal(text.emptyRest, rest.filter((line) => line === '').length) + }), + propertyRuns(2000), + ) +}) diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts index 6e0ecb0..f422454 100644 --- a/src/markdown/emit/block-text.ts +++ b/src/markdown/emit/block-text.ts @@ -1,24 +1,24 @@ -import { isBlankLine } from '../commonmark/grammar.ts' +import { holdsOnlyRuleCharacters, isBlankLine } from '../commonmark/grammar.ts' // A block's markdown, its prefixes applied only when written out, so a nesting level costs its own blocks rather than the text below it. export type BlockText = Lines & Shape -type Prefix = { prefix: Lines & { kind: 'prefixed' | 'quoted' }; started: boolean } type Lines = | { body: BlockText; head: string; indent: string; kind: 'prefixed' } | { body: BlockText; kind: 'quoted' } | { kind: 'joined'; parts: readonly BlockText[] } | { kind: 'text'; lines: readonly string[] } +type Prefix = Lines & { kind: 'prefixed' | 'quoted' } +// The open prefixes, outermost first, and where the quotes stand among them; a line starts every open prefix, so the unstarted ones are the innermost. +type Prefixes = { open: Prefix[]; quotes: number[]; started: number } // The written text's measures; the flags answer for the first line unread, since reading a concatenated string copies it whole, at every level it nests. type Shape = { blankRest: number; emptyRest: number; first: string; firstBlank: boolean; firstCharacter: string; firstRuleCharacters: boolean; length: number; lineCount: number } -const ruleCharacters = /^[-*_ \t]*$/ - export const emptyLine = blockText('') export function blockText(text: string): BlockText { const [first = '', ...rest] = text.split('\n') const blankRest = rest.filter((line) => line !== '' && isBlankLine(line)).length - const firstShape = { first, firstBlank: first !== '' && isBlankLine(first), firstCharacter: first.charAt(0), firstRuleCharacters: ruleCharacters.test(first) } + const firstShape = { first, firstBlank: first !== '' && isBlankLine(first), firstCharacter: first.charAt(0), firstRuleCharacters: holdsOnlyRuleCharacters(first) } return { ...firstShape, blankRest, emptyRest: rest.filter((line) => line === '').length, kind: 'text', length: text.length, lineCount: rest.length + 1, lines: [first, ...rest] } } @@ -41,7 +41,7 @@ export function joinedText(parts: readonly BlockText[]): BlockText { export function prefixedText(head: string, indent: string, body: BlockText): BlockText { const first = `${head}${body.first}` const length = body.length + head.length + indent.length * (body.lineCount - 1 - body.emptyRest) - const firstRuleCharacters = ruleCharacters.test(head) && body.firstRuleCharacters + const firstRuleCharacters = holdsOnlyRuleCharacters(head) && body.firstRuleCharacters return { blankRest: body.blankRest, body, emptyRest: body.emptyRest, first, firstBlank: false, firstCharacter: head.charAt(0), firstRuleCharacters, head, indent, kind: 'prefixed', length, lineCount: body.lineCount } } @@ -58,27 +58,35 @@ export function opening(text: BlockText): string { export function writtenText(text: BlockText): string { const lines: string[] = [] - const prefixes: Prefix[] = [] + const prefixes: Prefixes = { open: [], quotes: [], started: 0 } const pending: (BlockText | 'leave')[] = [text] for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - if (next === 'leave') prefixes.pop() + if (next === 'leave') leavePrefix(prefixes) else if (next.kind === 'text') for (const line of next.lines) lines.push(prefixedLine(line, prefixes)) else if (next.kind === 'joined') for (const part of next.parts.slice().reverse()) pending.push(part) else { - prefixes.push({ prefix: next, started: false }) + if (next.kind === 'quoted') prefixes.quotes.push(prefixes.open.length) + prefixes.open.push(next) pending.push('leave', next.body) } } return lines.join('\n') } +function leavePrefix(prefixes: Prefixes): void { + if (prefixes.open.pop()?.kind === 'quoted') prefixes.quotes.pop() + prefixes.started = Math.min(prefixes.started, prefixes.open.length) +} + // Innermost first: whether a line is empty when an outer prefix meets it depends on the prefixes inside. -function prefixedLine(line: string, prefixes: readonly Prefix[]): string { - return prefixes.reduceRight((written, level) => { - const { prefix, started } = level - level.started = true +function prefixedLine(line: string, prefixes: Prefixes): string { + const { open, quotes, started } = prefixes + prefixes.started = open.length + // Started prefixes inside the innermost quote leave an empty line empty, so its walk begins at that quote. + const walked = line === '' && started === open.length ? open.slice(0, (quotes.at(-1) ?? -1) + 1) : open + return walked.reduceRight((written, prefix, depth) => { if (prefix.kind === 'quoted') return written === '' ? '>' : `> ${written}` - if (!started) return `${prefix.head}${written}` + if (depth >= started) return `${prefix.head}${written}` return written === '' ? '' : `${prefix.indent}${written}` }, line) } diff --git a/src/markdown/emit/inline-line.test.ts b/src/markdown/emit/inline-line.test.ts new file mode 100644 index 0000000..be9add5 --- /dev/null +++ b/src/markdown/emit/inline-line.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import fc from 'fast-check' +import test from 'node:test' + +import { emitInlineLine } from './inline-line.ts' +import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.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')) + }), + propertyRuns(3000), + ) +}) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 150832f..48edcc7 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -25,6 +25,11 @@ type EmittedLine = { line: string; openingLinkAsDirective: boolean; segments: In // Every carry one walk finds, taken together: carrying a node changes no other carry's range. type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } | { carries?: undefined; segments: InlineSegment[] } +type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link' + +// How many of a pass's fallbacks it takes: `every`, or the `first` alone, which a property holds to the same line. +export type FallbacksPerPass = 'every' | 'first' + type InlineContext = { atBlockEnd: boolean bracketed: boolean @@ -38,20 +43,22 @@ type InlineContext = { type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } -type LineAttempt = { fallback: readonly NodeRange[] | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string } +type LineAttempt = { fallback: Fallback; line?: undefined } | { fallback?: undefined; line: string } type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } +type LinePass = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } + export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } -export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { - const emitted = emitLine(nodes, container, path, flavour) +export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass = 'every'): Result { + const emitted = emitLine(nodes, container, path, flavour, perPass) 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') + const emitted = emitLine(nodes, 'paragraph', path, 'lossless', 'every') if (!emitted.ok) return emitted return success(emitted.value.openingLinkAsDirective) } @@ -65,7 +72,7 @@ export function portableLineFallback(nodes: readonly AdfNode[], container: LineC } export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined { - const emitted = emitLine(nodes, 'table-cell', path, flavour) + const emitted = emitLine(nodes, 'table-cell', path, flavour, 'every') 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 @@ -84,26 +91,31 @@ 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): Result { +function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass): Result { const fallbacks = withheldMarkFallbacks(nodes, flavour) // Terminates because takeFallback refuses a pass that took no new fallback. for (;;) { - const emission = lineSegments(nodes, container, path, fallbacks) - if (!emission.ok) return emission - if (emission.value.carries !== undefined) { - const taken = takeFallback(fallbacks, emission.value.carries, path) - if (!taken.ok) return taken - continue - } - const attempt = attemptLine(emission.value.segments, container, flavour) - if (attempt.line !== undefined) { - return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments }) - } - const taken = takeFallback(fallbacks, attempt.fallback, path) + const pass = linePass(nodes, container, path, fallbacks) + if (!pass.ok) return pass + const { fallback, line, segments } = pass.value + if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments }) + const taken = takeFallback(fallbacks, takenThisPass(fallback, perPass), path) if (!taken.ok) return taken } } +function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { + const emission = lineSegments(nodes, container, path, fallbacks) + if (!emission.ok) return emission + if (emission.value.carries !== undefined) return success({ fallback: emission.value.carries }) + const attempt = attemptLine(emission.value.segments, container, fallbacks.flavour) + return success(attempt.line === undefined ? { fallback: attempt.fallback } : { line: attempt.line, segments: emission.value.segments }) +} + +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 { const carried = new Set() @@ -114,7 +126,7 @@ function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: WrittenFlavou return { carried, flavour, openingLinkAsDirective: false } } -function takeFallback(fallbacks: LineFallbacks, fallback: readonly NodeRange[] | 'opening-link', path: ConvertErrorPath): Result { +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 172242b..ff128d8 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -30,6 +30,9 @@ export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspe type ScanLine = { position: LinePosition; start: number; text: string } +// An emphasis run's delimiters, written alike, and where each landed in the output. +type EmphasisPair = { closer: number; delimiter: string; nodes: MarkRun; opener: number } + type Escapings = (index: number) => InlineEscaping | undefined // Where a segment's first character landed in the output. @@ -267,7 +270,7 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text: } } -// Of the runs the line does not read back as written, the first emphasis run to close and every later one whose own delimiters cannot flank, else every highlight run: one misread emphasis pair can re-pair the delimiters after it. +// Of the runs the line does not read back as written, the first emphasis run to close and every later one that gives up with it, else every highlight run: one misread emphasis pair can re-pair the delimiters after it. function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading, output: string): MarkRun[] { const misread: MarkRun[] = [] const misflanked: MarkRun[] = [] @@ -276,28 +279,30 @@ function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReadin if (segment.emphasis === 'open') openers.push({ nodes: segment.nodes, start }) // No `==` in text can open or close, and highlights never nest, so a highlight run reads back where both its delimiters flank. if (segment.highlight !== undefined && !highlightFlanks(output, start, segment.highlight) && misflanked.at(-1) !== segment.nodes) misflanked.push(segment.nodes) - if (segment.emphasis === 'close') { - const opener = openers.pop() - if (opener === undefined) continue - const latest = misread.at(-1) - if (latest === undefined ? !readAsWritten(reading, opener.start, start, segment.text.length) : givesUpWith(latest, opener.nodes, output, opener.start, start, segment.text)) misread.push(opener.nodes) - } + const opener = segment.emphasis === 'close' ? openers.pop() : undefined + if (opener === undefined) continue + const pair: EmphasisPair = { closer: start, delimiter: segment.text, nodes: opener.nodes, opener: opener.start } + if (givesUp(pair, misread.at(-1), reading, output)) misread.push(pair.nodes) } return misread.length > 0 ? misread : misflanked } -// A later run gives up with the earlier ones where it holds none of them, so their fallbacks leave its range, and a delimiter of it no other delimiter character touches fails to flank, beside text or its own hard break, which their fallbacks leave too. Runs close in order, so a run holding any earlier one holds the latest. -function givesUpWith(latest: MarkRun, run: MarkRun, output: string, opener: number, closer: number, delimiter: string): boolean { - if (run.first <= latest.first && latest.last <= run.last) return false - const flanks = (start: number, role: DelimiterRole): boolean => { - const before = output.charAt(start - 1) - const after = output.charAt(start + delimiter.length) - const character = delimiter.charAt(0) - if (before === character || after === character) return true - const flags = delimiterFlags(character, before, after) - return role === 'open' ? flags.canOpen : flags.canClose - } - return !flanks(opener, 'open') || !flanks(closer, 'close') +// The first run gives up where the reading pairs its delimiters otherwise; a later one, with the earlier ones, where it holds none of them, so their fallbacks leave its range, and a delimiter of it cannot flank. +function givesUp(pair: EmphasisPair, latest: MarkRun | undefined, reading: InlineReading, output: string): boolean { + if (latest === undefined) return !readAsWritten(reading, pair.opener, pair.closer, pair.delimiter.length) + // Runs close in order, so a run holding any earlier one holds the latest. + if (pair.nodes.first <= latest.first && latest.last <= pair.nodes.last) return false + return !mayFlank(output, pair.opener, pair.delimiter, 'open') || !mayFlank(output, pair.closer, pair.delimiter, 'close') +} + +// Whether the delimiter flanks once earlier runs fall back: a delimiter character beside it may fall back, while text or its own hard break stays. +function mayFlank(output: string, start: number, delimiter: string, role: DelimiterRole): boolean { + const before = output.charAt(start - 1) + const after = output.charAt(start + delimiter.length) + const character = delimiter.charAt(0) + if (before === character || after === character) return true + const flags = delimiterFlags(character, before, after) + return role === 'open' ? flags.canOpen : flags.canClose } function highlightFlanks(output: string, start: number, role: DelimiterRole): boolean { diff --git a/src/markdown/portable/block-spelling.ts b/src/markdown/portable/block-spelling.ts index 27cb8ce..e309ab2 100644 --- a/src/markdown/portable/block-spelling.ts +++ b/src/markdown/portable/block-spelling.ts @@ -1,7 +1,7 @@ import type { AdfNode } from '../../adf/document.ts' import type { ConvertErrorPath, Result } from '../../result.ts' import type { EmittedBlock, FlavourBlockWriters, PlacedBlock, PlacedBlocks, Writing, WrittenAs } from '../emit/adf-to-markdown.ts' -import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from './conventions.ts' +import { alertMarker, foldedAlertMarker, leadingMarker, markerOpening, readAlertMarker, readTaskMarker, taskMarker } from './conventions.ts' import { blockText, emptyLine, joinedText, opening, prefixedText, type BlockText } from '../emit/block-text.ts' import { emitBlock, joinBlocks, quotedBlocks, spelledList, walkBlocks } from '../emit/adf-to-markdown.ts' import { emitInlineLine } from '../emit/inline-line.ts' @@ -12,8 +12,8 @@ import { nodeAttrs, nodeContent } from '../../adf/document.ts' const portableBlockWriters: FlavourBlockWriters = { // GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold. - claimsItem: (text) => text.firstCharacter === '[' && leadingMarker(opening(text), readTaskMarker) !== undefined, - claimsQuote: (text) => text.firstCharacter === '[' && leadingMarker(opening(text), readAlertMarker) !== undefined, + claimsItem: (text) => text.firstCharacter === markerOpening && leadingMarker(opening(text), readTaskMarker) !== undefined, + claimsQuote: (text) => text.firstCharacter === markerOpening && leadingMarker(opening(text), readAlertMarker) !== undefined, spell: spellPortableBlock, } diff --git a/src/markdown/portable/conventions.ts b/src/markdown/portable/conventions.ts index 8c73b16..5bd0786 100644 --- a/src/markdown/portable/conventions.ts +++ b/src/markdown/portable/conventions.ts @@ -1,6 +1,8 @@ type AlertMarker = { folded: boolean; length: number; panelType: string } export const foldedAlertMarker = '[!NOTE]-' +// Task and alert markers open with it, so a writer tests a block's first character before reading its first line, which flattens that line at every level it nests. +export const markerOpening = '[' const alertWords: Readonly> = { error: 'CAUTION', diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts new file mode 100644 index 0000000..0ffac25 --- /dev/null +++ b/src/markdown/portable/inline-reduction.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import fc from 'fast-check' +import test from 'node:test' + +import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.ts' +import { reduceInline } from './inline-reduction.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')) + }), + // A line holding a later run that must not drop with the first, which the seed's runs rarely reach. + { ...propertyRuns(3000), examples: [[[{ attrs: { id: 'a' }, type: 'mention' }, { marks: [{ type: 'strike' }, { type: 'strong' }], text: 'un*', type: 'text' }, { marks: [{ type: 'strong' }], text: '[', type: 'text' }, { text: 'a ', type: 'text' }, { text: ' ', type: 'text' }], 'heading']] }, + ) +}) diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 4b0f528..bc7c30d 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -8,7 +8,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, type PortableLineFallback } from '../emit/inline-line.ts' +import { portableLineFallback, type FallbacksPerPass, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' const highlight = 'backgroundColor' @@ -16,10 +16,10 @@ 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): Result { +export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number, perPass: FallbacksPerPass = 'every'): Result { const leaves = inlineLeaves(nodes, container, path, depth) if (!leaves.ok) return leaves - return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path) + return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path, perPass) } export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { @@ -218,19 +218,19 @@ 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): Result { +function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath, perPass: FallbacksPerPass): Result { for (let current = leaves; ; ) { const fallback = portableLineFallback(current, container, path) if (!fallback.ok) return fallback if (fallback.value === undefined) return success(current) - const fixed = withoutFallback(current, fallback.value) + 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(fixed) } } -function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback): AdfNode[] | undefined { - if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs) +function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, perPass: FallbacksPerPass): AdfNode[] | undefined { + if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, droppedRuns(leaves, fallback.runs, perPass)) const mark = nodeMarks(leaves[0] ?? {})[0] if (mark === undefined || mark.type !== 'link') return undefined let last = 0 @@ -241,18 +241,22 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallb } // The runs cover disjoint leaves, so one pass drops them all. -function withoutMarks(leaves: readonly AdfNode[], [first, ...others]: readonly [MarkRun, ...MarkRun[]]): AdfNode[] { +function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] { const depths = new Map() - for (const run of [first, ...others.filter((run) => dropsWithFirst(leaves, run))]) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth) + for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth) return leaves.map((leaf, index) => { const depth = depths.get(index) return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth)) }) } -// Every misflanked highlight drops at once, a later emphasis run only where dropping its mark merges no run and moves no edge: its mark is its leaves' innermost and no leaf beside it holds a mark that deep. -function dropsWithFirst(leaves: readonly AdfNode[], run: MarkRun): boolean { - if (nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === highlight) return true +// Every misflanked highlight drops with the first run; taking `every`, so does a later emphasis run whose drop merges no run and moves no edge. +function droppedRuns(leaves: readonly AdfNode[], [first, ...others]: readonly [MarkRun, ...MarkRun[]], perPass: FallbacksPerPass): MarkRun[] { + return [first, ...others.filter((run) => nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === highlight || (perPass === 'every' && dropsAlone(leaves, run)))] +} + +// Its mark is its leaves' innermost, and no leaf beside it holds a mark that deep. +function dropsAlone(leaves: readonly AdfNode[], run: MarkRun): boolean { const beside = [leaves[run.first - 1], leaves[run.last + 1]].every((leaf) => leaf === undefined || nodeMarks(leaf).length <= run.depth) return beside && leaves.slice(run.first, run.last + 1).every((leaf) => nodeMarks(leaf).length === run.depth + 1) }