Charge a carried node against the levels its position leaves, and part the causes one guard hid
CI / gate (push) Successful in 8s
CI / gate (push) Successful in 8s
This commit is contained in:
@@ -77,6 +77,15 @@ export function readInlineDirective(text: string, index: number): Read<Directive
|
||||
return readNestedDirective(text, index, 1)
|
||||
}
|
||||
|
||||
export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<string> {
|
||||
if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content`) }
|
||||
const spelled = span.attributes.get(key)
|
||||
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone`) }
|
||||
const spelling = spellStringAttribute(spelled.decoded)
|
||||
if (spelling !== spelled.spelling) return { fault: unsupportedNodeShape(`${span.name} spells its ${key} attribute as ${key}=${spelling}`) }
|
||||
return { value: spelled.decoded }
|
||||
}
|
||||
|
||||
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
|
||||
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined {
|
||||
if (!/[\n\r]/.test(text)) return undefined
|
||||
@@ -116,6 +125,10 @@ export function unknownDirectiveFault(name: string): ConvertFault {
|
||||
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` }
|
||||
}
|
||||
|
||||
export function unsupportedNodeShape(message: string): ConvertFault {
|
||||
return { code: 'unsupported-node-shape', message }
|
||||
}
|
||||
|
||||
function keyOrder(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
return left > right ? 1 : 0
|
||||
|
||||
@@ -167,11 +167,17 @@ test('breaks a mark run at the node it carries', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses a carried node nested deeper than the emitter carries', () => {
|
||||
test('refuses a carried node nested deeper than the levels its position leaves', () => {
|
||||
let node: AdfNode = { type: 'blockCard' }
|
||||
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' }
|
||||
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth')
|
||||
let shallow: AdfNode = { type: 'blockCard' }
|
||||
for (let depth = 0; depth < 200; depth += 1) shallow = { content: [shallow], type: 'blockCard' }
|
||||
assert.ok(adfToMarkdown(document(shallow)).ok)
|
||||
let quoted: AdfNode = shallow
|
||||
for (let depth = 0; depth < 150; depth += 1) quoted = { content: [quoted], type: 'blockquote' }
|
||||
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
|
||||
})
|
||||
|
||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ function interruptsParagraph(node: AdfNode): boolean {
|
||||
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const directive = blockDirective(node.type)
|
||||
if (directive === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const readable = readableBlock(node, path, depth)
|
||||
if (readable !== undefined) return readable
|
||||
return emitDirectiveBlock(node, directive, path, depth)
|
||||
@@ -114,9 +114,9 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv
|
||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
|
||||
const content = node.content ?? []
|
||||
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
|
||||
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path)
|
||||
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
|
||||
const header = spellDirectiveHeader(node, directive)
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) {
|
||||
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
|
||||
}
|
||||
@@ -154,10 +154,10 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
|
||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||
}
|
||||
|
||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> {
|
||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const slot = languageSlot(node.attrs?.['language'])
|
||||
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const text = codeBlockText(node, path)
|
||||
if (!text.ok) return text
|
||||
const info = slot.kind === 'fence' ? slot.info : ''
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import type { JsonSpelling } from '../canonical-json.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||
@@ -7,58 +6,57 @@ import { isAdfNode } from '../adf/document.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { fencedCodeBlock } from './backtick-runs.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { malformedDirective, spellAttributes, spellStringAttribute } from './directive-syntax.ts'
|
||||
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
export const carryName = 'adf'
|
||||
|
||||
const jsonAttribute = 'json'
|
||||
|
||||
export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
const json = carriedJson(node, 'two-space', path)
|
||||
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
const json = carriedJson(node, 'two-space', path, largestNesting - depth)
|
||||
if (!json.ok) return json
|
||||
return success(fencedCodeBlock(carryName, json.value))
|
||||
}
|
||||
|
||||
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
const json = carriedJson(node, 'compact', path)
|
||||
const json = carriedJson(node, 'compact', path, largestNesting)
|
||||
if (!json.ok) return json
|
||||
return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`)
|
||||
}
|
||||
|
||||
export function readCarriedBlock(body: string): Read<AdfNode> {
|
||||
return readCarriedJson(body, 'two-space')
|
||||
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
|
||||
return readCarriedJson(body, 'two-space', largestNesting - depth)
|
||||
}
|
||||
|
||||
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
|
||||
if (span.name !== carryName) return undefined
|
||||
if (span.content !== undefined) return { fault: unsupported(`${carryName} takes no content`) }
|
||||
const spelled = span.attributes.get(jsonAttribute)
|
||||
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${carryName} holds one ${jsonAttribute} attribute alone`) }
|
||||
const spelling = spellStringAttribute(spelled.decoded)
|
||||
if (spelling !== spelled.spelling) return { fault: unsupported(`${carryName} spells its ${jsonAttribute} attribute as ${jsonAttribute}=${spelling}`) }
|
||||
return readCarriedJson(spelled.decoded, 'compact')
|
||||
const spelled = readSoleStringAttribute(span, jsonAttribute)
|
||||
if (spelled.fault !== undefined) return spelled
|
||||
return readCarriedJson(spelled.value, 'compact', largestNesting)
|
||||
}
|
||||
|
||||
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
|
||||
if (!isJsonValue(node)) {
|
||||
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<string> {
|
||||
if (!isJsonValue(node, levels)) {
|
||||
return failure('unsupported-nesting-depth', `a carried node's JSON nests the document deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
}
|
||||
return success(serializeCanonicalJson(node, spelling))
|
||||
}
|
||||
|
||||
function readCarriedJson(raw: string, spelling: JsonSpelling): Read<AdfNode> {
|
||||
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read<AdfNode> {
|
||||
const parsed = parseJsonText(raw)
|
||||
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
|
||||
const { value } = parsed
|
||||
if (!isJsonValue(value)) {
|
||||
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries` } }
|
||||
if (!isJsonValue(value, levels)) {
|
||||
// Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8).
|
||||
if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
|
||||
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests the input deeper than the ${largestNesting} levels the parser carries` } }
|
||||
}
|
||||
if (serializeCanonicalJson(value, spelling) !== raw) {
|
||||
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
|
||||
return { fault: unsupported(`the opaque carry spells its node's JSON canonically: ${shape}`) }
|
||||
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) }
|
||||
}
|
||||
if (!isAdfNode(value)) return { fault: unsupported("the opaque carry holds one ADF node's JSON") }
|
||||
if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON") }
|
||||
return { value }
|
||||
}
|
||||
|
||||
@@ -70,7 +68,3 @@ function parseJsonText(raw: string): { value: unknown } | undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function unsupported(message: string): ConvertFault {
|
||||
return { code: 'unsupported-node-shape', message }
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
|
||||
const text = readTextDirective(span)
|
||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
||||
const slot = slotNodes(scan, span.content)
|
||||
const slot = slotContent(scan, span.content)
|
||||
if (!slot.ok) return slot
|
||||
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
||||
if (mark !== undefined) {
|
||||
@@ -189,7 +189,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
|
||||
return success({ kind: 'nodes', nodes: [node.value] })
|
||||
}
|
||||
|
||||
function slotNodes(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
|
||||
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
|
||||
if (content === undefined) return success(undefined)
|
||||
const parsed = parseInline(content, scan.definitions, scan.path, false)
|
||||
if (!parsed.ok) return parsed
|
||||
|
||||
@@ -312,9 +312,19 @@ test('names the shape the inline carry reads alone', () => {
|
||||
assert.equal(content(markdownToAdf(':adf{json="null"}\n')), 'unsupported-node-shape: adf spells its json attribute as json=null')
|
||||
})
|
||||
|
||||
test('holds a carried JSON value to the nesting the parser carries', () => {
|
||||
const deep = `:adf{json="${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}"}\n`
|
||||
assert.equal(content(markdownToAdf(deep)), `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries`)
|
||||
test('holds a carried JSON value to the nesting its position leaves', () => {
|
||||
const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}`
|
||||
const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
|
||||
const deeper = `unsupported-nesting-depth: a carried node's JSON nests the input deeper than the ${largestNesting} levels the parser carries`
|
||||
assert.equal(content(markdownToAdf(`:adf{json="${nested(largestNesting + 2)}"}\n`)), deeper)
|
||||
assert.equal(code(markdownToAdf(fence('', largestNesting + 1))), 'unsupported-node-shape')
|
||||
assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper)
|
||||
})
|
||||
|
||||
test('names the number no JSON spelling carries in an opaque carry', () => {
|
||||
const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell'
|
||||
assert.equal(content(markdownToAdf(':adf{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('```adf\n1e999\n```\n')), named)
|
||||
})
|
||||
|
||||
test('names the mark spelling no opaque carry sits inside', () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
|
||||
case 'bulletList':
|
||||
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
|
||||
case 'code':
|
||||
return codeBlockNode(block.language, block.text, path)
|
||||
return codeBlockNode(block.language, block.text, path, depth)
|
||||
case 'directive':
|
||||
return directiveNode(block, definitions, path, depth)
|
||||
case 'fault':
|
||||
@@ -134,9 +134,9 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
|
||||
return success({ ...node, content })
|
||||
}
|
||||
|
||||
function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result<AdfNode> {
|
||||
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
if (language === carryName) {
|
||||
const carried = readCarriedBlock(text)
|
||||
const carried = readCarriedBlock(text, depth)
|
||||
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
||||
return success(carried.value)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import { spellAttributes, spellLeafDirective, spellStringAttribute } from './directive-syntax.ts'
|
||||
import { readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
|
||||
const name = 'text'
|
||||
const whitespaceRun = /^(?:[ \t]+|\n+)$/
|
||||
@@ -13,15 +12,8 @@ export function spellTextDirective(text: string): string {
|
||||
|
||||
export function readTextDirective(span: DirectiveSpan): Read<string> | undefined {
|
||||
if (span.name !== name) return undefined
|
||||
if (span.content !== undefined) return { fault: unsupported(`${name} takes no content`) }
|
||||
const spelled = span.attributes.get(name)
|
||||
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${name} holds one ${name} attribute alone`) }
|
||||
const spelling = spellStringAttribute(spelled.decoded)
|
||||
if (spelling !== spelled.spelling) return { fault: unsupported(`${name} spells its ${name} attribute as ${name}=${spelling}`) }
|
||||
if (!whitespaceRun.test(spelled.decoded)) return { fault: unsupported(`${name} spells one run of spaces and tabs, or one run of newlines`) }
|
||||
return { value: spelled.decoded }
|
||||
}
|
||||
|
||||
function unsupported(message: string): ConvertFault {
|
||||
return { code: 'unsupported-node-shape', message }
|
||||
const spelled = readSoleStringAttribute(span, name)
|
||||
if (spelled.fault !== undefined) return spelled
|
||||
if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines`) }
|
||||
return spelled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user