3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Span a run inside inlineRuns, so runs group in one place

This commit is contained in:
2026-10-31 04:32:55 +01:00
committed by Lillem4n
parent 433d9442d2
commit e473fa92d0
3 changed files with 68 additions and 86 deletions
+65 -21
View File
@@ -6,19 +6,18 @@ import type { RunFallback } from './unspellable-runs.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { commonMarkLink, insideRun, isEmphasis, linkHref, markSpelling, spellMarkAttributes, type RunNode } from '../mark-spellings.ts'
import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { directivePrefix, slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts'
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightDelimiter } from '../inline-tokens.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts'
import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel, takenMarkTypes, takesMark } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts'
import { spanSkippedNodes } from './skipped-nodes.ts'
import { spellInlineNodeAttributes } from '../directive/inline-spelling.ts'
import { spellTextDirective } from '../directive/text.ts'
@@ -118,12 +117,12 @@ export function linePass(nodes: readonly AdfNode[], container: LineContainer, pa
// spec/flavour.md, Marks: a pass keeps the spanned line only where it needs no fallback; otherwise it writes the parted line and takes that line's fallbacks.
function spannedLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): LineAttempt | undefined {
const spanned = spanSkippedNodes(nodes, (node, index) => carries(node, fallbacks.carried, index))
if (spanned === nodes) return undefined
const spanned = spannedNodes(nodes, fallbacks.carried)
if (spanned === undefined) return undefined
const emission = lineSegments(spanned, container, path, fallbacks)
if (!emission.ok || emission.value.segments === undefined) return undefined
// A span that leaves whitespace at a line edge spells it `!adf:text`, which portable markdown lacks.
if (fallbacks.flavour === 'portable' && emission.value.segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith('!adf:'))) return undefined
// A span can leave whitespace at a line edge, which only `!adf:text` spells.
if (!flavourClaims[fallbacks.flavour].directives && emission.value.segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix))) return undefined
const attempt = attemptLine(emission.value.segments, container, fallbacks.flavour)
return attempt.fallback === undefined ? attempt : undefined
}
@@ -229,7 +228,7 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c
if (depth > largestNesting) {
return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
}
const runs = inlineRuns(nodes, firstIndex, context.carried)
const runs = inlineRuns(nodes, depth, firstIndex, context.carried, false)
const carries: NodeRange[] = []
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
@@ -244,25 +243,70 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c
return success(carry === undefined ? { segments } : { carries: [carry, ...others] })
}
function inlineRuns(nodes: readonly RunNode[], firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
// Where `spans`, an emphasis or link run also takes in the nodes between two of its own that cannot hold its mark, lending them the mark.
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>, spans: boolean): InlineRun[] {
const runs: InlineRun[] = []
for (const [offset, runNode] of nodes.entries()) {
let spanEnd = 0
for (const [offset, node] of nodes.entries()) {
const index = firstIndex + offset
const { node, outer } = runNode
// A carried node spells its marks inside the carry.
const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[outer]
if (mark === undefined) {
runs.push({ index, kind: 'plain', node })
const previous = runs[runs.length - 1]
if (offset < spanEnd && previous?.kind === 'marked') {
previous.nodes.push(withMark(node, previous.mark, depth))
continue
}
const previous = runs[runs.length - 1]
const inside = previous?.kind === 'marked' ? insideRun(runNode, previous.mark) : undefined
if (previous?.kind === 'marked' && inside !== undefined) previous.nodes.push(inside)
else runs.push({ index, kind: 'marked', mark, nodes: [{ node, outer: outer + 1 }] })
const mark = heldMark(node, index, depth, carried)
if (mark === undefined) runs.push({ index, kind: 'plain', node })
else if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
if (spans && mark !== undefined) spanEnd = spannedTo(nodes, offset + 1, firstIndex, depth, mark, carried)
}
return runs
}
// `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry.
function heldMark(node: AdfNode, index: number, depth: number, carried: ReadonlySet<number>): AdfMark | undefined {
return carries(node, carried, index) ? undefined : nodeMarks(node)[depth]
}
// The offset where the nodes from `start` that a run of `mark` spans end: `start` itself unless a node holding the mark follows them.
function spannedTo(nodes: readonly AdfNode[], start: number, firstIndex: number, depth: number, mark: AdfMark, carried: ReadonlySet<number>): number {
let end = start
while (spansNode(nodes[end], firstIndex + end, mark, carried)) end += 1
const next = nodes[end]
const held = next === undefined ? undefined : heldMark(next, firstIndex + end, depth, carried)
return held !== undefined && identicalMark(held, mark) ? end : start
}
// spec/flavour.md, Marks: the readers give no node a mark Atlassian's schema withholds from it, and a lent mark would erase the `marks=empty` of an empty marks array.
function spansNode(node: AdfNode | undefined, index: number, mark: AdfMark, carried: ReadonlySet<number>): boolean {
const kind = markSpelling(mark.type)?.kind
return node !== undefined && (kind === 'emphasis' || kind === 'link') && !carries(node, carried, index) && !takesMark(node, mark.type) && node.marks?.length !== 0
}
function withMark(node: AdfNode, mark: AdfMark, depth: number): AdfNode {
const marks = nodeMarks(node)
return { ...node, marks: [...marks.slice(0, depth), mark, ...marks.slice(depth)] }
}
// The nodes as the spanned line writes them, `undefined` where no run spans a node.
export function spannedNodes(nodes: readonly AdfNode[], carried: ReadonlySet<number>): AdfNode[] | undefined {
const spanned: AdfNode[] = []
lendMarks(nodes, 0, 0, carried, spanned)
return spanned.some((node, index) => node !== nodes[index]) ? spanned : undefined
}
function lendMarks(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>, spanned: AdfNode[]): void {
// Past this depth the spanned line is refused as the parted one is.
if (depth > largestNesting) {
for (const node of nodes) spanned.push(node)
return
}
for (const run of inlineRuns(nodes, depth, firstIndex, carried, true)) {
if (run.kind === 'plain') spanned.push(run.node)
else lendMarks(run.nodes, depth + 1, run.index, carried, spanned)
}
}
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
-61
View File
@@ -1,61 +0,0 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import { identicalMark, nodeMarks } from '../../adf/document.ts'
import { largestNesting } from '../../nesting.ts'
import { markSpelling } from '../mark-spellings.ts'
import { takesMark } from '../../adf/inline-nodes.ts'
type Carries = (node: AdfNode, index: number) => boolean
// Lends each emphasis or link run's mark to the nodes inside the run that cannot hold it; returns `nodes` itself where no run skips a node.
export function spanSkippedNodes(nodes: readonly AdfNode[], carries: Carries): readonly AdfNode[] {
const spanned = [...nodes]
return spanRuns(spanned, 0, spanned.length, 0, carries) ? spanned : nodes
}
// Groups the runs as `inlineRuns` does.
function spanRuns(spanned: AdfNode[], first: number, end: number, depth: number, carries: Carries): boolean {
if (depth > largestNesting) return false
let lent = false
for (let start = first; start < end; ) {
const mark = heldMark(spanned, start, depth, carries)
if (mark === undefined) {
start += 1
continue
}
let last = start
for (;;) {
let next = last + 1
while (next < end && skipsMark(spanned, next, mark, carries)) next += 1
const held = next < end ? heldMark(spanned, next, depth, carries) : undefined
if (held === undefined || !identicalMark(held, mark)) break
for (const [offset, node] of spanned.slice(last + 1, next).entries()) spanned[last + 1 + offset] = withMark(node, mark, depth)
lent ||= next > last + 1
last = next
}
lent = spanRuns(spanned, start, last + 1, depth + 1, carries) || lent
start = last + 1
}
return lent
}
function heldMark(spanned: readonly AdfNode[], index: number, depth: number, carries: Carries): AdfMark | undefined {
const node = spanned[index]
return node === undefined || carries(node, index) ? undefined : nodeMarks(node)[depth]
}
// spec/flavour.md, Marks: the readers give no node a mark Atlassian's schema withholds from it.
function skipsMark(spanned: readonly AdfNode[], index: number, mark: AdfMark, carries: Carries): boolean {
const node = spanned[index]
const kind = markSpelling(mark.type)?.kind
return node !== undefined && (kind === 'emphasis' || kind === 'link') && !carries(node, index) && !takesMark(node, mark.type) && !holdsEmptyMarks(node)
}
// An empty marks array is spelled `marks=empty`, which a lent mark would erase.
function holdsEmptyMarks(node: AdfNode): boolean {
return Array.isArray(node.marks) && node.marks.length === 0
}
function withMark(node: AdfNode, mark: AdfMark, depth: number): AdfNode {
const marks = nodeMarks(node)
return { ...node, marks: [...marks.slice(0, depth), mark, ...marks.slice(depth)] }
}
+3 -4
View File
@@ -9,8 +9,7 @@ import { insideRun, isEmphasis, type RunNode } from '../mark-spellings.ts'
import { joinsWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts'
import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts'
import { spanSkippedNodes } from '../emit/skipped-nodes.ts'
import { portableLineFallback, readBackPasses, spannedNodes, type PortableLineFallback } from '../emit/inline-line.ts'
import { spellDestination } from '../commonmark/link-syntax.ts'
const highlight = 'backgroundColor'
@@ -166,8 +165,8 @@ export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
// Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark.
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal))
// Whitespace beside a code span inside a run keeps the run's mark, since the writer spans the run across the code.
const spanned = spanSkippedNodes(merged, () => false)
// Judged beside the spanned line's nodes, so whitespace beside a code span inside a run keeps the run's mark.
const spanned = spannedNodes(merged, new Set()) ?? merged
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {