diff --git a/AGENTS.md b/AGENTS.md index 703e84f..d793301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ code from a closed list a consumer may switch exhaustively, the message free tex node's place from the document root, the position where a parse read the refusal in its input. A message reads `rule: violation` — a rule alone states a truth the reader must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose it names the escape that -unclaims the line. +unclaims the form claimed: `\:::` or `\|` for a line, `\:` for an inline directive. Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the cause; where one cause recurs across node types or across directions, one code covers them all and diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index bdf205c..ae9f20f 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -91,13 +91,14 @@ test('names the attributes no spelling reads', () => { assert.equal(fault('::panel {a=1'), 'the {attrs} closing brace is missing') assert.equal(fault('::panel {a="x}'), 'the {attrs} quoted value is unclosed') assert.equal(fault('::panel {a="\\uzzzz"}'), 'the {attrs} quoted value is not a JSON string') - assert.equal(fault('::panel {a}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') - assert.equal(fault('::panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') - assert.equal(fault('::panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') - assert.equal(fault('::panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') - assert.equal(fault('::panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') - assert.equal(fault('::panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') + assert.equal(fault('::panel {a}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') assert.equal(fault('::panel {a=1 a=2}'), 'the attribute key a is spelled twice') + assert.equal(inline(':mention[@A]{id}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\: keeps the colon literal') }) test('breaks the directive on the raw characters a quoted value spells as escapes', () => { @@ -131,10 +132,10 @@ test('binds an inline directive as a unit, its content balancing brackets like l }) test('names the inline directive left unclosed at the end of its line', () => { - assert.equal(inline(':mention[@A'), 'an inline directive [content] is unclosed') - assert.equal(inline(':mention[@A\nB]'), 'an inline directive [content] is unclosed') - assert.equal(inline(':mention[a `b\nc` d]'), 'an inline directive [content] is unclosed') - assert.equal(inline(':underline[:status[x'), 'an inline directive [content] is unclosed') + assert.equal(inline(':mention[@A'), 'an inline directive [content] is unclosed; \\: keeps the colon literal') + assert.equal(inline(':mention[@A\nB]'), 'an inline directive [content] is unclosed; \\: keeps the colon literal') + assert.equal(inline(':mention[a `b\nc` d]'), 'an inline directive [content] is unclosed; \\: keeps the colon literal') + assert.equal(inline(':underline[:status[x'), 'an inline directive [content] is unclosed; \\: keeps the colon literal') assert.equal(inline(':mention[@A]{id=1'), 'the {attrs} closing brace is missing') assert.equal(inline(':mention{id=1'), 'the {attrs} closing brace is missing') assert.equal(inline(':text{text="a\nb"}'), 'the {attrs} quoted value is not a JSON string') diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 273a0c2..e686284 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -37,6 +37,7 @@ const rawReserved = new RegExp(reservedSource) const noAttributes: DirectiveAttributes = new Map() export const directiveLineEscape = '\\::: keeps the line literal text' +export const inlineDirectiveEscape = '\\: keeps the colon literal' const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}' const nameFault = `a directive name reads [a-z][A-Za-z0-9]*: this one does not; ${directiveLineEscape}` @@ -123,14 +124,18 @@ export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, stri return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) } -export function unknownDirectiveFault(name: string): ConvertFault { - return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } +export function unknownDirectiveFault(name: string, escape: string): ConvertFault { + return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${escape}` } } export function unsupportedNodeShape(message: string): ConvertFault { return { code: 'unsupported-node-shape', message } } +function attributePairFault(escape: string): ConvertFault { + return malformedDirective(`${pairFault}; ${escape}`) +} + function keyOrder(left: string, right: string): number { if (left < right) return -1 return left > right ? 1 : 0 @@ -164,7 +169,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; cursor += 1 + argument.length } if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { - const read = readAttributes(rest, cursor + 1) + const read = readAttributes(rest, cursor + 1, directiveLineEscape) if (read.fault !== undefined) return { fault: read.fault } if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) } attributes = read.value.attributes @@ -190,7 +195,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read { +function readAttributes(text: string, index: number, escape: string): Read { const attributes = new Map() let cursor = index + 1 let previous = '' while (cursor < text.length && text.charAt(cursor) !== '}') { if (attributes.size > 0) { - if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) } + if (text.charAt(cursor) !== ' ') return { fault: attributePairFault(escape) } cursor += 1 } - const pair = readAttributePair(text, cursor) + const pair = readAttributePair(text, cursor, escape) if (pair.fault !== undefined) return { fault: pair.fault } const key = pair.value.key if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) } @@ -259,10 +264,10 @@ function readAttributes(text: string, index: number): Read { return { value: { attributes, length: cursor + 1 - index } } } -function readAttributePair(text: string, index: number): Read { +function readAttributePair(text: string, index: number, escape: string): Read { bareRun.lastIndex = index const key = bareRun.exec(text)?.[0] - if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: malformedDirective(pairFault) } + if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: attributePairFault(escape) } const start = index + key.length + 1 if (text.charAt(start) === '"') { const quoted = readQuotedValue(text, start) @@ -271,7 +276,7 @@ function readAttributePair(text: string, index: number): Read { } bareRun.lastIndex = start const bare = bareRun.exec(text)?.[0] - if (bare === undefined) return { fault: malformedDirective(pairFault) } + if (bare === undefined) return { fault: attributePairFault(escape) } return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } } } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 33920c7..3d3dfba 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -3,7 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts' import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { Elsewhere } from './directive-attributes.ts' -import { attributeValue, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' +import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { blockArgument } from '../block-directive-arguments.ts' import { blockDirective } from '../../adf/block-directives.ts' import { carryName } from '../opaque-carry.ts' @@ -27,7 +27,7 @@ export function readBlockDirectiveNode( return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the fence`, path) } const directive = blockDirective(name) - if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path) + if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name, directiveLineEscape), path) const argumentKey = blockArgument(name) const rest = new Map(attributes) rest.delete(marksAttribute) @@ -51,7 +51,7 @@ export function readInlineDirectiveNode( path: ConvertErrorPath, ): Result { const directive = inlineDirective(name) - if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path) + if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name, inlineDirectiveEscape), path) const slot = directive.textAttribute if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 96bc37b..1d1e99c 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -248,8 +248,9 @@ test('gives back the refusal the CommonMark spelling itself raises', () => { test('names the directive name no node reads back to', () => { assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name') - assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node') - assert.equal(code(markdownToAdf(':widget[x]\n')), 'unknown-directive-name') + assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\::: keeps the line literal text') + assert.equal(content(markdownToAdf(':widget[x]\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\: keeps the colon literal') + assert.equal(content(markdownToAdf('ratio a:b[c]{d}\n')), 'malformed-directive: an attribute reads key=value, the value bare or double-quoted: this one does not; \\: keeps the colon literal') assert.deepEqual(path(markdownToAdf('Part.\n\n::widget\n')), ['content', 1]) assert.equal(content(markdownToAdf('Part.\n:::x\n')), 'malformed-directive: a container fenced with 3 colons is unclosed') assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1]) @@ -417,7 +418,7 @@ test('leaves the colon that opens no directive the text it is', () => { }) test('names the inline directive left unclosed at the end of its line', () => { - assert.equal(code(markdownToAdf('Part :mention[@A\n')), 'malformed-directive') + assert.equal(content(markdownToAdf('Part :mention[@A\n')), 'malformed-directive: an inline directive [content] is unclosed; \\: keeps the colon literal') assert.equal(code(markdownToAdf('Part :mention[@A]{id=\n')), 'malformed-directive') assert.deepEqual(path(markdownToAdf('> Part :mention[@A\n')), ['content', 0, 'content', 0]) }) diff --git a/todo-history.md b/todo-history.md index bee35af..ebecfc5 100644 --- a/todo-history.md +++ b/todo-history.md @@ -425,5 +425,7 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: parts into `adfDocumentFault`, the guard reading it, so the first failing check is the message — the wrapper mistake names the key it found. Two carve-outs claim a line, not three: a matched `~~` pair spells `strike` silently, so nothing refuses it and no message - names `\~~`; the escape lands on the directive-line and pipe-table refusals a prose line - hits. `a pipe table row holds 1 cells` gained its plural. + names `\~~`. The escape lands on the refusals a prose line hits, in the form that was + claimed — `\:::` on the directive line's, `\|` on the pipe table's, `\:` on the inline + directive's, the attribute-pair and unknown-name faults taking whichever form read them. + `a pipe table row holds 1 cells` gained its plural.