diff --git a/src/markdown/commonmark/grammar.ts b/src/markdown/commonmark/grammar.ts index 17b776c..cd4aca9 100644 --- a/src/markdown/commonmark/grammar.ts +++ b/src/markdown/commonmark/grammar.ts @@ -203,7 +203,7 @@ export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: st return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker } -// Every line isThematicBreak accepts passes this, and a joined line passes exactly when each part does, so BlockText derives it from a prefix and its body. +// Every line isThematicBreak accepts passes this. export function holdsOnlyRuleCharacters(text: string): boolean { for (let index = 0; index < text.length; index += 1) { const character = text.charAt(index) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index a704f58..e9af1b5 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -3,13 +3,13 @@ import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { WrittenFlavour } from '../flavour.ts' import { blockDirectiveForm, documentSpelling } from '../directive/block.ts' import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts' -import { blockText, emptyLine, joinedText, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts' +import { blockText, emptyLine, holdsBlankLine, joinedText, opensWithThematicBreak, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { fencedCodeBlock } from '../commonmark/backtick-runs.ts' import { flavourClaims } from '../flavour.ts' -import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts' +import { holdsNullCharacter, markerInterruptsParagraph } from '../commonmark/grammar.ts' import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts' import { languageSlot, type LanguageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' @@ -309,12 +309,12 @@ function listStart(node: AdfNode, items: number): number | undefined { function tryListItemLines(inner: BlockText, marker: string): BlockText | undefined { if (inner.length === 0) return blockText(marker.trimEnd()) - if (inner.firstBlank || inner.blankRest > 0) return undefined + if (holdsBlankLine(inner)) return undefined const indent = ' '.repeat(marker.length) const lines = prefixedText(marker, indent, inner) - if (!lines.firstRuleCharacters || !isThematicBreak(lines.first)) return lines + if (!opensWithThematicBreak(lines)) return lines // An item opens with `*` only as text, a list there taking `- ` and a rule `---`; a backslash keeps a first line reading as a rule text. - return inner.firstCharacter === '*' ? prefixedText(`${marker}\\`, indent, inner) : undefined + return inner.first.character === '*' ? prefixedText(`${marker}\\`, indent, inner) : undefined } function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result | undefined { diff --git a/src/markdown/emit/block-text.test.ts b/src/markdown/emit/block-text.test.ts index 97cb302..4b0f840 100644 --- a/src/markdown/emit/block-text.test.ts +++ b/src/markdown/emit/block-text.test.ts @@ -2,8 +2,8 @@ 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 { blockText, holdsBlankLine, joinedText, opensWithThematicBreak, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts' +import { holdsOnlyRuleCharacters, isBlankLine, isThematicBreak } from '../commonmark/grammar.ts' import { propertyRuns } from '../../conformance/property-harness.ts' const { tree } = fc.letrec<{ tree: BlockText }>((tie) => ({ @@ -20,15 +20,13 @@ 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') + const [first = '', ...later] = written.split('\n') + const blank = (line: string): boolean => line !== '' && isBlankLine(line) 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) + assert.deepEqual(text.first, { blank: blank(first), character: first.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(first), text: first }) + assert.deepEqual(text.later, { blank: later.filter(blank).length, empty: later.filter((line) => line === '').length, written: later.filter((line) => line !== '').length }) + assert.equal(holdsBlankLine(text), [first, ...later].some(blank)) + assert.equal(opensWithThematicBreak(text), isThematicBreak(first)) }), propertyRuns(2000), ) diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts index 21b5137..d533399 100644 --- a/src/markdown/emit/block-text.ts +++ b/src/markdown/emit/block-text.ts @@ -1,91 +1,102 @@ -import { holdsOnlyRuleCharacters, isBlankLine } from '../commonmark/grammar.ts' +import { holdsOnlyRuleCharacters, isBlankLine, isThematicBreak } 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 = Readonly +export type BlockText = Readonly 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' } -// Measures of the written text. The flags describe `first` so no caller reads it: reading a concatenated string copies it whole, once per nesting level. -type Shape = { blankRest: number; emptyRest: number; first: string; firstBlank: boolean; firstCharacter: string; firstRuleCharacters: boolean; length: number; lineCount: number } +// The written first line. The flags describe `text` so no caller reads it: reading a concatenated string copies it whole, once per nesting level. +type FirstLine = Readonly<{ blank: boolean; character: string; ruleCharacters: boolean; text: string }> +// How many written lines after the first are blank, empty, or hold text, blank ones included. +type LaterLines = Readonly<{ blank: number; empty: number; written: number }> 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: holdsOnlyRuleCharacters(first) } - return { ...firstShape, blankRest, emptyRest: rest.filter((line) => line === '').length, kind: 'text', length: text.length, lineCount: rest.length + 1, lines: [first, ...rest] } + const empty = rest.filter((line) => line === '').length + const later = { blank: rest.filter((line) => line !== '' && isBlankLine(line)).length, empty, written: rest.length - empty } + return { first: { blank: first !== '' && isBlankLine(first), character: first.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(first), text: first }, kind: 'text', later, length: text.length, lines: [first, ...rest] } } export function joinedText(parts: readonly BlockText[]): BlockText { const [first, ...others] = parts if (first === undefined) return emptyLine - let { blankRest, emptyRest, length, lineCount } = first + let { blank, empty, written } = first.later + let { length } = first for (const part of others) { - blankRest += part.blankRest + (part.firstBlank ? 1 : 0) - emptyRest += part.emptyRest + (part.first === '' ? 1 : 0) + blank += part.later.blank + (part.first.blank ? 1 : 0) + empty += part.later.empty + (part.first.text === '' ? 1 : 0) + written += part.later.written + (part.first.text === '' ? 0 : 1) length += 1 + part.length - lineCount += part.lineCount } - const { firstBlank, firstCharacter, firstRuleCharacters } = first - return { blankRest, emptyRest, first: first.first, firstBlank, firstCharacter, firstRuleCharacters, kind: 'joined', length, lineCount, parts } + return { first: first.first, kind: 'joined', later: { blank, empty, written }, length, parts } } // head, never blank, leads the first line; indent, spaces alone, leads every later line holding text. 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 = 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 } + const length = body.length + head.length + indent.length * body.later.written + return { body, first: headedLine(head, body.first), head, indent, kind: 'prefixed', later: body.later, length } } +// Each line takes `> `, an empty one `>`. export function quotedText(body: BlockText): BlockText { - const empty = body.emptyRest + (body.first === '' ? 1 : 0) - const first = body.first === '' ? '>' : `> ${body.first}` - return { blankRest: 0, body, emptyRest: 0, first, firstBlank: false, firstCharacter: '>', firstRuleCharacters: false, kind: 'quoted', length: body.length + 2 * body.lineCount - empty, lineCount: body.lineCount } + const head = body.first.text === '' ? '>' : '> ' + const later = { blank: 0, empty: 0, written: body.later.empty + body.later.written } + return { body, first: headedLine(head, body.first), kind: 'quoted', later, length: body.length + head.length + 2 * body.later.written + body.later.empty } +} + +// A line holds only rule characters exactly when each of its parts does. +function headedLine(head: string, line: FirstLine): FirstLine { + return { blank: false, character: head.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(head) && line.ruleCharacters, text: `${head}${line.text}` } +} + +export function holdsBlankLine(text: BlockText): boolean { + return text.first.blank || text.later.blank > 0 +} + +export function opensWithThematicBreak(text: BlockText): boolean { + return text.first.ruleCharacters && isThematicBreak(text.first.text) } // The first line, and the line break after it where one follows: enough to read a marker opening the text. export function opening(text: BlockText): string { - return text.lineCount > 1 ? `${text.first}\n` : text.first + return text.later.empty + text.later.written > 0 ? `${text.first.text}\n` : text.first.text } export function writtenText(text: BlockText): string { const lines: string[] = [] - // The open prefixes, outermost first, and where the quotes stand among them. + // The open prefixes, outermost first. A line starts every open prefix, so the unstarted ones are the innermost. const open: Prefix[] = [] - const quotes: number[] = [] - // A line starts every open prefix, so the unstarted ones are the innermost. let started = 0 - const pending: (BlockText | 'leave')[] = [text] + // Each pending text with how many prefixes stay open around it. + const pending: { text: BlockText; within: number }[] = [{ text, within: 0 }] for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - if (next === 'leave') { - if (open.pop()?.kind === 'quoted') quotes.pop() - started = Math.min(started, open.length) - } else if (next.kind === 'text') { - for (const line of next.lines) { - lines.push(prefixedLine(line, open, quotes.at(-1), started)) + open.length = next.within + started = Math.min(started, next.within) + const current = next.text + if (current.kind === 'text') { + for (const line of current.lines) { + lines.push(prefixedLine(line, open, started)) started = open.length } - } else if (next.kind === 'joined') for (const part of next.parts.slice().reverse()) pending.push(part) + } else if (current.kind === 'joined') for (const part of current.parts.slice().reverse()) pending.push({ text: part, within: open.length }) else { - if (next.kind === 'quoted') quotes.push(open.length) - open.push(next) - pending.push('leave', next.body) + open.push(current) + pending.push({ text: current.body, within: open.length }) } } return lines.join('\n') } -// Innermost first: whether a line is empty when an outer prefix meets it depends on the prefixes inside. -function prefixedLine(line: string, open: readonly Prefix[], innermostQuote: number | undefined, started: number): string { - // An empty line takes no indent, so where every prefix has started its walk begins at the innermost quote. - const walked = line === '' && started === open.length ? open.slice(0, (innermostQuote ?? -1) + 1) : open - return walked.reduceRight((written, prefix, depth) => { +// Innermost first, since an empty line takes no indent and a quote turns it into `>`. +function prefixedLine(line: string, open: readonly Prefix[], started: number): string { + return open.reduceRight((written, prefix, depth) => { if (prefix.kind === 'quoted') return written === '' ? '>' : `> ${written}` - return depth >= started ? `${prefix.head}${written}` : `${prefix.indent}${written}` + if (depth >= started) return `${prefix.head}${written}` + return written === '' ? '' : `${prefix.indent}${written}` }, line) } diff --git a/src/markdown/portable/block-spelling.ts b/src/markdown/portable/block-spelling.ts index e309ab2..878649a 100644 --- a/src/markdown/portable/block-spelling.ts +++ b/src/markdown/portable/block-spelling.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 === markerOpening && leadingMarker(opening(text), readTaskMarker) !== undefined, - claimsQuote: (text) => text.firstCharacter === markerOpening && leadingMarker(opening(text), readAlertMarker) !== undefined, + claimsItem: (text) => leadsWithMarker(text, readTaskMarker), + claimsQuote: (text) => leadsWithMarker(text, readAlertMarker), spell: spellPortableBlock, } @@ -30,6 +30,11 @@ function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number return line.ok ? quotedBlocks(node, path, depth, writing, headed(`${foldedAlertMarker} ${line.value}`)) : line } +// Reading a concatenated line copies it whole, so the first character is checked before the line is read. +function leadsWithMarker(text: BlockText, read: (text: string) => { length: number } | undefined): boolean { + return text.first.character === markerOpening && leadingMarker(opening(text), read) !== undefined +} + function headed(head: string): (body: BlockText) => BlockText { return (body) => (body.length === 0 ? blockText(head) : joinedText([blockText(head), emptyLine, body])) } diff --git a/src/markdown/portable/conventions.test.ts b/src/markdown/portable/conventions.test.ts new file mode 100644 index 0000000..4a7c917 --- /dev/null +++ b/src/markdown/portable/conventions.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import fc from 'fast-check' +import test from 'node:test' + +import { markerOpening, readAlertMarker, readTaskMarker } from './conventions.ts' +import { propertyRuns } from '../../conformance/property-harness.ts' + +test('reads a task or alert marker only where the text opens with the marker opening', () => { + fc.assert( + fc.property(fc.string({ maxLength: 8, unit: fc.constantFrom(' ', '!', '+', '-', 'N', 'x', '[', '\\', ']') }), (text) => { + if (readTaskMarker(text) !== undefined || readAlertMarker(text) !== undefined) assert.equal(text.charAt(0), markerOpening) + }), + propertyRuns(2000), + ) +})