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:
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
@@ -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('[, 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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']] },
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user