2025-12-14 12:33:23 +00:00
|
|
|
import * as plugins from './smartdiff.plugins.js';
|
2021-12-16 09:20:48 +01:00
|
|
|
|
2025-12-14 12:33:23 +00:00
|
|
|
// =============================================================================
|
|
|
|
|
// Types
|
|
|
|
|
// =============================================================================
|
2021-12-16 09:20:48 +01:00
|
|
|
|
2025-12-14 12:33:23 +00:00
|
|
|
export interface IDiffSegment {
|
|
|
|
|
type: 'equal' | 'insert' | 'delete';
|
|
|
|
|
value: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ILineDiff {
|
|
|
|
|
lineNumber: number;
|
|
|
|
|
type: 'equal' | 'insert' | 'delete';
|
|
|
|
|
value: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// ANSI Color Helpers (for console output)
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
const ANSI = {
|
|
|
|
|
red: '\x1b[31m',
|
|
|
|
|
green: '\x1b[32m',
|
|
|
|
|
gray: '\x1b[90m',
|
|
|
|
|
reset: '\x1b[0m',
|
|
|
|
|
bgRed: '\x1b[41m',
|
|
|
|
|
bgGreen: '\x1b[42m',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Backward Compatible API (createDiff / applyPatch)
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a compact JSON-encoded diff between two strings.
|
|
|
|
|
* Format: [[0, length], [1, "inserted text"], [-1, length], ...]
|
|
|
|
|
* - [0, n] = keep n characters from original
|
|
|
|
|
* - [1, "text"] = insert "text"
|
|
|
|
|
* - [-1, n] = delete n characters from original
|
|
|
|
|
*/
|
|
|
|
|
export const createDiff = (originalArg: string, revisionArg: string): string => {
|
|
|
|
|
const changes = plugins.diff.diffChars(originalArg, revisionArg);
|
|
|
|
|
const result: Array<[number, string | number]> = [];
|
|
|
|
|
|
|
|
|
|
for (const change of changes) {
|
|
|
|
|
if (change.added) {
|
|
|
|
|
// INSERT: store the actual text
|
|
|
|
|
result.push([1, change.value]);
|
|
|
|
|
} else if (change.removed) {
|
|
|
|
|
// DELETE: store just the length
|
|
|
|
|
result.push([-1, change.value.length]);
|
|
|
|
|
} else {
|
|
|
|
|
// EQUAL: store just the length
|
|
|
|
|
result.push([0, change.value.length]);
|
2021-12-16 09:20:48 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-14 12:33:23 +00:00
|
|
|
|
2021-12-16 09:20:48 +01:00
|
|
|
return JSON.stringify(result);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-14 12:33:23 +00:00
|
|
|
/**
|
|
|
|
|
* Applies a diff to the original text to reconstruct the revised text.
|
|
|
|
|
*/
|
|
|
|
|
export const applyPatch = (originalArg: string, deltaStringArg: string): string => {
|
|
|
|
|
const deltaArg: Array<[number, string | number]> = JSON.parse(deltaStringArg);
|
|
|
|
|
let result = '';
|
|
|
|
|
let index = 0;
|
2021-12-16 09:20:48 +01:00
|
|
|
|
2025-12-14 12:33:23 +00:00
|
|
|
for (const item of deltaArg) {
|
2021-12-16 09:20:48 +01:00
|
|
|
const operation = item[0];
|
|
|
|
|
const value = item[1];
|
|
|
|
|
|
|
|
|
|
if (operation === -1) {
|
2025-12-14 12:33:23 +00:00
|
|
|
// DELETE: skip characters in original
|
|
|
|
|
index += value as number;
|
2021-12-16 09:20:48 +01:00
|
|
|
} else if (operation === 0) {
|
2025-12-14 12:33:23 +00:00
|
|
|
// KEEP: copy characters from original
|
|
|
|
|
const length = value as number;
|
|
|
|
|
result += originalArg.slice(index, index + length);
|
|
|
|
|
index += length;
|
2021-12-16 09:20:48 +01:00
|
|
|
} else {
|
2025-12-14 12:33:23 +00:00
|
|
|
// INSERT: add the new text
|
|
|
|
|
result += value as string;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Character-Level Diff
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get character-level diff segments between two strings.
|
|
|
|
|
*/
|
|
|
|
|
export const getCharDiff = (original: string, revision: string): IDiffSegment[] => {
|
|
|
|
|
const changes = plugins.diff.diffChars(original, revision);
|
|
|
|
|
return changes.map((change) => ({
|
|
|
|
|
type: change.added ? 'insert' : change.removed ? 'delete' : 'equal',
|
|
|
|
|
value: change.value,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format character diff for console output with ANSI colors.
|
|
|
|
|
* - Deletions: red background
|
|
|
|
|
* - Insertions: green background
|
|
|
|
|
*/
|
|
|
|
|
export const formatCharDiffForConsole = (original: string, revision: string): string => {
|
|
|
|
|
const segments = getCharDiff(original, revision);
|
|
|
|
|
let result = '';
|
|
|
|
|
|
|
|
|
|
for (const segment of segments) {
|
|
|
|
|
switch (segment.type) {
|
|
|
|
|
case 'delete':
|
|
|
|
|
result += `${ANSI.bgRed}${segment.value}${ANSI.reset}`;
|
|
|
|
|
break;
|
|
|
|
|
case 'insert':
|
|
|
|
|
result += `${ANSI.bgGreen}${segment.value}${ANSI.reset}`;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
result += segment.value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format character diff as HTML with styled spans.
|
|
|
|
|
*/
|
|
|
|
|
export const formatCharDiffAsHtml = (original: string, revision: string): string => {
|
|
|
|
|
const segments = getCharDiff(original, revision);
|
|
|
|
|
let result = '';
|
|
|
|
|
|
|
|
|
|
for (const segment of segments) {
|
|
|
|
|
const escaped = escapeHtml(segment.value);
|
|
|
|
|
switch (segment.type) {
|
|
|
|
|
case 'delete':
|
|
|
|
|
result += `<span class="smartdiff-delete">${escaped}</span>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'insert':
|
|
|
|
|
result += `<span class="smartdiff-insert">${escaped}</span>`;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
result += `<span class="smartdiff-equal">${escaped}</span>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Word-Level Diff
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get word-level diff segments between two strings.
|
|
|
|
|
*/
|
|
|
|
|
export const getWordDiff = (original: string, revision: string): IDiffSegment[] => {
|
|
|
|
|
const changes = plugins.diff.diffWords(original, revision);
|
|
|
|
|
return changes.map((change) => ({
|
|
|
|
|
type: change.added ? 'insert' : change.removed ? 'delete' : 'equal',
|
|
|
|
|
value: change.value,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format word diff for console output with ANSI colors.
|
|
|
|
|
*/
|
|
|
|
|
export const formatWordDiffForConsole = (original: string, revision: string): string => {
|
|
|
|
|
const segments = getWordDiff(original, revision);
|
|
|
|
|
let result = '';
|
|
|
|
|
|
|
|
|
|
for (const segment of segments) {
|
|
|
|
|
switch (segment.type) {
|
|
|
|
|
case 'delete':
|
|
|
|
|
result += `${ANSI.red}${segment.value}${ANSI.reset}`;
|
|
|
|
|
break;
|
|
|
|
|
case 'insert':
|
|
|
|
|
result += `${ANSI.green}${segment.value}${ANSI.reset}`;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
result += segment.value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format word diff as HTML with styled spans.
|
|
|
|
|
*/
|
|
|
|
|
export const formatWordDiffAsHtml = (original: string, revision: string): string => {
|
|
|
|
|
const segments = getWordDiff(original, revision);
|
|
|
|
|
let result = '';
|
|
|
|
|
|
|
|
|
|
for (const segment of segments) {
|
|
|
|
|
const escaped = escapeHtml(segment.value);
|
|
|
|
|
switch (segment.type) {
|
|
|
|
|
case 'delete':
|
|
|
|
|
result += `<span class="smartdiff-delete">${escaped}</span>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'insert':
|
|
|
|
|
result += `<span class="smartdiff-insert">${escaped}</span>`;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
result += `<span class="smartdiff-equal">${escaped}</span>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Line-Level Diff
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get line-level diff between two strings.
|
|
|
|
|
*/
|
|
|
|
|
export const getLineDiff = (original: string, revision: string): ILineDiff[] => {
|
|
|
|
|
const changes = plugins.diff.diffLines(original, revision);
|
|
|
|
|
const result: ILineDiff[] = [];
|
|
|
|
|
let lineNumber = 1;
|
|
|
|
|
|
|
|
|
|
for (const change of changes) {
|
|
|
|
|
const lines = change.value.split('\n');
|
|
|
|
|
// Remove last empty element if the value ends with newline
|
|
|
|
|
if (lines[lines.length - 1] === '') {
|
|
|
|
|
lines.pop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
result.push({
|
|
|
|
|
lineNumber: change.removed ? lineNumber : change.added ? -1 : lineNumber,
|
|
|
|
|
type: change.added ? 'insert' : change.removed ? 'delete' : 'equal',
|
|
|
|
|
value: line,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!change.added) {
|
|
|
|
|
lineNumber++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format line diff for console output in unified diff style with ANSI colors.
|
|
|
|
|
*/
|
|
|
|
|
export const formatLineDiffForConsole = (original: string, revision: string): string => {
|
|
|
|
|
const changes = plugins.diff.diffLines(original, revision);
|
|
|
|
|
let result = '';
|
|
|
|
|
|
|
|
|
|
for (const change of changes) {
|
|
|
|
|
const lines = change.value.split('\n');
|
|
|
|
|
// Remove last empty element if the value ends with newline
|
|
|
|
|
if (lines[lines.length - 1] === '') {
|
|
|
|
|
lines.pop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (change.removed) {
|
|
|
|
|
result += `${ANSI.red}- ${line}${ANSI.reset}\n`;
|
|
|
|
|
} else if (change.added) {
|
|
|
|
|
result += `${ANSI.green}+ ${line}${ANSI.reset}\n`;
|
|
|
|
|
} else {
|
|
|
|
|
result += `${ANSI.gray} ${line}${ANSI.reset}\n`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format line diff as HTML with styled divs.
|
|
|
|
|
*/
|
|
|
|
|
export const formatLineDiffAsHtml = (original: string, revision: string): string => {
|
|
|
|
|
const changes = plugins.diff.diffLines(original, revision);
|
|
|
|
|
let result = '<div class="smartdiff-lines">\n';
|
|
|
|
|
|
|
|
|
|
for (const change of changes) {
|
|
|
|
|
const lines = change.value.split('\n');
|
|
|
|
|
// Remove last empty element if the value ends with newline
|
|
|
|
|
if (lines[lines.length - 1] === '') {
|
|
|
|
|
lines.pop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const escaped = escapeHtml(line);
|
|
|
|
|
if (change.removed) {
|
|
|
|
|
result += ` <div class="smartdiff-line smartdiff-delete"><span class="smartdiff-prefix">-</span>${escaped}</div>\n`;
|
|
|
|
|
} else if (change.added) {
|
|
|
|
|
result += ` <div class="smartdiff-line smartdiff-insert"><span class="smartdiff-prefix">+</span>${escaped}</div>\n`;
|
|
|
|
|
} else {
|
|
|
|
|
result += ` <div class="smartdiff-line smartdiff-equal"><span class="smartdiff-prefix"> </span>${escaped}</div>\n`;
|
|
|
|
|
}
|
2021-12-16 09:20:48 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-14 12:33:23 +00:00
|
|
|
|
|
|
|
|
result += '</div>';
|
2021-12-16 09:20:48 +01:00
|
|
|
return result;
|
2025-12-14 12:33:23 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Unified Diff (Git-style)
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a unified diff patch string (like git diff output).
|
|
|
|
|
*/
|
|
|
|
|
export const createUnifiedDiff = (
|
|
|
|
|
original: string,
|
|
|
|
|
revision: string,
|
|
|
|
|
options?: {
|
|
|
|
|
originalFileName?: string;
|
|
|
|
|
revisedFileName?: string;
|
|
|
|
|
context?: number;
|
|
|
|
|
}
|
|
|
|
|
): string => {
|
|
|
|
|
const opts = {
|
|
|
|
|
originalFileName: options?.originalFileName ?? 'original',
|
|
|
|
|
revisedFileName: options?.revisedFileName ?? 'revised',
|
|
|
|
|
context: options?.context ?? 3,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return plugins.diff.createTwoFilesPatch(
|
|
|
|
|
opts.originalFileName,
|
|
|
|
|
opts.revisedFileName,
|
|
|
|
|
original,
|
|
|
|
|
revision,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
{ context: opts.context }
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format unified diff for console with ANSI colors.
|
|
|
|
|
*/
|
|
|
|
|
export const formatUnifiedDiffForConsole = (
|
|
|
|
|
original: string,
|
|
|
|
|
revision: string,
|
|
|
|
|
options?: {
|
|
|
|
|
originalFileName?: string;
|
|
|
|
|
revisedFileName?: string;
|
|
|
|
|
context?: number;
|
|
|
|
|
}
|
|
|
|
|
): string => {
|
|
|
|
|
const patch = createUnifiedDiff(original, revision, options);
|
|
|
|
|
const lines = patch.split('\n');
|
|
|
|
|
let result = '';
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (line.startsWith('---') || line.startsWith('+++')) {
|
|
|
|
|
result += `${ANSI.gray}${line}${ANSI.reset}\n`;
|
|
|
|
|
} else if (line.startsWith('-')) {
|
|
|
|
|
result += `${ANSI.red}${line}${ANSI.reset}\n`;
|
|
|
|
|
} else if (line.startsWith('+')) {
|
|
|
|
|
result += `${ANSI.green}${line}${ANSI.reset}\n`;
|
|
|
|
|
} else if (line.startsWith('@@')) {
|
|
|
|
|
result += `${ANSI.gray}${line}${ANSI.reset}\n`;
|
|
|
|
|
} else {
|
|
|
|
|
result += `${line}\n`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.trimEnd();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// CSS Helper
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get default CSS styles for HTML diff output.
|
|
|
|
|
*/
|
|
|
|
|
export const getDefaultDiffCss = (): string => {
|
|
|
|
|
return `
|
|
|
|
|
.smartdiff-delete {
|
|
|
|
|
background-color: #ffebe9;
|
|
|
|
|
color: #82071e;
|
|
|
|
|
text-decoration: line-through;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-insert {
|
|
|
|
|
background-color: #dafbe1;
|
|
|
|
|
color: #116329;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-equal {
|
|
|
|
|
color: inherit;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-lines {
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
white-space: pre;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-line {
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-line.smartdiff-delete {
|
|
|
|
|
background-color: #ffebe9;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-line.smartdiff-insert {
|
|
|
|
|
background-color: #dafbe1;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-line.smartdiff-equal {
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
}
|
|
|
|
|
.smartdiff-prefix {
|
|
|
|
|
user-select: none;
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
color: #6e7781;
|
|
|
|
|
}
|
|
|
|
|
`.trim();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Utility Functions
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Escape HTML special characters.
|
|
|
|
|
*/
|
|
|
|
|
function escapeHtml(text: string): string {
|
|
|
|
|
return text
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
}
|