mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Keep BlockText's measures as two named records, read its flags through two functions, and claim a marker through one guard
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<EmittedBlock> | undefined {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<Lines & Shape>
|
||||
export type BlockText = Readonly<Lines & { first: FirstLine; later: LaterLines; length: number }>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user