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
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
- `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
adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument>
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string>
htmlToAdf(html: string): Result<AdfDocument>
htmlToAdf(html: string): Result<AdfDocument, ParseError>
markdownToHtml(markdown: string): Result<string> // via ADF
htmlToMarkdown(html: string): Result<string> // via ADF
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.
`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
in, at the start of the line the refused block begins on, `line` counted from 1. Emitting reads no
source, so an emit error carries no `position`.
in, at the start of the line the refused block begins on, `line` counted from 1.
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
-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 }
}
+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
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,
`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
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /