diff --git a/src/comparison/comparisonAlignment.ts b/src/comparison/comparisonAlignment.ts new file mode 100644 index 00000000000..40b4747fa4e --- /dev/null +++ b/src/comparison/comparisonAlignment.ts @@ -0,0 +1,392 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonCoarseReason as CoarseReason } from './markdownComparisonTypes.ts' + +export const DEFAULT_COMPARISON_CELL_LEDGER = 40_000 +export const DEFAULT_COMPARISON_TOKEN_LEDGER = 84_000_000 + +export interface ComparisonWorkLedger { + remainingCells: number + remainingTokenComparisons: number +} + +export interface ComparisonAlignmentOptions { + work: ComparisonWorkLedger + fingerprint: (item: T) => string + profile: (item: T) => readonly string[] + compatible: (before: T, after: T) => boolean +} + +export interface ComparisonAlignmentStep { + before: number | null + after: number | null +} + +export interface ComparisonCoarseAlignmentRegion { + before: { from: number, to: number } + after: { from: number, to: number } + coarseReason: CoarseReason +} + +export type ComparisonAlignmentRegion = ComparisonAlignmentStep | ComparisonCoarseAlignmentRegion + +export interface ExactComparisonPair { + before: number + after: number +} +type Options = ComparisonAlignmentOptions +type Step = ComparisonAlignmentStep +type Region = ComparisonAlignmentRegion +type Pair = ExactComparisonPair +type Ledger = ComparisonWorkLedger + +export function createComparisonWorkLedger(): Ledger { + return { + remainingCells: DEFAULT_COMPARISON_CELL_LEDGER, + remainingTokenComparisons: DEFAULT_COMPARISON_TOKEN_LEDGER, + } +} + +export function forcedIncreasingPairs(pairs: readonly Pair[]): readonly Pair[] { + if (pairs.length < 2) { + return pairs + } + const ordered = pairs.toSorted((a, b) => a.before - b.before || a.after - b.after) + const left = increasingSubsequence(ordered.map(({ after }) => after)).lengths + const right = increasingSubsequence(ordered.toReversed().map(({ after }) => -after)).lengths.toReversed() + let maximum = 0 + for (const length of left) { + if (length > maximum) { + maximum = length + } + } + const candidatesPerLevel = new Uint32Array(maximum + 1) + for (let index = 0; index < ordered.length; index++) { + if (left[index]! + right[index]! - 1 === maximum) { + candidatesPerLevel[left[index]!]++ + } + } + return ordered.filter((_pair, index) => left[index]! + right[index]! - 1 === maximum + && candidatesPerLevel[left[index]!] === 1) +} + +export function increasingSubsequence(values: readonly number[]) { + const tails: number[] = [] + const previous = new Int32Array(values.length).fill(-1) + const lengths = values.map((value, candidate) => { + let low = 0 + let high = tails.length + while (low < high) { + const middle = (low + high) >>> 1 + if (values[tails[middle]!]! < value) { + low = middle + 1 + } else { + high = middle + } + } + if (low > 0) { + previous[candidate] = tails[low - 1]! + } + tails[low] = candidate + return low + 1 + }) + const indices: number[] = [] + for (let index = tails.at(-1) ?? -1; index >= 0; index = previous[index]!) { + indices.push(index) + } + return { lengths, indices: indices.reverse() } +} + +export function alignComparisonAxis(before: readonly T[], after: readonly T[], options: Options): readonly Region[] { + const beforeKeys = before.map(options.fingerprint) + const afterKeys = after.map(options.fingerprint) + return equalAxis(beforeKeys, afterKeys) + ?? planAxis(before, after, beforeKeys, afterKeys, options, uniqueExactPairs(beforeKeys, afterKeys), true) +} + +export function alignComparisonColumns(before: readonly T[], after: readonly T[], options: Options): readonly Region[] { + const beforeKeys = before.map(options.fingerprint) + const afterKeys = after.map(options.fingerprint) + return equalAxis(beforeKeys, afterKeys) + ?? planAxis(before, after, beforeKeys, afterKeys, options, rankedExactPairs(beforeKeys, afterKeys), false) +} + +function equalAxis(beforeKeys: readonly string[], afterKeys: readonly string[]) { + if (beforeKeys.length !== afterKeys.length + || beforeKeys.some((key, index) => key !== afterKeys[index])) { + return null + } + return beforeKeys.map((_key, index) => ({ before: index, after: index })) +} + +function planAxis(before: readonly T[], after: readonly T[], beforeKeys: readonly string[], afterKeys: readonly string[], options: Options, exactPairs: readonly Pair[], trimEdges: boolean): readonly Region[] { + const regions: Region[] = [] + let beforeStart = 0 + let afterStart = 0 + for (const anchor of [...forcedIncreasingPairs(exactPairs), { before: before.length, after: after.length }]) { + let beforeEnd = anchor.before + let afterEnd = anchor.after + const suffix: Step[] = [] + if (trimEdges) { + while (beforeStart < beforeEnd && afterStart < afterEnd + && beforeKeys[beforeStart] === afterKeys[afterStart]) { + regions.push({ before: beforeStart++, after: afterStart++ }) + } + while (beforeStart < beforeEnd && afterStart < afterEnd + && beforeKeys[beforeEnd - 1] === afterKeys[afterEnd - 1]) { + suffix.unshift({ before: --beforeEnd, after: --afterEnd }) + } + } + if (beforeEnd - beforeStart === 1 && afterEnd - afterStart === 1) { + if (options.compatible(before[beforeStart]!, after[afterStart]!)) { + regions.push({ before: beforeStart, after: afterStart }) + } else { + regions.push({ before: beforeStart, after: null }) + regions.push({ before: null, after: afterStart }) + } + beforeStart++ + afterStart++ + } else if (beforeStart < beforeEnd && afterStart < afterEnd) { + const solved = solveWeightedGap( + before.slice(beforeStart, beforeEnd), + after.slice(afterStart, afterEnd), + options, + ) + if ('coarseReason' in solved) { + regions.push({ + before: { from: beforeStart, to: beforeEnd }, + after: { from: afterStart, to: afterEnd }, + coarseReason: solved.coarseReason, + }) + } else { + regions.push(...solved.steps.map((step) => ({ + before: step.before === null ? null : step.before + beforeStart, + after: step.after === null ? null : step.after + afterStart, + }))) + } + beforeStart = beforeEnd + afterStart = afterEnd + } + for (let index = beforeStart; index < beforeEnd; index++) { + regions.push({ before: index, after: null }) + } + for (let index = afterStart; index < afterEnd; index++) { + regions.push({ before: null, after: index }) + } + regions.push(...suffix) + if (anchor.before < before.length) { + regions.push(anchor) + } + beforeStart = anchor.before + 1 + afterStart = anchor.after + 1 + } + return regions +} + +function uniqueExactPairs(beforeKeys: readonly string[], afterKeys: readonly string[]) { + const beforeIndices = groupIndices(beforeKeys) + const afterIndices = groupIndices(afterKeys) + return beforeKeys.flatMap((key, index) => ( + beforeIndices.get(key)!.length === 1 && afterIndices.get(key)?.length === 1 + ? [{ before: index, after: afterIndices.get(key)![0]! }] + : [] + )) +} + +function uniqueCompatiblePairs(before: readonly T[], after: readonly T[], compatible: (before: T, after: T) => boolean) { + const afterMatches = before.map((item) => after + .map((candidate, index) => compatible(item, candidate) ? index : -1) + .filter((index) => index >= 0)) + const beforeMatches = after.map((item) => before + .map((candidate, index) => compatible(candidate, item) ? index : -1) + .filter((index) => index >= 0)) + return afterMatches.flatMap((matches, beforeIndex) => { + const afterIndex = matches[0] + return matches.length === 1 && beforeMatches[afterIndex!]?.length === 1 + ? [{ before: beforeIndex, after: afterIndex! }] + : [] + }) +} + +function rankedExactPairs(beforeKeys: readonly string[], afterKeys: readonly string[]) { + const beforeIndices = groupIndices(beforeKeys) + const afterIndices = groupIndices(afterKeys) + return [...beforeIndices].flatMap(([key, indices]) => { + const matches = afterIndices.get(key) + return matches?.length === indices.length + ? indices.map((before, rank) => ({ before, after: matches[rank]! })) + : [] + }) +} + +function groupIndices(keys: readonly string[]) { + const grouped = new Map() + for (const [index, key] of keys.entries()) { + const indices = grouped.get(key) + if (indices) { + indices.push(index) + } else { + grouped.set(key, [index]) + } + } + return grouped +} + +interface RationalScore { + numerator: bigint + denominator: bigint +} + +interface AlignmentState { + score: RationalScore + signatures: readonly number[] +} + +export function solveWeightedGap(before: readonly T[], after: readonly T[], options: Options): { steps: readonly Step[] } | { coarseReason: CoarseReason } { + const cellCharge = before.length * after.length + if (cellCharge > options.work.remainingCells) { + return { coarseReason: 'comparison-limit' } + } + const beforeProfiles = before.map(options.profile) + const afterProfiles = after.map(options.profile) + const tokenCharge = weightedTokenCharge(beforeProfiles, afterProfiles) + if (tokenCharge > BigInt(options.work.remainingTokenComparisons)) { + return { coarseReason: 'comparison-limit' } + } + options.work.remainingCells -= cellCharge + options.work.remainingTokenComparisons -= Number(tokenCharge) + const structuralPairs = uniqueCompatiblePairs(before, after, options.compatible) + if (before.length === after.length + && structuralPairs.length === before.length + && structuralPairs.every((pair, index) => pair.before === index && pair.after === index)) { + return { steps: alignmentSteps(before.length, after.length, structuralPairs) } + } + + const parents = [-1] + const beforeOf = [-1] + const afterOf = [-1] + const zero: AlignmentState = { + score: { numerator: 0n, denominator: 1n }, + signatures: [0], + } + let previous = Array.from({ length: after.length + 1 }).fill(zero) + for (let beforeIndex = 1; beforeIndex <= before.length; beforeIndex++) { + const current = Array.from({ length: after.length + 1 }) + current[0] = zero + for (let afterIndex = 1; afterIndex <= after.length; afterIndex++) { + let best = betterState(previous[afterIndex]!, current[afterIndex - 1]!) + const beforeItem = before[beforeIndex - 1]! + const afterItem = after[afterIndex - 1]! + if (options.compatible(beforeItem, afterItem)) { + const source = previous[afterIndex - 1]! + best = betterState(best, { + score: addScores(source.score, pairScore( + beforeProfiles[beforeIndex - 1]!, + afterProfiles[afterIndex - 1]!, + options.fingerprint(beforeItem) === options.fingerprint(afterItem), + )), + signatures: source.signatures.map((parent) => { + parents.push(parent) + beforeOf.push(beforeIndex - 1) + afterOf.push(afterIndex - 1) + return parents.length - 1 + }), + }) + } + current[afterIndex] = best + } + previous = current + } + + const signatures = previous[after.length]!.signatures + if (signatures.length > 1) { + return { coarseReason: 'ambiguous-attribution' } + } + const matches: Pair[] = [] + for (let id = signatures[0]!; id > 0; id = parents[id]!) { + matches.push({ before: beforeOf[id]!, after: afterOf[id]! }) + } + return { steps: alignmentSteps(before.length, after.length, matches.reverse()) } +} + +function betterState(a: AlignmentState, b: AlignmentState): AlignmentState { + const order = compareScores(a.score, b.score) + if (order > 0) { + return a + } + if (order < 0) { + return b + } + return { + score: a.score, + signatures: [...new Set([...a.signatures, ...b.signatures])].slice(0, 2), + } +} + +function weightedTokenCharge(before: readonly (readonly string[])[], after: readonly (readonly string[])[]) { + let charge = 0n + for (const beforeProfile of before) { + for (const afterProfile of after) { + charge += BigInt(Math.min(beforeProfile.length, afterProfile.length)) + } + } + return charge * 2n +} + +function pairScore(before: readonly string[], after: readonly string[], exact: boolean): RationalScore { + if (exact) { + return { numerator: 3n, denominator: 1n } + } + let prefix = 0 + while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) { + prefix++ + } + let suffix = 0 + const maximumSuffix = Math.min(before.length, after.length) - prefix + while (suffix < maximumSuffix + && before[before.length - suffix - 1] === after[after.length - suffix - 1]) { + suffix++ + } + const denominator = BigInt(Math.max(before.length, after.length, 1)) + return { + numerator: denominator + BigInt(prefix + suffix), + denominator, + } +} + +function addScores(a: RationalScore, b: RationalScore): RationalScore { + return { + numerator: a.numerator * b.denominator + b.numerator * a.denominator, + denominator: a.denominator * b.denominator, + } +} + +function compareScores(a: RationalScore, b: RationalScore) { + const difference = a.numerator * b.denominator - b.numerator * a.denominator + return difference < 0n ? -1 : difference > 0n ? 1 : 0 +} + +function alignmentSteps(beforeCount: number, afterCount: number, matches: readonly Pair[]) { + const steps: Step[] = [] + let beforeIndex = 0 + let afterIndex = 0 + for (const match of matches) { + while (beforeIndex < match.before) { + steps.push({ before: beforeIndex++, after: null }) + } + while (afterIndex < match.after) { + steps.push({ before: null, after: afterIndex++ }) + } + steps.push({ before: beforeIndex++, after: afterIndex++ }) + } + while (beforeIndex < beforeCount) { + steps.push({ before: beforeIndex++, after: null }) + } + while (afterIndex < afterCount) { + steps.push({ before: null, after: afterIndex++ }) + } + return steps +} diff --git a/src/comparison/comparisonDocumentIndex.ts b/src/comparison/comparisonDocumentIndex.ts new file mode 100644 index 00000000000..6a8383714c9 --- /dev/null +++ b/src/comparison/comparisonDocumentIndex.ts @@ -0,0 +1,169 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonRange as Range } from './markdownComparisonTypes.ts' + +export interface LocatedComparisonNode { + node: Node + path: readonly number[] + index: number + from: number + to: number + parent: Location | null + children: readonly Location[] +} +type Location = LocatedComparisonNode + +export interface ComparisonDocumentIndex { + children: readonly Location[] + nodeAtPath: (path: readonly number[]) => Location +} + +interface Mutable extends Omit { + children: Mutable[] +} + +const minimalRootsCache = new WeakMap() + +export function createComparisonDocumentIndex(doc: Node): ComparisonDocumentIndex { + const byPath = new Map() + const locateChildren = ( + parentNode: Node, + parentLocation: Mutable | null, + parentPath: readonly number[], + contentFrom: number, + ) => { + const children: Mutable[] = [] + parentNode.forEach((node, offset, index) => { + const from = contentFrom + offset + const path = [...parentPath, index] + const location: Mutable = { + node, + path, + index, + from, + to: from + node.nodeSize, + parent: parentLocation, + children: [], + } + children.push(location) + byPath.set(pathKey(path), location) + if (!node.isLeaf) { + location.children = locateChildren(node, location, path, from + 1) + } + }) + return children + } + const children = locateChildren(doc, null, [], 0) + return { + children, + nodeAtPath(path) { + const location = byPath.get(pathKey(path)) + if (!location) { + throw new Error(`Comparison document path does not exist: ${path.join('.')}`) + } + return location + }, + } +} + +export function findComparisonNodes(range: Range, roots: readonly Location[]) { + const found = new Map() + const add = (location: Location) => found.set(pathKey(location.path), location) + const visit = (location: Location) => { + if (!touches(location, range)) { + return + } + add(location) + visitChildren(location.children, range, visit) + } + visitChildren(minimalRoots(roots), range, (root) => { + for (let ancestor = root.parent; ancestor; ancestor = ancestor.parent) { + add(ancestor) + } + visit(root) + }) + return [...found.values()].toSorted((a, b) => a.from - b.from || a.path.length - b.path.length) +} + +export function comparisonRangeText(range: Range, roots: readonly Location[]) { + if (range.from === range.to) { + return '' + } + return minimalRoots(roots) + .filter((root) => touches(root, range)) + .map((root) => textFromRoot(root, range)) + .join('\n') +} + +function visitChildren(children: readonly Location[], range: Range, visit: (location: Location) => void) { + let lower = 0 + let upper = children.length + while (lower < upper) { + const middle = (lower + upper) >>> 1 + const beforeRange = range.from === range.to + ? children[middle]!.to < range.from + : children[middle]!.to <= range.from + if (beforeRange) { + lower = middle + 1 + } else { + upper = middle + } + } + for (let index = lower; index < children.length; index++) { + const child = children[index]! + if (range.from === range.to ? child.from > range.from : child.from >= range.to) { + break + } + visit(child) + } +} + +function touches(location: Location, range: Range) { + return range.from === range.to + ? range.from >= location.from && range.from <= location.to + : range.from < location.to && range.to > location.from +} + +function minimalRoots(roots: readonly Location[]) { + const cached = minimalRootsCache.get(roots) + if (cached) { + return cached + } + const rootPaths = new Set(roots.map(({ path }) => pathKey(path))) + const minimal = roots.filter((root) => { + for (let ancestor = root.parent; ancestor; ancestor = ancestor.parent) { + if (rootPaths.has(pathKey(ancestor.path))) { + return false + } + } + return true + }) + minimalRootsCache.set(roots, minimal) + return minimal +} + +function textFromRoot(root: Location, range: Range) { + if (root.node.isText) { + return root.node.text?.slice( + Math.max(0, range.from - root.from), + Math.min(root.node.nodeSize, range.to - root.from), + ) ?? '' + } + if (root.node.isLeaf) { + return '\ufffc' + } + const contentFrom = root.from + 1 + return root.node.textBetween( + Math.max(0, range.from - contentFrom), + Math.min(root.node.content.size, range.to - contentFrom), + '\n', + '\ufffc', + ) +} +function pathKey(path: readonly number[]) { + return path.join('.') +} diff --git a/src/comparison/createComparisonEditor.ts b/src/comparison/createComparisonEditor.ts new file mode 100644 index 00000000000..9adff9bcd34 --- /dev/null +++ b/src/comparison/createComparisonEditor.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Schema } from '@tiptap/pm/model' + +import { Editor } from '@tiptap/vue-3' +import { renderEditorContent } from '../composables/useEditorMethods.ts' +import RichText from '../extensions/RichText.ts' + +interface ComparisonEditorOptions { + ariaLabel?: string + filePath?: string + noLazyImages?: boolean + openLink?: (href: string) => void + schema?: Schema +} + +export function createComparisonEditor(content: string, options: ComparisonEditorOptions = {}) { + if (typeof content !== 'string') { + throw new TypeError('Comparison content must be a string') + } + const editor = new Editor({ + content: renderEditorContent(content, true), + editable: false, + editorProps: options.ariaLabel ? { attributes: { 'aria-label': options.ariaLabel } } : {}, + extensions: [RichText.configure({ + editing: false, + extensions: [], + isEmbedded: true, + noLazyImages: options.noLazyImages ?? false, + openLink: options.openLink, + relativePath: options.filePath, + })], + onBeforeCreate: ({ editor }) => { + if (options.schema) { + editor.schema = options.schema + editor.extensionManager.schema = options.schema + } + }, + }) + return editor +} diff --git a/src/comparison/hierarchicalMarkdownComparisonModel.ts b/src/comparison/hierarchicalMarkdownComparisonModel.ts new file mode 100644 index 00000000000..3f9122bfaa0 --- /dev/null +++ b/src/comparison/hierarchicalMarkdownComparisonModel.ts @@ -0,0 +1,842 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonWorkLedger as Ledger, ComparisonAlignmentOptions as Options, ComparisonAlignmentRegion as Region, ComparisonAlignmentStep as Step } from './comparisonAlignment.ts' +import type { ComparisonDocumentIndex as DocumentIndex, LocatedComparisonNode as Location } from './comparisonDocumentIndex.ts' +import type { ReservedExactMovePair as MovePair } from './markdownComparisonMoves.ts' +import type { ComparisonAttributeCode as Attr, ComparisonDescriptor as Descriptor, ComparisonEdit as Edit, ComparisonEditKind as EditKind, MarkdownComparisonModel as Model, ComparisonRange as Range, ComparisonCoarseReason as Reason, ComparisonSide as Side } from './markdownComparisonTypes.ts' + +import { ChangeSet, simplifyChanges } from '@tiptap/pm/changeset' +import { StepMap } from '@tiptap/pm/transform' +import { alignComparisonAxis as alignAxis, alignComparisonColumns as alignColumns, createComparisonWorkLedger as createLedger } from './comparisonAlignment.ts' +import { createComparisonDocumentIndex as indexDocument, comparisonRangeText as rangeText } from './comparisonDocumentIndex.ts' +import { classifyComparisonDescriptor as classify, classifyNodeMarkupDescriptor as classifyMarkup, compareCodeUnits, deepFreeze, nodeFingerprint as nodeKey, semanticTokenEncoder, stableSerialize as serialize } from './markdownComparisonClassification.ts' +import { confirmReservedExactMoves as confirmMoves } from './markdownComparisonMoves.ts' + +export const MAX_INLINE_ENVELOPE_SIZE = 4500 +export const MAX_RENDERED_COMPARISON_DESCRIPTORS = 10_000 +export const MAX_TABLE_ROWS = 512 +export const MAX_TABLE_PHYSICAL_CELLS = 10_000 + +export class ComparisonModelLimitError extends Error { + constructor() { + super('Rendered comparison change limit reached') + this.name = 'ComparisonModelLimitError' + } +} +interface ComparisonModelOptions { + maximumDescriptors?: number +} +type PendingEdit = Omit + +interface Builder { + originalBefore: Node + originalAfter: Node + originalBeforeIndex: DocumentIndex + originalAfterIndex: DocumentIndex + comparisonBefore: Node + edits: PendingEdit[] + descriptorCount: number + maximumDescriptors: number + work: Ledger +} +interface AxisEntry { + before: readonly Location[] + after: readonly Location[] + coarseReason?: Reason +} +interface TableRow { + location: Location + kind: 'header' | 'body' + cells: readonly Location[] +} +interface RowPair { + before: TableRow + after: TableRow + slot: number +} +interface Column { + index: number + fingerprint: string + profile: () => readonly string[] + cells: ReadonlyMap +} +interface AxisNode { + location: Location + fingerprint: string + profile: () => readonly string[] +} +const NODE_TOKEN = '\u0000' +const CHARACTER_TOKEN = '\u0001' +const PAIR_COUNT_TOKEN = '\u0002' +const ORDINAL_TOKEN = '\u0003' +const ROW_KIND_TOKEN = '\u0004' +const COLUMN_TOKEN = '\u0005' +const ABSENT_CELL_TOKEN = '\u0006' + +const profileCache = new WeakMap() + +export function createHierarchicalMarkdownComparisonModel(originalBefore: Node, originalAfter: Node, options: ComparisonModelOptions = {}): Model { + const comparisonBefore = normalizeSchema(originalBefore, originalAfter) + const originalBeforeIndex = indexDocument(originalBefore) + const originalAfterIndex = indexDocument(originalAfter) + const comparisonBeforeIndex = comparisonBefore === originalBefore + ? originalBeforeIndex + : indexDocument(comparisonBefore) + const builder: Builder = { + originalBefore, + originalAfter, + originalBeforeIndex, + originalAfterIndex, + comparisonBefore, + edits: [], + descriptorCount: 0, + maximumDescriptors: options.maximumDescriptors ?? MAX_RENDERED_COMPARISON_DESCRIPTORS, + work: createLedger(), + } + compareSiblingAxis( + builder, + comparisonBeforeIndex.children, + originalAfterIndex.children, + 0, + comparisonBefore.content.size, + 0, + originalAfter.content.size, + true, + ) + + return deepFreeze({ edits: finalizeEdits(builder.edits) }) +} +function normalizeSchema(before: Node, after: Node) { + if (before.type.schema === after.type.schema) { + return before + } + const rebuilt = after.type.schema.nodeFromJSON(before.toJSON()) + if (rebuilt.nodeSize !== before.nodeSize) { + throw new Error('Markdown comparison schema normalization changed document positions') + } + if (rebuilt.textBetween(0, rebuilt.content.size, '\n', '\ufffc') + !== before.textBetween(0, before.content.size, '\n', '\ufffc')) { + throw new Error('Markdown comparison schema normalization changed document content') + } + if (serialize(rebuilt.toJSON()) !== serialize(before.toJSON())) { + throw new Error('Markdown comparison schema normalization lost semantics') + } + return rebuilt +} +function compareSiblingAxis(builder: Builder, before: readonly Location[], after: readonly Location[], beforeStart: number, beforeEnd: number, afterStart: number, afterEnd: number, topLevel: boolean, excluded: readonly Attr[] = []) { + const entries = axisEntries(before, after, alignAxis(before, after, axisOptions(builder))) + const groups = topLevel ? confirmedMoveGroups(builder, entries) : [] + const moved = new Set(groups.flat().flatMap(({ before: a, after: b }) => [a, b])) + const missingBefore = precomputeMissing(entries, 'before', beforeStart, beforeEnd) + const missingAfter = precomputeMissing(entries, 'after', afterStart, afterEnd) + for (const [index, entry] of entries.entries()) { + if (entry.coarseReason) { + emitCoarse(builder, entry.before, entry.after, entry.coarseReason, excluded) + } else if (entry.before[0] && entry.after[0]) { + compareNodes(builder, entry.before[0], entry.after[0], true, excluded) + } else if (entry.before[0] && !moved.has(entry.before[0])) { + const missing = missingAfter[index]! + emitBlock(builder, entry.before[0], null, missing, excluded) + } else if (entry.after[0] && !moved.has(entry.after[0])) { + const missing = missingBefore[index]! + emitBlock(builder, null, entry.after[0], missing, excluded) + } + } + for (const group of groups) { + emitMove(builder, group) + } +} +function axisEntries(before: readonly Location[], after: readonly Location[], regions: readonly Region[]): readonly AxisEntry[] { + return regions.map((region) => { + if ('coarseReason' in region) { + return { + before: before.slice(region.before.from, region.before.to), + after: after.slice(region.after.from, region.after.to), + coarseReason: region.coarseReason, + } + } + return { + before: region.before === null ? [] : [before[region.before]!], + after: region.after === null ? [] : [after[region.after]!], + } + }) +} +function confirmedMoveGroups(builder: Builder, entries: readonly AxisEntry[]) { + const deleted = entries.filter((entry) => !entry.coarseReason && entry.before[0] && !entry.after[0]) + const inserted = entries.filter((entry) => !entry.coarseReason && entry.after[0] && !entry.before[0]) + const insertedByFingerprint = new Map(inserted.map((entry) => [ + nodeKey(entry.after[0]!.node), + entry.after[0]!, + ])) + const candidates: MovePair[] = [] + for (const entry of deleted) { + const fingerprint = nodeKey(entry.before[0]!.node) + const match = insertedByFingerprint.get(fingerprint) + if (match) { + candidates.push({ before: entry.before[0]!, after: match, fingerprint }) + } + } + const groups = confirmMoves(builder.comparisonBefore, builder.originalAfter, candidates).groups + const deletedNodes = new Map(deleted.map((entry) => [entry.before[0]!.index, entry.before[0]!])) + const insertedNodes = new Map(inserted.map((entry) => [entry.after[0]!.index, entry.after[0]!])) + return groups.map((group) => extendMovedHeadingGroup(group, deletedNodes, insertedNodes)) +} +function extendMovedHeadingGroup(group: readonly MovePair[], deletedNodes: ReadonlyMap, insertedNodes: ReadonlyMap) { + const first = group[0]! + if (first.before.node.type.name !== 'heading' || first.after.node.type.name !== 'heading') { + return [...group] + } + const extended = [...group] + let beforeIndex = group.at(-1)!.before.index + 1 + let afterIndex = group.at(-1)!.after.index + 1 + while (true) { + const before = deletedNodes.get(beforeIndex) + const after = insertedNodes.get(afterIndex) + if (!before || !after) { + break + } + if (before.node.type.name === 'heading' || after.node.type.name === 'heading' + || !before.node.eq(after.node)) { + break + } + extended.push({ before, after, fingerprint: nodeKey(before.node) }) + beforeIndex++ + afterIndex++ + } + return extended +} +function precomputeMissing(entries: readonly AxisEntry[], side: Side, start: number, end: number) { + const previousAt: Array = new Array(entries.length) + let previous: Location | null = null + for (const [index, entry] of entries.entries()) { + previousAt[index] = previous + previous = entry[side].at(-1) ?? previous + } + const locations = new Array(entries.length) + let next: Location | null = null + for (let index = entries.length - 1; index >= 0; index--) { + previous = previousAt[index]! + locations[index] = next?.from + ?? previous?.to + ?? Math.min(Math.max(start, 0), end) + next = entries[index]![side][0] ?? next + } + return locations +} +function compareNodes(builder: Builder, before: Location, after: Location, markup = true, excluded: readonly Attr[] = []) { + if (before.node.eq(after.node)) { + return + } + if (before.node.type.name === 'table' && after.node.type.name === 'table') { + compareTables(builder, before, after) + return + } + if (before.node.isTextblock && after.node.isTextblock) { + compareTextblocks(builder, before, after, markup, excluded) + return + } + if (before.node.isLeaf || after.node.isLeaf || before.node.isAtom || after.node.isAtom) { + emitBlock(builder, before, after) + return + } + const nestedExcludedAttributes = markup && !before.node.sameMarkup(after.node) + ? [...new Set([...excluded, ...emitMarkup(builder, before, after)])] + : excluded + compareSiblingAxis( + builder, + before.children, + after.children, + before.from + 1, + before.to - 1, + after.from + 1, + after.to - 1, + false, + nestedExcludedAttributes, + ) +} +function compareTextblocks(builder: Builder, before: Location, after: Location, markup = true, excluded: readonly Attr[] = []) { + const start = before.node.content.findDiffStart(after.node.content) + if (start === null) { + if (markup && !before.node.sameMarkup(after.node)) { + emitMarkup(builder, before, after) + } + return + } + const diffEnd = before.node.content.findDiffEnd(after.node.content) + if (!diffEnd) { + emitBlock(builder, before, after, 0, excluded) + return + } + let { a: endA, b: endB } = diffEnd + if (endA < start) { + endB += start - endA + endA = start + } + if (endB < start) { + endA += start - endB + endB = start + } + if ((endA - start) + (endB - start) > MAX_INLINE_ENVELOPE_SIZE) { + emitBlock(builder, before, after, 0, excluded) + return + } + const map = new StepMap([0, before.node.content.size, after.node.content.size]) + const changes = simplifyChanges( + ChangeSet.create(before.node, undefined, semanticTokenEncoder) + .addSteps(after.node, [map], null) + .changes, + after.node, + ) + const beforeRoot = originalLocation(builder, 'before', before) + const afterRoot = originalLocation(builder, 'after', after) + const inlineRanges = changes.map((change) => ({ + before: { from: before.from + 1 + change.fromA, to: before.from + 1 + change.toA }, + after: { from: after.from + 1 + change.fromB, to: after.from + 1 + change.toB }, + local: change, + })) + const valid = inlineRanges.length > 0 && inlineRanges.every(({ before: beforeRange, after: afterRange, local }) => ( + validLocalRange(local.fromA, local.toA, before.node.content.size) + && validLocalRange(local.fromB, local.toB, after.node.content.size) + && before.node.textBetween(local.fromA, local.toA, '\n', '\ufffc') + === rangeText(beforeRange, [beforeRoot]) + && after.node.textBetween(local.fromB, local.toB, '\n', '\ufffc') + === rangeText(afterRange, [afterRoot]) + )) + if (!valid) { + emitBlock(builder, before, after, 0, excluded) + return + } + const contentExcludedAttributes = markup && !before.node.sameMarkup(after.node) + ? [...new Set([...excluded, ...emitMarkup(builder, before, after)])] + : excluded + for (const { before: beforeRange, after: afterRange } of inlineRanges) { + pushContent(builder, descriptorFor( + builder, + beforeRange, + afterRange, + [before], + [after], + 'inline', + contentExcludedAttributes, + )) + } +} +function validLocalRange(from: number, to: number, maximum: number) { + return Number.isInteger(from) && Number.isInteger(to) && from >= 0 && to >= from && to <= maximum +} +function tableShape(table: Location) { + const rows: TableRow[] = [] + let physicalCells = 0 + let index = table.children[0]?.node.type.name === 'tableCaption' ? 1 : 0 + if (table.children[index]?.node.type.name !== 'tableHeadRow') { + return null + } + for (; index < table.children.length; index++) { + const child = table.children[index]! + const name = child.node.type.name + const header = rows.length === 0 + if ((header ? name !== 'tableHeadRow' : name !== 'tableRow') || child.children.length === 0) { + return null + } + const expectedCell = header ? 'tableHeader' : 'tableCell' + for (const cell of child.children) { + if (cell.node.type.name !== expectedCell + || (cell.node.attrs.colspan ?? 1) !== 1 + || (cell.node.attrs.rowspan ?? 1) !== 1) { + return null + } + } + physicalCells += child.children.length + rows.push({ + location: child, + kind: header ? 'header' : 'body', + cells: child.children, + }) + if (rows.length > MAX_TABLE_ROWS || physicalCells > MAX_TABLE_PHYSICAL_CELLS) { + return null + } + } + return rows +} +function compareTables(builder: Builder, before: Location, after: Location) { + const beforeRows = tableShape(before) + const afterRows = tableShape(after) + if (!beforeRows || !afterRows) { + emitCoarse(builder, [before], [after], 'unsupported-table') + return + } + const seedEntries = axisEntries( + before.children, + after.children, + alignAxis(before.children, after.children, axisOptions(builder)), + ) + const coarse = seedEntries.find(({ coarseReason }) => coarseReason) + if (coarse) { + emitCoarse(builder, [before], [after], coarse.coarseReason!) + return + } + const beforeRowOf = new Map(beforeRows.map((row) => [row.location, row])) + const afterRowOf = new Map(afterRows.map((row) => [row.location, row])) + const seedRowPairs = pairedRows(seedEntries, beforeRowOf, afterRowOf) + const seedPlan = tablePlan(builder, seedRowPairs, seedEntries.length) + if ('coarseReason' in seedPlan) { + emitCoarse(builder, [before], [after], seedPlan.coarseReason) + return + } + const { beforeCols: seedBeforeColumns, afterCols: seedAfterColumns, steps: seedSteps } = seedPlan + if (seedSteps.every((step) => step.before !== null && step.after !== null)) { + if (exactEvidenceConflict(seedBeforeColumns, seedAfterColumns, seedSteps)) { + emitCoarse(builder, [before], [after], 'table-evidence-conflict') + return + } + emitTablePlan( + builder, + before, + after, + seedEntries, + seedRowPairs, + seedBeforeColumns, + seedAfterColumns, + seedSteps, + ) + return + } + const beforeAxis = tableAxisRecords(before.children, beforeRowOf, seedSteps, 'before') + const afterAxis = tableAxisRecords(after.children, afterRowOf, seedSteps, 'after') + const entries = axisEntries( + before.children, + after.children, + alignAxis(beforeAxis, afterAxis, { + work: builder.work, + fingerprint: ({ fingerprint }) => fingerprint, + profile: ({ profile }) => profile(), + compatible: (left, right) => left.location.node.type === right.location.node.type, + }), + ) + const sharedCoarse = entries.find(({ coarseReason }) => coarseReason) + if (sharedCoarse) { + emitCoarse(builder, [before], [after], tableConflictReason(sharedCoarse.coarseReason!)) + return + } + const rowPairs = pairedRows(entries, beforeRowOf, afterRowOf) + const plan = tablePlan(builder, rowPairs, entries.length) + if ('coarseReason' in plan) { + emitCoarse(builder, [before], [after], tableConflictReason(plan.coarseReason)) + return + } + const { beforeCols, afterCols, steps } = plan + if (!sameSteps(seedSteps, steps) + || exactEvidenceConflict(beforeCols, afterCols, steps)) { + emitCoarse(builder, [before], [after], 'table-evidence-conflict') + return + } + emitTablePlan(builder, before, after, entries, rowPairs, beforeCols, afterCols, steps) +} +function tableConflictReason(reason: Reason): Reason { + return reason === 'comparison-limit' ? reason : 'table-evidence-conflict' +} +function pairedRows(entries: readonly AxisEntry[], beforeRows: ReadonlyMap, afterRows: ReadonlyMap) { + return entries.flatMap((entry, slot) => { + const before = entry.before[0] && beforeRows.get(entry.before[0]) + const after = entry.after[0] && afterRows.get(entry.after[0]) + return before && after ? [{ before, after, slot }] : [] + }) +} +function tablePlan(builder: Builder, rows: readonly RowPair[], slots: number) { + const beforeCols = columnRecords(rows, 'before', slots) + const afterCols = columnRecords(rows, 'after', slots) + const plan = tableColumnPlan(builder, beforeCols, afterCols) + return 'coarseReason' in plan ? plan : { beforeCols, afterCols, steps: plan.steps } +} +function tableColumnPlan(builder: Builder, beforeCols: readonly Column[], afterCols: readonly Column[]): { steps: readonly Step[] } | { coarseReason: Reason } { + const steps: Step[] = [] + for (const region of alignColumns(beforeCols, afterCols, { + work: builder.work, + fingerprint: ({ fingerprint }) => fingerprint, + profile: ({ profile }) => profile(), + compatible: () => true, + })) { + if ('coarseReason' in region) { + return { coarseReason: region.coarseReason } + } + steps.push(region) + } + return { steps } +} +function columnRecords(rowPairs: readonly RowPair[], side: Side, slotCount: number): readonly Column[] { + let width = 0 + for (const pair of rowPairs) { + const row = pair[side] + width = Math.max(width, row.cells.length) + } + const cellsByColumn = Array.from({ length: width }, () => new Map()) + const rowKinds = new Map() + for (const pair of rowPairs) { + const row = pair[side] + rowKinds.set(pair.slot, row.kind) + for (const [index, cell] of row.cells.entries()) { + cellsByColumn[index]!.set(pair.slot, cell) + } + } + return cellsByColumn.map((cells, index) => { + const fingerprint = [ + `${slotCount}`, + ...[...cells].map(([slot, cell]) => `${slot}:${rowKinds.get(slot)!}:${nodeKey(cell.node)}`), + ].join('|') + let materializedProfile: readonly string[] | undefined + const profile = () => materializedProfile ??= [ + `${PAIR_COUNT_TOKEN}${slotCount}`, + ...[...cells].flatMap(([slot, cell]) => [ + `${ORDINAL_TOKEN}${slot}`, + `${ROW_KIND_TOKEN}${rowKinds.get(slot)!}`, + ...[...cell.node.textContent.normalize('NFC')].map((character) => `${CHARACTER_TOKEN}${character}`), + ]), + ] + return { index, fingerprint, profile, cells } + }) +} +function tableAxisRecords(locations: readonly Location[], rowOf: ReadonlyMap, steps: readonly Step[], side: Side): readonly AxisNode[] { + const retainedColumns = steps.flatMap((step) => { + if (step.before === null || step.after === null) { + return [] + } + return [side === 'before' ? step.before : step.after] + }) + return locations.map((location) => { + const row = rowOf.get(location) + if (!row) { + return { + location, + fingerprint: nodeKey(location.node), + profile: () => nodeProfile(location.node), + } + } + const cells = retainedColumns.map((column) => row.cells[column]) + const fingerprint = serialize([ + row.kind, + ...cells.map((cell) => cell ? nodeKey(cell.node) : null), + ]) + let materializedProfile: readonly string[] | undefined + const profile = () => materializedProfile ??= [ + `${ROW_KIND_TOKEN}${row.kind}`, + ...cells.flatMap((cell, column) => [ + `${COLUMN_TOKEN}${column}`, + ...(cell ? nodeProfile(cell.node) : [ABSENT_CELL_TOKEN]), + ]), + ] + return { location, fingerprint, profile } + }) +} +function sameSteps(candidate: readonly Step[], refined: readonly Step[]) { + return candidate.length === refined.length + && candidate.every((step, index) => ( + step.before === refined[index]!.before && step.after === refined[index]!.after + )) +} +function exactEvidenceConflict(beforeCols: readonly Column[], afterCols: readonly Column[], steps: readonly Step[]) { + const unmatchedColumns = { before: [] as string[], after: [] as string[] } + const unmatchedCells = { before: [] as string[], after: [] as string[] } + for (const step of steps) { + const columns = [ + step.before === null ? null : beforeCols[step.before]!, + step.after === null ? null : afterCols[step.after]!, + ] as const + if (!columns[0] || !columns[1]) { + const side = columns[0] ? 'before' : 'after' + const column = columns[0] ?? columns[1]! + unmatchedColumns[side].push(column.fingerprint) + unmatchedCells[side].push(...[...column.cells.values()].map(({ node }) => nodeKey(node))) + continue + } + if (columns[0].fingerprint !== columns[1].fingerprint) { + unmatchedColumns.before.push(columns[0].fingerprint) + unmatchedColumns.after.push(columns[1].fingerprint) + } + for (const ordinal of new Set([...columns[0].cells.keys(), ...columns[1].cells.keys()])) { + const cells = [columns[0].cells.get(ordinal), columns[1].cells.get(ordinal)] as const + const fingerprints = cells.map((cell) => cell && nodeKey(cell.node)) + if (fingerprints[0] !== fingerprints[1]) { + if (fingerprints[0]) { + unmatchedCells.before.push(fingerprints[0]) + } + if (fingerprints[1]) { + unmatchedCells.after.push(fingerprints[1]) + } + } + } + } + return sharesValue(unmatchedColumns.before, unmatchedColumns.after) + || sharesValue(unmatchedCells.before, unmatchedCells.after) +} +function sharesValue(before: readonly string[], after: readonly string[]) { + const known = new Set(before) + return after.some((value) => known.has(value)) +} +function emitTablePlan(builder: Builder, before: Location, after: Location, entries: readonly AxisEntry[], rowPairs: readonly RowPair[], beforeCols: readonly Column[], afterCols: readonly Column[], steps: readonly Step[]) { + const pairedRows = new Set(rowPairs.map(({ before: row }) => row.location)) + const missingBefore = precomputeMissing(entries, 'before', before.from + 1, before.to - 1) + const missingAfter = precomputeMissing(entries, 'after', after.from + 1, after.to - 1) + for (const [index, entry] of entries.entries()) { + if (entry.before[0] && entry.after[0]) { + if (!pairedRows.has(entry.before[0])) { + compareNodes(builder, entry.before[0], entry.after[0]) + } + } else if (entry.before[0]) { + const missing = missingAfter[index]! + emitBlock(builder, entry.before[0], null, missing) + } else if (entry.after[0]) { + const missing = missingBefore[index]! + emitBlock(builder, null, entry.after[0], missing) + } + } + const slots = counterpartSlots(steps, beforeCols.length, afterCols.length) + for (const step of steps) { + if (step.before !== null && step.after !== null) { + comparePairedColumn(builder, rowPairs, beforeCols[step.before]!, afterCols[step.after]!) + } else if (step.before !== null) { + emitColumnEdit(builder, rowPairs, beforeCols[step.before]!, 'before', slots.after[step.before]!) + } else if (step.after !== null) { + emitColumnEdit(builder, rowPairs, afterCols[step.after]!, 'after', slots.before[step.after]!) + } + } +} +function counterpartSlots(steps: readonly Step[], beforeCount: number, afterCount: number) { + const after = new Array(beforeCount).fill(afterCount) + const before = new Array(afterCount).fill(beforeCount) + let pendingAfter = afterCount + let pendingBefore = beforeCount + for (const step of steps.toReversed()) { + if (step.after !== null) { + pendingAfter = step.after + } else if (step.before !== null) { + after[step.before] = pendingAfter + } + if (step.before !== null) { + pendingBefore = step.before + } else if (step.after !== null) { + before[step.after] = pendingBefore + } + } + return { before, after } +} +function comparePairedColumn(builder: Builder, rowPairs: readonly RowPair[], before: Column, after: Column) { + const pairedCells = rowPairs.flatMap((pair) => { + const beforeCell = before.cells.get(pair.slot) + const afterCell = after.cells.get(pair.slot) + return beforeCell && afterCell ? [{ pair, beforeCell, afterCell }] : [] + }) + const markup = pairedCells.flatMap(({ pair, beforeCell, afterCell }) => { + const descriptor = markupDescriptor(builder, beforeCell, afterCell) + return descriptor ? [{ descriptor, row: pair.before.kind }] : [] + }) + if (markup.length) { + const header = markup.findIndex(({ row }) => row === 'header') + pushEdit( + builder, + 'content', + markup[header < 0 ? 0 : header]!.descriptor, + markup.map(({ descriptor }) => descriptor), + ) + } + for (const pair of rowPairs) { + const beforeCell = before.cells.get(pair.slot) + const afterCell = after.cells.get(pair.slot) + if (beforeCell && afterCell) { + compareNodes(builder, beforeCell, afterCell, false, ['table-alignment']) + } else if (beforeCell) { + const slot = cellSlot(pair.after, after.index) + emitBlock(builder, beforeCell, null, slot) + } else if (afterCell) { + const slot = cellSlot(pair.before, before.index) + emitBlock(builder, null, afterCell, slot) + } + } +} +function emitColumnEdit(builder: Builder, rowPairs: readonly RowPair[], column: Column, side: Side, counterpart: number) { + const rowPairOf = new Map(rowPairs.map((pair) => [pair.slot, pair])) + const present = [...column.cells].flatMap(([slot, cell]) => { + const pair = rowPairOf.get(slot) + return pair ? [{ pair, cell }] : [] + }) + if (present.length === 0) { + return + } + const descriptors = present.map(({ pair, cell }) => { + const slot = cellSlot(side === 'before' ? pair.after : pair.before, counterpart) + return blockDescriptor( + builder, + side === 'before' ? cell : null, + side === 'before' ? null : cell, + slot, + ) + }) + const header = present.findIndex(({ pair }) => pair[side].kind === 'header') + pushEdit(builder, 'table-column', descriptors[header < 0 ? 0 : header]!, descriptors) +} +function cellSlot(row: TableRow, columnIndex: number) { + const cell = row.cells[columnIndex] + if (cell) { + return cell.from + } + return row.location.to - 1 +} +function emitMarkup(builder: Builder, before: Location, after: Location) { + const descriptor = markupDescriptor(builder, before, after) + pushContent(builder, descriptor) + return descriptor?.signals.flatMap((signal) => signal.type === 'attribute' ? [signal.attribute] : []) ?? [] +} +function emitBlock(builder: Builder, before: Location | null, after: Location | null, absentPosition = 0, excluded: readonly Attr[] = []) { + pushContent(builder, blockDescriptor(builder, before, after, absentPosition, excluded)) +} +function blockDescriptor(builder: Builder, before: Location | null, after: Location | null, absentPosition: number, excluded: readonly Attr[] = []) { + return descriptorFor( + builder, + before ? rangeFor(before) : emptyRange(absentPosition), + after ? rangeFor(after) : emptyRange(absentPosition), + before ? [before] : [], + after ? [after] : [], + 'block', + excluded, + ) +} +function emitCoarse(builder: Builder, before: readonly Location[], after: readonly Location[], coarseReason: Reason, excluded: readonly Attr[] = []) { + pushContent(builder, { + ...descriptorFor( + builder, + { from: before[0]!.from, to: before.at(-1)!.to }, + { from: after[0]!.from, to: after.at(-1)!.to }, + before, + after, + 'block', + excluded, + ), + coarseReason, + }) +} +function emitMove(builder: Builder, group: readonly MovePair[]) { + const first = group[0]! + const last = group.at(-1)! + pushContent(builder, { + ...descriptorFor( + builder, + { from: first.before.from, to: last.before.to }, + { from: first.after.from, to: last.after.to }, + group.map(({ before }) => before), + group.map(({ after }) => after), + 'block', + ), + operation: 'move', + facets: ['structure'], + signals: [{ type: 'node' }], + }) +} +function pushContent(builder: Builder, descriptor: Descriptor | null) { + if (descriptor) { + pushEdit(builder, 'content', descriptor, [descriptor]) + } +} +function pushEdit(builder: Builder, kind: EditKind, primary: Descriptor, descriptors: Descriptor[]) { + builder.descriptorCount += descriptors.length + if (builder.descriptorCount > builder.maximumDescriptors) { + throw new ComparisonModelLimitError() + } + builder.edits.push({ kind, primary, descriptors }) +} +function finalizeEdits(pending: readonly PendingEdit[]): readonly Edit[] { + let descriptorCount = 0 + return pending + .toSorted((a, b) => compareDescriptors(a.primary, b.primary)) + .map((edit, index) => { + const identified = new Map(edit.descriptors + .toSorted(compareDescriptors) + .map((descriptor) => [descriptor, { + ...descriptor, + id: `change-${(descriptorCount++).toString(36)}`, + }])) + return { + id: `edit-${index.toString(36)}`, + kind: edit.kind, + primary: identified.get(edit.primary)!, + descriptors: [...identified.values()], + } + }) +} +function compareDescriptors(a: Descriptor, b: Descriptor) { + return a.after.from - b.after.from + || a.before.from - b.before.from + || a.after.to - b.after.to + || a.before.to - b.before.to + || compareCodeUnits(a.operation, b.operation) +} +function axisOptions(builder: Builder): Options { + return { + work: builder.work, + fingerprint: ({ node }) => nodeKey(node), + profile: ({ node }) => nodeProfile(node), + compatible: (before, after) => before.node.type === after.node.type + || (before.node.isTextblock && after.node.isTextblock), + } +} +function nodeProfile(node: Node): readonly string[] { + const cached = profileCache.get(node) + if (cached !== undefined) { + return cached + } + const profile = node.isTextblock + ? [...node.textContent.normalize('NFC').replace(/\s+/gu, ' ').trim()] + : structuralTokens(node, []) + profileCache.set(node, profile) + return profile +} +function structuralTokens(node: Node, tokens: string[]) { + node.forEach((child) => { + if (child.isText) { + for (const character of (child.text ?? '').normalize('NFC')) { + tokens.push(`${CHARACTER_TOKEN}${character}`) + } + } else { + tokens.push(`${NODE_TOKEN}${child.type.name}`) + structuralTokens(child, tokens) + } + }) + return tokens +} +function descriptorFor(builder: Builder, before: Range, after: Range, beforeNodes: readonly Location[], afterNodes: readonly Location[], detail: Descriptor['detail'], excluded: readonly Attr[] = []) { + return classify( + builder.originalBefore, + builder.originalAfter, + before, + after, + originalLocations(builder, 'before', beforeNodes), + originalLocations(builder, 'after', afterNodes), + detail, + excluded, + ) +} +function markupDescriptor(builder: Builder, before: Location, after: Location) { + return classifyMarkup( + builder.originalBefore, + builder.originalAfter, + rangeFor(before), + rangeFor(after), + originalLocation(builder, 'before', before), + originalLocation(builder, 'after', after), + ) +} +function originalLocations(builder: Builder, side: Side, locations: readonly Location[]) { + return locations.map((location) => originalLocation(builder, side, location)) +} +function originalLocation(builder: Builder, side: Side, location: Location) { + return (side === 'before' ? builder.originalBeforeIndex : builder.originalAfterIndex) + .nodeAtPath(location.path) +} +function rangeFor(node: Location): Range { + return { from: node.from, to: node.to } +} +function emptyRange(position: number): Range { + return { from: position, to: position } +} diff --git a/src/comparison/markdownComparison.ts b/src/comparison/markdownComparison.ts new file mode 100644 index 00000000000..5f67df1f445 --- /dev/null +++ b/src/comparison/markdownComparison.ts @@ -0,0 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export { ComparisonModelLimitError, createHierarchicalMarkdownComparisonModel as createMarkdownComparisonModel } from './hierarchicalMarkdownComparisonModel.ts' +export type * from './markdownComparisonTypes.ts' diff --git a/src/comparison/markdownComparisonClassification.ts b/src/comparison/markdownComparisonClassification.ts new file mode 100644 index 00000000000..42f48c1902e --- /dev/null +++ b/src/comparison/markdownComparisonClassification.ts @@ -0,0 +1,540 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Mark, Node } from '@tiptap/pm/model' +import type { LocatedComparisonNode as Location } from './comparisonDocumentIndex.ts' +import type { ComparisonAttributeCode as Attr, ComparisonContext as Context, ComparisonContextCode as ContextCode, ComparisonContextLocation as ContextLocation, ComparisonDescriptor as Descriptor, ComparisonFacet as Facet, ComparisonMarkCode as MarkCode, ComparisonOperation as Operation, ComparisonPreviewAtom as Preview, ComparisonRange as Range, ComparisonSignal as Signal } from './markdownComparisonTypes.ts' + +import { getTextDirection } from '../extensions/TextDirection.ts' +import { findComparisonNodes as findNodes, comparisonRangeText as rangeText } from './comparisonDocumentIndex.ts' + +interface AttributeRecord { + nodeName: string + attribute: string + value: unknown + textContent: string +} +const marksCache = new WeakMap() +const fingerprints = new WeakMap() +const nodeShapeCache = new WeakMap() +const graphemeSegmenter = typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null + +const contextCodes: Record = { + frontMatter: 'front-matter', + paragraph: 'paragraph', + heading: 'heading', + bulletList: 'list-item', + orderedList: 'list-item', + taskList: 'list-item', + listItem: 'list-item', + taskItem: 'task', + table: 'table', + tableRow: 'table-row', + tableHeadRow: 'table-row', + tableCell: 'table-cell', + tableHeader: 'table-cell', + codeBlock: 'code-block', + blockquote: 'quote', + callout: 'callout', + details: 'details', + detailsContent: 'details', + detailsSummary: 'details', + footnotes: 'footnote', + footnote: 'footnote', + footnoteReference: 'footnote-reference', + image: 'image', + imageInline: 'image', + mention: 'mention', + inlineMath: 'mathematics', + blockMath: 'mathematics', + preview: 'preview', +} +const contextPriority: Record = { + 'footnote-reference': 100, + image: 95, + mention: 95, + mathematics: 95, + preview: 95, + footnote: 90, + 'table-cell': 85, + task: 80, + 'list-item': 75, + 'front-matter': 70, + 'code-block': 70, + callout: 65, + details: 65, + quote: 60, + heading: 50, + paragraph: 40, + 'table-row': 20, + table: 10, + unknown: 0, +} +const markCodes: Record = { + strong: 'bold', + em: 'italic', + strike: 'strike', + highlight: 'highlight', + underline: 'underline', + code: 'inline-code', +} +const meaningfulAttributes: Record> = { + heading: { level: 'heading-level' }, + orderedList: { start: 'list-start' }, + taskItem: { checked: 'task-state' }, + codeBlock: { language: 'code-language' }, + image: { src: 'image-target', alt: 'image-alt' }, + imageInline: { src: 'image-target', alt: 'image-alt' }, + mention: { id: 'mention-identity', label: 'mention-identity' }, + inlineMath: { latex: 'mathematics' }, + blockMath: { latex: 'mathematics' }, + preview: { href: 'preview-target' }, + footnoteReference: { referenceId: 'footnote-reference' }, + footnote: { referenceId: 'footnote-reference' }, + callout: { type: 'callout-type' }, + details: { open: 'details-state' }, + tableCell: { align: 'table-alignment', colspan: 'table-span', rowspan: 'table-span' }, + tableHeader: { align: 'table-alignment', colspan: 'table-span', rowspan: 'table-span' }, +} +function serialize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? String(value) + } + if (Array.isArray(value)) { + return `[${value.map(serialize).join(',')}]` + } + return `{${Object.entries(value) + .toSorted(([a], [b]) => compareCodeUnits(a, b)) + .map(([key, child]) => `${JSON.stringify(key)}:${serialize(child)}`) + .join(',')}}` +} +export { serialize as stableSerialize } + +function fingerprint(node: Node) { + let value = fingerprints.get(node) + if (value === undefined) { + value = serialize(node.toJSON()) + fingerprints.set(node, value) + } + return value +} +export { fingerprint as nodeFingerprint } + +export function compareCodeUnits(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0 +} +function encodeMarks(marks: readonly Mark[]) { + const cached = marksCache.get(marks) + if (cached !== undefined) { + return cached + } + const encoded = marks + .map((mark) => `${mark.type.name}:${serialize(mark.attrs)}`) + .toSorted() + .join('|') + marksCache.set(marks, encoded) + return encoded +} +export const semanticTokenEncoder = { + encodeCharacter(character: number, marks: readonly Mark[]) { + return `character:${character}:${encodeMarks(marks)}` + }, + encodeNodeStart(node: Node) { + return `node-start:${node.type.name}:${serialize(node.attrs)}:${encodeMarks(node.marks)}` + }, + encodeNodeEnd(node: Node) { + return `node-end:${node.type.name}` + }, + compareTokens(a: string, b: string) { + return a === b + }, +} +export function classifyComparisonDescriptor(beforeDoc: Node, afterDoc: Node, before: Range, after: Range, beforeRoots: readonly Location[], afterRoots: readonly Location[], detail: Descriptor['detail'] = 'inline', excluded: readonly Attr[] = []): Descriptor { + const safeBefore = boundedRange(before, beforeDoc.content.size) + const safeAfter = boundedRange(after, afterDoc.content.size) + const beforeNodes = findNodes(safeBefore, beforeRoots) + const afterNodes = findNodes(safeAfter, afterRoots) + const context: Context = { + before: resolveContext(beforeNodes, safeBefore), + after: resolveContext(afterNodes, safeAfter), + } + const facets = new Set() + const signals: Signal[] = [] + const beforeText = rangeText(safeBefore, beforeRoots) + const afterText = rangeText(safeAfter, afterRoots) + + if (beforeText !== afterText) { + facets.add('text') + } + classifyMarks(beforeDoc, afterDoc, safeBefore, safeAfter, beforeNodes, afterNodes, facets, signals) + classifyNodes(beforeNodes, afterNodes, safeBefore, safeAfter, facets, signals) + classifyAttributes(beforeNodes, afterNodes, facets, signals, excluded) + + if (facets.size === 0) { + facets.add('unknown') + } + return { + id: '', + operation: operationFor(safeBefore, safeAfter), + detail, + facets: orderedFacets(facets), + before: safeBefore, + after: safeAfter, + context, + preview: { + before: previewAtom(safeBefore, beforeText, beforeNodes), + after: previewAtom(safeAfter, afterText, afterNodes), + }, + signals: deduplicateSignals(signals), + } +} +export function classifyNodeMarkupDescriptor(beforeDoc: Node, afterDoc: Node, before: Range, after: Range, beforeRoot: Location, afterRoot: Location): Descriptor | null { + const safeBefore = boundedRange(before, beforeDoc.content.size) + const safeAfter = boundedRange(after, afterDoc.content.size) + const beforeNodes = findNodes(safeBefore, [beforeRoot]) + const afterNodes = findNodes(safeAfter, [afterRoot]) + const facets = new Set() + const signals: Signal[] = [] + classifyDirectAttributes(beforeRoot.node, afterRoot.node, facets, signals) + classifyDirectMarks(beforeRoot.node, afterRoot.node, facets, signals) + if (beforeRoot.node.type.name !== afterRoot.node.type.name) { + facets.add('structure') + signals.push({ type: 'node' }) + } + if (facets.size === 0) { + return null + } + return { + id: '', + operation: 'replace', + detail: 'block', + facets: orderedFacets(facets), + before: safeBefore, + after: safeAfter, + context: { + before: resolveContext(beforeNodes, safeBefore), + after: resolveContext(afterNodes, safeAfter), + }, + preview: { + before: previewAtom(safeBefore, rangeText(safeBefore, [beforeRoot]), beforeNodes), + after: previewAtom(safeAfter, rangeText(safeAfter, [afterRoot]), afterNodes), + }, + signals: deduplicateSignals(signals), + } +} +function operationFor(before: Range, after: Range): Operation { + const beforeEmpty = before.from === before.to + const afterEmpty = after.from === after.to + return beforeEmpty !== afterEmpty ? (beforeEmpty ? 'insert' : 'delete') : 'replace' +} +function classifyMarks(beforeDoc: Node, afterDoc: Node, before: Range, after: Range, beforeNodes: readonly Location[], afterNodes: readonly Location[], facets: Set, signals: Signal[]) { + classifyMarkMaps( + collectMarks(beforeDoc, before, beforeNodes), + collectMarks(afterDoc, after, afterNodes), + false, + facets, + signals, + ) +} +function classifyMarkMaps(previous: ReadonlyMap, next: ReadonlyMap, direct: boolean, facets: Set, signals: Signal[]) { + const names = new Set([...previous.keys(), ...next.keys()]) + for (const name of [...names].toSorted()) { + const before = previous.get(name) + const after = next.get(name) + if (serialize(before) === serialize(after)) { + continue + } + const change = before === undefined ? 'added' : after === undefined ? 'removed' : 'changed' + if (name === 'link') { + facets.add('attribute') + signals.push({ + type: 'attribute', + attribute: direct || change === 'changed' ? 'link-target' : 'link', + change, + }) + } else if (markCodes[name]) { + facets.add('formatting') + signals.push({ type: 'mark', mark: markCodes[name], change }) + } else { + facets.add('unknown') + } + } +} +function classifyNodes(beforeNodes: readonly Location[], afterNodes: readonly Location[], before: Range, after: Range, facets: Set, signals: Signal[]) { + if (structuralShape(beforeNodes, before) === structuralShape(afterNodes, after)) { + return + } + facets.add('structure') + signals.push({ type: 'node' }) +} +function classifyAttributes(beforeNodes: readonly Location[], afterNodes: readonly Location[], facets: Set, signals: Signal[], excluded: readonly Attr[]) { + const previous = collectAttributes(beforeNodes) + const next = collectAttributes(afterNodes) + const keys = [...previous.keys()].filter((key) => next.has(key)).toSorted() + for (const key of keys) { + const before = previous.get(key)! + const after = next.get(key)! + if (serialize(before.value) === serialize(after.value)) { + continue + } + if (before.nodeName !== after.nodeName) { + continue + } + if (after.attribute === 'dir' && isInferredDirectionTransition( + before.value, + after.value, + before.textContent, + after.textContent, + )) { + continue + } + const code = attributeCode(after.nodeName, after.attribute) + if (code && excluded.includes(code)) { + continue + } + addAttributeSignal(code, 'changed', facets, signals) + } +} +function classifyDirectAttributes(before: Node, after: Node, facets: Set, signals: Signal[]) { + const names = new Set([...Object.keys(before.attrs), ...Object.keys(after.attrs)]) + for (const attribute of [...names].toSorted()) { + const previous = before.attrs[attribute] + const next = after.attrs[attribute] + if (serialize(previous) === serialize(next)) { + continue + } + if (attribute === 'dir' && isInferredDirectionTransition( + previous, + next, + before.textContent, + after.textContent, + )) { + continue + } + addAttributeSignal( + attributeCode(after.type.name, attribute), + previous === undefined ? 'added' : next === undefined ? 'removed' : 'changed', + facets, + signals, + ) + } +} +function attributeCode(nodeName: string, attribute: string) { + return attribute === 'dir' ? 'text-direction' : meaningfulAttributes[nodeName]?.[attribute] +} +function addAttributeSignal(code: Attr | undefined, change: 'added' | 'removed' | 'changed', facets: Set, signals: Signal[]) { + facets.add('attribute') + if (!code) { + facets.add('unknown') + } + signals.push({ type: 'attribute', attribute: code ?? 'unknown-attribute', change }) +} +function classifyDirectMarks(before: Node, after: Node, facets: Set, signals: Signal[]) { + const previous = new Map(before.marks.map((mark) => [mark.type.name, serialize(mark.attrs)])) + const next = new Map(after.marks.map((mark) => [mark.type.name, serialize(mark.attrs)])) + classifyMarkMaps(previous, next, true, facets, signals) +} +function collectMarks(doc: Node, range: Range, nodes: readonly Location[]) { + const marks = new Map() + const add = (mark: Mark) => { + const values = marks.get(mark.type.name) ?? [] + const encoded = serialize(mark.attrs) + if (!values.includes(encoded)) { + values.push(encoded) + values.sort() + } + marks.set(mark.type.name, values) + } + if (range.from === range.to) { + for (const mark of doc.resolve(range.from).marks()) { + add(mark) + } + } else { + for (const { node } of nodes) { + for (const mark of node.marks) { + add(mark) + } + } + } + return marks +} +function resolveContext(nodes: readonly Location[], range: Range): ContextLocation | null { + const candidate = contextCandidates(nodes).toSorted((a, b) => compareContextCandidates(a, b, range))[0] + if (!candidate) { + return nodes[0] + ? { + code: 'unknown', + path: nodes[0].path, + from: nodes[0].from, + to: nodes[0].to, + } + : null + } + return { + code: contextCodes[candidate.node.type.name]!, + path: candidate.path, + from: candidate.from, + to: candidate.to, + } +} +function contextCandidates(nodes: readonly Location[]) { + return nodes.filter(({ node }) => contextCodes[node.type.name] !== undefined) +} +function structuralShape(nodes: readonly Location[], range: Range) { + const contained = nodes.filter(({ node, from, to }) => !node.isText + && range.from <= from + && range.to >= to) + const containedNodes = new Set(contained) + const roots = contained.filter(({ parent }) => !parent || !containedNodes.has(parent)) + return roots.map(({ node }) => nodeShape(node)).join('|') +} +function isInferredDirectionTransition(before: unknown, after: unknown, beforeText: string, afterText: string) { + return (!before || !after) + && beforeText !== afterText + && before === getTextDirection(beforeText) + && after === getTextDirection(afterText) +} +function nodeShape(node: Node): string { + const cached = nodeShapeCache.get(node) + if (cached !== undefined) { + return cached + } + if (node.isText) { + return '' + } + const children: string[] = [] + node.forEach((child) => { + const shape = nodeShape(child) + if (shape && children.at(-1) !== shape) { + children.push(shape) + } + }) + const shape = `${node.type.name}(${children.join(',')})` + nodeShapeCache.set(node, shape) + return shape +} +function collectAttributes(nodes: readonly Location[]) { + const records = new Map() + const topLevelIndices = [...new Set(nodes.map(({ path }) => path[0]).filter((index) => index !== undefined))] + .toSorted((a, b) => a - b) + const topLevelOrder = new Map(topLevelIndices.map((index, order) => [index, order])) + for (const { node, path } of nodes) { + if (node.isText) { + continue + } + const relativePath = path.length + ? [topLevelOrder.get(path[0]!) ?? 0, ...path.slice(1)] + : [] + for (const [attribute, value] of Object.entries(node.attrs)) { + records.set(`${relativePath.join('.')}:${node.type.name}:${attribute}`, { + nodeName: node.type.name, + attribute, + value, + textContent: node.textContent, + }) + } + } + return records +} +function previewAtom(range: Range, rangeText: string, nodes: readonly Location[]): Preview | null { + if (range.from === range.to) { + return null + } + const text = frontMatterPreview(range, nodes) + || normalizePreview(rangeText.replaceAll('\ufffc', '')) + if (text) { + return { kind: 'text', text: truncateGraphemes(text, 96) } + } + const contextNode = contextCandidates(nodes).toSorted(compareContextCandidates)[0] ?? nodes[0] + const contextText = normalizePreview(contextNode?.node.textContent ?? '') + if (contextText) { + return { kind: 'text', text: truncateGraphemes(contextText, 96) } + } + const nodeName = contextNode?.node.type.name + const node = nodeName === 'frontMatter' + ? 'front-matter' + : nodeName === 'image' || nodeName === 'imageInline' + ? 'image' + : nodeName === 'mention' + ? 'mention' + : nodeName === 'inlineMath' || nodeName === 'blockMath' + ? 'mathematics' + : nodeName === 'footnoteReference' + ? 'footnote-reference' + : nodeName === 'horizontalRule' + ? 'horizontal-rule' + : 'changed-content' + return { kind: 'node', node } +} +function frontMatterPreview(range: Range, nodes: readonly Location[]) { + const frontMatter = nodes.find(({ node, from, to }) => ( + node.type.name === 'frontMatter' && range.from <= to && range.to >= from + )) + if (!frontMatter) { + return '' + } + const content = frontMatter.node.textContent + const contentStart = frontMatter.from + 1 + const from = clamp(range.from - contentStart, 0, content.length) + const to = clamp(range.to - contentStart, from, content.length) + const lineStart = content.lastIndexOf('\n', Math.max(0, from - 1)) + 1 + const nextBreak = content.indexOf('\n', to) + return normalizePreview(content.slice(lineStart, nextBreak < 0 ? content.length : nextBreak)) +} +function compareContextCandidates(a: Location, b: Location, range?: Range) { + const aCode = contextCodes[a.node.type.name]! + const bCode = contextCodes[b.node.type.name]! + return Number(coversRange(b, bCode, range)) - Number(coversRange(a, aCode, range)) + || contextPriority[bCode] - contextPriority[aCode] + || b.path.length - a.path.length + || a.from - b.from +} +function coversRange(location: Location, code: ContextCode, range: Range | undefined) { + return range !== undefined + && (code === 'table' || code === 'table-row') + && location.from === range.from + && location.to === range.to +} +function normalizePreview(value: string) { + return value.replace(/\s+/gu, ' ').trim() +} +export function truncateGraphemes(value: string, maximum: number) { + let count = 0 + let truncated = '' + const segments = graphemeSegmenter?.segment(value) ?? value + for (const item of segments) { + if (count++ === maximum) { + return `${truncated}…` + } + truncated += typeof item === 'string' ? item : item.segment + } + return value +} +function orderedFacets(facets: Set) { + const order: Facet[] = ['text', 'formatting', 'attribute', 'structure', 'unknown'] + return order.filter((facet) => facets.has(facet)) +} +function deduplicateSignals(signals: Signal[]) { + const byValue = new Map(signals.map((signal) => [serialize(signal), signal])) + return [...byValue.values()].toSorted((a, b) => compareCodeUnits(serialize(a), serialize(b))) +} +function boundedRange(range: Range, maximum: number) { + const from = clamp(range.from, 0, maximum) + return { from, to: clamp(range.to, from, maximum) } +} +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum) +} +export function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value) + for (const child of Object.values(value)) { + deepFreeze(child) + } + } + return value +} diff --git a/src/comparison/markdownComparisonMoves.ts b/src/comparison/markdownComparisonMoves.ts new file mode 100644 index 00000000000..c5eb8adb044 --- /dev/null +++ b/src/comparison/markdownComparisonMoves.ts @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { LocatedComparisonNode as Location } from './comparisonDocumentIndex.ts' + +import { nodeFingerprint as keyFor } from './markdownComparisonClassification.ts' + +export interface ReservedExactMovePair { + before: Location + after: Location + fingerprint: string +} +type Pair = ReservedExactMovePair + +export function confirmReservedExactMoves( + before: Node, + after: Node, + candidates: readonly Pair[], +) { + if (candidates.length === 0) { + return { groups: [] as Pair[][] } + } + const beforeCounts = documentFingerprintCounts(before) + const afterCounts = documentFingerprintCounts(after) + const confirmed = candidates.filter(({ fingerprint, before: beforeNode, after: afterNode }) => ( + beforeCounts.get(fingerprint) === 1 + && afterCounts.get(fingerprint) === 1 + && beforeNode.node.eq(afterNode.node) + )) + + const groups: Pair[][] = [] + for (const pair of confirmed.toSorted((a, b) => ( + a.before.index - b.before.index || a.after.index - b.after.index + ))) { + const previous = groups.at(-1)?.at(-1) + if (previous + && pair.before.index === previous.before.index + 1 + && pair.after.index === previous.after.index + 1) { + groups.at(-1)!.push(pair) + } else { + groups.push([pair]) + } + } + return { groups } +} + +function documentFingerprintCounts(doc: Node) { + const counts = new Map() + doc.descendants((node) => { + const fingerprint = keyFor(node) + counts.set(fingerprint, (counts.get(fingerprint) ?? 0) + 1) + }) + return counts +} diff --git a/src/comparison/markdownComparisonTypes.ts b/src/comparison/markdownComparisonTypes.ts new file mode 100644 index 00000000000..382e252d2a4 --- /dev/null +++ b/src/comparison/markdownComparisonTypes.ts @@ -0,0 +1,148 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type ComparisonOperation = 'insert' | 'delete' | 'replace' | 'move' + +export type ComparisonDetail = 'inline' | 'block' + +export type ComparisonFacet + = | 'text' + | 'formatting' + | 'attribute' + | 'structure' + | 'unknown' + +export interface ComparisonRange { + from: number + to: number +} + +export type ComparisonContextCode + = | 'front-matter' + | 'paragraph' + | 'heading' + | 'list-item' + | 'task' + | 'table' + | 'table-row' + | 'table-cell' + | 'code-block' + | 'quote' + | 'callout' + | 'details' + | 'footnote' + | 'footnote-reference' + | 'image' + | 'mention' + | 'mathematics' + | 'preview' + | 'unknown' + +export interface ComparisonContextLocation { + code: ComparisonContextCode + path: readonly number[] + from: number + to: number +} + +export interface ComparisonContext { + before: ComparisonContextLocation | null + after: ComparisonContextLocation | null +} + +export type ComparisonPreviewNode + = | 'front-matter' + | 'image' + | 'mention' + | 'mathematics' + | 'footnote-reference' + | 'horizontal-rule' + | 'changed-content' + +export type ComparisonPreviewAtom + = | { kind: 'text', text: string } + | { kind: 'node', node: ComparisonPreviewNode } + +export interface ComparisonPreview { + before: ComparisonPreviewAtom | null + after: ComparisonPreviewAtom | null +} + +export type ComparisonMarkCode + = | 'bold' + | 'italic' + | 'strike' + | 'highlight' + | 'underline' + | 'inline-code' + +export type ComparisonAttributeCode + = | 'link' + | 'link-target' + | 'heading-level' + | 'list-start' + | 'task-state' + | 'code-language' + | 'text-direction' + | 'image-target' + | 'image-alt' + | 'mention-identity' + | 'mathematics' + | 'preview-target' + | 'footnote-reference' + | 'callout-type' + | 'details-state' + | 'table-alignment' + | 'table-span' + | 'unknown-attribute' + +export type ComparisonSignal + = | { + type: 'mark' + mark: ComparisonMarkCode + change: 'added' | 'removed' | 'changed' + } + | { + type: 'attribute' + attribute: ComparisonAttributeCode + change: 'added' | 'removed' | 'changed' + } + | { + type: 'node' + } + +export type ComparisonCoarseReason + = | 'ambiguous-attribution' + | 'comparison-limit' + | 'table-evidence-conflict' + | 'unsupported-table' + +export interface ComparisonDescriptor { + id: string + operation: ComparisonOperation + detail: ComparisonDetail + facets: readonly ComparisonFacet[] + before: ComparisonRange + after: ComparisonRange + context: ComparisonContext + preview: ComparisonPreview + signals: readonly ComparisonSignal[] + coarseReason?: ComparisonCoarseReason +} + +export type ComparisonEditKind = 'content' | 'table-column' + +export interface ComparisonEdit { + id: string + kind: ComparisonEditKind + primary: ComparisonDescriptor + descriptors: readonly ComparisonDescriptor[] +} + +export interface MarkdownComparisonModel { + edits: readonly ComparisonEdit[] +} + +export type ComparisonSide = 'before' | 'after' diff --git a/src/composables/useEditorMethods.ts b/src/composables/useEditorMethods.ts index bee105461f7..7dfab0f7f7a 100644 --- a/src/composables/useEditorMethods.ts +++ b/src/composables/useEditorMethods.ts @@ -12,6 +12,12 @@ import Markdown from '../extensions/Markdown.js' import markdownit from '../markdownit/index.js' import { isUser } from '../services/SyncService.ts' +export function renderEditorContent(content: string, markdown: boolean) { + return markdown + ? markdownit.render(content) + '

' + : `

\n${escapeHtml(content)}
` +} + /** * * @param editor to apply methods to @@ -29,12 +35,9 @@ export function useEditorMethods(editor: Editor) { ) => void = (content, { addToHistory = true } = {}) => { const hasMarkdownContent = editor.extensionManager.extensions.includes(Markdown) - const html = hasMarkdownContent - ? markdownit.render(content) + '

' - : `

\n${escapeHtml(content)}
` editor .chain() - .setContent(html, { emitUpdate: addToHistory }) + .setContent(renderEditorContent(content, hasMarkdownContent), { emitUpdate: addToHistory }) .command(({ tr }) => { tr.setMeta('addToHistory', addToHistory) return true diff --git a/src/markdownit/details.ts b/src/markdownit/details.ts index fcfe0462a88..6a1f64214ea 100644 --- a/src/markdownit/details.ts +++ b/src/markdownit/details.ts @@ -7,9 +7,8 @@ import type MarkdownIt from 'markdown-it' import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs' import type Token from 'markdown-it/lib/token.mjs' -const DETAILS_START_REGEX = /^
\s*$/ -const DETAILS_AND_SUMMARY_START_REGEX - = /(?<=^
\s*).*(?=<\/summary>\s*$)/ +const DETAILS_START_REGEX = /^\s+open(?:=(?:""|''|open))?)?>\s*$/ +const DETAILS_AND_SUMMARY_START_REGEX = /^\s+open(?:=(?:""|''|open))?)?>\s*(?.*)<\/summary>\s*$/ const DETAILS_END_REGEX = /^<\/details>\s*$/ const SUMMARY_REGEX = /(?<=^).*(?=<\/summary>\s*$)/ @@ -32,16 +31,22 @@ function parseDetails( let detailsFound = false let detailsSummary = null + let openDetails: boolean let startLineCount = 2 - const m = state.src.slice(start, max).match(DETAILS_AND_SUMMARY_START_REGEX) - if (m) { + const openingLine = state.src.slice(start, max) + const combined = openingLine.match(DETAILS_AND_SUMMARY_START_REGEX) + if (combined) { // Details block start and summary in same line - detailsSummary = m[0].trim() + detailsSummary = combined.groups!.summary!.trim() + openDetails = Boolean(combined.groups!.open) startLineCount = 1 - } else if (!state.src.slice(start, max).match(DETAILS_START_REGEX)) { - // Details block start in separate line - return false + } else { + const opening = openingLine.match(DETAILS_START_REGEX) + if (!opening) { + return false + } + openDetails = Boolean(opening.groups!.open) } // Since start is found, we can report success here in validation mode @@ -105,6 +110,9 @@ function parseDetails( token.block = true token.info = detailsSummary token.map = [startLine, nextLine] + if (openDetails) { + token.attrSet('open', '') + } token = state.push('details_summary', 'summary', 1) token.block = false diff --git a/src/nodes/Details.js b/src/nodes/Details.js index 7b5f9073f8a..01b814f245c 100644 --- a/src/nodes/Details.js +++ b/src/nodes/Details.js @@ -67,6 +67,11 @@ const Details = Node.create({ openDetails: { default: false, }, + open: { + default: false, + parseHTML: (element) => element.hasAttribute('open'), + renderHTML: ({ open }) => open ? { open: '' } : {}, + }, } }, @@ -91,7 +96,7 @@ const Details = Node.create({ }, toMarkdown: (state, node) => { - state.write('
\n') + state.write(node.attrs.open ? '
\n' : '
\n') state.renderContent(node) state.closeBlock(node) state.ensureNewLine() diff --git a/src/tests/comparison/a15HistoryDifferential.spec.ts b/src/tests/comparison/a15HistoryDifferential.spec.ts new file mode 100644 index 00000000000..15e84bdb803 --- /dev/null +++ b/src/tests/comparison/a15HistoryDifferential.spec.ts @@ -0,0 +1,183 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +import { describe, expect, it } from 'vitest' +import { createComparisonEditor } from '../../comparison/createComparisonEditor.ts' +import { createHierarchicalMarkdownComparisonModel } from '../../comparison/hierarchicalMarkdownComparisonModel.ts' + +interface EditorLike { + state: { doc: ProseMirrorNode } + destroy: () => void +} + +interface ExpectedReplacement { + operation: string + detail: string + before: string + after: string +} + +interface RegressionCase { + name: string + before: string + after: string + expected: readonly ExpectedReplacement[] +} + +const regressionCorpus: readonly RegressionCase[] = [ + { + name: 'keeps an exact trailing block outside earlier replacements', + before: `# Wiki App + +Old build instructions. + +Shared footer. +`, + after: `# Collective Wiki + +New installation instructions. + +Shared footer. +`, + expected: [ + { operation: 'insert', detail: 'inline', before: '', after: 'Collective ' }, + { operation: 'delete', detail: 'inline', before: ' App', after: '' }, + { operation: 'replace', detail: 'inline', before: 'Old build', after: 'New installation' }, + ], + }, + { + name: 'keeps a weighted multi-block gap precise', + before: `## Development Background: Ownership + +Individual users own files. Collective pages should be owned by the collective. + +## Stable anchor + +This block is unchanged. +`, + after: `## Development background: Ownership + +Collective data is owned by the collective instead. + +## Stable anchor + +This block is unchanged. +`, + expected: [ + { operation: 'replace', detail: 'inline', before: 'B', after: 'b' }, + { operation: 'delete', detail: 'inline', before: 'Individual users own files. ', after: '' }, + { operation: 'replace', detail: 'inline', before: 'pages should be ', after: 'data is ' }, + { operation: 'insert', detail: 'inline', before: '', after: ' instead' }, + ], + }, + { + name: 'reports code text and language replacements directly', + before: `\`\`\` +const value = 1 +\`\`\` +`, + after: `\`\`\`js +const value = 1 // comment +\`\`\` +`, + expected: [ + { operation: 'replace', detail: 'block', before: 'const value = 1', after: 'const value = 1 // comment' }, + { operation: 'insert', detail: 'inline', before: '', after: ' // comment' }, + ], + }, + { + name: 'reports a removed list item as a node deletion', + before: `- Remove me +- Keep me +`, + after: `- Keep me +`, + expected: [ + { operation: 'delete', detail: 'block', before: 'Remove me', after: '' }, + ], + }, + { + name: 'does not expose parser-only Markdown syntax as a change signal', + before: `GNU AGPL v3 or later +`, + after: `Files: * +Copyright: Azul +License: AGPL v3 or later +`, + expected: [ + { + operation: 'replace', + detail: 'inline', + before: 'GNU', + after: 'Files: *\nCopyright: Azul azul@example.com\nLicense:', + }, + ], + }, +] + +function replacementOutput(beforeContent: string, afterContent: string) { + const beforeEditor = createComparisonEditor(beforeContent) as EditorLike + const afterEditor = createComparisonEditor(afterContent) as EditorLike + try { + const before = beforeEditor.state.doc + const after = afterEditor.state.doc + return createHierarchicalMarkdownComparisonModel(before, after).edits + .flatMap(({ descriptors }) => descriptors) + .map((descriptor) => ({ + operation: descriptor.operation, + detail: descriptor.detail, + facets: descriptor.facets, + before: before.textBetween(descriptor.before.from, descriptor.before.to, '\n', '\ufffc'), + after: after.textBetween(descriptor.after.from, descriptor.after.to, '\n', '\ufffc'), + context: descriptor.context, + signals: descriptor.signals, + coarseReason: descriptor.coarseReason ?? null, + })) + } finally { + beforeEditor.destroy() + afterEditor.destroy() + } +} + +function replacementProjection(replacements: ReturnType): ExpectedReplacement[] { + return replacements.map(({ operation, detail, before, after }) => ({ operation, detail, before, after })) +} + +function corpusCase(name: string) { + return regressionCorpus.find((candidate) => candidate.name === name)! +} + +describe('A15 repository-history regression corpus', () => { + it.each(regressionCorpus)('$name', ({ before, after, expected }) => { + const replacements = replacementOutput(before, after) + + expect(replacementProjection(replacements)).toEqual(expected) + expect(replacements.every(({ coarseReason }) => coarseReason === null)).toBe(true) + }) + + it('keeps replacement descriptor signals observable', () => { + const code = corpusCase('reports code text and language replacements directly') + const codeReplacements = replacementOutput(code.before, code.after) + expect(codeReplacements[0]).toMatchObject({ + facets: ['attribute'], + signals: [{ type: 'attribute', attribute: 'code-language', change: 'changed' }], + }) + + const deletion = corpusCase('reports a removed list item as a node deletion') + expect(replacementOutput(deletion.before, deletion.after)[0]).toMatchObject({ + facets: ['text', 'structure'], + context: { before: { code: 'list-item' }, after: null }, + signals: [{ type: 'node' }], + }) + + const syntax = corpusCase('does not expose parser-only Markdown syntax as a change signal') + expect(replacementOutput(syntax.before, syntax.after)[0]).toMatchObject({ + facets: ['text', 'attribute', 'unknown'], + signals: [{ type: 'attribute', attribute: 'link', change: 'added' }], + }) + }) +}) diff --git a/src/tests/comparison/comparisonAlignment.spec.ts b/src/tests/comparison/comparisonAlignment.spec.ts new file mode 100644 index 00000000000..3783e9d7087 --- /dev/null +++ b/src/tests/comparison/comparisonAlignment.spec.ts @@ -0,0 +1,343 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' +import { + alignComparisonAxis, + createComparisonWorkLedger, + DEFAULT_COMPARISON_CELL_LEDGER, + DEFAULT_COMPARISON_TOKEN_LEDGER, + forcedIncreasingPairs, +} from '../../comparison/comparisonAlignment.ts' + +function options(work = createComparisonWorkLedger()) { + return { + work, + fingerprint: (value: string) => value, + profile: (value: string) => [...value], + compatible: () => true, + } +} + +interface EvidencedItem { + id: number + side: 'before' | 'after' +} + +function evidencedOptions(work = createComparisonWorkLedger()) { + return { + work, + fingerprint: ({ id, side }: EvidencedItem) => `${side}:${id}`, + profile: ({ id, side }: EvidencedItem) => [`item:${id}`, side], + compatible: () => true, + } +} + +function evidencedAxis(size: number, side: EvidencedItem['side']): EvidencedItem[] { + return Array.from({ length: size }, (_value, id) => ({ id, side })) +} + +const FINAL_CELL_LEDGER = DEFAULT_COMPARISON_CELL_LEDGER +const FINAL_TOKEN_LEDGER = DEFAULT_COMPARISON_TOKEN_LEDGER +const FINAL_SQUARE_SIZE = Math.floor(Math.sqrt(FINAL_CELL_LEDGER)) + +function permutations(values: readonly number[]): number[][] { + if (values.length < 2) { + return [Array.from(values)] + } + return values.flatMap((value, index) => permutations(values.toSpliced(index, 1)) + .map((suffix) => [value, ...suffix])) +} + +function exhaustiveForcedPairs(values: readonly number[]) { + let maximum = 0 + const optimal: number[][] = [] + for (let mask = 0; mask < 2 ** values.length; mask++) { + const indices = values.flatMap((_value, index) => mask & (1 << index) ? [index] : []) + if (!indices.every((index, offset) => offset === 0 || values[indices[offset - 1]!]! < values[index]!)) { + continue + } + if (indices.length > maximum) { + maximum = indices.length + optimal.length = 0 + } + if (indices.length === maximum) { + optimal.push(indices) + } + } + return optimal[0]!.filter((index) => optimal.every((candidate) => candidate.includes(index))) + .map((before) => ({ before, after: values[before]! })) +} + +describe('comparison alignment', () => { + it('uses the authoritative comparison-wide shipping ledgers', () => { + expect(createComparisonWorkLedger()).toEqual({ + remainingCells: 40_000, + remainingTokenComparisons: 84_000_000, + }) + }) + + it('pairs a whole equal axis without weighted work', () => { + const work = createComparisonWorkLedger() + + expect(alignComparisonAxis(['alpha', 'beta'], ['alpha', 'beta'], options(work))).toEqual([ + { before: 0, after: 0 }, + { before: 1, after: 1 }, + ]) + expect(work).toEqual({ + remainingCells: DEFAULT_COMPARISON_CELL_LEDGER, + remainingTokenComparisons: DEFAULT_COMPARISON_TOKEN_LEDGER, + }) + }) + + it('A03 uses ordered unique fingerprints as exact anchors', () => { + const work = createComparisonWorkLedger() + + expect(alignComparisonAxis(['alpha', 'omega'], ['new', 'alpha', 'omega'], options(work))).toEqual([ + { before: null, after: 0 }, + { before: 0, after: 1 }, + { before: 1, after: 2 }, + ]) + expect(work.remainingCells).toBe(40_000) + }) + + it('matches exhaustive forced-LIS anchors for crossing candidates', () => { + for (let size = 1; size <= 6; size++) { + for (const values of permutations(Array.from({ length: size }, (_value, index) => index))) { + const pairs = values.map((after, before) => ({ before, after })) + expect(forcedIncreasingPairs(pairs), values.join(',')) + .toEqual(exhaustiveForcedPairs(values)) + } + } + }) + + it('aligns around the forced subset of crossing exact candidates', () => { + expect(alignComparisonAxis(['alpha', 'beta', 'gamma'], ['beta', 'gamma', 'alpha'], options())).toEqual([ + { before: 0, after: null }, + { before: 1, after: 0 }, + { before: 2, after: 1 }, + { before: null, after: 2 }, + ]) + }) + + it('retains the first exact duplicate when one is inserted', () => { + expect(alignComparisonAxis(['same'], ['same', 'same'], options())).toEqual([ + { before: 0, after: 0 }, + { before: null, after: 1 }, + ]) + }) + + it('retains the first exact duplicate when one is deleted', () => { + expect(alignComparisonAxis(['same', 'same'], ['same'], options())).toEqual([ + { before: 0, after: 0 }, + { before: 1, after: null }, + ]) + }) + + it('A09 pairs one compatible changed item without weighted work', () => { + const work = createComparisonWorkLedger() + + expect(alignComparisonAxis(['before'], ['after'], options(work))).toEqual([ + { before: 0, after: 0 }, + ]) + expect(work.remainingCells).toBe(DEFAULT_COMPARISON_CELL_LEDGER) + expect(work.remainingTokenComparisons).toBe(DEFAULT_COMPARISON_TOKEN_LEDGER) + }) + + it('leaves one incompatible changed item unmatched', () => { + const incompatible = { + ...options(), + compatible: () => false, + } + + expect(alignComparisonAxis(['list'], ['quote'], incompatible)).toEqual([ + { before: 0, after: null }, + { before: null, after: 0 }, + ]) + }) + + it('A10 coarsens a multi-item rewrite with ambiguous exact attribution', () => { + expect(alignComparisonAxis(['aa', 'bb', 'cc'], ['xx', 'yy'], options())).toEqual([{ + before: { from: 0, to: 3 }, + after: { from: 0, to: 2 }, + coarseReason: 'ambiguous-attribution', + }]) + }) + + it.each([ + Math.floor(FINAL_SQUARE_SIZE / 2), + FINAL_SQUARE_SIZE - 1, + ])('A11 keeps a final-ledger-derived unique-evidence %i square gap precise', (size) => { + const result = alignComparisonAxis( + evidencedAxis(size, 'before'), + evidencedAxis(size, 'after'), + evidencedOptions(), + ) + + expect(result).toHaveLength(size) + expect(result.every((step, index) => step.before === index && step.after === index)).toBe(true) + }) + + it('A12 keeps the largest default-ledger square gap precise', () => { + const work = createComparisonWorkLedger() + const size = FINAL_SQUARE_SIZE + const result = alignComparisonAxis( + evidencedAxis(size, 'before'), + evidencedAxis(size, 'after'), + evidencedOptions(work), + ) + + expect(result).toHaveLength(size) + expect(work.remainingCells).toBe(0) + expect(work.remainingTokenComparisons).toBe(FINAL_TOKEN_LEDGER - 4 * size ** 2) + }) + + it('A13 refuses the first generated cell and token overflows atomically', () => { + const cellWork = createComparisonWorkLedger() + const size = FINAL_SQUARE_SIZE + expect(alignComparisonAxis( + evidencedAxis(size + 1, 'before'), + evidencedAxis(size, 'after'), + evidencedOptions(cellWork), + )).toEqual([{ + before: { from: 0, to: size + 1 }, + after: { from: 0, to: size }, + coarseReason: 'comparison-limit', + }]) + expect(cellWork).toEqual(createComparisonWorkLedger()) + + const tokenWork = createComparisonWorkLedger() + const longProfile = Array.from({ length: Math.floor(FINAL_TOKEN_LEDGER / (2 * size * size)) + 1 }, () => 'x') + expect(alignComparisonAxis( + evidencedAxis(size, 'before'), + evidencedAxis(size, 'after'), + { + ...evidencedOptions(tokenWork), + profile: () => longProfile, + }, + )).toEqual([{ + before: { from: 0, to: size }, + after: { from: 0, to: size }, + coarseReason: 'comparison-limit', + }]) + expect(tokenWork).toEqual(createComparisonWorkLedger()) + }) + + it('A14 allocates final-ledger cell work by axis order without debiting a refused gap', () => { + interface Item { + fingerprint: string + profile: readonly string[] + } + const gap = (size: number, name: string, side: 'before' | 'after'): Item[] => ( + Array.from({ length: size }, (_value, id) => ({ + fingerprint: `${name}:${side}:${id}`, + profile: [`${name}:${id}`, side], + })) + ) + const anchor = (name: string): Item => ({ fingerprint: name, profile: [name] }) + const firstSize = Math.floor(FINAL_SQUARE_SIZE / 2) + const refusedSize = FINAL_SQUARE_SIZE + const lastSize = Math.max(2, Math.floor(firstSize / 10)) + const before = [ + ...gap(firstSize, 'first', 'before'), + anchor('anchor-1'), + ...gap(refusedSize, 'refused', 'before'), + anchor('anchor-2'), + ...gap(lastSize, 'last', 'before'), + ] + const after = [ + ...gap(firstSize, 'first', 'after'), + anchor('anchor-1'), + ...gap(refusedSize, 'refused', 'after'), + anchor('anchor-2'), + ...gap(lastSize, 'last', 'after'), + ] + const work = createComparisonWorkLedger() + const result = alignComparisonAxis(before, after, { + work, + fingerprint: (item) => item.fingerprint, + profile: (item) => item.profile, + compatible: () => true, + }) + const refusedStart = firstSize + 1 + + expect(result).toContainEqual({ + before: { from: refusedStart, to: refusedStart + refusedSize }, + after: { from: refusedStart, to: refusedStart + refusedSize }, + coarseReason: 'comparison-limit', + }) + expect(result.at(-1)).toEqual({ before: before.length - 1, after: after.length - 1 }) + expect(work.remainingCells).toBe(FINAL_CELL_LEDGER - firstSize ** 2 - lastSize ** 2) + }) + + it('A14 retains token and tie debits while preserving work after a refused token gap', () => { + interface Item { + fingerprint: string + profile: readonly string[] + } + const gapSize = Math.floor(FINAL_SQUARE_SIZE / 2) + const firstAfterSize = gapSize - 1 + const lastSize = Math.max(2, Math.floor(gapSize / 10)) + const firstProduct = gapSize * firstAfterSize + const firstProfileLength = Math.floor(FINAL_TOKEN_LEDGER / (4 * firstProduct)) + const firstProfile = Array.from({ length: firstProfileLength }, () => 'same') + const tieCharge = 2 * firstProduct * firstProfileLength + const refusedProfileLength = Math.floor((FINAL_TOKEN_LEDGER - tieCharge) / (2 * gapSize ** 2)) + 1 + const refusedProfile = Array.from({ length: refusedProfileLength }, () => 'refused') + const gap = (size: number, name: string, side: 'before' | 'after', profile?: readonly string[]): Item[] => ( + Array.from({ length: size }, (_value, id) => ({ + fingerprint: `${name}:${side}:${id}`, + profile: profile ?? [`${name}:${id}`, side], + })) + ) + const anchor = (name: string): Item => ({ fingerprint: name, profile: [name] }) + const before = [ + ...gap(gapSize, 'tie', 'before', firstProfile), + anchor('anchor-1'), + ...gap(gapSize, 'refused', 'before', refusedProfile), + anchor('anchor-2'), + ...gap(lastSize, 'last', 'before'), + ] + const after = [ + ...gap(firstAfterSize, 'tie', 'after', firstProfile), + anchor('anchor-1'), + ...gap(gapSize, 'refused', 'after', refusedProfile), + anchor('anchor-2'), + ...gap(lastSize, 'last', 'after'), + ] + const work = createComparisonWorkLedger() + const result = alignComparisonAxis(before, after, { + work, + fingerprint: (item) => item.fingerprint, + profile: (item) => item.profile, + compatible: () => true, + }) + const lastCharge = 4 * lastSize ** 2 + + expect(result[0]).toMatchObject({ coarseReason: 'ambiguous-attribution' }) + expect(result).toContainEqual({ + before: { from: gapSize + 1, to: 2 * gapSize + 1 }, + after: { from: firstAfterSize + 1, to: firstAfterSize + gapSize + 1 }, + coarseReason: 'comparison-limit', + }) + expect(result.at(-1)).toEqual({ before: before.length - 1, after: after.length - 1 }) + expect(work).toEqual({ + remainingCells: FINAL_CELL_LEDGER - firstProduct - lastSize ** 2, + remainingTokenComparisons: FINAL_TOKEN_LEDGER - tieCharge - lastCharge, + }) + }) + + it('P05 admits a skewed one-by-many gap by its exact cell charge', () => { + const work = createComparisonWorkLedger() + const result = alignComparisonAxis( + evidencedAxis(1, 'before'), + evidencedAxis(20_000, 'after'), + evidencedOptions(work), + ) + + expect(result).not.toContainEqual(expect.objectContaining({ coarseReason: 'comparison-limit' })) + expect(work.remainingCells).toBe(20_000) + }) +}) diff --git a/src/tests/comparison/comparisonAlignmentOracle.spec.ts b/src/tests/comparison/comparisonAlignmentOracle.spec.ts new file mode 100644 index 00000000000..888fa76d654 --- /dev/null +++ b/src/tests/comparison/comparisonAlignmentOracle.spec.ts @@ -0,0 +1,167 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonAlignmentStep, ComparisonWorkLedger } from '../../comparison/comparisonAlignment.ts' + +import { describe, expect, it } from 'vitest' +import { + DEFAULT_COMPARISON_CELL_LEDGER, + DEFAULT_COMPARISON_TOKEN_LEDGER, + solveWeightedGap, +} from '../../comparison/comparisonAlignment.ts' + +interface OracleItem { + fingerprint: string + profile: readonly string[] +} + +interface Rational { + numerator: bigint + denominator: bigint +} + +interface OracleMatch { + before: number + after: number +} + +function compareRational(a: Rational, b: Rational) { + const difference = a.numerator * b.denominator - b.numerator * a.denominator + return difference < 0n ? -1 : difference > 0n ? 1 : 0 +} + +function addRational(a: Rational, b: Rational): Rational { + return { + numerator: a.numerator * b.denominator + b.numerator * a.denominator, + denominator: a.denominator * b.denominator, + } +} + +function oraclePairScore(before: OracleItem, after: OracleItem): Rational { + if (before.fingerprint === after.fingerprint) { + return { numerator: 3n, denominator: 1n } + } + let prefix = 0 + while (prefix < before.profile.length + && prefix < after.profile.length + && before.profile[prefix] === after.profile[prefix]) { + prefix++ + } + let suffix = 0 + const maximumSuffix = Math.min(before.profile.length, after.profile.length) - prefix + while (suffix < maximumSuffix + && before.profile[before.profile.length - suffix - 1] === after.profile[after.profile.length - suffix - 1]) { + suffix++ + } + const denominator = BigInt(Math.max(before.profile.length, after.profile.length, 1)) + return { numerator: denominator + BigInt(prefix + suffix), denominator } +} + +function enumerateMatchings( + beforeCount: number, + afterCount: number, + beforeStart = 0, + afterStart = 0, +): OracleMatch[][] { + const matchings: OracleMatch[][] = [[]] + for (let before = beforeStart; before < beforeCount; before++) { + for (let after = afterStart; after < afterCount; after++) { + for (const suffix of enumerateMatchings(beforeCount, afterCount, before + 1, after + 1)) { + matchings.push([{ before, after }, ...suffix]) + } + } + } + return matchings +} + +function oracleAlignment(before: readonly OracleItem[], after: readonly OracleItem[]) { + let bestScore: Rational = { numerator: -1n, denominator: 1n } + let best: OracleMatch[][] = [] + for (const matching of enumerateMatchings(before.length, after.length)) { + const score = matching.reduce( + (total, pair) => addRational(total, oraclePairScore(before[pair.before]!, after[pair.after]!)), + { numerator: 0n, denominator: 1n }, + ) + const order = compareRational(score, bestScore) + if (order > 0) { + bestScore = score + best = [matching] + } else if (order === 0) { + best.push(matching) + } + } + const signatures = new Map(best.map((matching) => [ + matching.map(({ before: a, after: b }) => `${a}:${b}`).join(','), + matching, + ])) + if (signatures.size > 1) { + return { coarseReason: 'ambiguous-attribution' as const } + } + return { steps: alignmentSteps(before.length, after.length, [...signatures.values()][0]!) } +} + +function alignmentSteps(beforeCount: number, afterCount: number, matches: readonly OracleMatch[]) { + const steps: ComparisonAlignmentStep[] = [] + let before = 0 + let after = 0 + for (const match of matches) { + while (before < match.before) { + steps.push({ before: before++, after: null }) + } + while (after < match.after) { + steps.push({ before: null, after: after++ }) + } + steps.push({ before: before++, after: after++ }) + } + while (before < beforeCount) { + steps.push({ before: before++, after: null }) + } + while (after < afterCount) { + steps.push({ before: null, after: after++ }) + } + return steps +} + +function axes(items: readonly OracleItem[], maximumLength: number): OracleItem[][] { + const result: OracleItem[][] = [[]] + for (let length = 1; length <= maximumLength; length++) { + for (const prefix of result.filter((axis) => axis.length === length - 1)) { + for (const item of items) { + result.push([...prefix, item]) + } + } + } + return result +} + +function freshWork(): ComparisonWorkLedger { + return { + remainingCells: DEFAULT_COMPARISON_CELL_LEDGER, + remainingTokenComparisons: DEFAULT_COMPARISON_TOKEN_LEDGER, + } +} + +describe('comparison alignment exact oracles', () => { + it('matches an independent exact-rational and signature oracle exhaustively', () => { + const items: OracleItem[] = [ + { fingerprint: 'a', profile: ['shared', 'a'] }, + { fingerprint: 'b', profile: ['shared', 'b'] }, + { fingerprint: 'c', profile: ['c'] }, + ] + const candidates = axes(items, 3) + for (const before of candidates) { + for (const after of candidates) { + const actual = solveWeightedGap(before, after, { + work: freshWork(), + fingerprint: (item) => item.fingerprint, + profile: (item) => item.profile, + compatible: () => true, + }) + expect(actual, `${before.map(({ fingerprint }) => fingerprint)} -> ${after.map(({ fingerprint }) => fingerprint)}`) + .toEqual(oracleAlignment(before, after)) + } + } + }) +}) diff --git a/src/tests/comparison/comparisonTestEditor.ts b/src/tests/comparison/comparisonTestEditor.ts new file mode 100644 index 00000000000..103a67e7973 --- /dev/null +++ b/src/tests/comparison/comparisonTestEditor.ts @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createComparisonEditor } from '../../comparison/createComparisonEditor.ts' + +export function createComparisonTestEditor(content: string) { + return createComparisonEditor(content, { noLazyImages: true }) +} + +export function comparisonTestDocument(content: string) { + const editor = createComparisonEditor(content, { noLazyImages: true }) + try { + return editor.state.doc + } finally { + editor.destroy() + } +} diff --git a/src/tests/comparison/hierarchicalMarkdownComparison.spec.ts b/src/tests/comparison/hierarchicalMarkdownComparison.spec.ts new file mode 100644 index 00000000000..88ac12c2f13 --- /dev/null +++ b/src/tests/comparison/hierarchicalMarkdownComparison.spec.ts @@ -0,0 +1,363 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonEdit, ComparisonSide } from '../../comparison/markdownComparisonTypes.ts' + +import { Schema } from '@tiptap/pm/model' +import { schema as basicSchema } from 'prosemirror-schema-basic' +import { describe, expect, it } from 'vitest' +import { + ComparisonModelLimitError, + createHierarchicalMarkdownComparisonModel, + MAX_INLINE_ENVELOPE_SIZE, + MAX_RENDERED_COMPARISON_DESCRIPTORS, +} from '../../comparison/hierarchicalMarkdownComparisonModel.ts' +import { comparisonTestDocument, createComparisonTestEditor } from './comparisonTestEditor.ts' + +function compare(beforeContent: string, afterContent: string) { + const before = comparisonTestDocument(beforeContent) + const after = comparisonTestDocument(afterContent) + return { + before, + after, + model: createHierarchicalMarkdownComparisonModel(before, after), + } +} + +function meaningfulSize(doc: Node) { + return doc.content.size - (doc.lastChild?.content.size === 0 ? doc.lastChild.nodeSize : 0) +} + +function editText(doc: { textBetween: (from: number, to: number, separator: string) => string }) { + return (edit: ComparisonEdit, side: ComparisonSide) => ( + doc.textBetween(edit.primary[side].from, edit.primary[side].to, '\n') + ) +} + +describe('hierarchical markdown comparison', () => { + it('A01 emits no edit for two equal documents', () => { + const { model } = compare('# Title\n\nBody text.\n', '# Title\n\nBody text.\n') + + expect(model.edits).toEqual([]) + }) + + it('A02 normalizes separate schema instances without position or content drift', () => { + const beforeEditor = createComparisonTestEditor('# Title\n\nOriginal body.\n') + const afterEditor = createComparisonTestEditor('# Title\n\nUpdated body.\n') + try { + const before = beforeEditor.state.doc + const after = afterEditor.state.doc + expect(before.type.schema).not.toBe(after.type.schema) + + const model = createHierarchicalMarkdownComparisonModel(before, after) + + expect(model.edits).toHaveLength(1) + const [edit] = model.edits + expect(before.textBetween(edit!.primary.before.from, edit!.primary.before.to)).toBe('Original') + expect(after.textBetween(edit!.primary.after.from, edit!.primary.after.to)).toBe('Updated') + } finally { + beforeEditor.destroy() + afterEditor.destroy() + } + }) + + it('AUD-19 rejects cross-schema normalization that drops node attributes', () => { + const paragraph = basicSchema.spec.nodes.get('paragraph')! + const beforeSchema = new Schema({ + nodes: basicSchema.spec.nodes.update('paragraph', { ...paragraph, attrs: { audit: { default: null } } }), + marks: basicSchema.spec.marks, + }) + const afterSchema = new Schema({ nodes: basicSchema.spec.nodes, marks: basicSchema.spec.marks }) + const before = beforeSchema.node('doc', null, [beforeSchema.node('paragraph', { audit: 'must-survive' }, beforeSchema.text('same'))]) + const after = afterSchema.node('doc', null, [afterSchema.node('paragraph', null, afterSchema.text('same'))]) + + expect(() => createHierarchicalMarkdownComparisonModel(before, after)).toThrow(/schema normalization lost semantics/) + }) + + it('A05 retains the first repeated paragraph and inserts the second copy', () => { + const { after, model } = compare('Anchor\n\nSame\n', 'Anchor\n\nSame\n\nSame\n') + const text = editText(after) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary.operation).toBe('insert') + expect(text(model.edits[0]!, 'after')).toBe('Same') + expect(model.edits[0]!.primary.after).toEqual({ + from: after.child(0).nodeSize + after.child(1).nodeSize, + to: meaningfulSize(after), + }) + }) + + it('A06 retains the first repeated paragraph and deletes the second copy', () => { + const { before, model } = compare('Anchor\n\nSame\n\nSame\n', 'Anchor\n\nSame\n') + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary.operation).toBe('delete') + expect(model.edits[0]!.primary.before).toEqual({ + from: before.child(0).nodeSize + before.child(1).nodeSize, + to: meaningfulSize(before), + }) + }) + + it.each([ + ['insertion', '# Existing\n', '## Added\n\n# Existing\n', 'insert'], + ['deletion', '## Removed\n\n# Existing\n', '# Existing\n', 'delete'], + ] as const)('AUD-12 reports a heading %s as added or removed without adjacent heading attributes', (_name, beforeContent, afterContent, operation) => { + const { model } = compare(beforeContent, afterContent) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary.operation).toBe(operation) + expect(model.edits[0]!.primary.signals).not.toContainEqual(expect.objectContaining({ + type: 'attribute', + attribute: 'heading-level', + })) + }) + + it('A07 reports a paragraph turning into a heading as one semantic replacement', () => { + const { model } = compare('Shared title\n', '# Shared title\n') + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + detail: 'block', + operation: 'replace', + context: { before: { code: 'paragraph' }, after: { code: 'heading' } }, + }, + }) + expect(model.edits[0]!.primary.facets).toContain('structure') + expect(model.edits[0]!.primary.signals).toContainEqual({ + type: 'attribute', + attribute: 'heading-level', + change: 'added', + }) + }) + + it('A08 does not pair incompatible non-text containers', () => { + const { model } = compare('- Shared item\n', '> Shared item\n') + + expect(model.edits).toHaveLength(2) + expect(model.edits.map(({ primary }) => primary.operation).toSorted()).toEqual(['delete', 'insert']) + }) + + it.each([ + ['section headings', '# Setup\n\n# Usage\n\n# Notes\n', '# Install\n\n# Config\n'], + ['shopping items', 'Buy milk\n\nBuy eggs\n\nBuy rice\n', 'Get bread\n\nGet jam\n'], + ])('A10 coarsens a no-affix %s rewrite', (_name, beforeContent, afterContent) => { + const { before, after, model } = compare(beforeContent, afterContent) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary).toMatchObject({ + coarseReason: 'ambiguous-attribution', + detail: 'block', + operation: 'replace', + before: { from: 0, to: meaningfulSize(before) }, + after: { from: 0, to: meaningfulSize(after) }, + }) + }) + + it('reports one reordered block as one move edit', () => { + const { model } = compare( + 'Unique alpha\n\nUnique beta\n\nUnique gamma\n', + 'Unique beta\n\nUnique gamma\n\nUnique alpha\n', + ) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary).toMatchObject({ + operation: 'move', + detail: 'block', + facets: ['structure'], + }) + }) + + it('pairs uniquely compatible changed containers instead of coarsening their shared gap', () => { + const before = [ + '# Operational notes', + '', + '> Draft maintenance window starts at 20:00.', + '', + '::: info', + 'The canary is limited to one region.', + ':::', + '', + '
', + 'Draft runbook', + '', + 'Rollback after two failed health checks.', + '', + '
', + '', + ].join('\n') + const after = [ + '# Operational notes', + '', + '> Approved maintenance window starts at 21:00.', + '', + '::: warn', + 'The canary is limited to two regions.', + ':::', + '', + '
', + 'Approved runbook', + '', + 'Rollback after one failed health check.', + '', + '
', + '', + ].join('\n') + const { model } = compare(before, after) + const attributes = model.edits.flatMap(({ descriptors }) => descriptors) + .flatMap(({ signals }) => signals) + .filter((signal) => signal.type === 'attribute') + .map(({ attribute }) => attribute) + const primaryAttributes = model.edits + .flatMap(({ primary }) => primary.signals) + .filter((signal) => signal.type === 'attribute') + .map(({ attribute }) => attribute) + + expect(model.edits.length).toBeGreaterThan(3) + expect(model.edits.every(({ primary }) => primary.coarseReason === undefined)).toBe(true) + expect(attributes).toContain('callout-type') + expect(attributes).toContain('details-state') + expect(primaryAttributes.filter((attribute) => attribute === 'callout-type')).toHaveLength(1) + expect(primaryAttributes.filter((attribute) => attribute === 'details-state')).toHaveLength(1) + }) + + it('does not label code content edits as language changes', () => { + const { model } = compare( + '```javascript\nconst stage = "draft"\n```\n', + '```typescript\nconst stage = "ready"\n```\n', + ) + const languageEdits = model.edits.filter(({ primary }) => primary.signals.some((signal) => ( + signal.type === 'attribute' && signal.attribute === 'code-language' + ))) + + expect(languageEdits).toHaveLength(1) + expect(model.edits.some(({ primary }) => ( + primary.preview.before?.kind === 'text' + && primary.preview.before.text === 'draft' + && primary.signals.every((signal) => signal.type !== 'attribute' || signal.attribute !== 'code-language') + ))).toBe(true) + }) + + it('expands front-matter previews to complete changed lines', () => { + const { model } = compare([ + '---', + 'release: atlas-2.4', + 'status: draft', + 'owner: Maya Chen', + 'tags: [payments, canary]', + 'legacy: true', + '---', + '', + '# Release record', + ].join('\n'), [ + '---', + 'release: atlas-2.4.1', + 'status: approved', + 'owner: Noor Rahman', + 'tags: [payments, canary, customer-visible]', + 'approved-at: 2026-08-30T21:00:00+07:00', + '---', + '', + '# Release record', + ].join('\n')) + const previews = model.edits + .filter(({ primary }) => primary.context.before?.code === 'front-matter') + .map(({ primary }) => [primary.preview.before, primary.preview.after]) + .flat() + .map((preview) => preview?.kind === 'text' ? preview.text : '') + + expect(previews).toHaveLength(4) + expect(previews[0]).toMatch(/^release: atlas-2\.4\b/) + expect(previews[1]).toMatch(/^release: atlas-2\.4\.1\b/) + expect(previews[2]).toMatch(/^tags: \[payments, canary\]/) + expect(previews[3]).toMatch(/^tags: \[payments, canary, customer-visible\]/) + }) + + it('moves exact repeated section content with its unique heading anchor', () => { + const repeated = 'Health check passed.\n\nDeploy the stable build.' + const before = [ + '# Repeated deployment notes', + `## Region A\n\n${repeated}`, + `## Region B\n\n${repeated}`, + `## Retired region\n\n${repeated}`, + `## Region C\n\n${repeated}`, + ].join('\n\n') + const after = [ + '# Repeated deployment notes', + `## Region C\n\n${repeated}`, + `## Region A\n\n${repeated}`, + '## New region\n\nHealth check passed.\n\nDeploy the canary build.', + `## Region B\n\n${repeated}`, + ].join('\n\n') + const { before: beforeDoc, after: afterDoc, model } = compare(before, after) + const moved = model.edits.find(({ primary }) => primary.operation === 'move')! + const repeatedCopies = model.edits.filter(({ primary }) => primary.operation !== 'move') + .map((edit) => ({ + operation: edit.primary.operation, + before: beforeDoc.textBetween(edit.primary.before.from, edit.primary.before.to, '\n'), + after: afterDoc.textBetween(edit.primary.after.from, edit.primary.after.to, '\n'), + })) + + expect(model.edits).toHaveLength(7) + expect(beforeDoc.textBetween(moved.primary.before.from, moved.primary.before.to, '\n')) + .toBe('Region C\nHealth check passed.\nDeploy the stable build.') + expect(afterDoc.textBetween(moved.primary.after.from, moved.primary.after.to, '\n')) + .toBe('Region C\nHealth check passed.\nDeploy the stable build.') + expect(repeatedCopies.filter(({ before }) => before === 'Health check passed.')).toHaveLength(1) + expect(repeatedCopies.filter(({ after }) => after === 'Health check passed.')).toHaveLength(1) + }) + + it('F08 keeps a large inline envelope as one rendered block change', () => { + const shared = 'z'.repeat(MAX_INLINE_ENVELOPE_SIZE) + const { before, after, model } = compare(`start ${shared}\n`, `${shared} finish\n`) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary).toMatchObject({ + detail: 'block', + before: { from: 0, to: meaningfulSize(before) }, + after: { from: 0, to: meaningfulSize(after) }, + }) + }) + + it('accepts 10000 descriptors and rejects 10001 at the production default', () => { + const editor = createComparisonTestEditor('placeholder') + try { + const document = (count: number, side: 'before' | 'after') => { + const nodes = Array.from({ length: count }, (_value, index) => [ + editor.schema.nodes.paragraph!.create(null, editor.schema.text(`${side} ${index}`)), + editor.schema.nodes.heading!.create({ level: 2 }, editor.schema.text(`Exact anchor ${index}`)), + ]).flat() + return editor.schema.nodes.doc!.create(null, nodes) + } + const before = document(MAX_RENDERED_COMPARISON_DESCRIPTORS + 1, 'before') + const atLimit = createHierarchicalMarkdownComparisonModel( + editor.schema.nodes.doc!.create(null, before.content.content.slice(0, -2)), + document(MAX_RENDERED_COMPARISON_DESCRIPTORS, 'after'), + ) + + expect(MAX_RENDERED_COMPARISON_DESCRIPTORS).toBe(10_000) + expect(atLimit.edits).toHaveLength(MAX_RENDERED_COMPARISON_DESCRIPTORS) + expect(() => createHierarchicalMarkdownComparisonModel( + before, + document(MAX_RENDERED_COMPARISON_DESCRIPTORS + 1, 'after'), + )).toThrow(ComparisonModelLimitError) + } finally { + editor.destroy() + } + }) + + it('freezes the model and keeps the primary inside its own descriptors', () => { + const { model } = compare('one\n\ntwo\n', 'one edited\n\ntwo edited\n') + + expect(Object.isFrozen(model)).toBe(true) + expect(Object.isFrozen(model.edits)).toBe(true) + expect(model.edits.every((edit) => edit.descriptors.includes(edit.primary))).toBe(true) + expect(new Set(model.edits.map(({ id }) => id))).toHaveLength(model.edits.length) + expect(new Set(model.edits.flatMap(({ descriptors }) => descriptors.map(({ id }) => id)))) + .toHaveLength(model.edits.flatMap(({ descriptors }) => descriptors).length) + }) +}) diff --git a/src/tests/comparison/tableGridComparison.spec.ts b/src/tests/comparison/tableGridComparison.spec.ts new file mode 100644 index 00000000000..6d14c301559 --- /dev/null +++ b/src/tests/comparison/tableGridComparison.spec.ts @@ -0,0 +1,655 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Schema } from '@tiptap/pm/model' +import type { ComparisonEdit, ComparisonSide } from '../../comparison/markdownComparisonTypes.ts' + +import { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { describe, expect, it, vi } from 'vitest' +import { + alignComparisonColumns, + DEFAULT_COMPARISON_TOKEN_LEDGER, +} from '../../comparison/comparisonAlignment.ts' +import { createHierarchicalMarkdownComparisonModel } from '../../comparison/hierarchicalMarkdownComparisonModel.ts' +import { comparisonTestDocument, createComparisonTestEditor } from './comparisonTestEditor.ts' + +function markdownTable(header: readonly string[], rows: readonly (readonly string[])[]) { + return [ + `| ${header.join(' | ')} |`, + `|${header.map(() => '---').join('|')}|`, + ...rows.map((row) => `| ${row.join(' | ')} |`), + ].join('\n') +} + +function compare(before: string, after: string) { + return createHierarchicalMarkdownComparisonModel( + comparisonTestDocument(before), + comparisonTestDocument(after), + ) +} + +interface TableSpec { + header: readonly { text: string, colspan?: number, rowspan?: number }[] + rows: readonly (readonly { text: string, colspan?: number, rowspan?: number }[])[] +} + +function tableNode(schema: Schema, spec: TableSpec) { + const text = (value: string) => value ? schema.text(value) : undefined + const cell = (name: 'tableHeader' | 'tableCell', value: TableSpec['header'][number]) => schema.nodes[name]!.create( + { colspan: value.colspan ?? 1, rowspan: value.rowspan ?? 1 }, + name === 'tableHeader' ? text(value.text) : schema.nodes.paragraph!.create(null, text(value.text)), + ) + const header = schema.nodes.tableHeadRow!.create(null, spec.header.map((value) => cell('tableHeader', value))) + const rows = spec.rows.map((row) => schema.nodes.tableRow!.create(null, row.map((value) => cell('tableCell', value)))) + return schema.nodes.table!.create(null, [header, ...rows]) +} + +function tableDocument(schema: Schema, spec: TableSpec) { + return schema.nodes.doc!.create(null, tableNode(schema, spec)) +} + +function compareTableSpecs(before: TableSpec, after: TableSpec) { + const editor = createComparisonTestEditor('placeholder') + try { + return createHierarchicalMarkdownComparisonModel( + tableDocument(editor.schema, before), + tableDocument(editor.schema, after), + ) + } finally { + editor.destroy() + } +} + +function malformedTableDocument( + schema: Schema, + children: readonly ('caption' | 'header' | 'body' | 'header-with-body-cell' | 'body-with-header-cell')[], +) { + const text = (value: string) => schema.text(value) + const headerCell = () => schema.nodes.tableHeader!.create(null, text('header')) + const bodyCell = () => schema.nodes.tableCell!.create(null, schema.nodes.paragraph!.create(null, text('body'))) + const nodes = children.map((kind) => { + if (kind === 'caption') { + return schema.nodes.tableCaption!.create(null, text('caption')) + } + if (kind === 'header') { + return schema.nodes.tableHeadRow!.create(null, headerCell()) + } + if (kind === 'body') { + return schema.nodes.tableRow!.create(null, bodyCell()) + } + if (kind === 'header-with-body-cell') { + return schema.nodes.tableHeadRow!.create(null, bodyCell()) + } + return schema.nodes.tableRow!.create(null, headerCell()) + }) + return schema.nodes.doc!.create(null, schema.nodes.table!.create(null, nodes)) +} + +function expectColumnEdit( + edit: ComparisonEdit, + operation: 'delete' | 'insert', + column: number, + rows: number, +) { + const side: ComparisonSide = operation === 'delete' ? 'before' : 'after' + expect(edit.kind).toBe('table-column') + expect(edit.primary.operation).toBe(operation) + expect(edit.descriptors).toHaveLength(rows) + expect(edit.descriptors.every((descriptor) => descriptor.operation === operation)).toBe(true) + expect(edit.descriptors.map((descriptor) => descriptor.context[side]?.path.at(-1))) + .toEqual(Array.from({ length: rows }, () => column)) + expect(edit.descriptors).toContain(edit.primary) +} + +describe('sparse table comparison', () => { + it('AUD-14 does not materialize table profiles before matrix budget admission', () => { + const editor = createComparisonTestEditor('placeholder') + const columns = 201 + const before = tableDocument(editor.schema, { + header: Array.from({ length: columns }, (_value, index) => ({ text: `before-${index}` })), + rows: [], + }) + const after = tableDocument(editor.schema, { + header: Array.from({ length: columns }, (_value, index) => ({ text: `after-${index}` })), + rows: [], + }) + const textContent = vi.spyOn(ProseMirrorNode.prototype, 'textContent', 'get') + try { + const model = createHierarchicalMarkdownComparisonModel(before, after) + + expect(model.edits[0]!.primary.coarseReason).toBe('comparison-limit') + const tableCellReads = textContent.mock.contexts.filter((node) => ( + (node as ProseMirrorNode).type.name === 'tableHeader' + )).length + expect(tableCellReads).toBeLessThanOrEqual(columns * 2 * 5) + } finally { + textContent.mockRestore() + editor.destroy() + } + }) + + it.each([ + ['first', 0], + ['middle', 1], + ['last', 2], + ] as const)('T01 attributes the %s duplicate-body column deletion', (_name, column) => { + const widerHeader = ['A', 'B', 'C'] + const narrowerHeader = widerHeader.filter((_value, index) => index !== column) + const wider = markdownTable(widerHeader, Array.from({ length: 2 }, () => ['x', 'x', 'x'])) + const narrower = markdownTable(narrowerHeader, Array.from({ length: 2 }, () => ['x', 'x'])) + + const deletion = compare(wider, narrower) + expect(deletion.edits).toHaveLength(1) + expectColumnEdit(deletion.edits[0]!, 'delete', column, 3) + }) + + it.each([ + ['first', 0], + ['middle', 1], + ['last', 2], + ] as const)('T02 attributes the %s duplicate-body column insertion', (_name, column) => { + const widerHeader = ['A', 'B', 'C'] + const narrowerHeader = widerHeader.filter((_value, index) => index !== column) + const wider = markdownTable(widerHeader, Array.from({ length: 2 }, () => ['x', 'x', 'x'])) + const narrower = markdownTable(narrowerHeader, Array.from({ length: 2 }, () => ['x', 'x'])) + + const insertion = compare(narrower, wider) + expect(insertion.edits).toHaveLength(1) + expectColumnEdit(insertion.edits[0]!, 'insert', column, 3) + }) + + it('T03 distinguishes duplicate headers with an exact body row', () => { + const model = compare( + markdownTable(['X', 'X', 'X'], [['a', 'b', 'c'], ['x', 'x', 'x']]), + markdownTable(['X', 'X'], [['a', 'b'], ['x', 'x']]), + ) + + expect(model.edits).toHaveLength(1) + expectColumnEdit(model.edits[0]!, 'delete', 2, 3) + }) + + it('T04 pairs equal duplicate-vector multiplicity by rank beside a unique deletion', () => { + const model = compare( + markdownTable(['A', 'X', 'X', 'C'], [['x', 'x', 'x', 'x']]), + markdownTable(['A', 'X', 'X'], [['x', 'x', 'x']]), + ) + + expect(model.edits).toHaveLength(1) + expectColumnEdit(model.edits[0]!, 'delete', 3, 2) + }) + + it('T05 keeps unequal identical-column multiplicity coarse', () => { + const model = compare( + markdownTable(['X', 'X', 'X'], [['', '', ''], ['x', 'x', 'x']]), + markdownTable(['X', 'X'], [['', ''], ['x', 'x']]), + ) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'ambiguous-attribution', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + }) + + it('T06 keeps a crossing exact column reorder coarse', () => { + const model = compare( + markdownTable(['A', 'B', 'C'], [['a', 'b', 'c']]), + markdownTable(['C', 'B', 'A'], [['c', 'b', 'a']]), + ) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'table-evidence-conflict', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + }) + + it('T07 keeps one edited retained column precise at cell altitude', () => { + const model = compare( + markdownTable(['A', 'B', 'C'], [['1', '2', '3']]), + markdownTable(['A', 'X', 'C'], [['1', 'changed', '3']]), + ) + const descriptors = model.edits.flatMap(({ descriptors }) => descriptors) + + expect(model.edits).toHaveLength(2) + expect(model.edits.every(({ kind }) => kind === 'content')).toBe(true) + expect(descriptors).toHaveLength(2) + expect(descriptors.map(({ context }) => context.before?.path)).toEqual([ + [0, 0, 1], + [0, 1, 1], + ]) + expect(descriptors.map(({ before, after }) => ({ before, after }))).toEqual([ + { before: { from: 6, to: 7 }, after: { from: 6, to: 7 } }, + { before: { from: 20, to: 21 }, after: { from: 20, to: 27 } }, + ]) + expect(descriptors.every(({ context }) => ( + context.before?.code === 'table-cell' && context.after?.code === 'table-cell' + ))).toBe(true) + }) + + it('T08 keeps two adjacent edited columns precise when the optimum is unique', () => { + const model = compare( + markdownTable(['A', 'B old', 'C old', 'D'], [['1', 'before b', 'before c', '4']]), + markdownTable(['A', 'B new', 'C new', 'D'], [['1', 'after b', 'after c', '4']]), + ) + const descriptors = model.edits.flatMap(({ descriptors }) => descriptors) + + expect(descriptors).toHaveLength(4) + expect(model.edits.every(({ kind }) => kind === 'content')).toBe(true) + expect(descriptors.every(({ coarseReason }) => coarseReason === undefined)).toBe(true) + expect(descriptors.every(({ context }) => ( + context.before?.code === 'table-cell' && context.after?.code === 'table-cell' + ))).toBe(true) + }) + + it('groups one column alignment change across its physical cells without duplicating content edits', () => { + const model = compare([ + '# Evidence matrix', + '', + '| Signal | Evidence | State |', + '| :--- | :---: | ---: |', + '| API \\| worker | **Draft** [runbook](https://example.org/draft) | 10% |', + '| Screenshot | ![Audit thumbnail](demo-assets/table-thumbnail.jpg) | Pending |', + ].join('\n'), [ + '# Evidence matrix', + '', + '| Signal | Evidence | State |', + '| ---: | :--- | :---: |', + '| API \\| worker | **Approved** [runbook](https://example.org/approved) | 25% |', + '| Screenshot | ![Approved audit thumbnail](demo-assets/table-thumbnail.jpg) | Complete |', + ].join('\n')) + const alignmentEdits = model.edits.filter(({ primary }) => primary.signals.some((signal) => ( + signal.type === 'attribute' && signal.attribute === 'table-alignment' + ))) + expect(model.edits).toHaveLength(7) + expect(alignmentEdits).toHaveLength(3) + expect(alignmentEdits.map(({ descriptors }) => descriptors.length)).toEqual([3, 3, 3]) + expect(alignmentEdits.every(({ descriptors }) => descriptors.every(({ signals }) => signals.some((signal) => ( + signal.type === 'attribute' && signal.attribute === 'table-alignment' + ))))).toBe(true) + }) + + it('T09 combines an edited retained column with one adjacent column deletion', () => { + const model = compare( + markdownTable(['A', 'B old', 'C'], [['1', 'before', '3']]), + markdownTable(['A', 'B new'], [['1', 'after']]), + ) + const columnEdits = model.edits.filter(({ kind }) => kind === 'table-column') + const cellEdits = model.edits.filter(({ kind }) => kind === 'content') + + expect(columnEdits).toHaveLength(1) + expectColumnEdit(columnEdits[0]!, 'delete', 2, 2) + expect(cellEdits).toHaveLength(2) + expect(cellEdits.flatMap(({ descriptors }) => descriptors).every(({ context }) => ( + context.before?.code === 'table-cell' && context.after?.code === 'table-cell' + ))).toBe(true) + }) + + it('T10 keeps a row insertion at row altitude beside a column deletion', () => { + const model = compare( + markdownTable(['A', 'B', 'C'], [['one', 'x', 'x'], ['two', 'x', 'x']]), + markdownTable(['A', 'B'], [['one', 'x'], ['inserted row', 'unique'], ['two', 'x']]), + ) + const columnEdits = model.edits.filter(({ kind }) => kind === 'table-column') + const rowInsertions = model.edits.filter(({ primary }) => ( + primary.operation === 'insert' && primary.context.after?.code === 'table-row' + )) + + expect(columnEdits).toHaveLength(1) + expectColumnEdit(columnEdits[0]!, 'delete', 2, 3) + expect(rowInsertions).toHaveLength(1) + }) + + it('T10 keeps a row deletion at row altitude beside a column insertion', () => { + const model = compare( + markdownTable(['A', 'B'], [['one', 'x'], ['deleted row', 'unique'], ['two', 'x']]), + markdownTable(['A', 'B', 'C'], [['one', 'x', 'x'], ['two', 'x', 'x']]), + ) + const columnEdits = model.edits.filter(({ kind }) => kind === 'table-column') + const rowDeletions = model.edits.filter(({ primary }) => ( + primary.operation === 'delete' && primary.context.before?.code === 'table-row' + )) + + expect(columnEdits).toHaveLength(1) + expectColumnEdit(columnEdits[0]!, 'insert', 2, 3) + expect(rowDeletions).toHaveLength(1) + }) + + it('T11 keeps a ragged typo precise at physical-cell altitude', () => { + const model = compare( + markdownTable(['A', 'B', 'C'], [['one', 'before']]), + markdownTable(['A', 'B', 'C'], [['one', 'after']]), + ) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + context: { before: { code: 'table-cell' }, after: { code: 'table-cell' } }, + }, + }) + }) + + it.each([ + ['fills', 'insert', ['one'], ['one', 'two']], + ['removes', 'delete', ['one', 'two'], ['one']], + ] as const)('T12 %s a ragged absent-present slot as one local cell change', (_name, operation, beforeRow, afterRow) => { + const model = compare( + markdownTable(['A', 'B'], [beforeRow]), + markdownTable(['A', 'B'], [afterRow]), + ) + const side: ComparisonSide = operation === 'delete' ? 'before' : 'after' + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ kind: 'content', primary: { operation } }) + expect(model.edits[0]!.primary.context[side]?.code).toBe('table-cell') + }) + + it('T13 groups only present physical cells from a deleted ragged column', () => { + const wider = markdownTable(['A', 'B', 'C'], [ + ['x', 'x'], + ['x', 'x', 'distinct C'], + ]) + const narrower = markdownTable(['A', 'B'], [ + ['x', 'x'], + ['x', 'x'], + ]) + const model = compare(wider, narrower) + + expect(model.edits).toHaveLength(1) + expectColumnEdit(model.edits[0]!, 'delete', 2, 2) + }) + + it.each(['deletion', 'insertion'] as const)('T14 fails a ragged middle identity conflict transactionally on %s', (direction) => { + const wider = markdownTable(['A', 'B', 'C'], [ + ['x', 'x', 'x'], + ['x', 'x'], + ]) + const narrower = markdownTable(['A', 'C'], [ + ['x', 'x'], + ['x'], + ]) + const model = direction === 'deletion' + ? compare(wider, narrower) + : compare(narrower, wider) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'table-evidence-conflict', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + expect(model.edits[0]!.descriptors).toHaveLength(1) + }) + + it.each(['deletion', 'insertion'] as const)('AUD-01 fails closed when a ragged width change creates cross-row exact identity on %s', (direction) => { + for (const width of [2, 3, 4]) { + const wider = markdownTable( + Array.from({ length: width + 1 }, () => 'N'), + [ + Array.from({ length: width }, () => 'x'), + Array.from({ length: width + 1 }, () => 'x'), + ], + ) + const narrower = markdownTable( + Array.from({ length: width }, () => 'N'), + [ + Array.from({ length: width - 1 }, () => 'x'), + Array.from({ length: width }, () => 'x'), + ], + ) + const model = direction === 'deletion' + ? compare(wider, narrower) + : compare(narrower, wider) + const rowOperations = model.edits.filter(({ primary }) => ( + primary.context.before?.code === 'table-row' || primary.context.after?.code === 'table-row' + )) + + expect(rowOperations).toHaveLength(0) + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'table-evidence-conflict', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + expect(model.edits[0]!.descriptors).toHaveLength(1) + } + }) + + it.each(['deletion', 'insertion'] as const)('AUD-01 reconciles the complete row basis when row count and width change on %s', (direction) => { + const wider = markdownTable(['N', 'N', 'N'], [ + ['x', 'x'], + ['x', 'x', 'x'], + ]) + const narrower = markdownTable(['N', 'N'], [ + ['inserted', 'unique'], + ['x'], + ['x', 'x'], + ]) + const model = direction === 'deletion' + ? compare(wider, narrower) + : compare(narrower, wider) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'table-evidence-conflict', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + expect(model.edits[0]!.descriptors).toHaveLength(1) + }) + + it.each(['deletion', 'insertion'] as const)('AUD-01 keeps a genuine ragged row replacement beside a column %s', (direction) => { + const wider = markdownTable(['A', 'B', 'C'], [ + ['retained', 'x'], + ['deleted', 'y', 'z'], + ]) + const narrower = markdownTable(['A', 'B'], [ + ['inserted', 'q'], + ['retained', 'x'], + ]) + const model = direction === 'deletion' + ? compare(wider, narrower) + : compare(narrower, wider) + const columnEdits = model.edits.filter(({ kind }) => kind === 'table-column') + const rowOperations = model.edits.filter(({ primary }) => ( + primary.context.before?.code === 'table-row' || primary.context.after?.code === 'table-row' + )) + + expect(columnEdits).toHaveLength(1) + expectColumnEdit(columnEdits[0]!, direction === 'deletion' ? 'delete' : 'insert', 2, 1) + expect(rowOperations.map(({ primary }) => primary.operation).toSorted()).toEqual(['delete', 'insert']) + expect(model.edits.every(({ primary }) => primary.coarseReason === undefined)).toBe(true) + }) + + it.each([ + ['deletes', 'delete', 3, 2], + ['inserts', 'insert', 3, 4], + ] as const)('T15 %s the outer repeated row after retaining the first exact copies', (_name, operation, beforeCount, afterCount) => { + const rows = (count: number) => Array.from({ length: count }, () => ['Same', 'x']) + const model = compare( + markdownTable(['H1', 'H2'], rows(beforeCount)), + markdownTable(['H1', 'H2'], rows(afterCount)), + ) + const side: ComparisonSide = operation === 'delete' ? 'before' : 'after' + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ kind: 'content', primary: { operation } }) + expect(model.edits[0]!.primary.context[side]?.code).toBe('table-row') + expect(model.edits[0]!.primary.context[side]?.path.at(-1)).toBe(operation === 'delete' ? 3 : 4) + }) + + it.each([ + ['span', { header: [{ text: 'changed', colspan: 2 }], rows: [] }], + ['row span', { header: [{ text: 'changed', rowspan: 2 }], rows: [[{ text: 'body' }]] }], + ['malformed empty row', { header: [{ text: 'changed' }], rows: [[]] }], + ['more than 512 rows', { + header: [{ text: 'changed' }], + rows: Array.from({ length: 512 }, (_value, index) => [{ text: `row ${index}` }]), + }], + ['more than 10000 physical cells', { + header: Array.from({ length: 100 }, (_value, index) => ({ text: index === 0 ? 'changed' : `head ${index}` })), + rows: Array.from({ length: 100 }, (_value, row) => ( + Array.from({ length: 100 }, (_cell, column) => ({ text: `cell ${row}:${column}` })) + )), + }], + ] as const)('T16 emits one unsupported-table edit for %s geometry', (_name, invalid) => { + const valid = { header: [{ text: 'valid' }], rows: [] } + const model = compareTableSpecs(valid, invalid) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'unsupported-table', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + expect(model.edits[0]!.descriptors).toHaveLength(1) + }) + + it.each([ + ['a missing header', ['body']], + ['duplicate headers', ['header', 'header']], + ['a header after a body row', ['body', 'header']], + ['a misplaced caption', ['header', 'caption']], + ['duplicate captions', ['caption', 'caption', 'header']], + ['a body cell in the header', ['header-with-body-cell']], + ['a header cell in a body row', ['header', 'body-with-header-cell']], + ] as const)('T16 rejects %s transactionally', (_name, children) => { + const editor = createComparisonTestEditor('placeholder') + try { + const valid = tableDocument(editor.schema, { header: [{ text: 'valid' }], rows: [] }) + const invalid = malformedTableDocument(editor.schema, children) + const model = createHierarchicalMarkdownComparisonModel(valid, invalid) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]).toMatchObject({ + kind: 'content', + primary: { + operation: 'replace', + coarseReason: 'unsupported-table', + context: { before: { code: 'table' }, after: { code: 'table' } }, + }, + }) + expect(model.edits[0]!.descriptors).toHaveLength(1) + } finally { + editor.destroy() + } + }) + + it('T17 rejects a 10001-column table before attempting column alignment', () => { + const valid = { header: [{ text: 'valid' }], rows: [] } + const hugeWidth = { + header: Array.from({ length: 10_001 }, (_value, index) => ({ text: `column ${index}` })), + rows: [], + } + const model = compareTableSpecs(valid, hugeWidth) + + expect(model.edits).toHaveLength(1) + expect(model.edits[0]!.primary.coarseReason).toBe('unsupported-table') + expect(model.edits[0]!.descriptors).toHaveLength(1) + }) + + it('F09 retains solved table work without partial edits when a later exact-evidence veto coarsens the table', () => { + const editor = createComparisonTestEditor('placeholder') + try { + const columnCount = 100 + const bodyTextLength = Math.floor(DEFAULT_COMPARISON_TOKEN_LEDGER / (4 * columnCount ** 2)) - 6 + const body = (suffix: string) => Array.from({ length: columnCount }, (_value, index) => ({ + text: `${String.fromCodePoint(0x100 + index).repeat(bodyTextLength - 1)}${suffix}`, + })) + const beforeHeader = Array.from({ length: columnCount }, (_value, index) => ({ text: index === 0 ? 'q' : 'a' })) + const afterHeader = Array.from({ length: columnCount }, (_value, index) => ({ text: index === columnCount - 1 ? 'q' : 'b' })) + const headerRowProfileLength = 2 * columnCount + const bodyRowProfileLength = (bodyTextLength + 2) * columnCount + const rowCharge = 2 * (3 * headerRowProfileLength + bodyRowProfileLength) + const columnProfileLength = bodyTextLength + 6 + const columnCharge = 2 * columnCount ** 2 * columnProfileLength + const remainingTokens = DEFAULT_COMPARISON_TOKEN_LEDGER - rowCharge - columnCharge + const laterProfileLength = Math.floor(remainingTokens / (2 * columnCount ** 2)) + 1 + const laterTextLength = laterProfileLength - 3 + const repeatedHeaders = (character: string) => Array.from( + { length: columnCount }, + () => ({ text: character.repeat(laterTextLength) }), + ) + const anchor = editor.schema.nodes.paragraph!.create(null, editor.schema.text('exact veto boundary anchor')) + const before = editor.schema.nodes.doc!.create(null, [ + tableNode(editor.schema, { header: beforeHeader, rows: [body('x')] }), + anchor, + tableNode(editor.schema, { header: repeatedHeaders('c'), rows: [] }), + ]) + const after = editor.schema.nodes.doc!.create(null, [ + tableNode(editor.schema, { header: afterHeader, rows: [body('y')] }), + anchor, + tableNode(editor.schema, { header: repeatedHeaders('d'), rows: [] }), + ]) + const model = createHierarchicalMarkdownComparisonModel(before, after) + + expect(2 * columnCount ** 2 * laterProfileLength).toBeGreaterThan(remainingTokens) + expect(2 * columnCount ** 2 * laterProfileLength).toBeLessThan(DEFAULT_COMPARISON_TOKEN_LEDGER) + expect(model.edits.map(({ primary }) => primary.coarseReason)).toEqual([ + 'table-evidence-conflict', + 'comparison-limit', + ]) + expect(model.edits.every(({ descriptors }) => descriptors.length === 1)).toBe(true) + } finally { + editor.destroy() + } + }) + + it('T18 shares table token work across consecutive column plans', () => { + interface Column { + key: string + profile: readonly string[] + } + const before: Column[] = [ + { key: 'before-a', profile: ['a', 'same-a'] }, + { key: 'before-b', profile: ['b', 'same-b'] }, + ] + const after: Column[] = [ + { key: 'after-a', profile: ['a', 'same-a'] }, + { key: 'after-b', profile: ['b', 'same-b'] }, + ] + const work = { remainingCells: 8, remainingTokenComparisons: 20 } + const options = { + work, + fingerprint: ({ key }: Column) => key, + profile: ({ profile }: Column) => profile, + compatible: () => true, + } + + const first = alignComparisonColumns(before, after, options) + expect(first.every((region) => !('coarseReason' in region && region.coarseReason === 'comparison-limit'))).toBe(true) + expect(work).toEqual({ remainingCells: 4, remainingTokenComparisons: 4 }) + + const second = alignComparisonColumns(before, after, options) + expect(second).toEqual([{ + before: { from: 0, to: 2 }, + after: { from: 0, to: 2 }, + coarseReason: 'comparison-limit', + }]) + expect(work).toEqual({ remainingCells: 4, remainingTokenComparisons: 4 }) + }) +}) diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 2f5bb9e5011..f67768a5c50 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -175,6 +175,7 @@ describe('Markdown though editor', () => { test('details', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('
\n**summary**\n* list\n\n
\n')).toBe('
\n**summary**\n* list\n\n
\n') + expect(markdownThroughEditor('
\nsummary\ncontent\n\n
\n')).toBe('
\nsummary\ncontent\n\n
\n') }) test('nested details', ({ markdownThroughEditor }) => { diff --git a/src/tests/markdownit/details.spec.js b/src/tests/markdownit/details.spec.js index dd077287e52..86f1c636f08 100644 --- a/src/tests/markdownit/details.spec.js +++ b/src/tests/markdownit/details.spec.js @@ -11,6 +11,10 @@ describe('Details extension', () => { const rendered = markdownit.render('
\nsummary\ncontent\n
') expect(stripIndent(rendered)).toBe('
summary

content

') }) + it('renders the native open state', () => { + const rendered = markdownit.render('
\nsummary\ncontent\n
') + expect(stripIndent(rendered)).toBe('
summary

content

') + }) it('renders with empty summary', () => { const rendered = markdownit.render('
\n\ncontent\n
') expect(stripIndent(rendered)).toBe('

content

')