packages/jsondiffpatch/src/filters/texts.ts
import type dmp from 'diff-match-patch';
import type DiffContext from '../contexts/diff.js';
import type PatchContext from '../contexts/patch.js';
import type ReverseContext from '../contexts/reverse.js';
import type {
AddedDelta,
DeletedDelta,
Filter,
ModifiedDelta,
MovedDelta,
Options,
TextDiffDelta,
} from '../types.js';
interface DiffPatch {
diff: (txt1: string, txt2: string) => string;
patch: (txt1: string, string: string) => string;
}
const TEXT_DIFF = 2;
const DEFAULT_MIN_LENGTH = 60;
let cachedDiffPatch: DiffPatch | null = null;
function getDiffMatchPatch(
options: Options | undefined,
required: true,
): DiffPatch;
function getDiffMatchPatch(
options: Options | undefined,
required?: boolean,
): DiffPatch | null;
function getDiffMatchPatch(options: Options | undefined, required?: boolean) {
if (!cachedDiffPatch) {
let instance: dmp;
if (options?.textDiff?.diffMatchPatch) {
instance = new options.textDiff.diffMatchPatch();
} else {
if (!required) {
return null;
}
const error: Error & { diff_match_patch_not_found?: boolean } = new Error(
'The diff-match-patch library was not provided. Pass the library in through the options or use the `jsondiffpatch/with-text-diffs` entry-point.',
);
// eslint-disable-next-line camelcase
error.diff_match_patch_not_found = true;
throw error;
}
cachedDiffPatch = {
diff: function (txt1, txt2) {
return instance.patch_toText(instance.patch_make(txt1, txt2));
},
patch: function (txt1, patch) {
const results = instance.patch_apply(
instance.patch_fromText(patch),
txt1,
);
for (let i = 0; i < results[1].length; i++) {
if (!results[1][i]) {
const error: Error & { textPatchFailed?: boolean } = new Error(
'text patch failed',
);
error.textPatchFailed = true;
}
}
return results[0];
},
};
}
return cachedDiffPatch;
}
export const diffFilter: Filter<DiffContext> = function textsDiffFilter(
context,
) {
if (context.leftType !== 'string') {
return;
}
const left = context.left as string;
const right = context.right as string;
const minLength =
(context.options &&
context.options.textDiff &&
context.options.textDiff.minLength) ||
DEFAULT_MIN_LENGTH;
if (left.length < minLength || right.length < minLength) {
context.setResult([left, right]).exit();
return;
}
// large text, try to use a text-diff algorithm
const diffMatchPatch = getDiffMatchPatch(context.options);
if (!diffMatchPatch) {
// diff-match-patch library not available,
// fallback to regular string replace
context.setResult([left, right]).exit();
return;
}
const diff = diffMatchPatch.diff;
context.setResult([diff(left, right), 0, TEXT_DIFF]).exit();
};
diffFilter.filterName = 'texts';
export const patchFilter: Filter<PatchContext> = function textsPatchFilter(
context,
) {
if (context.nested) {
return;
}
const nonNestedDelta = context.delta as
| AddedDelta
| ModifiedDelta
| DeletedDelta
| MovedDelta
| TextDiffDelta;
if (nonNestedDelta[2] !== TEXT_DIFF) {
return;
}
const textDiffDelta = nonNestedDelta as TextDiffDelta;
// text-diff, use a text-patch algorithm
const patch = getDiffMatchPatch(context.options, true).patch;
context.setResult(patch(context.left as string, textDiffDelta[0])).exit();
};
patchFilter.filterName = 'texts';
const textDeltaReverse = function (delta: string) {
let i;
let l;
let line;
let lineTmp;
let header = null;
const headerRegex = /^@@ +-(\d+),(\d+) +\+(\d+),(\d+) +@@$/;
let lineHeader;
const lines = delta.split('\n');
for (i = 0, l = lines.length; i < l; i++) {
line = lines[i];
const lineStart = line.slice(0, 1);
if (lineStart === '@') {
header = headerRegex.exec(line)!;
lineHeader = i;
// fix header
lines[lineHeader] =
'@@ -' +
header[3] +
',' +
header[4] +
' +' +
header[1] +
',' +
header[2] +
' @@';
} else if (lineStart === '+') {
lines[i] = '-' + lines[i].slice(1);
if (lines[i - 1].slice(0, 1) === '+') {
// swap lines to keep default order (-+)
lineTmp = lines[i];
lines[i] = lines[i - 1];
lines[i - 1] = lineTmp;
}
} else if (lineStart === '-') {
lines[i] = '+' + lines[i].slice(1);
}
}
return lines.join('\n');
};
export const reverseFilter: Filter<ReverseContext> =
function textsReverseFilter(context) {
if (context.nested) {
return;
}
const nonNestedDelta = context.delta as
| AddedDelta
| ModifiedDelta
| DeletedDelta
| MovedDelta
| TextDiffDelta;
if (nonNestedDelta[2] !== TEXT_DIFF) {
return;
}
const textDiffDelta = nonNestedDelta as TextDiffDelta;
// text-diff, use a text-diff algorithm
context
.setResult([textDeltaReverse(textDiffDelta[0]), 0, TEXT_DIFF])
.exit();
};
reverseFilter.filterName = 'texts';