3
0
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:
2026-10-30 21:53:59 +01:00
committed by Lillem4n
parent d28bb4a13f
commit 7941972471
6 changed files with 88 additions and 59 deletions
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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 {
+8 -10
View File
@@ -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),
)
+52 -41
View File
@@ -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)
}
+7 -2
View File
@@ -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]))
}
+15
View File
@@ -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),
)
})