// Port of the pi-fabric word-diff change-block pipeline // (src/ui/word-diff/change-block.ts, token-alignment.ts, ranges.ts). // The port keeps the trio the diff preview needs — tokens, alignment, // change blocks — and drops the full fabric engine's confidence tiers, // soft substitution, identifier-part refinement and smart filtering. import { suffixAlignedPairs } from "./alignment.ts"; import { type WordToken, wordTokens, wordTokenWeight } from "./tokens.ts"; export type TextRange = readonly [number, number]; export interface TokenGroup { readonly end: number; readonly start: number; } export interface ChangeBlock { readonly added: TokenGroup; readonly removed: TokenGroup; } export interface WordChangeRanges { readonly added: readonly TextRange[]; readonly removed: readonly TextRange[]; } const MAX_EXACT_ALIGNMENT_CELLS = 262_144; export function collectChangeBlocks( before: readonly WordToken[], after: readonly WordToken[] ): ChangeBlock[] { let beforeStart = 0; let beforeEnd = before.length; let afterStart = 0; let afterEnd = after.length; while ( beforeStart < beforeEnd && afterStart < afterEnd && before[beforeStart].value === after[afterStart].value ) { beforeStart += 1; afterStart += 1; } while ( beforeStart < beforeEnd && afterStart < afterEnd && before[beforeEnd - 1].value === after[afterEnd - 1].value ) { beforeEnd -= 1; afterEnd -= 1; } const blocks: ChangeBlock[] = []; if (beforeStart === beforeEnd && afterStart === afterEnd) { return blocks; } const beforeLength = beforeEnd - beforeStart; const afterLength = afterEnd - afterStart; if (beforeLength * afterLength > MAX_EXACT_ALIGNMENT_CELLS) { appendChangeBlock(blocks, beforeStart, beforeEnd, afterStart, afterEnd); return blocks; } const pairs = suffixAlignedPairs( beforeLength, afterLength, (beforeOffset, afterOffset) => { const beforeToken = before[beforeStart + beforeOffset]; const afterToken = after[afterStart + afterOffset]; return beforeToken.value === afterToken.value ? wordTokenWeight(beforeToken.value) : Number.NEGATIVE_INFINITY; } ); let beforeIndex = 0; let afterIndex = 0; for (const [nextBeforeIndex, nextAfterIndex] of pairs) { appendChangeBlock( blocks, beforeStart + beforeIndex, beforeStart + nextBeforeIndex, afterStart + afterIndex, afterStart + nextAfterIndex ); beforeIndex = nextBeforeIndex + 1; afterIndex = nextAfterIndex + 1; } appendChangeBlock( blocks, beforeStart + beforeIndex, beforeEnd, afterStart + afterIndex, afterEnd ); return blocks; } function appendChangeBlock( blocks: ChangeBlock[], beforeStart: number, beforeEnd: number, afterStart: number, afterEnd: number ): void { if (beforeStart === beforeEnd && afterStart === afterEnd) { return; } blocks.push({ removed: { start: beforeStart, end: beforeEnd }, added: { start: afterStart, end: afterEnd }, }); } export function changedWordRanges( beforeText: string, afterText: string ): WordChangeRanges { const before = wordTokens(beforeText); const after = wordTokens(afterText); const removed: TextRange[] = []; const added: TextRange[] = []; for (const block of collectChangeBlocks(before, after)) { for ( let index = block.removed.start; index < block.removed.end; index += 1 ) { const token = before[index]; if (token !== undefined) { removed.push([token.start, token.end]); } } for (let index = block.added.start; index < block.added.end; index += 1) { const token = after[index]; if (token !== undefined) { added.push([token.start, token.end]); } } } return { removed: mergeRanges(removed), added: mergeRanges(added) }; } function mergeRanges(ranges: readonly TextRange[]): readonly TextRange[] { const merged: TextRange[] = []; for (const range of ranges) { const previous = merged.at(-1); if (previous !== undefined && range[0] - previous[1] <= 1) { merged[merged.length - 1] = [previous[0], range[1]]; } else { merged.push([range[0], range[1]]); } } return merged; }