12c: one block opener speller beside its reader, the line readers split along the union, and AGENTS.md records the leaf body
CI / gate (push) Successful in 26s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-16 19:21:06 +02:00
parent 4aa9e88238
commit ba91900b89
7 changed files with 69 additions and 54 deletions
+5 -3
View File
@@ -133,9 +133,11 @@ reserved name is a known name, so never that code, and the two the flavour reser
a form the grammar does not have is a claim code — `!adf:carry`, whose carry is the fence — and a a form the grammar does not have is a claim code — `!adf:carry`, whose carry is the fence — and a
well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything
but two adjacent lists of one type. What but two adjacent lists of one type. What
the grammar itself refuses stays a claim code, key order among it; a well-formed directive the the grammar itself refuses stays a claim code, key order among it, and a leaf given a body is refused
node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its at its opener, as a container missing its closer is (the maintainer, 2026-09-16); a well-formed
kind or its canonical spelling, an argument or a body its content model does not take — is directive the node tables refuse — an attribute a node does not hold or spells elsewhere, a value
outside its kind or its canonical spelling, an argument, or a body of a shape its content model does
not take — is
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code `unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
across both directions for good, since the call site knows which direction it called and parting across both directions for good, since the call site knows which direction it called and parting
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
+8 -7
View File
@@ -16,6 +16,7 @@ import {
directivePrefix, directivePrefix,
spellAttributes, spellAttributes,
spellDirectiveCloser, spellDirectiveCloser,
spellDirectiveOpener,
spellInlineDirectiveOpener, spellInlineDirectiveOpener,
spellInlineLeafDirective, spellInlineLeafDirective,
spellJsonAttribute, spellJsonAttribute,
@@ -160,7 +161,7 @@ const hostileArgument = fc.oneof(
{ arbitrary: fc.oneof(bareToken.map((held) => ` ${held}`), fc.constantFrom(' info', ' a b', ' "a"')), weight: 1 }, { arbitrary: fc.oneof(bareToken.map((held) => ` ${held}`), fc.constantFrom(' info', ' a b', ' "a"')), weight: 1 },
) )
function directiveOpener(name: string, argument: string, attrs: string): string { function hostileOpener(name: string, argument: string, attrs: string): string {
return `${directivePrefix}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}` return `${directivePrefix}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}`
} }
@@ -310,7 +311,7 @@ function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, te
weight: 1, weight: 1,
}, },
{ {
arbitrary: fc.tuple(directiveName, hostileArgument, hostileAttributes).map(([name, argument, attrs]) => directiveOpener(name, argument, attrs)), arbitrary: fc.tuple(directiveName, hostileArgument, hostileAttributes).map(([name, argument, attrs]) => hostileOpener(name, argument, attrs)),
hostile: true, hostile: true,
weight: 1, weight: 1,
}, },
@@ -330,13 +331,13 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => {
const argument = const argument =
blockArgument(name) === undefined blockArgument(name) === undefined
? fc.constant('') ? fc.constant(undefined)
: fc.oneof({ arbitrary: fc.constantFrom(' DONE', ' TODO', ' custom', ' info', ' warning'), weight: 3 }, { arbitrary: bareToken.map((held) => ` ${held}`), weight: 1 }) : fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 })
const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes) const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes)
if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => directiveOpener(name, held, spelled)) if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
return fc return fc
.tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null)) .tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null))
.map(([held, spelled, body, closer]) => container(directiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name))) .map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name)))
}) })
return { return {
block: choose( block: choose(
@@ -365,7 +366,7 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
{ {
arbitrary: fc arbitrary: fc
.tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), closerDrift) .tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), closerDrift)
.map(([name, argument, attrs, body, closer]) => container(directiveOpener(name, argument, attrs), body, closer ?? spellDirectiveCloser(name))), .map(([name, argument, attrs, body, closer]) => container(hostileOpener(name, argument, attrs), body, closer ?? spellDirectiveCloser(name))),
hostile: true, hostile: true,
weight: 1, weight: 1,
}, },
+37 -28
View File
@@ -77,17 +77,7 @@ export function malformedDirective(message: string): ConvertFault {
export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined { export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined {
if (!claimsDirectivePrefix(line, 0)) return undefined if (!claimsDirectivePrefix(line, 0)) return undefined
const closer = line.charAt(directivePrefix.length) === '/' return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line)
const start = directivePrefix.length + (closer ? 1 : 0)
directiveName.lastIndex = start
const name = directiveName.exec(line)?.[0]
if (name === undefined) return undefined
const rest = line.slice(start + name.length)
if (closer) return lineEnd.test(rest) ? { value: { kind: 'closer', name } } : { fault: malformedDirective(closerFault) }
if (!rest.startsWith(' ') && !lineEnd.test(rest)) return undefined
const opener = readOpenerRest(rest)
if (opener.fault !== undefined) return { fault: opener.fault }
return { value: { argument: opener.value.argument, attributes: opener.value.attributes, kind: 'opener', name } }
} }
export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> | undefined { export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> | undefined {
@@ -119,6 +109,10 @@ export function spellDirectiveCloser(name: string): string {
return `${directivePrefix}/${name}` return `${directivePrefix}/${name}`
} }
export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string {
return `${directivePrefix}${name}${argument === undefined ? '' : ` ${argument}`}${attributes === '' ? '' : ` ${attributes}`}`
}
export function spellJsonAttribute(value: JsonValue): string { export function spellJsonAttribute(value: JsonValue): string {
return quote(serializeCanonicalJson(value, 'compact')) return quote(serializeCanonicalJson(value, 'compact'))
} }
@@ -173,24 +167,39 @@ function inlineDirectiveName(text: string, index: number): string | undefined {
return opened === undefined ? undefined : opened.slice(directivePrefix.length, -1) return opened === undefined ? undefined : opened.slice(directivePrefix.length, -1)
} }
function readCloserLine(line: string): Read<DirectiveLine> | undefined {
const start = directivePrefix.length + 1
const name = readDirectiveName(line, start)
if (name === undefined) return undefined
return lineEnd.test(line.slice(start + name.length)) ? { value: { kind: 'closer', name } } : { fault: malformedDirective(closerFault) }
}
function readOpenerLine(line: string): Read<DirectiveLine> | undefined {
const name = readDirectiveName(line, directivePrefix.length)
if (name === undefined) return undefined
const rest = line.slice(directivePrefix.length + name.length)
if (!rest.startsWith(' ') && !lineEnd.test(rest)) return undefined
const opener = readOpenerRest(rest)
if (opener.fault !== undefined) return { fault: opener.fault }
return { value: { argument: opener.value.argument, attributes: opener.value.attributes, kind: 'opener', name } }
}
function readDirectiveName(text: string, index: number): string | undefined {
directiveName.lastIndex = index
return directiveName.exec(text)?.[0]
}
function readOpenerRest(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes }> { function readOpenerRest(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes }> {
let cursor = 0 bareRun.lastIndex = 1
let argument: string | undefined const argument = bareRun.exec(rest)?.[0]
let attributes = noAttributes const attributes = readOpenerAttributes(rest, argument === undefined ? 0 : 1 + argument.length)
if (rest.charAt(0) === ' ' && rest.charAt(1) !== '{' && !lineEnd.test(rest)) { if (attributes.fault !== undefined) return { fault: attributes.fault }
bareRun.lastIndex = 1 if (!lineEnd.test(rest.slice(attributes.value.end))) return { fault: malformedDirective(openerFault) }
argument = bareRun.exec(rest)?.[0] return { value: { argument, attributes: attributes.value.attributes } }
if (argument === undefined) return { fault: malformedDirective(openerFault) } }
cursor = 1 + argument.length
} function readOpenerAttributes(rest: string, index: number): Read<Attributes> {
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { return rest.charAt(index) === ' ' ? readAttributesAt(rest, index + 1, false) : { value: { attributes: noAttributes, end: index } }
const read = readAttributesAt(rest, cursor + 1, false)
if (read.fault !== undefined) return { fault: read.fault }
attributes = read.value.attributes
cursor = read.value.end
}
if (!lineEnd.test(rest.slice(cursor))) return { fault: malformedDirective(openerFault) }
return { value: { argument, attributes } }
} }
function readNestedDirective(text: string, index: number, depth: number): Read<DirectiveSpan> | undefined { function readNestedDirective(text: string, index: number, depth: number): Read<DirectiveSpan> | undefined {
+7 -4
View File
@@ -113,13 +113,16 @@ function directivePair(node: AdfNode, opener: string, body: string): EmittedBloc
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
const content = nodeContent(node) if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
const leaf = blockDirectiveForm(node.type) === 'leaf'
if (leaf && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
const opener = spellBlockDirectiveOpener(node, directive) const opener = spellBlockDirectiveOpener(node, directive)
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (leaf) return success({ spelling: 'directive', text: opener }) return emitDirectiveBody(node, directive, opener, path, depth)
}
function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: string, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (blockDirectiveForm(node.type) === 'leaf') return success({ spelling: 'directive', text: opener })
const content = nodeContent(node)
const body = directive.contentModel === 'inline' ? emitInlineLine(content, 'paragraph', path) : emitBlocks(content, 'directive', path, depth + 1) const body = directive.contentModel === 'inline' ? emitInlineLine(content, 'paragraph', path) : emitBlocks(content, 'directive', path, depth + 1)
if (!body.ok) return body if (!body.ok) return body
return success(directivePair(node, opener, body.value)) return success(directivePair(node, opener, body.value))
@@ -1,28 +1,27 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockDirective } from '../../adf/block-directives.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { directivePrefix, isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts' import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { export function spellBlockDirectiveOpener(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
const argumentAttribute = blockArgument(node.type) const argumentAttribute = blockArgument(node.type)
const argument = spellArgument(node, argumentAttribute) const slot = bareArgument(node, argumentAttribute)
if (argument === undefined) return undefined if (slot === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled) const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs) return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))
return `${directivePrefix}${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
} }
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { // `undefined` where the argument slot holds a value no bare token spells.
function bareArgument(node: AdfNode, argumentAttribute: string | undefined): { argument: string | undefined } | undefined {
const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute] const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute]
if (value === undefined) return '' if (value === undefined) return { argument: undefined }
if (typeof value !== 'string' || !isBareToken(value)) return undefined return typeof value === 'string' && isBareToken(value) ? { argument: value } : undefined
return ` ${value}`
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { directivePrefix } from './directive-syntax.ts' import { spellDirectiveOpener } from './directive-syntax.ts'
export const listBreakName = 'listBreak' export const listBreakName = 'listBreak'
export const listBreakSpelling = `${directivePrefix}${listBreakName}` export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
+2 -1
View File
@@ -75,7 +75,8 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
nobody typed. nobody typed. `readDirectiveContent`'s scan splits into named steps with that fix rather
than keeping its complexity (the maintainer, 2026-09-16).
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces - [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its