Compare commits

...

2 Commits

Author SHA1 Message Date
lilleman 86dfa61b75 4.2: the review's generator, runner and pattern fixes
CI / gate (push) Successful in 23s
CI / publish (push) Has been cancelled
2026-09-14 23:24:32 +02:00
lilleman 254ee3652b 4.2: a link target's would-be directive escapes inside a directive's content 2026-09-14 23:24:21 +02:00
9 changed files with 169 additions and 29 deletions
@@ -0,0 +1,107 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "underline"
},
{
"attrs": {
"href": ":a{"
},
"type": "link"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
},
{
"attrs": {
"href": "y",
"title": ":a{"
},
"type": "link"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
},
{
"attrs": {
"href": ":a[]{"
},
"type": "link"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
},
{
"attrs": {
"href": "https://example.com/?q=:emoji{"
},
"type": "link"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
},
{
"attrs": {
"href": "ab:c{"
},
"type": "link"
}
],
"text": "ab:c{",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,9 @@
:underline[[x](\:a{)]
:underline[[x](y "\:a{")]
:underline[[x](\:a[]{)]
:underline[[x](https://example.com/?q=\:emoji{)]
:underline[[ab\:c{](ab\:c{)]
+1 -1
View File
@@ -7,7 +7,7 @@ node_image=node:24.19.0-alpine3.24
in_image() { in_image() {
local image=$1 entrypoint=$2 local image=$1 entrypoint=$2
shift 2 shift 2
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS:+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
} }
with_firefox() { with_firefox() {
+6 -6
View File
@@ -48,8 +48,8 @@ normalizes to it through the round-trip.
title; title in double quotes. A backslash escapes a parenthesis the destination leaves title; title in double quotes. A backslash escapes a parenthesis the destination leaves
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
when the text equals the destination and the destination is a valid CommonMark autolink when the text equals the destination and the destination is a valid CommonMark autolink
(absolute URI) — inside an inline directive's `[content]`, one holding no backtick and no (absolute URI) — inside an inline directive's `[content]`, one holding no backtick, no
unbalanced bracket. unbalanced bracket and no inline directive opener.
- Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space. - Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space.
- Entity references in input decode to their characters; output backslash-escapes only where text - Entity references in input decode to their characters; output backslash-escapes only where text
would otherwise parse as syntax, scanning the assembled line rather than each text node: escape would otherwise parse as syntax, scanning the assembled line rather than each text node: escape
@@ -132,10 +132,10 @@ each a named error naming the spelling to write instead.
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's
destination and title inside content leave unbalanced and a backtick there that would open a code destination and title inside content leave unbalanced, a backtick there that would open a code span
span, a `{` right after a directive's closing `]`, which would otherwise be read as the attributes and a `:` there that would open an inline directive, a `{` right after a directive's closing `]`,
it has none of; outside code spans and code blocks, a backslash before `:` in input yields a which would otherwise be read as the attributes it has none of; outside code spans and code blocks,
literal colon. a backslash before `:` in input yields a literal colon.
**Malformed directives are error results**, named: an unclosed container at end of input, a body **Malformed directives are error results**, named: an unclosed container at end of input, a body
fence line of the container's length or longer, a bare colon-run line outside any container or fence line of the container's length or longer, a bare colon-run line outside any container or
+35 -18
View File
@@ -18,17 +18,21 @@ import { toEditorNormal } from './adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode } type Positions = { block: AdfNode; inline: AdfNode }
const deepRunsVariable = 'PROPERTY_RUNS' const deepRunsVariable = 'PROPERTY_RUNS'
const gateRuns = 3000 const gateRuns = 1600
const gateSeed = 20260914 const gateSeed = 20260914
// Bun's test runner stops a test after five seconds unless the test sets its own timeout.
const propertyTimeout = 600000
const depthIdentifier = fc.createDepthIdentifier() const depthIdentifier = fc.createDepthIdentifier()
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉') const flatCommonMarkShapeWeight = 4
const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', ':a[', ':a{', 'ab:', 'http://')
const nestingCommonMarkShapeWeight = 21
const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)])
function textOf(minLength: number): Arbitrary<string> { function textOf(minLength: number): Arbitrary<string> {
return fc.oneof( return fc.oneof(
{ arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownCharacters }), weight: 4 }, { arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownPieces }), weight: 4 },
{ arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 }, { arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 },
) )
} }
@@ -37,11 +41,11 @@ const text = textOf(1)
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 })
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash; Bun is unaffected. // V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
const keyCharacter = fc const keyPiece = fc
.oneof({ arbitrary: markdownCharacters, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 }) .oneof({ arbitrary: markdownPieces, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 })
.filter((character) => !/[\\"\x00-\x1f]/.test(character)) .filter((piece) => !/[\\"\x00-\x1f]/.test(piece))
const jsonKey = fc.string({ maxLength: 8, unit: keyCharacter }) const jsonKey = fc.string({ maxLength: 8, unit: keyPiece })
const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({ const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({
jsonValue: fc.oneof( jsonValue: fc.oneof(
@@ -85,10 +89,18 @@ const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))
const autolinkTextNode = fc
.record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks })
.map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) =>
fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
) )
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
return arbitraries.map((arbitrary) => ({ arbitrary, weight }))
}
const positions = fc.letrec<Positions>((tie) => { const positions = fc.letrec<Positions>((tie) => {
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 })
@@ -117,27 +129,33 @@ const positions = fc.letrec<Positions>((tie) => {
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
{ depthIdentifier, maxLength: 3, minLength: 1 }, { depthIdentifier, maxLength: 3, minLength: 1 },
) )
const commonMarkShapes = [ const flatCommonMarkShapes = [
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })),
paragraph,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
]
const nestingCommonMarkShapes = [
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })),
fc fc
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
paragraph,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
] ]
return { return {
block: fc.oneof( block: fc.oneof(
{ depthIdentifier, depthSize: 'small', maxDepth: 4 }, { depthIdentifier, depthSize: 'small', maxDepth: 4 },
{ arbitrary: fc.oneof(...leafBlocks), weight: leafBlocks.length * 2 }, {
arbitrary: fc.oneof(...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)),
weight: leafBlocks.length * 2 + flatCommonMarkShapes.length * flatCommonMarkShapeWeight,
},
{ arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 },
{ arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight },
{ arbitrary: fc.oneof(...commonMarkShapes), weight: blockNodes.length * 2 + misplacedWeight }, { arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight },
), ),
inline: fc.oneof( inline: fc.oneof(
{ depthIdentifier, depthSize: 'small', maxDepth: 4 }, { depthIdentifier, depthSize: 'small', maxDepth: 4 },
{ arbitrary: textNode, weight: 12 }, { arbitrary: textNode, weight: 12 },
{ arbitrary: autolinkTextNode, weight: 2 },
{ arbitrary: fc.oneof(...inlineNodes), weight: 7 }, { arbitrary: fc.oneof(...inlineNodes), weight: 7 },
{ arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, { arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 },
), ),
@@ -149,12 +167,11 @@ const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, m
function runParameters(): { numRuns: number; seed?: number } { function runParameters(): { numRuns: number; seed?: number } {
const deepRuns = env[deepRunsVariable] const deepRuns = env[deepRunsVariable]
if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed } if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed }
const numRuns = Number(deepRuns) assert.ok(/^[1-9]\d*$/.test(deepRuns), `${deepRunsVariable} is a run count in digits, such as ${deepRunsVariable}=10000: found ${JSON.stringify(deepRuns)}`)
assert.ok(Number.isSafeInteger(numRuns) && numRuns > 0, `${deepRunsVariable} holds a whole number of runs: found ${deepRuns}`) return { numRuns: Number(deepRuns) }
return { numRuns }
} }
test('a generated document refuses to emit, or its markdown reads back to it', () => { test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
fc.assert( fc.assert(
fc.property(adfDocument, (document) => { fc.property(adfDocument, (document) => {
const emitted = adfToMarkdown(document) const emitted = adfToMarkdown(document)
+2 -1
View File
@@ -51,6 +51,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [
] ]
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/ const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
const blankLine = /^[ \t]*$/
const codeFenceOpener = /^(`{3,}|~{3,})/ const codeFenceOpener = /^(`{3,}|~{3,})/
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/ const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
const pipeClaim = /^\|/ const pipeClaim = /^\|/
@@ -168,7 +169,7 @@ export function isAutolink(text: string): boolean {
} }
export function isBlankLine(line: string): boolean { export function isBlankLine(line: string): boolean {
return /^[ \t]*$/.test(line) return blankLine.test(line)
} }
export function isThematicBreak(line: string): boolean { export function isThematicBreak(line: string): boolean {
+5
View File
@@ -56,6 +56,11 @@ export function attributeValue(text: string, kind: AttributeKind): AttributeRead
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } } return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
} }
export function holdsInlineDirectiveOpener(text: string): boolean {
for (let index = text.indexOf(':'); index !== -1; index = text.indexOf(':', index + 1)) if (opensInlineDirective(text, index)) return true
return false
}
export function isBareToken(text: string): boolean { export function isBareToken(text: string): boolean {
return bareToken.test(text) return bareToken.test(text)
} }
+2 -2
View File
@@ -6,13 +6,13 @@ import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-gramma
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { holdsEntityReference } from '../entity-references.ts' import { holdsEntityReference } from '../entity-references.ts'
import { holdsInlineDirectiveOpener, slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts' import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts' import { spellTextDirective } from '../text-directive.ts'
@@ -284,7 +284,7 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range
if (typeof href !== 'string') return success({ carry: range }) if (typeof href !== 'string') return success({ carry: range })
const node = nodes[0] const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
const autolinkHolds = !context.bracketed || (!href.includes('`') && escapeUnbalanced(href, '[', ']') === href) const autolinkHolds = !context.bracketed || (!href.includes('`') && !holdsInlineDirectiveOpener(href) && escapeUnbalanced(href, '[', ']') === href)
if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path)
if (!target.ok) return target if (!target.ok) return target
+2 -1
View File
@@ -81,7 +81,8 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
(claimsLineStart(line, index, container) || (claimsLineStart(line, index, container) ||
mergesWithSyntax(scan, escapings, index) || mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) ||
(escaping === 'bracketed-link-target' && scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) (escaping === 'bracketed-link-target' &&
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || (scan.charAt(index) === ':' && opensInlineDirective(scan, index))))
) { ) {
output += '\\' output += '\\'
escaped.add(index) escaped.add(index)