diff --git a/AGENTS.md b/AGENTS.md index 910b2cb..a076fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything 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 -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 its content model does not take — is +the grammar itself refuses stays a claim code, key order among it, and a leaf given a body is refused +at its opener, as a container missing its closer is (the maintainer, 2026-09-16); a well-formed +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 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 diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index f11bc7a..768ba3a 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -16,6 +16,7 @@ import { directivePrefix, spellAttributes, spellDirectiveCloser, + spellDirectiveOpener, spellInlineDirectiveOpener, spellInlineLeafDirective, spellJsonAttribute, @@ -160,7 +161,7 @@ const hostileArgument = fc.oneof( { 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}`}` } @@ -310,7 +311,7 @@ function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, te 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, weight: 1, }, @@ -330,13 +331,13 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, { const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { const argument = blockArgument(name) === undefined - ? fc.constant('') - : fc.oneof({ arbitrary: fc.constantFrom(' DONE', ' TODO', ' custom', ' info', ' warning'), weight: 3 }, { arbitrary: bareToken.map((held) => ` ${held}`), weight: 1 }) + ? fc.constant(undefined) + : 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) - 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 .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 { block: choose( @@ -365,7 +366,7 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, { { arbitrary: fc .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, weight: 1, }, diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 240c46e..af3a669 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -77,17 +77,7 @@ export function malformedDirective(message: string): ConvertFault { export function readDirectiveLine(line: string): Read | undefined { if (!claimsDirectivePrefix(line, 0)) return undefined - const closer = line.charAt(directivePrefix.length) === '/' - 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 } } + return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line) } export function readInlineDirective(text: string, index: number): Read | undefined { @@ -119,6 +109,10 @@ export function spellDirectiveCloser(name: string): string { 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 { 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) } +function readCloserLine(line: string): Read | 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 | 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 }> { - let cursor = 0 - let argument: string | undefined - let attributes = noAttributes - if (rest.charAt(0) === ' ' && rest.charAt(1) !== '{' && !lineEnd.test(rest)) { - bareRun.lastIndex = 1 - argument = bareRun.exec(rest)?.[0] - if (argument === undefined) return { fault: malformedDirective(openerFault) } - cursor = 1 + argument.length - } - if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { - 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 } } + bareRun.lastIndex = 1 + const argument = bareRun.exec(rest)?.[0] + const attributes = readOpenerAttributes(rest, argument === undefined ? 0 : 1 + argument.length) + if (attributes.fault !== undefined) return { fault: attributes.fault } + if (!lineEnd.test(rest.slice(attributes.value.end))) return { fault: malformedDirective(openerFault) } + return { value: { argument, attributes: attributes.value.attributes } } +} + +function readOpenerAttributes(rest: string, index: number): Read { + return rest.charAt(index) === ' ' ? readAttributesAt(rest, index + 1, false) : { value: { attributes: noAttributes, end: index } } } function readNestedDirective(text: string, index: number, depth: number): Read | undefined { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 7bee79b..3fcabfa 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -113,13 +113,16 @@ function directivePair(node: AdfNode, opener: string, body: string): EmittedBloc function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) - const content = nodeContent(node) - 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 (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).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) const opener = spellBlockDirectiveOpener(node, directive) 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 { + 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) if (!body.ok) return body return success(directivePair(node, opener, body.value)) diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 3399353..dbead55 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,28 +1,27 @@ import type { AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.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 { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellBlockDirectiveOpener(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { const argumentAttribute = blockArgument(node.type) - const argument = spellArgument(node, argumentAttribute) - if (argument === undefined) return undefined + const slot = bareArgument(node, argumentAttribute) + if (slot === undefined) return undefined const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled) if (pairs === undefined) return undefined const spelledPairs = spellVocabulary(pairs) const marks = nodeMarks(node) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) - const attributes = spellAttributes(spelledPairs) - return `${directivePrefix}${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` + return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)) } -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] - if (value === undefined) return '' - if (typeof value !== 'string' || !isBareToken(value)) return undefined - return ` ${value}` + if (value === undefined) return { argument: undefined } + return typeof value === 'string' && isBareToken(value) ? { argument: value } : undefined } diff --git a/src/markdown/list-break.ts b/src/markdown/list-break.ts index e6d17e3..eba28c8 100644 --- a/src/markdown/list-break.ts +++ b/src/markdown/list-break.ts @@ -1,5 +1,5 @@ -import { directivePrefix } from './directive-syntax.ts' +import { spellDirectiveOpener } from './directive-syntax.ts' export const listBreakName = 'listBreak' -export const listBreakSpelling = `${directivePrefix}${listBreakName}` +export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '') diff --git a/todo.md b/todo.md index 5000451..ef169ea 100644 --- a/todo.md +++ b/todo.md @@ -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 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 - 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 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