5b1: guarantee the parse direction's position in the type, and name it one level deeper
CI / gate (push) Successful in 9s

This commit is contained in:
2026-09-03 17:09:14 +02:00
parent 51009ac3f6
commit 8a88c50630
11 changed files with 58 additions and 33 deletions
-1
View File
@@ -206,7 +206,6 @@ for (const name of pairedNames(errorsRoot, '.md', '.error')) {
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
const { position } = result.error
assert.ok(position !== undefined, 'the refusal names no position in the input')
assert.deepEqual(position, lineStarting(markdown, position.offset))
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, Result } from './result.ts'
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { isAdfDocument } from './adf/document.ts'
+6 -1
View File
@@ -1,4 +1,4 @@
import { readEntityReference } from './entity-references.ts'
import { readEntityReference, replacementCharacter } from './entity-references.ts'
export type LinePosition = 'first' | 'later'
@@ -27,6 +27,7 @@ const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y')
const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const htmlTag = new RegExp(htmlTagSource, 'y')
const nullCharacter = new RegExp(nullCharacterSource)
const nullCharacters = new RegExp(nullCharacterSource, 'g')
const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`)
// The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`).
const inlineHtmlConstructs = [
@@ -137,6 +138,10 @@ export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text)
}
export function replaceNullCharacters(text: string): string {
return text.replaceAll(nullCharacters, replacementCharacter)
}
function htmlTagName(text: string): string {
return text.replace(tagName, '<$1>')
}
+1 -1
View File
@@ -5,7 +5,7 @@ const anchoredEntityReference = new RegExp(`(?:${entityReferenceSource})`, 'y')
const decimalReference = /^&#(\d+);/
const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/
const largestCodePoint = 0x10ffff
const replacementCharacter = '\ufffd'
export const replacementCharacter = '\ufffd'
const surrogates = { first: 0xd800, last: 0xdfff }
// HTML5's named character references (https://html.spec.whatwg.org/entities.json), the semicolon-terminated half
+10 -7
View File
@@ -12,6 +12,7 @@ import {
markerInterruptsParagraph,
openingCodeFence,
openingHtmlBlock,
replaceNullCharacters,
setextHeadingLevel,
} from '../commonmark-grammar.ts'
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
@@ -424,24 +425,26 @@ function currentBlocks(walk: Walk): Block[] {
return walk.stack.at(-1)?.blocks ?? walk.blocks
}
// The line ending stays as the input spells it: an offset indexes the string the caller passed.
function sourceLines(markdown: string): { position: SourcePosition; text: string }[] {
const source = markdown.replaceAll('\u0000', '\ufffd')
const lines: { position: SourcePosition; text: string }[] = []
let line = 1
let start = 0
for (let index = 0; index < source.length; index += 1) {
const character = source.charAt(index)
for (let index = 0; index < markdown.length; index += 1) {
const character = markdown.charAt(index)
if (character !== '\n' && character !== '\r') continue
lines.push({ position: { line, offset: start }, text: source.slice(start, index) })
if (character === '\r' && source.charAt(index + 1) === '\n') index += 1
lines.push(sourceLine(markdown, line, start, index))
if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1
line += 1
start = index + 1
}
if (start < source.length) lines.push({ position: { line, offset: start }, text: source.slice(start) })
if (start < markdown.length) lines.push(sourceLine(markdown, line, start, markdown.length))
return lines
}
function sourceLine(markdown: string, line: number, start: number, end: number): { position: SourcePosition; text: string } {
return { position: { line, offset: start }, text: replaceNullCharacters(markdown.slice(start, end)) }
}
function leadingColumns(line: Line): number {
let columns = 0
for (const character of line.text) {
+6 -4
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result, SourcePosition } from '../../result.ts'
import type { ParseError, Result, SourcePosition } from '../../result.ts'
import { largestNesting } from '../../nesting.ts'
import { markdownToAdf } from './markdown-to-adf.ts'
@@ -23,9 +23,8 @@ function path(result: Result<AdfDocument>): readonly (number | string)[] {
return result.ok ? ['built'] : result.error.path
}
function position(result: Result<AdfDocument>): SourcePosition | string {
if (result.ok) return 'built'
return result.error.position ?? 'no position'
function position(result: Result<AdfDocument, ParseError>): SourcePosition | string {
return result.ok ? 'built' : result.error.position
}
function text(value: string): AdfNode {
@@ -459,6 +458,9 @@ test('names the line and the offset in the input a refusal sits at, the innermos
assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('a\nb <span>c</span>\n')), { line: 1, offset: 0 })
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n<div>\r\n')), { line: 3, offset: 9 })
assert.deepEqual(position(markdownToAdf('a\u0000b\n\n<div>\n')), { line: 3, offset: 5 })
assert.deepEqual(position(markdownToAdf(':::caption\na <span>b</span>\n:::\n')), { line: 2, offset: 11 })
assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 })
})
test('gives up the link reference definitions a paragraph opens with', () => {
+6 -4
View File
@@ -4,16 +4,18 @@ import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> {
const documentStart: SourcePosition = { line: 1, offset: 0 }
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown)
const content = blockNodes(parsed.blocks, parsed.definitions, [], 0)
const content = positioned(blockNodes(parsed.blocks, parsed.definitions, [], 0), documentStart)
if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
}
@@ -111,7 +113,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: Li
if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path)
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path)
return contentNode(node, only.text, definitions, path)
return positioned(contentNode(node, only.text, definitions, path), only.position)
}
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
+9 -9
View File
@@ -19,16 +19,16 @@ export type ConvertErrorPath = readonly (number | string)[]
export type SourcePosition = { line: number; offset: number }
export type ConvertError = {
export type ConvertFault = {
code: ConvertErrorCode
message: string
path: ConvertErrorPath
position?: SourcePosition
}
export type ConvertFault = Omit<ConvertError, 'path' | 'position'>
export type ConvertError = ConvertFault & { path: ConvertErrorPath; position?: SourcePosition }
export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T }
export type ParseError = ConvertError & { position: SourcePosition }
export type Result<T, E extends ConvertError = ConvertError> = { error: E; ok: false } | { ok: true; value: T }
export function failure<T>(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result<T> {
return { error: { code, message, path }, ok: false }
@@ -38,11 +38,11 @@ export function faulted<T>(fault: ConvertFault, path: ConvertErrorPath): Result<
return failure(fault.code, fault.message, path)
}
export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T> {
if (result.ok || result.error.position !== undefined) return result
return { error: { ...result.error, position }, ok: false }
export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T, ParseError> {
if (result.ok) return result
return { error: { ...result.error, position: result.error.position ?? position }, ok: false }
}
export function success<T>(value: T): Result<T> {
export function success<T>(value: T): { ok: true; value: T } {
return { ok: true, value }
}