5b1: the error's source position #44

Merged
lilleman merged 5 commits from error-source-position into main 2026-09-03 17:31:30 +02:00
11 changed files with 58 additions and 33 deletions
Showing only changes of commit 8a88c50630 - Show all commits
+7
View File
@@ -117,6 +117,13 @@ begins on — the offset indexing the string the caller passed, the line counted
the block walk and attached as results return, so the innermost block wins, the emitter's own the block walk and attached as results return, so the innermost block wins, the emitter's own
refusals the parser re-enters for the CommonMark spelling included. refusals the parser re-enters for the CommonMark spelling included.
A parse names a position for every refusal it returns, so the type says so rather than the prose:
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
`Result<T, ParseError>``ConvertError` with `position` required. An optional field a direction
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
`htmlToAdf` inherits this at `0.3.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
wide `Result<T>`, since half their refusals come from an emit stage that read no source.
## 9. Release automation ## 9. Release automation
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
+8 -4
View File
@@ -24,9 +24,9 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos
```ts ```ts
adfToMarkdown(doc: AdfDocument): Result<string> adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument> markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> adfToHtml(doc: AdfDocument): Result<string>
htmlToAdf(html: string): Result<AdfDocument> htmlToAdf(html: string): Result<AdfDocument, ParseError>
markdownToHtml(markdown: string): Result<string> // via ADF markdownToHtml(markdown: string): Result<string> // via ADF
htmlToMarkdown(html: string): Result<string> // via ADF htmlToMarkdown(html: string): Result<string> // via ADF
isAdfDocument(v: unknown): v is AdfDocument isAdfDocument(v: unknown): v is AdfDocument
@@ -35,8 +35,12 @@ isAdfDocument(v: unknown): v is AdfDocument
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
`ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the `ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the
node it names from the document root, and — parsing — a `{ line, offset }` into the string passed node it names from the document root, and — parsing — a `{ line, offset }` into the string passed
in, at the start of the line the refused block begins on, `line` counted from 1. Emitting reads no in, at the start of the line the refused block begins on, `line` counted from 1.
source, so an emit error carries no `position`.
Parsing a source always names where in it the refusal sits, so `markdownToAdf` and `htmlToAdf`
return the narrowed `ParseError`, whose `position` is there to read without a guard. Every other
direction emits, from a document with no source behind it, and carries `path` alone — including
`markdownToHtml` and `htmlToMarkdown`, where half the refusals come from the emit half.
## The guarantees ## The guarantees
-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.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd()) assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
const { position } = result.error const { position } = result.error
assert.ok(position !== undefined, 'the refusal names no position in the input')
assert.deepEqual(position, lineStarting(markdown, position.offset)) 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 { 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 type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { isAdfDocument } from './adf/document.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' export type LinePosition = 'first' | 'later'
@@ -27,6 +27,7 @@ const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y')
const controlCharacter = new RegExp(`[${controlCharacterRange}]`) const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const htmlTag = new RegExp(htmlTagSource, 'y') const htmlTag = new RegExp(htmlTagSource, 'y')
const nullCharacter = new RegExp(nullCharacterSource) const nullCharacter = new RegExp(nullCharacterSource)
const nullCharacters = new RegExp(nullCharacterSource, 'g')
const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`) const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`)
// The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`). // The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`).
const inlineHtmlConstructs = [ const inlineHtmlConstructs = [
@@ -137,6 +138,10 @@ export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text) return nullCharacter.test(text)
} }
export function replaceNullCharacters(text: string): string {
return text.replaceAll(nullCharacters, replacementCharacter)
}
function htmlTagName(text: string): string { function htmlTagName(text: string): string {
return text.replace(tagName, '<$1>') return text.replace(tagName, '<$1>')
} }
+1 -1
View File
@@ -5,7 +5,7 @@ const anchoredEntityReference = new RegExp(`(?:${entityReferenceSource})`, 'y')
const decimalReference = /^&#(\d+);/ const decimalReference = /^&#(\d+);/
const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/ const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/
const largestCodePoint = 0x10ffff const largestCodePoint = 0x10ffff
const replacementCharacter = '\ufffd' export const replacementCharacter = '\ufffd'
const surrogates = { first: 0xd800, last: 0xdfff } const surrogates = { first: 0xd800, last: 0xdfff }
// HTML5's named character references (https://html.spec.whatwg.org/entities.json), the semicolon-terminated half // 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, markerInterruptsParagraph,
openingCodeFence, openingCodeFence,
openingHtmlBlock, openingHtmlBlock,
replaceNullCharacters,
setextHeadingLevel, setextHeadingLevel,
} from '../commonmark-grammar.ts' } from '../commonmark-grammar.ts'
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.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 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 }[] { function sourceLines(markdown: string): { position: SourcePosition; text: string }[] {
const source = markdown.replaceAll('\u0000', '\ufffd')
const lines: { position: SourcePosition; text: string }[] = [] const lines: { position: SourcePosition; text: string }[] = []
let line = 1 let line = 1
let start = 0 let start = 0
for (let index = 0; index < source.length; index += 1) { for (let index = 0; index < markdown.length; index += 1) {
const character = source.charAt(index) const character = markdown.charAt(index)
if (character !== '\n' && character !== '\r') continue if (character !== '\n' && character !== '\r') continue
lines.push({ position: { line, offset: start }, text: source.slice(start, index) }) lines.push(sourceLine(markdown, line, start, index))
if (character === '\r' && source.charAt(index + 1) === '\n') index += 1 if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1
line += 1 line += 1
start = index + 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 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 { function leadingColumns(line: Line): number {
let columns = 0 let columns = 0
for (const character of line.text) { 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 test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' 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 { largestNesting } from '../../nesting.ts'
import { markdownToAdf } from './markdown-to-adf.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 return result.ok ? ['built'] : result.error.path
} }
function position(result: Result<AdfDocument>): SourcePosition | string { function position(result: Result<AdfDocument, ParseError>): SourcePosition | string {
if (result.ok) return 'built' return result.ok ? 'built' : result.error.position
return result.error.position ?? 'no position'
} }
function text(value: string): AdfNode { 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('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('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('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', () => { 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 type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.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 { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.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 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 if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }) 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) 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 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) 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> { 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 SourcePosition = { line: number; offset: number }
export type ConvertError = { export type ConvertFault = {
code: ConvertErrorCode code: ConvertErrorCode
message: string 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> { export function failure<T>(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result<T> {
return { error: { code, message, path }, ok: false } 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) return failure(fault.code, fault.message, path)
} }
export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T> { export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T, ParseError> {
if (result.ok || result.error.position !== undefined) return result if (result.ok) return result
return { error: { ...result.error, position }, ok: false } 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 } return { ok: true, value }
} }
+4 -1
View File
@@ -167,7 +167,10 @@ The numbering is the order the work was planned in, not the order it ships.
`bulletList` nodes are an error result — which the viewer persona needs told along with `bulletList` nodes are an error result — which the viewer persona needs told along with
what to do about it; and GFM past tables and strikethrough is literal text, task lists what to do about it; and GFM past tables and strikethrough is literal text, task lists
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors, taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
`message` is free text. `message` is free text. The type-level surface freezes at the same moment and gets the same
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
where a direction reads a source, and `ConvertFault` staying internal — the README table
names the shapes a consumer switches on, so the two audits are one.
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /