diff --git a/AGENTS.md b/AGENTS.md index ce93fec..165fcc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,10 @@ direction hits it. A claim code names the spelling claimed, never the node that a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no spelling writes rides the carry with its node. A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that -apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. +apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A +refusal found before its position is known — the block walk's, a directive reader's — is a +`ConvertFault`, the code and message without the path; the node walk attaches the path as it +descends, so a document reports its first error in document order. ## 9. Release automation @@ -122,7 +125,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga - Failures are values: everything returns `Result` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws. `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on - the spot. + the spot. A reader with no path to name returns `Read` instead, the same two arms over a + `ConvertFault`, and `faulted` attaches the path where the walk knows it. - Only the hard break's inline segment holds a raw newline — every other spelling escapes one or refuses it — which is how the whitespace carry finds a line edge. - Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text diff --git a/corpus/errors/nesting-depth-composed.error b/corpus/errors/nesting-depth-composed.error new file mode 100644 index 0000000..f54ac45 --- /dev/null +++ b/corpus/errors/nesting-depth-composed.error @@ -0,0 +1 @@ +unsupported-nesting-depth diff --git a/corpus/errors/nesting-depth-composed.md b/corpus/errors/nesting-depth-composed.md new file mode 100644 index 0000000..ec20183 --- /dev/null +++ b/corpus/errors/nesting-depth-composed.md @@ -0,0 +1 @@ +> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > :a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index fd4fdb5..d02750e 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -9,13 +9,13 @@ function attributes(...pairs: [string, string][]): ReadonlyMap { return new Map(pairs) } -function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): DirectiveLine { - return { argument, attributes: attributes(...pairs), colons, kind: 'header', name } +function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): { value: DirectiveLine } { + return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } } } function fault(line: string): string { const read = readDirectiveLine(line) - return read?.kind === 'fault' ? read.fault.message : `read ${JSON.stringify(read)}` + return read?.fault === undefined ? `read ${JSON.stringify(read)}` : read.fault.message } function inline(text: string): unknown { @@ -37,9 +37,9 @@ test('claims a colon-run line only where a name or nothing follows the colons', }) test('reads a bare colon run as the fence that closes a container', () => { - assert.deepEqual(readDirectiveLine(':::'), { colons: 3, kind: 'closing' }) - assert.deepEqual(readDirectiveLine('::'), { colons: 2, kind: 'closing' }) - assert.deepEqual(readDirectiveLine(':::::: \t'), { colons: 6, kind: 'closing' }) + assert.deepEqual(readDirectiveLine(':::'), { value: { colons: 3, kind: 'closing' } }) + assert.deepEqual(readDirectiveLine('::'), { value: { colons: 2, kind: 'closing' } }) + assert.deepEqual(readDirectiveLine(':::::: \t'), { value: { colons: 6, kind: 'closing' } }) }) test('reads the leaf and container forms, their argument and their attributes', () => { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index f8010ea..8fd3894 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -12,11 +12,10 @@ export type DirectiveAttributes = ReadonlyMap export type DirectiveLine = | { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } | { colons: number; kind: 'closing' } - | { fault: ConvertFault; kind: 'fault' } -export type InlineDirective = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } +export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } -type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } +export type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } type Attributes = { attributes: DirectiveAttributes; length: number } @@ -50,17 +49,17 @@ export function opensInlineDirective(text: string, index: number): boolean { return inlineDirectiveName(text, index) !== undefined } -export function readDirectiveLine(line: string): DirectiveLine | undefined { +export function readDirectiveLine(line: string): Read | undefined { if (!claimsDirectiveLine(line)) return undefined const colons = runLength(line, 0) const rest = line.slice(colons) - if (lineEnd.test(rest)) return { colons, kind: 'closing' } + if (lineEnd.test(rest)) return { value: { colons, kind: 'closing' } } const header = readDirectiveHeader(rest) - if (header.fault !== undefined) return { fault: header.fault, kind: 'fault' } - return { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } + if (header.fault !== undefined) return { fault: header.fault } + return { value: { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } } } -export function readInlineDirective(text: string, index: number): Read | undefined { +export function readInlineDirective(text: string, index: number): Read | undefined { return readNestedDirective(text, index, 1) } @@ -130,7 +129,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; return { value: { argument, attributes, name } } } -function readNestedDirective(text: string, index: number, depth: number): Read | undefined { +function readNestedDirective(text: string, index: number, depth: number): Read | undefined { const name = inlineDirectiveName(text, index) if (name === undefined) return undefined if (depth > largestNesting) { diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index 71fd8bb..60551e1 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -87,6 +87,16 @@ test('holds a directive container open until the fence that closes it', () => { assert.deepEqual(faults(':::panel info\n> Part.\n> :::\n'), []) assert.deepEqual(faults('::::panel info\n- :::expand\n Part.\n :::\n::::\n'), []) assert.deepEqual(faults(':::panel info\n```\n:::\n```\n:::\n'), []) + assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [ + { + argument: 'info', + attributes: new Map([['panelColor', '#ff0000']]), + blocks: [{ kind: 'paragraph', text: 'Part.' }], + kind: 'directive', + name: 'panel', + }, + ]) + assert.deepEqual(parseBlocks('::rule\n').blocks, [{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule' }]) }) test('names the directive fence a container does not sit longer than', () => { diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 1586ffb..c5b8689 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -181,10 +181,15 @@ function openContainer(walk: Walk, start: ContainerStart): void { function closeContainers(walk: Walk, depth: number): void { closeLeaf(walk) - for (const container of walk.stack.splice(depth)) { + for (const container of walk.stack.slice(depth)) { if (container.kind !== 'directive') continue container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' } } + dropContainers(walk, depth) +} + +function dropContainers(walk: Walk, depth: number): void { + walk.stack.length = depth } function openDirective(walk: Walk, directive: Extract): void { @@ -200,12 +205,7 @@ function openDirective(walk: Walk, directive: Extract { scan.pending += ':' return success(index + 1) } - const fault = directive.fault ?? unknownDirectiveFault(directive.value.name) - return failure(fault.code, fault.message, scan.path) + return faulted(directive.fault ?? unknownDirectiveFault(directive.value.name), scan.path) } function flush(scan: Scan, strip: boolean): void { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index d072d38..50e28a7 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -1,7 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block } from './blocks.ts' import type { LinkDefinitions } from './inline-content.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { largestNesting } from '../../nesting.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' @@ -33,12 +33,10 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) case 'code': return success(codeBlockNode(block.language, block.text)) - case 'directive': { - const fault = unknownDirectiveFault(block.name) - return failure(fault.code, fault.message, path) - } + case 'directive': + return faulted(unknownDirectiveFault(block.name), path) case 'fault': - return failure(block.fault.code, block.fault.message, path) + return faulted(block.fault, path) case 'heading': return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path) case 'html': diff --git a/src/result.ts b/src/result.ts index 3098b72..4ad939c 100644 --- a/src/result.ts +++ b/src/result.ts @@ -18,12 +18,13 @@ export type ConvertErrorCode = export type ConvertErrorPath = readonly (number | string)[] -export type ConvertFault = { +export type ConvertError = { code: ConvertErrorCode message: string + path: ConvertErrorPath } -export type ConvertError = ConvertFault & { path: ConvertErrorPath } +export type ConvertFault = Omit export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } @@ -31,6 +32,10 @@ export function failure(code: ConvertErrorCode, message: string, path: Conver return { error: { code, message, path }, ok: false } } +export function faulted(fault: ConvertFault, path: ConvertErrorPath): Result { + return failure(fault.code, fault.message, path) +} + export function success(value: T): Result { return { ok: true, value } } diff --git a/todo.md b/todo.md index 6eaa7d5..c449502 100644 --- a/todo.md +++ b/todo.md @@ -286,7 +286,9 @@ detail is settled at its own milestone. the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes `directive-syntax.ts` with the readers in it: the whole directive grammar, both - directions, beside the escaping regexes and the spellings it must not drift from. + directions, beside the escaping regexes and the spellings it must not drift from. And the + 500-level guards compose here for the first time — a recursive reader stacked on the block + walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third. - [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section