UNPKG

@limetech/lime-elements

Version:
309 lines (308 loc) • 10.2 kB
import { diffLines, diffWords } from "diff"; /** * Compute a structured diff between two strings. * * @param oldText - the "before" text * @param newText - the "after" text * @param contextLines - number of unchanged lines to show around each change * @returns a DiffResult with hunks, additions, and deletions counts */ export function computeDiff(oldText, newText, contextLines = 3) { const allLines = buildDiffLines(oldText, newText); return groupIntoHunks(allLines, contextLines); } /** * Re-group previously computed flat lines into hunks. * Used when expanding collapsed sections without re-diffing. * * @param allLines - the full flat diff lines from a previous computation * @param contextLines - number of context lines around changes * @returns a DiffResult with new hunk groupings */ export function regroupLines(allLines, contextLines) { return groupIntoHunks(allLines, contextLines); } /** * Build paired rows for split (side-by-side) view from flat diff lines. * Context lines appear on both sides. Adjacent removed+added lines * are paired into the same row. * * @param lines - flat diff lines * @returns paired rows for split rendering */ export function buildSplitLines(lines) { const rows = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.type === 'context') { rows.push({ left: line, right: line }); i++; continue; } i = collectAndPairChanges(lines, i, rows); } return rows; } /** * Collect consecutive removed then added lines starting at `index`, * pair them into split rows, and return the new index. * @param lines - flat diff lines * @param index - starting index * @param rows - output array to push paired rows into */ function collectAndPairChanges(lines, index, rows) { const removed = []; while (index < lines.length && lines[index].type === 'removed') { removed.push(lines[index]); index++; } const added = []; while (index < lines.length && lines[index].type === 'added') { added.push(lines[index]); index++; } const maxPairs = Math.max(removed.length, added.length); for (let j = 0; j < maxPairs; j++) { rows.push({ left: j < removed.length ? removed[j] : undefined, right: j < added.length ? added[j] : undefined, }); } return index; } /** * Normalize values for diffing. If `reformatJson` is true, * parse and re-stringify with sorted keys and consistent indentation. * @param value * @param reformatJson */ export function normalizeForDiff(value, reformatJson = false) { if (typeof value === 'object' && value !== null) { return JSON.stringify(sortKeysDeep(value), null, 4); } if (typeof value === 'string' && reformatJson) { try { const parsed = JSON.parse(value); return JSON.stringify(sortKeysDeep(parsed), null, 4); } catch (_a) { return value; } } return String(value !== null && value !== void 0 ? value : ''); } function sortKeysDeep(obj) { if (Array.isArray(obj)) { return obj.map(sortKeysDeep); } if (obj !== null && typeof obj === 'object') { const sorted = {}; const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b)); for (const key of keys) { sorted[key] = sortKeysDeep(obj[key]); } return sorted; } return obj; } /** * Build a flat list of DiffLines from two text strings. * @param oldText * @param newText */ function buildDiffLines(oldText, newText) { const changes = diffLines(oldText, newText); const lines = []; let oldLineNum = 1; let newLineNum = 1; for (const change of changes) { const changeLines = splitIntoLines(change.value); for (const line of changeLines) { if (change.added) { lines.push({ type: 'added', content: line, newLineNumber: newLineNum++, }); } else if (change.removed) { lines.push({ type: 'removed', content: line, oldLineNumber: oldLineNum++, }); } else { lines.push({ type: 'context', content: line, oldLineNumber: oldLineNum++, newLineNumber: newLineNum++, }); } } } addWordLevelHighlighting(lines); return lines; } /** * Split a string into lines, handling the trailing newline * that jsdiff includes in each change value. * @param text */ function splitIntoLines(text) { if (!text) { return []; } const lines = text.split('\n'); // jsdiff includes a trailing newline, producing an empty last element if (lines.length > 0 && lines.at(-1) === '') { lines.pop(); } return lines; } /** * Pair adjacent removed+added lines and compute word-level diffs * to highlight only the specific segments that changed. * @param lines */ function addWordLevelHighlighting(lines) { let i = 0; while (i < lines.length) { // Find consecutive removed lines const removedStart = i; while (i < lines.length && lines[i].type === 'removed') { i++; } const removedEnd = i; // Find consecutive added lines right after const addedStart = i; while (i < lines.length && lines[i].type === 'added') { i++; } const addedEnd = i; const removedCount = removedEnd - removedStart; const addedCount = addedEnd - addedStart; // Pair them up for word-level highlighting if (removedCount > 0 && addedCount > 0) { const pairCount = Math.min(removedCount, addedCount); for (let j = 0; j < pairCount; j++) { const removedLine = lines[removedStart + j]; const addedLine = lines[addedStart + j]; const [removedSegments, addedSegments] = computeWordSegments(removedLine.content, addedLine.content); removedLine.segments = removedSegments; addedLine.segments = addedSegments; } } // Skip context lines if (i === removedStart) { i++; } } } /** * Compute word-level diff segments for a pair of lines. * @param oldContent * @param newContent */ function computeWordSegments(oldContent, newContent) { const wordChanges = diffWords(oldContent, newContent); const removedSegments = []; const addedSegments = []; for (const change of wordChanges) { if (change.added) { addedSegments.push({ value: change.value, type: 'added' }); } else if (change.removed) { removedSegments.push({ value: change.value, type: 'removed' }); } else { removedSegments.push({ value: change.value, type: 'equal' }); addedSegments.push({ value: change.value, type: 'equal' }); } } return [removedSegments, addedSegments]; } /** * Group a flat list of diff lines into hunks with context. * @param lines * @param contextLines */ function groupIntoHunks(lines, contextLines) { if (lines.length === 0) { return { hunks: [], additions: 0, deletions: 0, allLines: lines }; } let additions = 0; let deletions = 0; for (const line of lines) { if (line.type === 'added') { additions++; } else if (line.type === 'removed') { deletions++; } } // If there are no changes, return a single empty result if (additions === 0 && deletions === 0) { return { hunks: [], additions: 0, deletions: 0, allLines: lines }; } // Find ranges of changed lines with their context const changeIndices = []; for (const [i, line] of lines.entries()) { if (line.type !== 'context') { changeIndices.push(i); } } // Build hunk boundaries const hunkBoundaries = buildHunkBoundaries(changeIndices, lines.length, contextLines); const hunks = []; let prevEnd = 0; for (const boundary of hunkBoundaries) { const hunkLines = lines.slice(boundary.start, boundary.end); const hiddenBefore = boundary.start - prevEnd; const collapsedBefore = hiddenBefore > 0 ? hiddenBefore : undefined; hunks.push({ lines: hunkLines, collapsedBefore, startIndex: boundary.start, }); prevEnd = boundary.end; } // Calculate collapsed lines after the last hunk const lastBoundary = hunkBoundaries.at(-1); const collapsedAfter = lastBoundary.end < lines.length ? lines.length - lastBoundary.end : undefined; return { hunks, additions, deletions, collapsedAfter, allLines: lines }; } /** * Build the start/end boundaries of each hunk based on change positions. * Merges hunks that overlap or are adjacent. * @param changeIndices * @param totalLines * @param contextLines */ function buildHunkBoundaries(changeIndices, totalLines, contextLines) { if (changeIndices.length === 0) { return []; } const boundaries = []; let currentStart = Math.max(0, changeIndices[0] - contextLines); let currentEnd = Math.min(totalLines, changeIndices[0] + contextLines + 1); for (let i = 1; i < changeIndices.length; i++) { const changeStart = Math.max(0, changeIndices[i] - contextLines); const changeEnd = Math.min(totalLines, changeIndices[i] + contextLines + 1); if (changeStart <= currentEnd) { // Merge overlapping hunks currentEnd = Math.max(currentEnd, changeEnd); } else { boundaries.push({ start: currentStart, end: currentEnd }); currentStart = changeStart; currentEnd = changeEnd; } } boundaries.push({ start: currentStart, end: currentEnd }); return boundaries; }