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

Answer architecture round 1: 0 🔴, 7 🟡 (0 misread); test BlockText's measures and the batching against one run per pass, one withinLongest, marker facts beside their readers

This commit is contained in:
2026-10-29 13:55:50 +01:00
committed by Lillem4n
parent 10cb24e706
commit 28ae354315
15 changed files with 201 additions and 85 deletions
+2 -2
View File
@@ -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<readonly AdfNode[
if (document.version !== 1) return failure('unsupported-document-version', `no format this library writes carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(undefined)
// Text alone past the bound is refused before anything is written (docs/decisions.md §Output past V8's longest string is refused on every engine).
return heldTextLength(document.content) > longestOutput ? tooLong() : success(document.content)
return withinLongest(heldTextLength(document.content), document.content)
}
export function attributeNestingMessage(key: string, type: string): string {
+12
View File
@@ -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<Positions>((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<AdfMark>({ 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<AdfNode>({ 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<LineContainer>('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<AdfDocument>({ content: [], type: 'doc', version: 1 }), weight: 1 },
+4 -3
View File
@@ -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', () => {
+2 -2
View File
@@ -18,8 +18,8 @@ export function tooLong<T>(): Result<T> {
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<string> {
return text.length > longestOutput ? tooLong() : success(text)
export function withinLongest<T>(length: number, value: T): Result<T> {
return length > longestOutput ? tooLong() : success(value)
}
export function joinedWithin(pieces: Iterable<string>): Result<string> {
+6
View File
@@ -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)
}
+11 -11
View File
@@ -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<string> {
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<PlacedBlocks> {
@@ -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<EmittedBlock> {
return block.text.length > longestOutput ? tooLong() : success(block)
}
function tooDeep(path: ConvertErrorPath): Result<never> {
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')
}
+35
View File
@@ -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),
)
})
+22 -14
View File
@@ -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)
}
+15
View File
@@ -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),
)
})
+31 -19
View File
@@ -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<number>; 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<string> {
const emitted = emitLine(nodes, container, path, flavour)
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass = 'every'): Result<string> {
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<boolean> {
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<EmittedLine> {
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass): Result<EmittedLine> {
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<LinePass> {
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<number>()
@@ -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<null> {
function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result<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
+24 -19
View File
@@ -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 {
+3 -3
View File
@@ -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,
}
+2
View File
@@ -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<Record<string, string>> = {
error: 'CAUTION',
@@ -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']] },
)
})
+16 -12
View File
@@ -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<AdfNode[]> {
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number, perPass: FallbacksPerPass = 'every'): Result<AdfNode[]> {
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<AdfNode[]> {
@@ -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<AdfNode[]> {
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath, perPass: FallbacksPerPass): Result<AdfNode[]> {
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<number, number>()
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)
}