5b2: name the inline claim's escape where prose reaches it
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-03 18:37:10 +02:00
parent 2578e342cf
commit 240ea8eb64
6 changed files with 39 additions and 30 deletions
+1 -1
View File
@@ -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. 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 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 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 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 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 cause; where one cause recurs across node types or across directions, one code covers them all and
+11 -10
View File
@@ -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=1'), 'the {attrs} closing brace is missing')
assert.equal(fault('::panel {a="x}'), 'the {attrs} quoted value is unclosed') 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="\\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; \\::: 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') 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') 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') 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') 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') 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(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', () => { 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', () => { 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'), 'an inline directive [content] is unclosed; \\: keeps the colon literal')
assert.equal(inline(':mention[@A\nB]'), 'an inline directive [content] is unclosed') 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') 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') 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[@A]{id=1'), 'the {attrs} closing brace is missing')
assert.equal(inline(':mention{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') assert.equal(inline(':text{text="a\nb"}'), 'the {attrs} quoted value is not a JSON string')
+16 -11
View File
@@ -37,6 +37,7 @@ const rawReserved = new RegExp(reservedSource)
const noAttributes: DirectiveAttributes = new Map() const noAttributes: DirectiveAttributes = new Map()
export const directiveLineEscape = '\\::: keeps the line literal text' 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 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}` 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)]) return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)])
} }
export function unknownDirectiveFault(name: string): ConvertFault { export function unknownDirectiveFault(name: string, escape: string): ConvertFault {
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${escape}` }
} }
export function unsupportedNodeShape(message: string): ConvertFault { export function unsupportedNodeShape(message: string): ConvertFault {
return { code: 'unsupported-node-shape', message } return { code: 'unsupported-node-shape', message }
} }
function attributePairFault(escape: string): ConvertFault {
return malformedDirective(`${pairFault}; ${escape}`)
}
function keyOrder(left: string, right: string): number { function keyOrder(left: string, right: string): number {
if (left < right) return -1 if (left < right) return -1
return left > right ? 1 : 0 return left > right ? 1 : 0
@@ -164,7 +169,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined;
cursor += 1 + argument.length cursor += 1 + argument.length
} }
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { 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.fault !== undefined) return { fault: read.fault }
if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) } if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes attributes = read.value.attributes
@@ -190,7 +195,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
} }
let attributes = noAttributes let attributes = noAttributes
if (text.charAt(cursor) === '{') { if (text.charAt(cursor) === '{') {
const read = readAttributes(text, cursor) const read = readAttributes(text, cursor, inlineDirectiveEscape)
if (read.fault !== undefined) return { fault: read.fault } if (read.fault !== undefined) return { fault: read.fault }
if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) } if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes attributes = read.value.attributes
@@ -226,7 +231,7 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
if (character === ']') brackets -= 1 if (character === ']') brackets -= 1
cursor += 1 cursor += 1
} }
return { fault: malformedDirective('an inline directive [content] is unclosed') } return { fault: malformedDirective(`an inline directive [content] is unclosed; ${inlineDirectiveEscape}`) }
} }
// `undefined` where the span crosses the line ending an inline directive may not cross. // `undefined` where the span crosses the line ending an inline directive may not cross.
@@ -237,16 +242,16 @@ function readCodeSpanEnd(text: string, index: number): number | undefined {
return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener
} }
function readAttributes(text: string, index: number): Read<Attributes> { function readAttributes(text: string, index: number, escape: string): Read<Attributes> {
const attributes = new Map<string, DirectiveValue>() const attributes = new Map<string, DirectiveValue>()
let cursor = index + 1 let cursor = index + 1
let previous = '' let previous = ''
while (cursor < text.length && text.charAt(cursor) !== '}') { while (cursor < text.length && text.charAt(cursor) !== '}') {
if (attributes.size > 0) { if (attributes.size > 0) {
if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) } if (text.charAt(cursor) !== ' ') return { fault: attributePairFault(escape) }
cursor += 1 cursor += 1
} }
const pair = readAttributePair(text, cursor) const pair = readAttributePair(text, cursor, escape)
if (pair.fault !== undefined) return { fault: pair.fault } if (pair.fault !== undefined) return { fault: pair.fault }
const key = pair.value.key const key = pair.value.key
if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) } 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<Attributes> {
return { value: { attributes, length: cursor + 1 - index } } return { value: { attributes, length: cursor + 1 - index } }
} }
function readAttributePair(text: string, index: number): Read<AttributePair> { function readAttributePair(text: string, index: number, escape: string): Read<AttributePair> {
bareRun.lastIndex = index bareRun.lastIndex = index
const key = bareRun.exec(text)?.[0] 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 const start = index + key.length + 1
if (text.charAt(start) === '"') { if (text.charAt(start) === '"') {
const quoted = readQuotedValue(text, start) const quoted = readQuotedValue(text, start)
@@ -271,7 +276,7 @@ function readAttributePair(text: string, index: number): Read<AttributePair> {
} }
bareRun.lastIndex = start bareRun.lastIndex = start
const bare = bareRun.exec(text)?.[0] 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 } } } return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } }
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.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 { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
import { carryName } from '../opaque-carry.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) return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the fence`, path)
} }
const directive = blockDirective(name) 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 argumentKey = blockArgument(name)
const rest = new Map(attributes) const rest = new Map(attributes)
rest.delete(marksAttribute) rest.delete(marksAttribute)
@@ -51,7 +51,7 @@ export function readInlineDirectiveNode(
path: ConvertErrorPath, path: ConvertErrorPath,
): Result<AdfNode> { ): Result<AdfNode> {
const directive = inlineDirective(name) 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 const slot = directive.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) 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' } const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
+4 -3
View File
@@ -248,8 +248,9 @@ test('gives back the refusal the CommonMark spelling itself raises', () => {
test('names the directive name no node reads back to', () => { test('names the directive name no node reads back to', () => {
assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name') 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(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\::: keeps the line literal text')
assert.equal(code(markdownToAdf(':widget[x]\n')), 'unknown-directive-name') 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.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.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]) 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', () => { 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.equal(code(markdownToAdf('Part :mention[@A]{id=\n')), 'malformed-directive')
assert.deepEqual(path(markdownToAdf('> Part :mention[@A\n')), ['content', 0, 'content', 0]) assert.deepEqual(path(markdownToAdf('> Part :mention[@A\n')), ['content', 0, 'content', 0])
}) })
+4 -2
View File
@@ -425,5 +425,7 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
parts into `adfDocumentFault`, the guard reading it, so the first failing check is the 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 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 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 names `\~~`. The escape lands on the refusals a prose line hits, in the form that was
hits. `a pipe table row holds 1 cells` gained its plural. 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.