2314 lines
109 KiB
JavaScript
2314 lines
109 KiB
JavaScript
|
|
(function (global, factory) {
|
|||
|
|
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
|||
|
|
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
|||
|
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Diff = {}));
|
|||
|
|
})(this, (function (exports) { 'use strict';
|
|||
|
|
|
|||
|
|
class Diff {
|
|||
|
|
diff(oldStr, newStr,
|
|||
|
|
// Type below is not accurate/complete - see above for full possibilities - but it compiles
|
|||
|
|
options = {}) {
|
|||
|
|
let callback;
|
|||
|
|
if (typeof options === 'function') {
|
|||
|
|
callback = options;
|
|||
|
|
options = {};
|
|||
|
|
}
|
|||
|
|
else if ('callback' in options) {
|
|||
|
|
callback = options.callback;
|
|||
|
|
}
|
|||
|
|
// Allow subclasses to massage the input prior to running
|
|||
|
|
const oldString = this.castInput(oldStr, options);
|
|||
|
|
const newString = this.castInput(newStr, options);
|
|||
|
|
const oldTokens = this.removeEmpty(this.tokenize(oldString, options));
|
|||
|
|
const newTokens = this.removeEmpty(this.tokenize(newString, options));
|
|||
|
|
return this.diffWithOptionsObj(oldTokens, newTokens, options, callback);
|
|||
|
|
}
|
|||
|
|
diffWithOptionsObj(oldTokens, newTokens, options, callback) {
|
|||
|
|
var _a;
|
|||
|
|
const done = (value) => {
|
|||
|
|
value = this.postProcess(value, options);
|
|||
|
|
if (callback) {
|
|||
|
|
setTimeout(function () { callback(value); }, 0);
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
const newLen = newTokens.length, oldLen = oldTokens.length;
|
|||
|
|
let editLength = 1;
|
|||
|
|
let maxEditLength = newLen + oldLen;
|
|||
|
|
if (options.maxEditLength != null) {
|
|||
|
|
maxEditLength = Math.min(maxEditLength, options.maxEditLength);
|
|||
|
|
}
|
|||
|
|
const maxExecutionTime = (_a = options.timeout) !== null && _a !== void 0 ? _a : Infinity;
|
|||
|
|
const abortAfterTimestamp = Date.now() + maxExecutionTime;
|
|||
|
|
const bestPath = [{ oldPos: -1, lastComponent: undefined }];
|
|||
|
|
// Seed editLength = 0, i.e. the content starts with the same values
|
|||
|
|
let newPos = this.extractCommon(bestPath[0], newTokens, oldTokens, 0, options);
|
|||
|
|
if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
|
|||
|
|
// Identity per the equality and tokenizer
|
|||
|
|
return done(this.buildValues(bestPath[0].lastComponent, newTokens, oldTokens));
|
|||
|
|
}
|
|||
|
|
// Once we hit the right edge of the edit graph on some diagonal k, we can
|
|||
|
|
// definitely reach the end of the edit graph in no more than k edits, so
|
|||
|
|
// there's no point in considering any moves to diagonal k+1 any more (from
|
|||
|
|
// which we're guaranteed to need at least k+1 more edits).
|
|||
|
|
// Similarly, once we've reached the bottom of the edit graph, there's no
|
|||
|
|
// point considering moves to lower diagonals.
|
|||
|
|
// We record this fact by setting minDiagonalToConsider and
|
|||
|
|
// maxDiagonalToConsider to some finite value once we've hit the edge of
|
|||
|
|
// the edit graph.
|
|||
|
|
// This optimization is not faithful to the original algorithm presented in
|
|||
|
|
// Myers's paper, which instead pointlessly extends D-paths off the end of
|
|||
|
|
// the edit graph - see page 7 of Myers's paper which notes this point
|
|||
|
|
// explicitly and illustrates it with a diagram. This has major performance
|
|||
|
|
// implications for some common scenarios. For instance, to compute a diff
|
|||
|
|
// where the new text simply appends d characters on the end of the
|
|||
|
|
// original text of length n, the true Myers algorithm will take O(n+d^2)
|
|||
|
|
// time while this optimization needs only O(n+d) time.
|
|||
|
|
let minDiagonalToConsider = -Infinity, maxDiagonalToConsider = Infinity;
|
|||
|
|
// Main worker method. checks all permutations of a given edit length for acceptance.
|
|||
|
|
const execEditLength = () => {
|
|||
|
|
for (let diagonalPath = Math.max(minDiagonalToConsider, -editLength); diagonalPath <= Math.min(maxDiagonalToConsider, editLength); diagonalPath += 2) {
|
|||
|
|
let basePath;
|
|||
|
|
const removePath = bestPath[diagonalPath - 1], addPath = bestPath[diagonalPath + 1];
|
|||
|
|
if (removePath) {
|
|||
|
|
// No one else is going to attempt to use this value, clear it
|
|||
|
|
// @ts-expect-error - perf optimisation. This type-violating value will never be read.
|
|||
|
|
bestPath[diagonalPath - 1] = undefined;
|
|||
|
|
}
|
|||
|
|
let canAdd = false;
|
|||
|
|
if (addPath) {
|
|||
|
|
// what newPos will be after we do an insertion:
|
|||
|
|
const addPathNewPos = addPath.oldPos - diagonalPath;
|
|||
|
|
canAdd = addPath && 0 <= addPathNewPos && addPathNewPos < newLen;
|
|||
|
|
}
|
|||
|
|
const canRemove = removePath && removePath.oldPos + 1 < oldLen;
|
|||
|
|
if (!canAdd && !canRemove) {
|
|||
|
|
// If this path is a terminal then prune
|
|||
|
|
// @ts-expect-error - perf optimisation. This type-violating value will never be read.
|
|||
|
|
bestPath[diagonalPath] = undefined;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// Select the diagonal that we want to branch from. We select the prior
|
|||
|
|
// path whose position in the old string is the farthest from the origin
|
|||
|
|
// and does not pass the bounds of the diff graph
|
|||
|
|
if (!canRemove || (canAdd && removePath.oldPos < addPath.oldPos)) {
|
|||
|
|
basePath = this.addToPath(addPath, true, false, 0, options);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
basePath = this.addToPath(removePath, false, true, 1, options);
|
|||
|
|
}
|
|||
|
|
newPos = this.extractCommon(basePath, newTokens, oldTokens, diagonalPath, options);
|
|||
|
|
if (basePath.oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
|
|||
|
|
// If we have hit the end of both strings, then we are done
|
|||
|
|
return done(this.buildValues(basePath.lastComponent, newTokens, oldTokens)) || true;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
bestPath[diagonalPath] = basePath;
|
|||
|
|
if (basePath.oldPos + 1 >= oldLen) {
|
|||
|
|
maxDiagonalToConsider = Math.min(maxDiagonalToConsider, diagonalPath - 1);
|
|||
|
|
}
|
|||
|
|
if (newPos + 1 >= newLen) {
|
|||
|
|
minDiagonalToConsider = Math.max(minDiagonalToConsider, diagonalPath + 1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
editLength++;
|
|||
|
|
};
|
|||
|
|
// Performs the length of edit iteration. Is a bit fugly as this has to support the
|
|||
|
|
// sync and async mode which is never fun. Loops over execEditLength until a value
|
|||
|
|
// is produced, or until the edit length exceeds options.maxEditLength (if given),
|
|||
|
|
// in which case it will return undefined.
|
|||
|
|
if (callback) {
|
|||
|
|
(function exec() {
|
|||
|
|
setTimeout(function () {
|
|||
|
|
if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) {
|
|||
|
|
return callback(undefined);
|
|||
|
|
}
|
|||
|
|
if (!execEditLength()) {
|
|||
|
|
exec();
|
|||
|
|
}
|
|||
|
|
}, 0);
|
|||
|
|
}());
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) {
|
|||
|
|
const ret = execEditLength();
|
|||
|
|
if (ret) {
|
|||
|
|
return ret;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
addToPath(path, added, removed, oldPosInc, options) {
|
|||
|
|
const last = path.lastComponent;
|
|||
|
|
if (last && !options.oneChangePerToken && last.added === added && last.removed === removed) {
|
|||
|
|
return {
|
|||
|
|
oldPos: path.oldPos + oldPosInc,
|
|||
|
|
lastComponent: { count: last.count + 1, added: added, removed: removed, previousComponent: last.previousComponent }
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
return {
|
|||
|
|
oldPos: path.oldPos + oldPosInc,
|
|||
|
|
lastComponent: { count: 1, added: added, removed: removed, previousComponent: last }
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
extractCommon(basePath, newTokens, oldTokens, diagonalPath, options) {
|
|||
|
|
const newLen = newTokens.length, oldLen = oldTokens.length;
|
|||
|
|
let oldPos = basePath.oldPos, newPos = oldPos - diagonalPath, commonCount = 0;
|
|||
|
|
while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(oldTokens[oldPos + 1], newTokens[newPos + 1], options)) {
|
|||
|
|
newPos++;
|
|||
|
|
oldPos++;
|
|||
|
|
commonCount++;
|
|||
|
|
if (options.oneChangePerToken) {
|
|||
|
|
basePath.lastComponent = { count: 1, previousComponent: basePath.lastComponent, added: false, removed: false };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (commonCount && !options.oneChangePerToken) {
|
|||
|
|
basePath.lastComponent = { count: commonCount, previousComponent: basePath.lastComponent, added: false, removed: false };
|
|||
|
|
}
|
|||
|
|
basePath.oldPos = oldPos;
|
|||
|
|
return newPos;
|
|||
|
|
}
|
|||
|
|
equals(left, right, options) {
|
|||
|
|
if (options.comparator) {
|
|||
|
|
return options.comparator(left, right);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
return left === right
|
|||
|
|
|| (!!options.ignoreCase && left.toLowerCase() === right.toLowerCase());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
removeEmpty(array) {
|
|||
|
|
const ret = [];
|
|||
|
|
for (let i = 0; i < array.length; i++) {
|
|||
|
|
if (array[i]) {
|
|||
|
|
ret.push(array[i]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ret;
|
|||
|
|
}
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
|
|
castInput(value, options) {
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
|
|
tokenize(value, options) {
|
|||
|
|
return Array.from(value);
|
|||
|
|
}
|
|||
|
|
join(chars) {
|
|||
|
|
// Assumes ValueT is string, which is the case for most subclasses.
|
|||
|
|
// When it's false, e.g. in diffArrays, this method needs to be overridden (e.g. with a no-op)
|
|||
|
|
// Yes, the casts are verbose and ugly, because this pattern - of having the base class SORT OF
|
|||
|
|
// assume tokens and values are strings, but not completely - is weird and janky.
|
|||
|
|
return chars.join('');
|
|||
|
|
}
|
|||
|
|
postProcess(changeObjects,
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
|
|
options) {
|
|||
|
|
return changeObjects;
|
|||
|
|
}
|
|||
|
|
get useLongestToken() {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
buildValues(lastComponent, newTokens, oldTokens) {
|
|||
|
|
// First we convert our linked list of components in reverse order to an
|
|||
|
|
// array in the right order:
|
|||
|
|
const components = [];
|
|||
|
|
let nextComponent;
|
|||
|
|
while (lastComponent) {
|
|||
|
|
components.push(lastComponent);
|
|||
|
|
nextComponent = lastComponent.previousComponent;
|
|||
|
|
delete lastComponent.previousComponent;
|
|||
|
|
lastComponent = nextComponent;
|
|||
|
|
}
|
|||
|
|
components.reverse();
|
|||
|
|
const componentLen = components.length;
|
|||
|
|
let componentPos = 0, newPos = 0, oldPos = 0;
|
|||
|
|
for (; componentPos < componentLen; componentPos++) {
|
|||
|
|
const component = components[componentPos];
|
|||
|
|
if (!component.removed) {
|
|||
|
|
if (!component.added && this.useLongestToken) {
|
|||
|
|
let value = newTokens.slice(newPos, newPos + component.count);
|
|||
|
|
value = value.map(function (value, i) {
|
|||
|
|
const oldValue = oldTokens[oldPos + i];
|
|||
|
|
return oldValue.length > value.length ? oldValue : value;
|
|||
|
|
});
|
|||
|
|
component.value = this.join(value);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
component.value = this.join(newTokens.slice(newPos, newPos + component.count));
|
|||
|
|
}
|
|||
|
|
newPos += component.count;
|
|||
|
|
// Common case
|
|||
|
|
if (!component.added) {
|
|||
|
|
oldPos += component.count;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
component.value = this.join(oldTokens.slice(oldPos, oldPos + component.count));
|
|||
|
|
oldPos += component.count;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return components;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class CharacterDiff extends Diff {
|
|||
|
|
}
|
|||
|
|
const characterDiff = new CharacterDiff();
|
|||
|
|
function diffChars(oldStr, newStr, options) {
|
|||
|
|
return characterDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function longestCommonPrefix(str1, str2) {
|
|||
|
|
let i;
|
|||
|
|
for (i = 0; i < str1.length && i < str2.length; i++) {
|
|||
|
|
if (str1[i] != str2[i]) {
|
|||
|
|
return str1.slice(0, i);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return str1.slice(0, i);
|
|||
|
|
}
|
|||
|
|
function longestCommonSuffix(str1, str2) {
|
|||
|
|
let i;
|
|||
|
|
// Unlike longestCommonPrefix, we need a special case to handle all scenarios
|
|||
|
|
// where we return the empty string since str1.slice(-0) will return the
|
|||
|
|
// entire string.
|
|||
|
|
if (!str1 || !str2 || str1[str1.length - 1] != str2[str2.length - 1]) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
for (i = 0; i < str1.length && i < str2.length; i++) {
|
|||
|
|
if (str1[str1.length - (i + 1)] != str2[str2.length - (i + 1)]) {
|
|||
|
|
return str1.slice(-i);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return str1.slice(-i);
|
|||
|
|
}
|
|||
|
|
function replacePrefix(string, oldPrefix, newPrefix) {
|
|||
|
|
if (string.slice(0, oldPrefix.length) != oldPrefix) {
|
|||
|
|
throw Error(`string ${JSON.stringify(string)} doesn't start with prefix ${JSON.stringify(oldPrefix)}; this is a bug`);
|
|||
|
|
}
|
|||
|
|
return newPrefix + string.slice(oldPrefix.length);
|
|||
|
|
}
|
|||
|
|
function replaceSuffix(string, oldSuffix, newSuffix) {
|
|||
|
|
if (!oldSuffix) {
|
|||
|
|
return string + newSuffix;
|
|||
|
|
}
|
|||
|
|
if (string.slice(-oldSuffix.length) != oldSuffix) {
|
|||
|
|
throw Error(`string ${JSON.stringify(string)} doesn't end with suffix ${JSON.stringify(oldSuffix)}; this is a bug`);
|
|||
|
|
}
|
|||
|
|
return string.slice(0, -oldSuffix.length) + newSuffix;
|
|||
|
|
}
|
|||
|
|
function removePrefix(string, oldPrefix) {
|
|||
|
|
return replacePrefix(string, oldPrefix, '');
|
|||
|
|
}
|
|||
|
|
function removeSuffix(string, oldSuffix) {
|
|||
|
|
return replaceSuffix(string, oldSuffix, '');
|
|||
|
|
}
|
|||
|
|
function maximumOverlap(string1, string2) {
|
|||
|
|
return string2.slice(0, overlapCount(string1, string2));
|
|||
|
|
}
|
|||
|
|
// Nicked from https://stackoverflow.com/a/60422853/1709587
|
|||
|
|
function overlapCount(a, b) {
|
|||
|
|
// Deal with cases where the strings differ in length
|
|||
|
|
let startA = 0;
|
|||
|
|
if (a.length > b.length) {
|
|||
|
|
startA = a.length - b.length;
|
|||
|
|
}
|
|||
|
|
let endB = b.length;
|
|||
|
|
if (a.length < b.length) {
|
|||
|
|
endB = a.length;
|
|||
|
|
}
|
|||
|
|
// Create a back-reference for each index
|
|||
|
|
// that should be followed in case of a mismatch.
|
|||
|
|
// We only need B to make these references:
|
|||
|
|
const map = Array(endB);
|
|||
|
|
let k = 0; // Index that lags behind j
|
|||
|
|
map[0] = 0;
|
|||
|
|
for (let j = 1; j < endB; j++) {
|
|||
|
|
if (b[j] == b[k]) {
|
|||
|
|
map[j] = map[k]; // skip over the same character (optional optimisation)
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
map[j] = k;
|
|||
|
|
}
|
|||
|
|
while (k > 0 && b[j] != b[k]) {
|
|||
|
|
k = map[k];
|
|||
|
|
}
|
|||
|
|
if (b[j] == b[k]) {
|
|||
|
|
k++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Phase 2: use these references while iterating over A
|
|||
|
|
k = 0;
|
|||
|
|
for (let i = startA; i < a.length; i++) {
|
|||
|
|
while (k > 0 && a[i] != b[k]) {
|
|||
|
|
k = map[k];
|
|||
|
|
}
|
|||
|
|
if (a[i] == b[k]) {
|
|||
|
|
k++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return k;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Returns true if the string consistently uses Windows line endings.
|
|||
|
|
*/
|
|||
|
|
function hasOnlyWinLineEndings(string) {
|
|||
|
|
return string.includes('\r\n') && !string.startsWith('\n') && !string.match(/[^\r]\n/);
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Returns true if the string consistently uses Unix line endings.
|
|||
|
|
*/
|
|||
|
|
function hasOnlyUnixLineEndings(string) {
|
|||
|
|
return !string.includes('\r\n') && string.includes('\n');
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Split a string into segments using a word segmenter, merging consecutive
|
|||
|
|
* segments if they are both whitespace segments. Whitespace segments can
|
|||
|
|
* appear adjacent to one another for two reasons:
|
|||
|
|
* - newlines always get their own segment
|
|||
|
|
* - where a diacritic is attached to a whitespace character in the text, the
|
|||
|
|
* segment ends after the diacritic, so e.g. " \u0300 " becomes two segments.
|
|||
|
|
* This function therefore runs the segmenter's .segment() method and then
|
|||
|
|
* merges consecutive segments of whitespace into a single part.
|
|||
|
|
*/
|
|||
|
|
function segment(string, segmenter) {
|
|||
|
|
const parts = [];
|
|||
|
|
for (const segmentObj of Array.from(segmenter.segment(string))) {
|
|||
|
|
const segment = segmentObj.segment;
|
|||
|
|
if (parts.length && (/\s/).test(parts[parts.length - 1]) && (/\s/).test(segment)) {
|
|||
|
|
parts[parts.length - 1] += segment;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
parts.push(segment);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return parts;
|
|||
|
|
}
|
|||
|
|
// The functions below take a `segmenter` argument so that, when called from
|
|||
|
|
// diffWords when it is using a segmenter, they can use a notion of what
|
|||
|
|
// constitutes "whitespace" that is consistent with the segmenter.
|
|||
|
|
//
|
|||
|
|
// USUALLY this will be identical to the result of the non-segmenter-based
|
|||
|
|
// logic, but it differs in at least one case: when whitespace characters are
|
|||
|
|
// modified by diacritics. A word segmenter considers these diacritics to be
|
|||
|
|
// part of the whitespace, whereas our non-segmenter-based logic does not.
|
|||
|
|
//
|
|||
|
|
// Because the segmenter-based approach necessarily requires segmenting the
|
|||
|
|
// entire string, we offer a leadingAndTrailingWs function to allow getting the
|
|||
|
|
// whitespace prefix AND whitespace suffix with a single call to the segmenter,
|
|||
|
|
// for efficiency's sake.
|
|||
|
|
function trailingWs(string, segmenter) {
|
|||
|
|
if (segmenter) {
|
|||
|
|
return leadingAndTrailingWs(string, segmenter)[1];
|
|||
|
|
}
|
|||
|
|
// Yes, this looks overcomplicated and dumb - why not replace the whole function with
|
|||
|
|
// return string.match(/\s*$/)[0]
|
|||
|
|
// you ask? Because:
|
|||
|
|
// 1. the trap described at https://markamery.com/blog/quadratic-time-regexes/ would mean doing
|
|||
|
|
// this would cause this function to take O(n²) time in the worst case (specifically when
|
|||
|
|
// there is a massive run of NON-TRAILING whitespace in `string`), and
|
|||
|
|
// 2. the fix proposed in the same blog post, of using a negative lookbehind, is incompatible
|
|||
|
|
// with old Safari versions that we'd like to not break if possible (see
|
|||
|
|
// https://github.com/kpdecker/jsdiff/pull/550)
|
|||
|
|
// It feels absurd to do this with an explicit loop instead of a regex, but I really can't see a
|
|||
|
|
// better way that doesn't result in broken behaviour.
|
|||
|
|
let i;
|
|||
|
|
for (i = string.length - 1; i >= 0; i--) {
|
|||
|
|
if (!string[i].match(/\s/)) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return string.substring(i + 1);
|
|||
|
|
}
|
|||
|
|
function leadingWs(string, segmenter) {
|
|||
|
|
if (segmenter) {
|
|||
|
|
return leadingAndTrailingWs(string, segmenter)[0];
|
|||
|
|
}
|
|||
|
|
// Thankfully the annoying considerations described in trailingWs don't apply here:
|
|||
|
|
const match = string.match(/^\s*/);
|
|||
|
|
return match ? match[0] : '';
|
|||
|
|
}
|
|||
|
|
function leadingAndTrailingWs(string, segmenter) {
|
|||
|
|
if (!segmenter) {
|
|||
|
|
return [leadingWs(string), trailingWs(string)];
|
|||
|
|
}
|
|||
|
|
if (segmenter.resolvedOptions().granularity != 'word') {
|
|||
|
|
throw new Error('The segmenter passed must have a granularity of "word"');
|
|||
|
|
}
|
|||
|
|
const segments = segment(string, segmenter);
|
|||
|
|
const firstSeg = segments[0];
|
|||
|
|
const lastSeg = segments[segments.length - 1];
|
|||
|
|
const head = (/\s/).test(firstSeg) ? firstSeg : '';
|
|||
|
|
const tail = (/\s/).test(lastSeg) ? lastSeg : '';
|
|||
|
|
return [head, tail];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Based on https://en.wikipedia.org/wiki/Latin_script_in_Unicode
|
|||
|
|
//
|
|||
|
|
// Chars/ranges counted as "word" characters by this regex are as follows:
|
|||
|
|
//
|
|||
|
|
// + U+00AD Soft hyphen
|
|||
|
|
// + 00C0–00FF (letters with diacritics from the Latin-1 Supplement), except:
|
|||
|
|
// - U+00D7 × Multiplication sign
|
|||
|
|
// - U+00F7 ÷ Division sign
|
|||
|
|
// + Latin Extended-A, 0100–017F
|
|||
|
|
// + Latin Extended-B, 0180–024F
|
|||
|
|
// + IPA Extensions, 0250–02AF
|
|||
|
|
// + Spacing Modifier Letters, 02B0–02FF, except:
|
|||
|
|
// - U+02C7 ˇ ˇ Caron
|
|||
|
|
// - U+02D8 ˘ ˘ Breve
|
|||
|
|
// - U+02D9 ˙ ˙ Dot Above
|
|||
|
|
// - U+02DA ˚ ˚ Ring Above
|
|||
|
|
// - U+02DB ˛ ˛ Ogonek
|
|||
|
|
// - U+02DC ˜ ˜ Small Tilde
|
|||
|
|
// - U+02DD ˝ ˝ Double Acute Accent
|
|||
|
|
// + Latin Extended Additional, 1E00–1EFF
|
|||
|
|
const extendedWordChars = 'a-zA-Z0-9_\\u{AD}\\u{C0}-\\u{D6}\\u{D8}-\\u{F6}\\u{F8}-\\u{2C6}\\u{2C8}-\\u{2D7}\\u{2DE}-\\u{2FF}\\u{1E00}-\\u{1EFF}';
|
|||
|
|
// Each token is one of the following:
|
|||
|
|
// - A punctuation mark plus the surrounding whitespace
|
|||
|
|
// - A word plus the surrounding whitespace
|
|||
|
|
// - Pure whitespace (but only in the special case where the entire text
|
|||
|
|
// is just whitespace)
|
|||
|
|
//
|
|||
|
|
// We have to include surrounding whitespace in the tokens because the two
|
|||
|
|
// alternative approaches produce horribly broken results:
|
|||
|
|
// * If we just discard the whitespace, we can't fully reproduce the original
|
|||
|
|
// text from the sequence of tokens and any attempt to render the diff will
|
|||
|
|
// get the whitespace wrong.
|
|||
|
|
// * If we have separate tokens for whitespace, then in a typical text every
|
|||
|
|
// second token will be a single space character. But this often results in
|
|||
|
|
// the optimal diff between two texts being a perverse one that preserves
|
|||
|
|
// the spaces between words but deletes and reinserts actual common words.
|
|||
|
|
// See https://github.com/kpdecker/jsdiff/issues/160#issuecomment-1866099640
|
|||
|
|
// for an example.
|
|||
|
|
//
|
|||
|
|
// Keeping the surrounding whitespace of course has implications for .equals
|
|||
|
|
// and .join, not just .tokenize.
|
|||
|
|
// This regex does NOT fully implement the tokenization rules described above.
|
|||
|
|
// Instead, it gives runs of whitespace their own "token". The tokenize method
|
|||
|
|
// then handles stitching whitespace tokens onto adjacent word or punctuation
|
|||
|
|
// tokens.
|
|||
|
|
const tokenizeIncludingWhitespace = new RegExp(`[${extendedWordChars}]+|\\s+|[^${extendedWordChars}]`, 'ug');
|
|||
|
|
class WordDiff extends Diff {
|
|||
|
|
equals(left, right, options) {
|
|||
|
|
if (options.ignoreCase) {
|
|||
|
|
left = left.toLowerCase();
|
|||
|
|
right = right.toLowerCase();
|
|||
|
|
}
|
|||
|
|
return left.trim() === right.trim();
|
|||
|
|
}
|
|||
|
|
tokenize(value, options = {}) {
|
|||
|
|
let parts;
|
|||
|
|
if (options.intlSegmenter) {
|
|||
|
|
const segmenter = options.intlSegmenter;
|
|||
|
|
if (segmenter.resolvedOptions().granularity != 'word') {
|
|||
|
|
throw new Error('The segmenter passed must have a granularity of "word"');
|
|||
|
|
}
|
|||
|
|
// We want `parts` to be an array whose elements alternate between being
|
|||
|
|
// pure whitespace and being pure non-whitespace. This is ALMOST what the
|
|||
|
|
// segments returned by a word-based Intl.Segmenter already look like,
|
|||
|
|
// but not quite - see explanation in the docs of our custom segment()
|
|||
|
|
// function.
|
|||
|
|
parts = segment(value, segmenter);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
parts = value.match(tokenizeIncludingWhitespace) || [];
|
|||
|
|
}
|
|||
|
|
const tokens = [];
|
|||
|
|
let prevPart = null;
|
|||
|
|
parts.forEach(part => {
|
|||
|
|
if ((/\s/).test(part)) {
|
|||
|
|
if (prevPart == null) {
|
|||
|
|
tokens.push(part);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
tokens.push(tokens.pop() + part);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (prevPart != null && (/\s/).test(prevPart)) {
|
|||
|
|
if (tokens[tokens.length - 1] == prevPart) {
|
|||
|
|
tokens.push(tokens.pop() + part);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
tokens.push(prevPart + part);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
tokens.push(part);
|
|||
|
|
}
|
|||
|
|
prevPart = part;
|
|||
|
|
});
|
|||
|
|
return tokens;
|
|||
|
|
}
|
|||
|
|
join(tokens) {
|
|||
|
|
// Tokens being joined here will always have appeared consecutively in the
|
|||
|
|
// same text, so we can simply strip off the leading whitespace from all the
|
|||
|
|
// tokens except the first (and except any whitespace-only tokens - but such
|
|||
|
|
// a token will always be the first and only token anyway) and then join them
|
|||
|
|
// and the whitespace around words and punctuation will end up correct.
|
|||
|
|
return tokens.map((token, i) => {
|
|||
|
|
if (i == 0) {
|
|||
|
|
return token;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
return token.replace((/^\s+/), '');
|
|||
|
|
}
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
postProcess(changes, options) {
|
|||
|
|
if (!changes || options.oneChangePerToken) {
|
|||
|
|
return changes;
|
|||
|
|
}
|
|||
|
|
let lastKeep = null;
|
|||
|
|
// Change objects representing any insertion or deletion since the last
|
|||
|
|
// "keep" change object. There can be at most one of each.
|
|||
|
|
let insertion = null;
|
|||
|
|
let deletion = null;
|
|||
|
|
changes.forEach(change => {
|
|||
|
|
if (change.added) {
|
|||
|
|
insertion = change;
|
|||
|
|
}
|
|||
|
|
else if (change.removed) {
|
|||
|
|
deletion = change;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
if (insertion || deletion) { // May be false at start of text
|
|||
|
|
dedupeWhitespaceInChangeObjects(lastKeep, deletion, insertion, change, options.intlSegmenter);
|
|||
|
|
}
|
|||
|
|
lastKeep = change;
|
|||
|
|
insertion = null;
|
|||
|
|
deletion = null;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
if (insertion || deletion) {
|
|||
|
|
dedupeWhitespaceInChangeObjects(lastKeep, deletion, insertion, null, options.intlSegmenter);
|
|||
|
|
}
|
|||
|
|
return changes;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const wordDiff = new WordDiff();
|
|||
|
|
function diffWords(oldStr, newStr, options) {
|
|||
|
|
// This option has never been documented and never will be (it's clearer to
|
|||
|
|
// just call `diffWordsWithSpace` directly if you need that behavior), but
|
|||
|
|
// has existed in jsdiff for a long time, so we retain support for it here
|
|||
|
|
// for the sake of backwards compatibility.
|
|||
|
|
if ((options === null || options === void 0 ? void 0 : options.ignoreWhitespace) != null && !options.ignoreWhitespace) {
|
|||
|
|
return diffWordsWithSpace(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
return wordDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
function dedupeWhitespaceInChangeObjects(startKeep, deletion, insertion, endKeep, segmenter) {
|
|||
|
|
// Before returning, we tidy up the leading and trailing whitespace of the
|
|||
|
|
// change objects to eliminate cases where trailing whitespace in one object
|
|||
|
|
// is repeated as leading whitespace in the next.
|
|||
|
|
// Below are examples of the outcomes we want here to explain the code.
|
|||
|
|
// I=insert, K=keep, D=delete
|
|||
|
|
// 1. diffing 'foo bar baz' vs 'foo baz'
|
|||
|
|
// Prior to cleanup, we have K:'foo ' D:' bar ' K:' baz'
|
|||
|
|
// After cleanup, we want: K:'foo ' D:'bar ' K:'baz'
|
|||
|
|
//
|
|||
|
|
// 2. Diffing 'foo bar baz' vs 'foo qux baz'
|
|||
|
|
// Prior to cleanup, we have K:'foo ' D:' bar ' I:' qux ' K:' baz'
|
|||
|
|
// After cleanup, we want K:'foo ' D:'bar' I:'qux' K:' baz'
|
|||
|
|
//
|
|||
|
|
// 3. Diffing 'foo\nbar baz' vs 'foo baz'
|
|||
|
|
// Prior to cleanup, we have K:'foo ' D:'\nbar ' K:' baz'
|
|||
|
|
// After cleanup, we want K'foo' D:'\nbar' K:' baz'
|
|||
|
|
//
|
|||
|
|
// 4. Diffing 'foo baz' vs 'foo\nbar baz'
|
|||
|
|
// Prior to cleanup, we have K:'foo\n' I:'\nbar ' K:' baz'
|
|||
|
|
// After cleanup, we ideally want K'foo' I:'\nbar' K:' baz'
|
|||
|
|
// but don't actually manage this currently (the pre-cleanup change
|
|||
|
|
// objects don't contain enough information to make it possible).
|
|||
|
|
//
|
|||
|
|
// 5. Diffing 'foo bar baz' vs 'foo baz'
|
|||
|
|
// Prior to cleanup, we have K:'foo ' D:' bar ' K:' baz'
|
|||
|
|
// After cleanup, we want K:'foo ' D:' bar ' K:'baz'
|
|||
|
|
//
|
|||
|
|
// Our handling is unavoidably imperfect in the case where there's a single
|
|||
|
|
// indel between keeps and the whitespace has changed. For instance, consider
|
|||
|
|
// diffing 'foo\tbar\nbaz' vs 'foo baz'. Unless we create an extra change
|
|||
|
|
// object to represent the insertion of the space character (which isn't even
|
|||
|
|
// a token), we have no way to avoid losing information about the texts'
|
|||
|
|
// original whitespace in the result we return. Still, we do our best to
|
|||
|
|
// output something that will look sensible if we e.g. print it with
|
|||
|
|
// insertions in green and deletions in red.
|
|||
|
|
// Between two "keep" change objects (or before the first or after the last
|
|||
|
|
// change object), we can have either:
|
|||
|
|
// * A "delete" followed by an "insert"
|
|||
|
|
// * Just an "insert"
|
|||
|
|
// * Just a "delete"
|
|||
|
|
// We handle the three cases separately.
|
|||
|
|
if (deletion && insertion) {
|
|||
|
|
const [oldWsPrefix, oldWsSuffix] = leadingAndTrailingWs(deletion.value, segmenter);
|
|||
|
|
const [newWsPrefix, newWsSuffix] = leadingAndTrailingWs(insertion.value, segmenter);
|
|||
|
|
if (startKeep) {
|
|||
|
|
const commonWsPrefix = longestCommonPrefix(oldWsPrefix, newWsPrefix);
|
|||
|
|
startKeep.value = replaceSuffix(startKeep.value, newWsPrefix, commonWsPrefix);
|
|||
|
|
deletion.value = removePrefix(deletion.value, commonWsPrefix);
|
|||
|
|
insertion.value = removePrefix(insertion.value, commonWsPrefix);
|
|||
|
|
}
|
|||
|
|
if (endKeep) {
|
|||
|
|
const commonWsSuffix = longestCommonSuffix(oldWsSuffix, newWsSuffix);
|
|||
|
|
endKeep.value = replacePrefix(endKeep.value, newWsSuffix, commonWsSuffix);
|
|||
|
|
deletion.value = removeSuffix(deletion.value, commonWsSuffix);
|
|||
|
|
insertion.value = removeSuffix(insertion.value, commonWsSuffix);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (insertion) {
|
|||
|
|
// The whitespaces all reflect what was in the new text rather than
|
|||
|
|
// the old, so we essentially have no information about whitespace
|
|||
|
|
// insertion or deletion. We just want to dedupe the whitespace.
|
|||
|
|
// We do that by having each change object keep its trailing
|
|||
|
|
// whitespace and deleting duplicate leading whitespace where
|
|||
|
|
// present.
|
|||
|
|
if (startKeep) {
|
|||
|
|
const ws = leadingWs(insertion.value, segmenter);
|
|||
|
|
insertion.value = insertion.value.substring(ws.length);
|
|||
|
|
}
|
|||
|
|
if (endKeep) {
|
|||
|
|
const ws = leadingWs(endKeep.value, segmenter);
|
|||
|
|
endKeep.value = endKeep.value.substring(ws.length);
|
|||
|
|
}
|
|||
|
|
// otherwise we've got a deletion and no insertion
|
|||
|
|
}
|
|||
|
|
else if (startKeep && endKeep) {
|
|||
|
|
const newWsFull = leadingWs(endKeep.value, segmenter), [delWsStart, delWsEnd] = leadingAndTrailingWs(deletion.value, segmenter);
|
|||
|
|
// Any whitespace that comes straight after startKeep in both the old and
|
|||
|
|
// new texts, assign to startKeep and remove from the deletion.
|
|||
|
|
const newWsStart = longestCommonPrefix(newWsFull, delWsStart);
|
|||
|
|
deletion.value = removePrefix(deletion.value, newWsStart);
|
|||
|
|
// Any whitespace that comes straight before endKeep in both the old and
|
|||
|
|
// new texts, and hasn't already been assigned to startKeep, assign to
|
|||
|
|
// endKeep and remove from the deletion.
|
|||
|
|
const newWsEnd = longestCommonSuffix(removePrefix(newWsFull, newWsStart), delWsEnd);
|
|||
|
|
deletion.value = removeSuffix(deletion.value, newWsEnd);
|
|||
|
|
endKeep.value = replacePrefix(endKeep.value, newWsFull, newWsEnd);
|
|||
|
|
// If there's any whitespace from the new text that HASN'T already been
|
|||
|
|
// assigned, assign it to the start:
|
|||
|
|
startKeep.value = replaceSuffix(startKeep.value, newWsFull, newWsFull.slice(0, newWsFull.length - newWsEnd.length));
|
|||
|
|
}
|
|||
|
|
else if (endKeep) {
|
|||
|
|
// We are at the start of the text. Preserve all the whitespace on
|
|||
|
|
// endKeep, and just remove whitespace from the end of deletion to the
|
|||
|
|
// extent that it overlaps with the start of endKeep.
|
|||
|
|
const endKeepWsPrefix = leadingWs(endKeep.value, segmenter);
|
|||
|
|
const deletionWsSuffix = trailingWs(deletion.value, segmenter);
|
|||
|
|
const overlap = maximumOverlap(deletionWsSuffix, endKeepWsPrefix);
|
|||
|
|
deletion.value = removeSuffix(deletion.value, overlap);
|
|||
|
|
}
|
|||
|
|
else if (startKeep) {
|
|||
|
|
// We are at the END of the text. Preserve all the whitespace on
|
|||
|
|
// startKeep, and just remove whitespace from the start of deletion to
|
|||
|
|
// the extent that it overlaps with the end of startKeep.
|
|||
|
|
const startKeepWsSuffix = trailingWs(startKeep.value, segmenter);
|
|||
|
|
const deletionWsPrefix = leadingWs(deletion.value, segmenter);
|
|||
|
|
const overlap = maximumOverlap(startKeepWsSuffix, deletionWsPrefix);
|
|||
|
|
deletion.value = removePrefix(deletion.value, overlap);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
class WordsWithSpaceDiff extends Diff {
|
|||
|
|
tokenize(value) {
|
|||
|
|
// Slightly different to the tokenizeIncludingWhitespace regex used above in
|
|||
|
|
// that this one treats each individual newline as a distinct token, rather
|
|||
|
|
// than merging them into other surrounding whitespace. This was requested
|
|||
|
|
// in https://github.com/kpdecker/jsdiff/issues/180 &
|
|||
|
|
// https://github.com/kpdecker/jsdiff/issues/211
|
|||
|
|
const regex = new RegExp(`(\\r?\\n)|[${extendedWordChars}]+|[^\\S\\n\\r]+|[^${extendedWordChars}]`, 'ug');
|
|||
|
|
return value.match(regex) || [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const wordsWithSpaceDiff = new WordsWithSpaceDiff();
|
|||
|
|
function diffWordsWithSpace(oldStr, newStr, options) {
|
|||
|
|
return wordsWithSpaceDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateOptions(options, defaults) {
|
|||
|
|
if (typeof options === 'function') {
|
|||
|
|
defaults.callback = options;
|
|||
|
|
}
|
|||
|
|
else if (options) {
|
|||
|
|
for (const name in options) {
|
|||
|
|
/* istanbul ignore else */
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(options, name)) {
|
|||
|
|
defaults[name] = options[name];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return defaults;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class LineDiff extends Diff {
|
|||
|
|
constructor() {
|
|||
|
|
super(...arguments);
|
|||
|
|
this.tokenize = tokenize;
|
|||
|
|
}
|
|||
|
|
equals(left, right, options) {
|
|||
|
|
// If we're ignoring whitespace, we need to normalise lines by stripping
|
|||
|
|
// whitespace before checking equality. (This has an annoying interaction
|
|||
|
|
// with newlineIsToken that requires special handling: if newlines get their
|
|||
|
|
// own token, then we DON'T want to trim the *newline* tokens down to empty
|
|||
|
|
// strings, since this would cause us to treat whitespace-only line content
|
|||
|
|
// as equal to a separator between lines, which would be weird and
|
|||
|
|
// inconsistent with the documented behavior of the options.)
|
|||
|
|
if (options.ignoreWhitespace) {
|
|||
|
|
if (!options.newlineIsToken || !left.includes('\n')) {
|
|||
|
|
left = left.trim();
|
|||
|
|
}
|
|||
|
|
if (!options.newlineIsToken || !right.includes('\n')) {
|
|||
|
|
right = right.trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (options.ignoreNewlineAtEof && !options.newlineIsToken) {
|
|||
|
|
if (left.endsWith('\n')) {
|
|||
|
|
left = left.slice(0, -1);
|
|||
|
|
}
|
|||
|
|
if (right.endsWith('\n')) {
|
|||
|
|
right = right.slice(0, -1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return super.equals(left, right, options);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const lineDiff = new LineDiff();
|
|||
|
|
function diffLines(oldStr, newStr, options) {
|
|||
|
|
return lineDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
function diffTrimmedLines(oldStr, newStr, options) {
|
|||
|
|
options = generateOptions(options, { ignoreWhitespace: true });
|
|||
|
|
return lineDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
// Exported standalone so it can be used from jsonDiff too.
|
|||
|
|
function tokenize(value, options) {
|
|||
|
|
if (options.stripTrailingCr) {
|
|||
|
|
// remove one \r before \n to match GNU diff's --strip-trailing-cr behavior
|
|||
|
|
value = value.replace(/\r\n/g, '\n');
|
|||
|
|
}
|
|||
|
|
const retLines = [], linesAndNewlines = value.split(/(\n|\r\n)/);
|
|||
|
|
// Ignore the final empty token that occurs if the string ends with a new line
|
|||
|
|
if (!linesAndNewlines[linesAndNewlines.length - 1]) {
|
|||
|
|
linesAndNewlines.pop();
|
|||
|
|
}
|
|||
|
|
// Merge the content and line separators into single tokens
|
|||
|
|
for (let i = 0; i < linesAndNewlines.length; i++) {
|
|||
|
|
const line = linesAndNewlines[i];
|
|||
|
|
if (i % 2 && !options.newlineIsToken) {
|
|||
|
|
retLines[retLines.length - 1] += line;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
retLines.push(line);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return retLines;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isSentenceEndPunct(char) {
|
|||
|
|
return char == '.' || char == '!' || char == '?';
|
|||
|
|
}
|
|||
|
|
class SentenceDiff extends Diff {
|
|||
|
|
tokenize(value) {
|
|||
|
|
var _a;
|
|||
|
|
// If in future we drop support for environments that don't support lookbehinds, we can replace
|
|||
|
|
// this entire function with:
|
|||
|
|
// return value.split(/(?<=[.!?])(\s+|$)/);
|
|||
|
|
// but until then, for similar reasons to the trailingWs function in string.ts, we are forced
|
|||
|
|
// to do this verbosely "by hand" instead of using a regex.
|
|||
|
|
const result = [];
|
|||
|
|
let tokenStartI = 0;
|
|||
|
|
for (let i = 0; i < value.length; i++) {
|
|||
|
|
if (i == value.length - 1) {
|
|||
|
|
result.push(value.slice(tokenStartI));
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (isSentenceEndPunct(value[i]) && value[i + 1].match(/\s/)) {
|
|||
|
|
// We've hit a sentence break - i.e. a punctuation mark followed by whitespace.
|
|||
|
|
// We now want to push TWO tokens to the result:
|
|||
|
|
// 1. the sentence
|
|||
|
|
result.push(value.slice(tokenStartI, i + 1));
|
|||
|
|
// 2. the whitespace
|
|||
|
|
i = tokenStartI = i + 1;
|
|||
|
|
while ((_a = value[i + 1]) === null || _a === void 0 ? void 0 : _a.match(/\s/)) {
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
result.push(value.slice(tokenStartI, i + 1));
|
|||
|
|
// Then the next token (a sentence) starts on the character after the whitespace.
|
|||
|
|
// (It's okay if this is off the end of the string - then the outer loop will terminate
|
|||
|
|
// here anyway.)
|
|||
|
|
tokenStartI = i + 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const sentenceDiff = new SentenceDiff();
|
|||
|
|
function diffSentences(oldStr, newStr, options) {
|
|||
|
|
return sentenceDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class CssDiff extends Diff {
|
|||
|
|
tokenize(value) {
|
|||
|
|
return value.split(/([{}:;,]|\s+)/);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const cssDiff = new CssDiff();
|
|||
|
|
function diffCss(oldStr, newStr, options) {
|
|||
|
|
return cssDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class JsonDiff extends Diff {
|
|||
|
|
constructor() {
|
|||
|
|
super(...arguments);
|
|||
|
|
this.tokenize = tokenize;
|
|||
|
|
}
|
|||
|
|
get useLongestToken() {
|
|||
|
|
// Discriminate between two lines of pretty-printed, serialized JSON where one of them has a
|
|||
|
|
// dangling comma and the other doesn't. Turns out including the dangling comma yields the nicest output:
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
castInput(value, options) {
|
|||
|
|
const { undefinedReplacement, stringifyReplacer = (k, v) => typeof v === 'undefined' ? undefinedReplacement : v } = options;
|
|||
|
|
return typeof value === 'string' ? value : JSON.stringify(canonicalize(value, null, null, stringifyReplacer), null, ' ');
|
|||
|
|
}
|
|||
|
|
equals(left, right, options) {
|
|||
|
|
return super.equals(left.replace(/,([\r\n])/g, '$1'), right.replace(/,([\r\n])/g, '$1'), options);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const jsonDiff = new JsonDiff();
|
|||
|
|
function diffJson(oldStr, newStr, options) {
|
|||
|
|
return jsonDiff.diff(oldStr, newStr, options);
|
|||
|
|
}
|
|||
|
|
// This function handles the presence of circular references by bailing out when encountering an
|
|||
|
|
// object that is already on the "stack" of items being processed. Accepts an optional replacer
|
|||
|
|
function canonicalize(obj, stack, replacementStack, replacer, key) {
|
|||
|
|
stack = stack || [];
|
|||
|
|
replacementStack = replacementStack || [];
|
|||
|
|
if (replacer) {
|
|||
|
|
obj = replacer(key === undefined ? '' : key, obj);
|
|||
|
|
}
|
|||
|
|
let i;
|
|||
|
|
for (i = 0; i < stack.length; i += 1) {
|
|||
|
|
if (stack[i] === obj) {
|
|||
|
|
return replacementStack[i];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let canonicalizedObj;
|
|||
|
|
if ('[object Array]' === Object.prototype.toString.call(obj)) {
|
|||
|
|
stack.push(obj);
|
|||
|
|
canonicalizedObj = new Array(obj.length);
|
|||
|
|
replacementStack.push(canonicalizedObj);
|
|||
|
|
for (i = 0; i < obj.length; i += 1) {
|
|||
|
|
canonicalizedObj[i] = canonicalize(obj[i], stack, replacementStack, replacer, String(i));
|
|||
|
|
}
|
|||
|
|
stack.pop();
|
|||
|
|
replacementStack.pop();
|
|||
|
|
return canonicalizedObj;
|
|||
|
|
}
|
|||
|
|
if (obj && obj.toJSON) {
|
|||
|
|
obj = obj.toJSON();
|
|||
|
|
}
|
|||
|
|
if (typeof obj === 'object' && obj !== null) {
|
|||
|
|
stack.push(obj);
|
|||
|
|
canonicalizedObj = {};
|
|||
|
|
replacementStack.push(canonicalizedObj);
|
|||
|
|
const sortedKeys = [];
|
|||
|
|
let key;
|
|||
|
|
for (key in obj) {
|
|||
|
|
/* istanbul ignore else */
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|||
|
|
sortedKeys.push(key);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
sortedKeys.sort();
|
|||
|
|
for (i = 0; i < sortedKeys.length; i += 1) {
|
|||
|
|
key = sortedKeys[i];
|
|||
|
|
canonicalizedObj[key] = canonicalize(obj[key], stack, replacementStack, replacer, key);
|
|||
|
|
}
|
|||
|
|
stack.pop();
|
|||
|
|
replacementStack.pop();
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
canonicalizedObj = obj;
|
|||
|
|
}
|
|||
|
|
return canonicalizedObj;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ArrayDiff extends Diff {
|
|||
|
|
tokenize(value) {
|
|||
|
|
return value.slice();
|
|||
|
|
}
|
|||
|
|
join(value) {
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
removeEmpty(value) {
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const arrayDiff = new ArrayDiff();
|
|||
|
|
function diffArrays(oldArr, newArr, options) {
|
|||
|
|
return arrayDiff.diff(oldArr, newArr, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function unixToWin(patch) {
|
|||
|
|
if (Array.isArray(patch)) {
|
|||
|
|
// It would be cleaner if instead of the line below we could just write
|
|||
|
|
// return patch.map(unixToWin)
|
|||
|
|
// but mysteriously TypeScript (v5.7.3 at the time of writing) does not like this and it will
|
|||
|
|
// refuse to compile, thinking that unixToWin could then return StructuredPatch[][] and the
|
|||
|
|
// result would be incompatible with the overload signatures.
|
|||
|
|
// See bug report at https://github.com/microsoft/TypeScript/issues/61398.
|
|||
|
|
return patch.map(p => unixToWin(p));
|
|||
|
|
}
|
|||
|
|
return Object.assign(Object.assign({}, patch), { hunks: patch.hunks.map(hunk => (Object.assign(Object.assign({}, hunk), { lines: hunk.lines.map((line, i) => {
|
|||
|
|
var _a;
|
|||
|
|
return (line.startsWith('\\') || line.endsWith('\r') || ((_a = hunk.lines[i + 1]) === null || _a === void 0 ? void 0 : _a.startsWith('\\')))
|
|||
|
|
? line
|
|||
|
|
: line + '\r';
|
|||
|
|
}) }))) });
|
|||
|
|
}
|
|||
|
|
function winToUnix(patch) {
|
|||
|
|
if (Array.isArray(patch)) {
|
|||
|
|
// (See comment above equivalent line in unixToWin)
|
|||
|
|
return patch.map(p => winToUnix(p));
|
|||
|
|
}
|
|||
|
|
return Object.assign(Object.assign({}, patch), { hunks: patch.hunks.map(hunk => (Object.assign(Object.assign({}, hunk), { lines: hunk.lines.map(line => line.endsWith('\r') ? line.substring(0, line.length - 1) : line) }))) });
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Returns true if the patch consistently uses Unix line endings (or only involves one line and has
|
|||
|
|
* no line endings).
|
|||
|
|
*/
|
|||
|
|
function isUnix(patch) {
|
|||
|
|
if (!Array.isArray(patch)) {
|
|||
|
|
patch = [patch];
|
|||
|
|
}
|
|||
|
|
return !patch.some(index => index.hunks.some(hunk => hunk.lines.some(line => !line.startsWith('\\') && line.endsWith('\r'))));
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Returns true if the patch uses Windows line endings and only Windows line endings.
|
|||
|
|
*/
|
|||
|
|
function isWin(patch) {
|
|||
|
|
if (!Array.isArray(patch)) {
|
|||
|
|
patch = [patch];
|
|||
|
|
}
|
|||
|
|
return patch.some(index => index.hunks.some(hunk => hunk.lines.some(line => line.endsWith('\r'))))
|
|||
|
|
&& patch.every(index => index.hunks.every(hunk => hunk.lines.every((line, i) => { var _a; return line.startsWith('\\') || line.endsWith('\r') || ((_a = hunk.lines[i + 1]) === null || _a === void 0 ? void 0 : _a.startsWith('\\')); })));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Parses a unified diff format patch into a structured patch object.
|
|||
|
|
*
|
|||
|
|
* `parsePatch` has some understanding of Git's particular dialect of unified diff format.
|
|||
|
|
* When parsing a Git patch, each index in the result may contain additional
|
|||
|
|
* fields (`isRename`, `isBinary`, etc) not included in the data structure returned by
|
|||
|
|
* `structuredPatch`; see the `StructuredPatch` interface for a full list.
|
|||
|
|
*
|
|||
|
|
* @return a JSON object representation of the patch, suitable for use with the `applyPatch`
|
|||
|
|
* method. This parses to the same structure returned by `structuredPatch`, except that
|
|||
|
|
* `oldFileName` and `newFileName` may be `undefined` if the patch doesn't contain enough
|
|||
|
|
* information to determine them (e.g. a hunk-only patch with no file headers).
|
|||
|
|
*/
|
|||
|
|
function parsePatch(uniDiff) {
|
|||
|
|
const diffstr = uniDiff.split(/\n/), list = [];
|
|||
|
|
let i = 0;
|
|||
|
|
// These helper functions identify line types that can appear between files
|
|||
|
|
// in a multi-file patch. Keeping them in one place avoids subtle
|
|||
|
|
// inconsistencies from having the same regexes duplicated in multiple places.
|
|||
|
|
// Matches `diff --git ...` lines specifically.
|
|||
|
|
function isGitDiffHeader(line) {
|
|||
|
|
return (/^diff --git /).test(line);
|
|||
|
|
}
|
|||
|
|
// Matches lines that denote the start of a new diff's section in a
|
|||
|
|
// multi-file patch: `diff --git ...`, `Index: ...`, or `diff -r ...`.
|
|||
|
|
function isDiffHeader(line) {
|
|||
|
|
return isGitDiffHeader(line)
|
|||
|
|
|| (/^Index:\s/).test(line)
|
|||
|
|
|| (/^diff(?: -r \w+)+\s/).test(line);
|
|||
|
|
}
|
|||
|
|
// Matches `--- ...` and `+++ ...` file header lines.
|
|||
|
|
function isFileHeader(line) {
|
|||
|
|
return (/^(---|\+\+\+)\s/).test(line);
|
|||
|
|
}
|
|||
|
|
// Matches `@@ ...` hunk header lines.
|
|||
|
|
function isHunkHeader(line) {
|
|||
|
|
return (/^@@\s/).test(line);
|
|||
|
|
}
|
|||
|
|
function parseIndex() {
|
|||
|
|
var _a;
|
|||
|
|
const index = {};
|
|||
|
|
index.hunks = [];
|
|||
|
|
list.push(index);
|
|||
|
|
// Parse diff metadata
|
|||
|
|
let seenDiffHeader = false;
|
|||
|
|
while (i < diffstr.length) {
|
|||
|
|
const line = diffstr[i];
|
|||
|
|
// File header (---, +++) or hunk header (@@) found; end parsing diff metadata
|
|||
|
|
if (isFileHeader(line) || isHunkHeader(line)) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
// The next two branches handle recognized diff headers. Note that
|
|||
|
|
// isDiffHeader deliberately does NOT match arbitrary `diff`
|
|||
|
|
// commands like `diff -u -p -r1.1 -r1.2`, because in some
|
|||
|
|
// formats (e.g. CVS diffs) such lines appear as metadata within
|
|||
|
|
// a single file's header section, after an `Index:` line. See the
|
|||
|
|
// diffx documentation (https://diffx.org) for examples.
|
|||
|
|
//
|
|||
|
|
// In both branches: if we've already seen a diff header for *this*
|
|||
|
|
// file and now we encounter another one, it must belong to the
|
|||
|
|
// next file, so break.
|
|||
|
|
if (isGitDiffHeader(line)) {
|
|||
|
|
if (seenDiffHeader) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
seenDiffHeader = true;
|
|||
|
|
index.isGit = true;
|
|||
|
|
// Parse the old and new filenames from the `diff --git` header and
|
|||
|
|
// tentatively set oldFileName and newFileName from them. These may
|
|||
|
|
// be overridden below by `rename from` / `rename to` or `copy from` /
|
|||
|
|
// `copy to` extended headers, or by --- and +++ lines. But for Git
|
|||
|
|
// diffs that lack all of those (e.g. mode-only changes, binary
|
|||
|
|
// file changes without rename), these are the only filenames we
|
|||
|
|
// get.
|
|||
|
|
// parseGitDiffHeader returns null if the header can't be parsed
|
|||
|
|
// (e.g. unterminated quoted filename, or unexpected format). In
|
|||
|
|
// that case we skip setting filenames here; they may still be
|
|||
|
|
// set from --- / +++ or rename from / rename to lines below.
|
|||
|
|
const paths = parseGitDiffHeader(line);
|
|||
|
|
if (paths) {
|
|||
|
|
index.oldFileName = paths.oldFileName;
|
|||
|
|
index.newFileName = paths.newFileName;
|
|||
|
|
}
|
|||
|
|
// Consume Git extended headers (`old mode`, `new mode`, `rename from`,
|
|||
|
|
// `rename to`, `similarity index`, `index`, `Binary files ... differ`,
|
|||
|
|
// etc.)
|
|||
|
|
i++;
|
|||
|
|
while (i < diffstr.length) {
|
|||
|
|
const extLine = diffstr[i];
|
|||
|
|
// Stop consuming extended headers if we hit a file header,
|
|||
|
|
// hunk header, or another diff header.
|
|||
|
|
if (isFileHeader(extLine) || isHunkHeader(extLine) || isDiffHeader(extLine)) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
// Parse `rename from` / `rename to` lines - these give us
|
|||
|
|
// unambiguous filenames. These lines don't include the
|
|||
|
|
// a/ and b/ prefixes that appear in the `diff --git` header
|
|||
|
|
// and --- / +++ lines, so we add them for consistency.
|
|||
|
|
// Git C-style quotes filenames containing special characters
|
|||
|
|
// (tabs, newlines, backslashes, double quotes), so we must
|
|||
|
|
// unquote them when present.
|
|||
|
|
const renameFromMatch = (/^rename from (.*)/).exec(extLine);
|
|||
|
|
if (renameFromMatch) {
|
|||
|
|
index.oldFileName = 'a/' + unquoteIfQuoted(renameFromMatch[1]);
|
|||
|
|
index.isRename = true;
|
|||
|
|
}
|
|||
|
|
const renameToMatch = (/^rename to (.*)/).exec(extLine);
|
|||
|
|
if (renameToMatch) {
|
|||
|
|
index.newFileName = 'b/' + unquoteIfQuoted(renameToMatch[1]);
|
|||
|
|
index.isRename = true;
|
|||
|
|
}
|
|||
|
|
// Parse copy from / copy to lines similarly
|
|||
|
|
const copyFromMatch = (/^copy from (.*)/).exec(extLine);
|
|||
|
|
if (copyFromMatch) {
|
|||
|
|
index.oldFileName = 'a/' + unquoteIfQuoted(copyFromMatch[1]);
|
|||
|
|
index.isCopy = true;
|
|||
|
|
}
|
|||
|
|
const copyToMatch = (/^copy to (.*)/).exec(extLine);
|
|||
|
|
if (copyToMatch) {
|
|||
|
|
index.newFileName = 'b/' + unquoteIfQuoted(copyToMatch[1]);
|
|||
|
|
index.isCopy = true;
|
|||
|
|
}
|
|||
|
|
const newFileModeMatch = (/^new file mode (\d+)/).exec(extLine);
|
|||
|
|
if (newFileModeMatch) {
|
|||
|
|
index.isCreate = true;
|
|||
|
|
index.newMode = newFileModeMatch[1];
|
|||
|
|
}
|
|||
|
|
const deletedFileModeMatch = (/^deleted file mode (\d+)/).exec(extLine);
|
|||
|
|
if (deletedFileModeMatch) {
|
|||
|
|
index.isDelete = true;
|
|||
|
|
index.oldMode = deletedFileModeMatch[1];
|
|||
|
|
}
|
|||
|
|
const oldModeMatch = (/^old mode (\d+)/).exec(extLine);
|
|||
|
|
if (oldModeMatch) {
|
|||
|
|
index.oldMode = oldModeMatch[1];
|
|||
|
|
}
|
|||
|
|
const newModeMatch = (/^new mode (\d+)/).exec(extLine);
|
|||
|
|
if (newModeMatch) {
|
|||
|
|
index.newMode = newModeMatch[1];
|
|||
|
|
}
|
|||
|
|
if ((/^Binary files /).test(extLine)) {
|
|||
|
|
index.isBinary = true;
|
|||
|
|
}
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
else if (isDiffHeader(line)) {
|
|||
|
|
if (seenDiffHeader) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
seenDiffHeader = true;
|
|||
|
|
// For Mercurial-style headers like
|
|||
|
|
// diff -r 9117c6561b0b -r 273ce12ad8f1 .hgignore
|
|||
|
|
// or Index: headers like
|
|||
|
|
// Index: something with multiple words
|
|||
|
|
// we extract the trailing filename as the index.
|
|||
|
|
//
|
|||
|
|
// TODO: It seems awkward that we indiscriminately trim off
|
|||
|
|
// trailing whitespace here. Theoretically, couldn't that
|
|||
|
|
// be meaningful - e.g. if the patch represents a diff of a
|
|||
|
|
// file whose name ends with a space? Seems wrong to nuke
|
|||
|
|
// it. But this behaviour has been around since v2.2.1 in
|
|||
|
|
// 2015, so if it's going to change, it should be done
|
|||
|
|
// cautiously and in a new major release, for
|
|||
|
|
// backwards-compat reasons.
|
|||
|
|
// -- ExplodingCabbage
|
|||
|
|
const headerMatch = (/^(?:Index:|diff(?: -r \w+)+)\s+/).exec(line);
|
|||
|
|
if (headerMatch) {
|
|||
|
|
index.index = line.substring(headerMatch[0].length).trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
// Parse file headers if they are defined. Unified diff requires them, but
|
|||
|
|
// there's no technical issues to have an isolated hunk without file header
|
|||
|
|
parseFileHeader(index);
|
|||
|
|
parseFileHeader(index);
|
|||
|
|
// If we got one file header but not the other, that's a malformed patch.
|
|||
|
|
if ((index.oldFileName === undefined) !== (index.newFileName === undefined)) {
|
|||
|
|
throw new Error('Missing ' + (index.oldFileName !== undefined ? '"+++ ..."' : '"--- ..."')
|
|||
|
|
+ ' file header for ' + ((_a = index.oldFileName) !== null && _a !== void 0 ? _a : index.newFileName));
|
|||
|
|
}
|
|||
|
|
while (i < diffstr.length) {
|
|||
|
|
const line = diffstr[i];
|
|||
|
|
if (isDiffHeader(line) || isFileHeader(line) || (/^===================================================================/).test(line)) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
else if (isHunkHeader(line)) {
|
|||
|
|
index.hunks.push(parseHunk());
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
// Skip blank lines and any other unrecognized content between
|
|||
|
|
// or after hunks. Real-world examples of such content include:
|
|||
|
|
// - `Only in <dir>: <file>` from GNU `diff -r`
|
|||
|
|
// - `Property changes on:` sections from `svn diff`
|
|||
|
|
// - Trailing prose or commentary in email patches
|
|||
|
|
// GNU `patch` tolerates all of these, and so do we.
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Parses the old and new filenames from a `diff --git` header line.
|
|||
|
|
*
|
|||
|
|
* The format is:
|
|||
|
|
* diff --git a/<old-path> b/<new-path>
|
|||
|
|
*
|
|||
|
|
* When filenames contain special characters (including newlines, tabs,
|
|||
|
|
* backslashes, or double quotes), Git quotes them with C-style escaping:
|
|||
|
|
* diff --git "a/file\twith\ttabs.txt" "b/file\twith\ttabs.txt"
|
|||
|
|
*
|
|||
|
|
* When filenames don't contain special characters and the old and new names
|
|||
|
|
* are the same, we can unambiguously split on ` b/` by finding where the
|
|||
|
|
* two halves (including their a/ and b/ prefixes) yield matching bare names.
|
|||
|
|
*
|
|||
|
|
* A pathological case exists in which we cannot reliably determine the paths
|
|||
|
|
* from the `diff --git` header. This case is when the following are true:
|
|||
|
|
* - the old and new file paths differ
|
|||
|
|
* - they are both unquoted (i.e. contain no special characters)
|
|||
|
|
* - at least one of the underlying file paths includes the substring ` b/`
|
|||
|
|
* In this scenario, we do not know which occurrence of ` b/` indicates the
|
|||
|
|
* start of the new file path, so the header is inherently ambiguous. We thus
|
|||
|
|
* select a possible interpretation arbitrarily and return that.
|
|||
|
|
*
|
|||
|
|
* Fortunately, this ambiguity should never matter, because in any patch
|
|||
|
|
* genuinely output by Git in which this pathological scenario occurs, there
|
|||
|
|
* must also be `rename from`/`rename to` or `copy from`/`copy to` extended
|
|||
|
|
* headers present below the `diff --git` header. `parseIndex` will parse
|
|||
|
|
* THOSE headers, from which we CAN unambiguously determine the filenames,
|
|||
|
|
* and will discard the result returned by this function.
|
|||
|
|
*
|
|||
|
|
* Returns null if the header can't be parsed at all — e.g. a quoted filename
|
|||
|
|
* has an unterminated quote, or if the unquoted header doesn't match the
|
|||
|
|
* expected `a/... b/...` format. In that case, the caller (parseIndex)
|
|||
|
|
* skips setting oldFileName/newFileName from this header, but they may
|
|||
|
|
* still be set later from `---`/`+++` lines or `rename from`/`rename to`
|
|||
|
|
* extended headers; if none of those are present either, they'll remain
|
|||
|
|
* undefined in the output.
|
|||
|
|
*/
|
|||
|
|
function parseGitDiffHeader(line) {
|
|||
|
|
// Strip the "diff --git " prefix
|
|||
|
|
const rest = line.substring('diff --git '.length);
|
|||
|
|
// Handle quoted paths: "a/path" "b/path"
|
|||
|
|
// Git quotes paths when they contain characters like newlines, tabs,
|
|||
|
|
// backslashes, or double quotes (but notably not spaces).
|
|||
|
|
if (rest.startsWith('"')) {
|
|||
|
|
const oldPath = parseQuotedFileName(rest);
|
|||
|
|
if (oldPath === null) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const afterOld = rest.substring(oldPath.rawLength + 1); // +1 for space
|
|||
|
|
let newFileName;
|
|||
|
|
if (afterOld.startsWith('"')) {
|
|||
|
|
const newPath = parseQuotedFileName(afterOld);
|
|||
|
|
if (newPath === null) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
newFileName = newPath.fileName;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
newFileName = afterOld;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
oldFileName: oldPath.fileName,
|
|||
|
|
newFileName
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
// Check if the second path is quoted
|
|||
|
|
// e.g. diff --git a/simple "b/renamed\nnewline.txt"
|
|||
|
|
const quoteIdx = rest.indexOf('"');
|
|||
|
|
if (quoteIdx > 0) {
|
|||
|
|
const oldFileName = rest.substring(0, quoteIdx - 1);
|
|||
|
|
const newPath = parseQuotedFileName(rest.substring(quoteIdx));
|
|||
|
|
if (newPath === null) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
oldFileName,
|
|||
|
|
newFileName: newPath.fileName
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
// Unquoted paths. Try to find the split point.
|
|||
|
|
// The format is: a/<old-path> b/<new-path>
|
|||
|
|
//
|
|||
|
|
// Note the potential ambiguity caused by the possibility of the file paths
|
|||
|
|
// themselves containing the substring ` b/`, plus the pathological case
|
|||
|
|
// described in the comment above.
|
|||
|
|
//
|
|||
|
|
// Strategy: find all occurrences of " b/" and split on the middle
|
|||
|
|
// one. When old and new names are the same (which is the only case where
|
|||
|
|
// we can't rely on extended headers later in the patch so HAVE to get
|
|||
|
|
// this right), this will always be the correct split.
|
|||
|
|
if (rest.startsWith('a/')) {
|
|||
|
|
const splits = [];
|
|||
|
|
let idx = 0;
|
|||
|
|
while (true) {
|
|||
|
|
idx = rest.indexOf(' b/', idx + 1);
|
|||
|
|
if (idx === -1) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
splits.push(idx);
|
|||
|
|
}
|
|||
|
|
if (splits.length > 0) {
|
|||
|
|
const mid = splits[Math.floor(splits.length / 2)];
|
|||
|
|
return {
|
|||
|
|
oldFileName: rest.substring(0, mid),
|
|||
|
|
newFileName: rest.substring(mid + 1)
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Fallback: can't parse, return null
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* If `s` starts with a double quote, unquotes it using C-style escape
|
|||
|
|
* rules (as used by Git). Otherwise returns `s` as-is.
|
|||
|
|
*/
|
|||
|
|
function unquoteIfQuoted(s) {
|
|||
|
|
if (s.startsWith('"')) {
|
|||
|
|
const parsed = parseQuotedFileName(s);
|
|||
|
|
if (parsed) {
|
|||
|
|
return parsed.fileName;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Parses a C-style quoted filename as used by Git or GNU `diff -u`.
|
|||
|
|
* Returns the unescaped filename and the raw length consumed (including quotes).
|
|||
|
|
*/
|
|||
|
|
function parseQuotedFileName(s) {
|
|||
|
|
if (!s.startsWith('"')) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
let result = '';
|
|||
|
|
let j = 1; // skip opening quote
|
|||
|
|
while (j < s.length) {
|
|||
|
|
if (s[j] === '"') {
|
|||
|
|
return { fileName: result, rawLength: j + 1 };
|
|||
|
|
}
|
|||
|
|
if (s[j] === '\\' && j + 1 < s.length) {
|
|||
|
|
j++;
|
|||
|
|
switch (s[j]) {
|
|||
|
|
case 'a':
|
|||
|
|
result += '\x07';
|
|||
|
|
break;
|
|||
|
|
case 'b':
|
|||
|
|
result += '\b';
|
|||
|
|
break;
|
|||
|
|
case 'f':
|
|||
|
|
result += '\f';
|
|||
|
|
break;
|
|||
|
|
case 'n':
|
|||
|
|
result += '\n';
|
|||
|
|
break;
|
|||
|
|
case 'r':
|
|||
|
|
result += '\r';
|
|||
|
|
break;
|
|||
|
|
case 't':
|
|||
|
|
result += '\t';
|
|||
|
|
break;
|
|||
|
|
case 'v':
|
|||
|
|
result += '\v';
|
|||
|
|
break;
|
|||
|
|
case '\\':
|
|||
|
|
result += '\\';
|
|||
|
|
break;
|
|||
|
|
case '"':
|
|||
|
|
result += '"';
|
|||
|
|
break;
|
|||
|
|
case '0':
|
|||
|
|
case '1':
|
|||
|
|
case '2':
|
|||
|
|
case '3':
|
|||
|
|
case '4':
|
|||
|
|
case '5':
|
|||
|
|
case '6':
|
|||
|
|
case '7': {
|
|||
|
|
// C-style octal escapes represent raw bytes. Collect
|
|||
|
|
// consecutive octal-escaped bytes and decode as UTF-8.
|
|||
|
|
// Validate that we have a full 3-digit octal escape
|
|||
|
|
if (j + 2 >= s.length || s[j + 1] < '0' || s[j + 1] > '7' || s[j + 2] < '0' || s[j + 2] > '7') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const bytes = [parseInt(s.substring(j, j + 3), 8)];
|
|||
|
|
j += 3;
|
|||
|
|
while (s[j] === '\\' && s[j + 1] >= '0' && s[j + 1] <= '7') {
|
|||
|
|
if (j + 3 >= s.length || s[j + 2] < '0' || s[j + 2] > '7' || s[j + 3] < '0' || s[j + 3] > '7') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
bytes.push(parseInt(s.substring(j + 1, j + 4), 8));
|
|||
|
|
j += 4;
|
|||
|
|
}
|
|||
|
|
result += new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
|||
|
|
continue; // j already points at the next character
|
|||
|
|
}
|
|||
|
|
// Note that in C, there are also three kinds of hex escape sequences:
|
|||
|
|
// - \xhh
|
|||
|
|
// - \uhhhh
|
|||
|
|
// - \Uhhhhhhhh
|
|||
|
|
// We do not bother to parse them here because, so far as we know,
|
|||
|
|
// they are never emitted by any tools that generate unified diff
|
|||
|
|
// format diffs, and so for now jsdiff does not consider them legal.
|
|||
|
|
default: return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
result += s[j];
|
|||
|
|
}
|
|||
|
|
j++;
|
|||
|
|
}
|
|||
|
|
// Unterminated quote
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
// Parses the --- and +++ headers, if none are found, no lines
|
|||
|
|
// are consumed.
|
|||
|
|
function parseFileHeader(index) {
|
|||
|
|
const fileHeaderMatch = (/^(---|\+\+\+)\s+/).exec(diffstr[i]);
|
|||
|
|
if (fileHeaderMatch) {
|
|||
|
|
const prefix = fileHeaderMatch[1], data = diffstr[i].substring(3).trim().split('\t', 2), header = (data[1] || '').trim();
|
|||
|
|
let fileName = data[0];
|
|||
|
|
if (fileName.startsWith('"')) {
|
|||
|
|
fileName = unquoteIfQuoted(fileName);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
fileName = fileName.replace(/\\\\/g, '\\');
|
|||
|
|
}
|
|||
|
|
if (prefix === '---') {
|
|||
|
|
index.oldFileName = fileName;
|
|||
|
|
index.oldHeader = header;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
index.newFileName = fileName;
|
|||
|
|
index.newHeader = header;
|
|||
|
|
}
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Parses a hunk
|
|||
|
|
// This assumes that we are at the start of a hunk.
|
|||
|
|
function parseHunk() {
|
|||
|
|
var _a;
|
|||
|
|
const chunkHeaderIndex = i, chunkHeaderLine = diffstr[i++], chunkHeader = chunkHeaderLine.split(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|||
|
|
const hunk = {
|
|||
|
|
oldStart: +chunkHeader[1],
|
|||
|
|
oldLines: typeof chunkHeader[2] === 'undefined' ? 1 : +chunkHeader[2],
|
|||
|
|
newStart: +chunkHeader[3],
|
|||
|
|
newLines: typeof chunkHeader[4] === 'undefined' ? 1 : +chunkHeader[4],
|
|||
|
|
lines: []
|
|||
|
|
};
|
|||
|
|
// Unified Diff Format quirk: If the chunk size is 0,
|
|||
|
|
// the first number is one lower than one would expect.
|
|||
|
|
// https://www.artima.com/weblogs/viewpost.jsp?thread=164293
|
|||
|
|
if (hunk.oldLines === 0) {
|
|||
|
|
hunk.oldStart += 1;
|
|||
|
|
}
|
|||
|
|
if (hunk.newLines === 0) {
|
|||
|
|
hunk.newStart += 1;
|
|||
|
|
}
|
|||
|
|
let addCount = 0, removeCount = 0;
|
|||
|
|
for (; i < diffstr.length && (removeCount < hunk.oldLines || addCount < hunk.newLines || ((_a = diffstr[i]) === null || _a === void 0 ? void 0 : _a.startsWith('\\'))); i++) {
|
|||
|
|
const operation = (diffstr[i].length == 0 && i != (diffstr.length - 1)) ? ' ' : diffstr[i][0];
|
|||
|
|
if (operation === '+' || operation === '-' || operation === ' ' || operation === '\\') {
|
|||
|
|
hunk.lines.push(diffstr[i]);
|
|||
|
|
if (operation === '+') {
|
|||
|
|
addCount++;
|
|||
|
|
}
|
|||
|
|
else if (operation === '-') {
|
|||
|
|
removeCount++;
|
|||
|
|
}
|
|||
|
|
else if (operation === ' ') {
|
|||
|
|
addCount++;
|
|||
|
|
removeCount++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
throw new Error(`Hunk at line ${chunkHeaderIndex + 1} contained invalid line ${diffstr[i]}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Handle the empty block count case
|
|||
|
|
if (!addCount && hunk.newLines === 1) {
|
|||
|
|
hunk.newLines = 0;
|
|||
|
|
}
|
|||
|
|
if (!removeCount && hunk.oldLines === 1) {
|
|||
|
|
hunk.oldLines = 0;
|
|||
|
|
}
|
|||
|
|
// Perform sanity checking
|
|||
|
|
if (addCount !== hunk.newLines) {
|
|||
|
|
throw new Error('Added line count did not match for hunk at line ' + (chunkHeaderIndex + 1));
|
|||
|
|
}
|
|||
|
|
if (removeCount !== hunk.oldLines) {
|
|||
|
|
throw new Error('Removed line count did not match for hunk at line ' + (chunkHeaderIndex + 1));
|
|||
|
|
}
|
|||
|
|
// Check for extra hunk-body-like lines after the declared line counts
|
|||
|
|
// were exhausted. If the very next line starts with ' ', '+', or '-',
|
|||
|
|
// the hunk's line counts were probably wrong — unless it's a file
|
|||
|
|
// header (--- or +++), which legitimately appears immediately after a
|
|||
|
|
// hunk in multi-file diffs without Index lines.
|
|||
|
|
if (i < diffstr.length && diffstr[i] && (/^[+ -]/).test(diffstr[i])
|
|||
|
|
&& !isFileHeader(diffstr[i])) {
|
|||
|
|
throw new Error('Hunk at line ' + (chunkHeaderIndex + 1)
|
|||
|
|
+ ' has more lines than expected (expected '
|
|||
|
|
+ hunk.oldLines + ' old lines and ' + hunk.newLines + ' new lines)');
|
|||
|
|
}
|
|||
|
|
return hunk;
|
|||
|
|
}
|
|||
|
|
while (i < diffstr.length) {
|
|||
|
|
parseIndex();
|
|||
|
|
}
|
|||
|
|
return list;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Iterator that traverses in the range of [min, max], stepping
|
|||
|
|
// by distance from a given start position. I.e. for [0, 4], with
|
|||
|
|
// start of 2, this will iterate 2, 3, 1, 4, 0.
|
|||
|
|
function distanceIterator (start, minLine, maxLine) {
|
|||
|
|
let wantForward = true, backwardExhausted = false, forwardExhausted = false, localOffset = 1;
|
|||
|
|
return function iterator() {
|
|||
|
|
if (wantForward && !forwardExhausted) {
|
|||
|
|
if (backwardExhausted) {
|
|||
|
|
localOffset++;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
wantForward = false;
|
|||
|
|
}
|
|||
|
|
// Check if trying to fit beyond text length, and if not, check it fits
|
|||
|
|
// after offset location (or desired location on first iteration)
|
|||
|
|
if (start + localOffset <= maxLine) {
|
|||
|
|
return start + localOffset;
|
|||
|
|
}
|
|||
|
|
forwardExhausted = true;
|
|||
|
|
}
|
|||
|
|
if (!backwardExhausted) {
|
|||
|
|
if (!forwardExhausted) {
|
|||
|
|
wantForward = true;
|
|||
|
|
}
|
|||
|
|
// Check if trying to fit before text beginning, and if not, check it fits
|
|||
|
|
// before offset location
|
|||
|
|
if (minLine <= start - localOffset) {
|
|||
|
|
return start - localOffset++;
|
|||
|
|
}
|
|||
|
|
backwardExhausted = true;
|
|||
|
|
return iterator();
|
|||
|
|
}
|
|||
|
|
// We tried to fit hunk before text beginning and beyond text length, then
|
|||
|
|
// hunk can't fit on the text. Return undefined
|
|||
|
|
return undefined;
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* attempts to apply a unified diff patch.
|
|||
|
|
*
|
|||
|
|
* Hunks are applied first to last.
|
|||
|
|
* `applyPatch` first tries to apply the first hunk at the line number specified in the hunk header, and with all context lines matching exactly.
|
|||
|
|
* If that fails, it tries scanning backwards and forwards, one line at a time, to find a place to apply the hunk where the context lines match exactly.
|
|||
|
|
* If that still fails, and `fuzzFactor` is greater than zero, it increments the maximum number of mismatches (missing, extra, or changed context lines) that there can be between the hunk context and a region where we are trying to apply the patch such that the hunk will still be considered to match.
|
|||
|
|
* Regardless of `fuzzFactor`, lines to be deleted in the hunk *must* be present for a hunk to match, and the context lines *immediately* before and after an insertion must match exactly.
|
|||
|
|
*
|
|||
|
|
* Once a hunk is successfully fitted, the process begins again with the next hunk.
|
|||
|
|
* Regardless of `fuzzFactor`, later hunks must be applied later in the file than earlier hunks.
|
|||
|
|
*
|
|||
|
|
* If a hunk cannot be successfully fitted *anywhere* with fewer than `fuzzFactor` mismatches, `applyPatch` fails and returns `false`.
|
|||
|
|
*
|
|||
|
|
* If a hunk is successfully fitted but not at the line number specified by the hunk header, all subsequent hunks have their target line number adjusted accordingly.
|
|||
|
|
* (e.g. if the first hunk is applied 10 lines below where the hunk header said it should fit, `applyPatch` will *start* looking for somewhere to apply the second hunk 10 lines below where its hunk header says it goes.)
|
|||
|
|
*
|
|||
|
|
* If the patch was applied successfully, returns a string containing the patched text.
|
|||
|
|
* If the patch could not be applied (because some hunks in the patch couldn't be fitted to the text in `source`), `applyPatch` returns false.
|
|||
|
|
*
|
|||
|
|
* @param patch a string diff or the output from the `parsePatch` or `structuredPatch` methods.
|
|||
|
|
*/
|
|||
|
|
function applyPatch(source, patch, options = {}) {
|
|||
|
|
let patches;
|
|||
|
|
if (typeof patch === 'string') {
|
|||
|
|
patches = parsePatch(patch);
|
|||
|
|
}
|
|||
|
|
else if (Array.isArray(patch)) {
|
|||
|
|
patches = patch;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
patches = [patch];
|
|||
|
|
}
|
|||
|
|
if (patches.length > 1) {
|
|||
|
|
throw new Error('applyPatch only works with a single input.');
|
|||
|
|
}
|
|||
|
|
return applyStructuredPatch(source, patches[0], options);
|
|||
|
|
}
|
|||
|
|
function applyStructuredPatch(source, patch, options = {}) {
|
|||
|
|
if (options.autoConvertLineEndings || options.autoConvertLineEndings == null) {
|
|||
|
|
if (hasOnlyWinLineEndings(source) && isUnix(patch)) {
|
|||
|
|
patch = unixToWin(patch);
|
|||
|
|
}
|
|||
|
|
else if (hasOnlyUnixLineEndings(source) && isWin(patch)) {
|
|||
|
|
patch = winToUnix(patch);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Apply the diff to the input
|
|||
|
|
const lines = source.split('\n'), hunks = patch.hunks, compareLine = options.compareLine || ((lineNumber, line, operation, patchContent) => line === patchContent), fuzzFactor = options.fuzzFactor || 0;
|
|||
|
|
let minLine = 0;
|
|||
|
|
if (fuzzFactor < 0 || !Number.isInteger(fuzzFactor)) {
|
|||
|
|
throw new Error('fuzzFactor must be a non-negative integer');
|
|||
|
|
}
|
|||
|
|
// Special case for empty patch.
|
|||
|
|
if (!hunks.length) {
|
|||
|
|
return source;
|
|||
|
|
}
|
|||
|
|
// Before anything else, handle EOFNL insertion/removal. If the patch tells us to make a change
|
|||
|
|
// to the EOFNL that is redundant/impossible - i.e. to remove a newline that's not there, or add a
|
|||
|
|
// newline that already exists - then we either return false and fail to apply the patch (if
|
|||
|
|
// fuzzFactor is 0) or simply ignore the problem and do nothing (if fuzzFactor is >0).
|
|||
|
|
// If we do need to remove/add a newline at EOF, this will always be in the final hunk:
|
|||
|
|
let prevLine = '', removeEOFNL = false, addEOFNL = false;
|
|||
|
|
for (let i = 0; i < hunks[hunks.length - 1].lines.length; i++) {
|
|||
|
|
const line = hunks[hunks.length - 1].lines[i];
|
|||
|
|
if (line[0] == '\\') {
|
|||
|
|
if (prevLine[0] == '+') {
|
|||
|
|
removeEOFNL = true;
|
|||
|
|
}
|
|||
|
|
else if (prevLine[0] == '-') {
|
|||
|
|
addEOFNL = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
prevLine = line;
|
|||
|
|
}
|
|||
|
|
if (removeEOFNL) {
|
|||
|
|
if (addEOFNL) {
|
|||
|
|
// This means the final line gets changed but doesn't have a trailing newline in either the
|
|||
|
|
// original or patched version. In that case, we do nothing if fuzzFactor > 0, and if
|
|||
|
|
// fuzzFactor is 0, we simply validate that the source file has no trailing newline.
|
|||
|
|
if (!fuzzFactor && lines[lines.length - 1] == '') {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (lines[lines.length - 1] == '') {
|
|||
|
|
lines.pop();
|
|||
|
|
}
|
|||
|
|
else if (!fuzzFactor) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (addEOFNL) {
|
|||
|
|
if (lines[lines.length - 1] != '') {
|
|||
|
|
lines.push('');
|
|||
|
|
}
|
|||
|
|
else if (!fuzzFactor) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Checks if the hunk can be made to fit at the provided location with at most `maxErrors`
|
|||
|
|
* insertions, substitutions, or deletions, while ensuring also that:
|
|||
|
|
* - lines deleted in the hunk match exactly, and
|
|||
|
|
* - wherever an insertion operation or block of insertion operations appears in the hunk, the
|
|||
|
|
* immediately preceding and following lines of context match exactly
|
|||
|
|
*
|
|||
|
|
* `toPos` should be set such that lines[toPos] is meant to match hunkLines[0].
|
|||
|
|
*
|
|||
|
|
* If the hunk can be applied, returns an object with properties `oldLineLastI` and
|
|||
|
|
* `replacementLines`. Otherwise, returns null.
|
|||
|
|
*/
|
|||
|
|
function applyHunk(hunkLines, toPos, maxErrors, hunkLinesI = 0, lastContextLineMatched = true, patchedLines = [], patchedLinesLength = 0) {
|
|||
|
|
let nConsecutiveOldContextLines = 0;
|
|||
|
|
let nextContextLineMustMatch = false;
|
|||
|
|
for (; hunkLinesI < hunkLines.length; hunkLinesI++) {
|
|||
|
|
const hunkLine = hunkLines[hunkLinesI], operation = (hunkLine.length > 0 ? hunkLine[0] : ' '), content = (hunkLine.length > 0 ? hunkLine.substr(1) : hunkLine);
|
|||
|
|
if (operation === '-') {
|
|||
|
|
if (compareLine(toPos + 1, lines[toPos], operation, content)) {
|
|||
|
|
toPos++;
|
|||
|
|
nConsecutiveOldContextLines = 0;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
if (!maxErrors || lines[toPos] == null) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
patchedLines[patchedLinesLength] = lines[toPos];
|
|||
|
|
return applyHunk(hunkLines, toPos + 1, maxErrors - 1, hunkLinesI, false, patchedLines, patchedLinesLength + 1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (operation === '+') {
|
|||
|
|
if (!lastContextLineMatched) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
patchedLines[patchedLinesLength] = content;
|
|||
|
|
patchedLinesLength++;
|
|||
|
|
nConsecutiveOldContextLines = 0;
|
|||
|
|
nextContextLineMustMatch = true;
|
|||
|
|
}
|
|||
|
|
if (operation === ' ') {
|
|||
|
|
nConsecutiveOldContextLines++;
|
|||
|
|
patchedLines[patchedLinesLength] = lines[toPos];
|
|||
|
|
if (compareLine(toPos + 1, lines[toPos], operation, content)) {
|
|||
|
|
patchedLinesLength++;
|
|||
|
|
lastContextLineMatched = true;
|
|||
|
|
nextContextLineMustMatch = false;
|
|||
|
|
toPos++;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
if (nextContextLineMustMatch || !maxErrors) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
// Consider 3 possibilities in sequence:
|
|||
|
|
// 1. lines contains a *substitution* not included in the patch context, or
|
|||
|
|
// 2. lines contains an *insertion* not included in the patch context, or
|
|||
|
|
// 3. lines contains a *deletion* not included in the patch context
|
|||
|
|
// The first two options are of course only possible if the line from lines is non-null -
|
|||
|
|
// i.e. only option 3 is possible if we've overrun the end of the old file.
|
|||
|
|
return (lines[toPos] && (applyHunk(hunkLines, toPos + 1, maxErrors - 1, hunkLinesI + 1, false, patchedLines, patchedLinesLength + 1) || applyHunk(hunkLines, toPos + 1, maxErrors - 1, hunkLinesI, false, patchedLines, patchedLinesLength + 1)) || applyHunk(hunkLines, toPos, maxErrors - 1, hunkLinesI + 1, false, patchedLines, patchedLinesLength));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Before returning, trim any unmodified context lines off the end of patchedLines and reduce
|
|||
|
|
// toPos (and thus oldLineLastI) accordingly. This allows later hunks to be applied to a region
|
|||
|
|
// that starts in this hunk's trailing context.
|
|||
|
|
patchedLinesLength -= nConsecutiveOldContextLines;
|
|||
|
|
toPos -= nConsecutiveOldContextLines;
|
|||
|
|
patchedLines.length = patchedLinesLength;
|
|||
|
|
return {
|
|||
|
|
patchedLines,
|
|||
|
|
oldLineLastI: toPos - 1
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
const resultLines = [];
|
|||
|
|
// Search best fit offsets for each hunk based on the previous ones
|
|||
|
|
let prevHunkOffset = 0;
|
|||
|
|
for (let i = 0; i < hunks.length; i++) {
|
|||
|
|
const hunk = hunks[i];
|
|||
|
|
let hunkResult;
|
|||
|
|
const maxLine = lines.length - hunk.oldLines + fuzzFactor;
|
|||
|
|
let toPos;
|
|||
|
|
for (let maxErrors = 0; maxErrors <= fuzzFactor; maxErrors++) {
|
|||
|
|
toPos = hunk.oldStart + prevHunkOffset - 1;
|
|||
|
|
const iterator = distanceIterator(toPos, minLine, maxLine);
|
|||
|
|
for (; toPos !== undefined; toPos = iterator()) {
|
|||
|
|
hunkResult = applyHunk(hunk.lines, toPos, maxErrors);
|
|||
|
|
if (hunkResult) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (hunkResult) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (!hunkResult) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
// Copy everything from the end of where we applied the last hunk to the start of this hunk
|
|||
|
|
for (let i = minLine; i < toPos; i++) {
|
|||
|
|
resultLines.push(lines[i]);
|
|||
|
|
}
|
|||
|
|
// Add the lines produced by applying the hunk:
|
|||
|
|
for (let i = 0; i < hunkResult.patchedLines.length; i++) {
|
|||
|
|
const line = hunkResult.patchedLines[i];
|
|||
|
|
resultLines.push(line);
|
|||
|
|
}
|
|||
|
|
// Set lower text limit to end of the current hunk, so next ones don't try
|
|||
|
|
// to fit over already patched text
|
|||
|
|
minLine = hunkResult.oldLineLastI + 1;
|
|||
|
|
// Note the offset between where the patch said the hunk should've applied and where we
|
|||
|
|
// applied it, so we can adjust future hunks accordingly:
|
|||
|
|
prevHunkOffset = toPos + 1 - hunk.oldStart;
|
|||
|
|
}
|
|||
|
|
// Copy over the rest of the lines from the old text
|
|||
|
|
for (let i = minLine; i < lines.length; i++) {
|
|||
|
|
resultLines.push(lines[i]);
|
|||
|
|
}
|
|||
|
|
return resultLines.join('\n');
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* applies one or more patches.
|
|||
|
|
*
|
|||
|
|
* `patch` may be either an array of structured patch objects, or a string representing a patch in unified diff format (which may patch one or more files).
|
|||
|
|
*
|
|||
|
|
* This method will iterate over the contents of the patch and apply to data provided through callbacks. The general flow for each patch index is:
|
|||
|
|
*
|
|||
|
|
* - `options.loadFile(index, callback)` is called. The caller should then load the contents of the file and then pass that to the `callback(err, data)` callback. Passing an `err` will terminate further patch execution.
|
|||
|
|
* - `options.patched(index, content, callback)` is called once the patch has been applied. `content` will be the return value from `applyPatch`. When it's ready, the caller should call `callback(err)` callback. Passing an `err` will terminate further patch execution.
|
|||
|
|
*
|
|||
|
|
* Once all patches have been applied or an error occurs, the `options.complete(err)` callback is made.
|
|||
|
|
*/
|
|||
|
|
function applyPatches(uniDiff, options) {
|
|||
|
|
const spDiff = typeof uniDiff === 'string' ? parsePatch(uniDiff) : uniDiff;
|
|||
|
|
let currentIndex = 0;
|
|||
|
|
function processIndex() {
|
|||
|
|
const index = spDiff[currentIndex++];
|
|||
|
|
if (!index) {
|
|||
|
|
return options.complete();
|
|||
|
|
}
|
|||
|
|
options.loadFile(index, function (err, data) {
|
|||
|
|
if (err) {
|
|||
|
|
return options.complete(err);
|
|||
|
|
}
|
|||
|
|
const updatedContent = applyPatch(data, index, options);
|
|||
|
|
options.patched(index, updatedContent, function (err) {
|
|||
|
|
if (err) {
|
|||
|
|
return options.complete(err);
|
|||
|
|
}
|
|||
|
|
processIndex();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
processIndex();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function swapPrefix(fileName) {
|
|||
|
|
if (fileName === undefined || fileName === '/dev/null') {
|
|||
|
|
return fileName;
|
|||
|
|
}
|
|||
|
|
if (fileName.startsWith('a/')) {
|
|||
|
|
return 'b/' + fileName.slice(2);
|
|||
|
|
}
|
|||
|
|
if (fileName.startsWith('b/')) {
|
|||
|
|
return 'a/' + fileName.slice(2);
|
|||
|
|
}
|
|||
|
|
return fileName;
|
|||
|
|
}
|
|||
|
|
function reversePatch(structuredPatch) {
|
|||
|
|
if (Array.isArray(structuredPatch)) {
|
|||
|
|
// (See comment in unixToWin for why we need the pointless-looking anonymous function here)
|
|||
|
|
return structuredPatch.map(patch => reversePatch(patch)).reverse();
|
|||
|
|
}
|
|||
|
|
const reversed = Object.assign(Object.assign({}, structuredPatch), { oldFileName: structuredPatch.isGit ? swapPrefix(structuredPatch.newFileName) : structuredPatch.newFileName, oldHeader: structuredPatch.newHeader, newFileName: structuredPatch.isGit ? swapPrefix(structuredPatch.oldFileName) : structuredPatch.oldFileName, newHeader: structuredPatch.oldHeader, oldMode: structuredPatch.newMode, newMode: structuredPatch.oldMode, isCreate: structuredPatch.isDelete, isDelete: structuredPatch.isCreate, hunks: structuredPatch.hunks.map(hunk => {
|
|||
|
|
return {
|
|||
|
|
oldLines: hunk.newLines,
|
|||
|
|
oldStart: hunk.newStart,
|
|||
|
|
newLines: hunk.oldLines,
|
|||
|
|
newStart: hunk.oldStart,
|
|||
|
|
lines: hunk.lines.map(l => {
|
|||
|
|
if (l.startsWith('-')) {
|
|||
|
|
return `+${l.slice(1)}`;
|
|||
|
|
}
|
|||
|
|
if (l.startsWith('+')) {
|
|||
|
|
return `-${l.slice(1)}`;
|
|||
|
|
}
|
|||
|
|
return l;
|
|||
|
|
})
|
|||
|
|
};
|
|||
|
|
}) });
|
|||
|
|
if (structuredPatch.isCopy) {
|
|||
|
|
// Reversing a copy means deleting the file that was created by the copy.
|
|||
|
|
// The "old" file in the reversed patch is the copy destination (which
|
|||
|
|
// exists and should be removed), and the "new" file is /dev/null.
|
|||
|
|
//
|
|||
|
|
// Note: we clear the hunks because the original copy's hunks describe
|
|||
|
|
// the diff between the source and destination, not the full content of
|
|||
|
|
// the destination file, so they can't be meaningfully reversed into a
|
|||
|
|
// deletion hunk. This means the resulting patch is not something
|
|||
|
|
// `git apply` will accept (it requires deletion patches to include a
|
|||
|
|
// hunk removing every line). Producing a correct deletion hunk would
|
|||
|
|
// require knowing the full content of the copy destination, which we
|
|||
|
|
// don't have. Consumers that need a `git apply`-compatible patch will
|
|||
|
|
// need to resolve the full file content themselves.
|
|||
|
|
reversed.newFileName = '/dev/null';
|
|||
|
|
reversed.newHeader = undefined;
|
|||
|
|
reversed.isDelete = true;
|
|||
|
|
delete reversed.isCreate;
|
|||
|
|
delete reversed.isCopy;
|
|||
|
|
delete reversed.isRename;
|
|||
|
|
reversed.hunks = [];
|
|||
|
|
}
|
|||
|
|
// Reversing a rename is just a rename in the opposite direction;
|
|||
|
|
// isRename stays set and the filenames are already swapped above.
|
|||
|
|
return reversed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Returns true if the filename contains characters that require C-style
|
|||
|
|
* quoting (as used by Git and GNU diffutils in diff output).
|
|||
|
|
*/
|
|||
|
|
function needsQuoting(s) {
|
|||
|
|
for (let i = 0; i < s.length; i++) {
|
|||
|
|
if (s[i] < '\x20' || s[i] > '\x7e' || s[i] === '"' || s[i] === '\\') {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* C-style quotes a filename, encoding special characters as escape sequences
|
|||
|
|
* and non-ASCII bytes as octal escapes. This is the inverse of
|
|||
|
|
* `parseQuotedFileName` in parse.ts.
|
|||
|
|
*
|
|||
|
|
* Non-ASCII bytes are encoded as UTF-8 before being emitted as octal escapes.
|
|||
|
|
* This matches the behaviour of both Git and GNU diffutils, which always emit
|
|||
|
|
* UTF-8 octal escapes regardless of the underlying filesystem encoding (e.g.
|
|||
|
|
* Git for Windows converts from NTFS's UTF-16 to UTF-8 internally).
|
|||
|
|
*
|
|||
|
|
* If the filename doesn't need quoting, returns it as-is.
|
|||
|
|
*/
|
|||
|
|
function quoteFileNameIfNeeded(s) {
|
|||
|
|
if (!needsQuoting(s)) {
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
let result = '"';
|
|||
|
|
const bytes = new TextEncoder().encode(s);
|
|||
|
|
let i = 0;
|
|||
|
|
while (i < bytes.length) {
|
|||
|
|
const b = bytes[i];
|
|||
|
|
// See https://en.wikipedia.org/wiki/Escape_sequences_in_C#Escape_sequences
|
|||
|
|
if (b === 0x07) {
|
|||
|
|
result += '\\a';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x08) {
|
|||
|
|
result += '\\b';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x09) {
|
|||
|
|
result += '\\t';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x0a) {
|
|||
|
|
result += '\\n';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x0b) {
|
|||
|
|
result += '\\v';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x0c) {
|
|||
|
|
result += '\\f';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x0d) {
|
|||
|
|
result += '\\r';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x22) {
|
|||
|
|
result += '\\"';
|
|||
|
|
}
|
|||
|
|
else if (b === 0x5c) {
|
|||
|
|
result += '\\\\';
|
|||
|
|
}
|
|||
|
|
else if (b >= 0x20 && b <= 0x7e) {
|
|||
|
|
// Just a printable ASCII character that is neither a double quote nor a
|
|||
|
|
// backslash; no need to escape it.
|
|||
|
|
result += String.fromCharCode(b);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
// Either part of a non-ASCII character or a control character without a
|
|||
|
|
// special escape sequence; needs escaping as a 3-digit octal escape
|
|||
|
|
result += '\\' + b.toString(8).padStart(3, '0');
|
|||
|
|
}
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
result += '"';
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
const INCLUDE_HEADERS = {
|
|||
|
|
includeIndex: true,
|
|||
|
|
includeUnderline: true,
|
|||
|
|
includeFileHeaders: true
|
|||
|
|
};
|
|||
|
|
const FILE_HEADERS_ONLY = {
|
|||
|
|
includeIndex: false,
|
|||
|
|
includeUnderline: false,
|
|||
|
|
includeFileHeaders: true
|
|||
|
|
};
|
|||
|
|
const OMIT_HEADERS = {
|
|||
|
|
includeIndex: false,
|
|||
|
|
includeUnderline: false,
|
|||
|
|
includeFileHeaders: false
|
|||
|
|
};
|
|||
|
|
function structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|||
|
|
let optionsObj;
|
|||
|
|
if (!options) {
|
|||
|
|
optionsObj = {};
|
|||
|
|
}
|
|||
|
|
else if (typeof options === 'function') {
|
|||
|
|
optionsObj = { callback: options };
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
optionsObj = options;
|
|||
|
|
}
|
|||
|
|
if (typeof optionsObj.context === 'undefined') {
|
|||
|
|
optionsObj.context = 4;
|
|||
|
|
}
|
|||
|
|
// We copy this into its own variable to placate TypeScript, which thinks
|
|||
|
|
// optionsObj.context might be undefined in the callbacks below.
|
|||
|
|
const context = optionsObj.context;
|
|||
|
|
// @ts-expect-error (runtime check for something that is correctly a static type error)
|
|||
|
|
if (optionsObj.newlineIsToken) {
|
|||
|
|
throw new Error('newlineIsToken may not be used with patch-generation functions, only with diffing functions');
|
|||
|
|
}
|
|||
|
|
if (!optionsObj.callback) {
|
|||
|
|
return diffLinesResultToPatch(diffLines(oldStr, newStr, optionsObj));
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
const { callback } = optionsObj;
|
|||
|
|
diffLines(oldStr, newStr, Object.assign(Object.assign({}, optionsObj), { callback: (diff) => {
|
|||
|
|
const patch = diffLinesResultToPatch(diff);
|
|||
|
|
// TypeScript is unhappy without the cast because it does not understand that `patch` may
|
|||
|
|
// be undefined here only if `callback` is StructuredPatchCallbackAbortable:
|
|||
|
|
callback(patch);
|
|||
|
|
} }));
|
|||
|
|
}
|
|||
|
|
function diffLinesResultToPatch(diff) {
|
|||
|
|
// STEP 1: Build up the patch with no "\ No newline at end of file" lines and with the arrays
|
|||
|
|
// of lines containing trailing newline characters. We'll tidy up later...
|
|||
|
|
if (!diff) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
diff.push({ value: '', lines: [] }); // Append an empty value to make cleanup easier
|
|||
|
|
function contextLines(lines) {
|
|||
|
|
return lines.map(function (entry) { return ' ' + entry; });
|
|||
|
|
}
|
|||
|
|
const hunks = [];
|
|||
|
|
let oldRangeStart = 0, newRangeStart = 0, curRange = [], oldLine = 1, newLine = 1;
|
|||
|
|
for (let i = 0; i < diff.length; i++) {
|
|||
|
|
const current = diff[i], lines = current.lines || splitLines(current.value);
|
|||
|
|
current.lines = lines;
|
|||
|
|
if (current.added || current.removed) {
|
|||
|
|
// If we have previous context, start with that
|
|||
|
|
if (!oldRangeStart) {
|
|||
|
|
const prev = diff[i - 1];
|
|||
|
|
oldRangeStart = oldLine;
|
|||
|
|
newRangeStart = newLine;
|
|||
|
|
if (prev) {
|
|||
|
|
curRange = context > 0 ? contextLines(prev.lines.slice(-context)) : [];
|
|||
|
|
oldRangeStart -= curRange.length;
|
|||
|
|
newRangeStart -= curRange.length;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Output our changes
|
|||
|
|
for (const line of lines) {
|
|||
|
|
curRange.push((current.added ? '+' : '-') + line);
|
|||
|
|
}
|
|||
|
|
// Track the updated file position
|
|||
|
|
if (current.added) {
|
|||
|
|
newLine += lines.length;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
oldLine += lines.length;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
// Identical context lines. Track line changes
|
|||
|
|
if (oldRangeStart) {
|
|||
|
|
// Close out any changes that have been output (or join overlapping)
|
|||
|
|
if (lines.length <= context * 2 && i < diff.length - 2) {
|
|||
|
|
// Overlapping
|
|||
|
|
for (const line of contextLines(lines)) {
|
|||
|
|
curRange.push(line);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
// end the range and output
|
|||
|
|
const contextSize = Math.min(lines.length, context);
|
|||
|
|
for (const line of contextLines(lines.slice(0, contextSize))) {
|
|||
|
|
curRange.push(line);
|
|||
|
|
}
|
|||
|
|
const hunk = {
|
|||
|
|
oldStart: oldRangeStart,
|
|||
|
|
oldLines: (oldLine - oldRangeStart + contextSize),
|
|||
|
|
newStart: newRangeStart,
|
|||
|
|
newLines: (newLine - newRangeStart + contextSize),
|
|||
|
|
lines: curRange
|
|||
|
|
};
|
|||
|
|
hunks.push(hunk);
|
|||
|
|
oldRangeStart = 0;
|
|||
|
|
newRangeStart = 0;
|
|||
|
|
curRange = [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
oldLine += lines.length;
|
|||
|
|
newLine += lines.length;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Step 2: eliminate the trailing `\n` from each line of each hunk, and, where needed, add
|
|||
|
|
// "\ No newline at end of file".
|
|||
|
|
for (const hunk of hunks) {
|
|||
|
|
for (let i = 0; i < hunk.lines.length; i++) {
|
|||
|
|
if (hunk.lines[i].endsWith('\n')) {
|
|||
|
|
hunk.lines[i] = hunk.lines[i].slice(0, -1);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
hunk.lines.splice(i + 1, 0, '\\ No newline at end of file');
|
|||
|
|
i++; // Skip the line we just added, then continue iterating
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
oldFileName: oldFileName, newFileName: newFileName,
|
|||
|
|
oldHeader: oldHeader, newHeader: newHeader,
|
|||
|
|
hunks: hunks
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* creates a unified diff patch.
|
|||
|
|
*
|
|||
|
|
* @param patch either a single structured patch object (as returned by `structuredPatch`) or an
|
|||
|
|
* array of them (as returned by `parsePatch`).
|
|||
|
|
* @param headerOptions behaves the same as the `headerOptions` option of `createTwoFilesPatch`.
|
|||
|
|
* Ignored for patches where `isGit` is `true`.
|
|||
|
|
*
|
|||
|
|
* When a patch has `isGit: true`, `formatPatch` output is changed to more closely match Git's
|
|||
|
|
* output: it emits a `diff --git` header, emits Git extended headers as appropriate based on
|
|||
|
|
* properties like `isRename`, `isCreate`, `newMode`, etc, and will omit `---`/`+++` file
|
|||
|
|
* headers for patches with no hunks (e.g. renames without content changes).
|
|||
|
|
*/
|
|||
|
|
function formatPatch(patch, headerOptions) {
|
|||
|
|
var _a, _b, _c, _d, _e, _f;
|
|||
|
|
if (!headerOptions) {
|
|||
|
|
headerOptions = INCLUDE_HEADERS;
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(patch)) {
|
|||
|
|
if (patch.length > 1 && !headerOptions.includeFileHeaders && !patch.every(p => p.isGit)) {
|
|||
|
|
throw new Error('Cannot omit file headers on a multi-file patch. '
|
|||
|
|
+ '(The result would be unparseable; how would a tool trying to apply '
|
|||
|
|
+ 'the patch know which changes are to which file?)');
|
|||
|
|
}
|
|||
|
|
return patch.map(p => formatPatch(p, headerOptions)).join('\n');
|
|||
|
|
}
|
|||
|
|
const ret = [];
|
|||
|
|
// Git patches have a fixed header format (diff --git, extended headers,
|
|||
|
|
// and ---/+++ when hunks are present), so headerOptions is ignored.
|
|||
|
|
if (patch.isGit) {
|
|||
|
|
headerOptions = INCLUDE_HEADERS;
|
|||
|
|
// Emit Git-style diff --git header and extended headers.
|
|||
|
|
// Git never puts /dev/null in the "diff --git" line; for file
|
|||
|
|
// creations/deletions it uses the real filename on both sides.
|
|||
|
|
if (!patch.oldFileName) {
|
|||
|
|
throw new Error('oldFileName must be specified for Git patches');
|
|||
|
|
}
|
|||
|
|
if (!patch.newFileName) {
|
|||
|
|
throw new Error('newFileName must be specified for Git patches');
|
|||
|
|
}
|
|||
|
|
let gitOldName = patch.oldFileName;
|
|||
|
|
let gitNewName = patch.newFileName;
|
|||
|
|
if (patch.isCreate && gitOldName === '/dev/null') {
|
|||
|
|
gitOldName = gitNewName.replace(/^b\//, 'a/');
|
|||
|
|
}
|
|||
|
|
else if (patch.isDelete && gitNewName === '/dev/null') {
|
|||
|
|
gitNewName = gitOldName.replace(/^a\//, 'b/');
|
|||
|
|
}
|
|||
|
|
ret.push('diff --git ' + quoteFileNameIfNeeded(gitOldName) + ' ' + quoteFileNameIfNeeded(gitNewName));
|
|||
|
|
if (patch.isDelete) {
|
|||
|
|
ret.push('deleted file mode ' + ((_a = patch.oldMode) !== null && _a !== void 0 ? _a : '100644'));
|
|||
|
|
}
|
|||
|
|
if (patch.isCreate) {
|
|||
|
|
ret.push('new file mode ' + ((_b = patch.newMode) !== null && _b !== void 0 ? _b : '100644'));
|
|||
|
|
}
|
|||
|
|
if (patch.oldMode && patch.newMode && !patch.isDelete && !patch.isCreate) {
|
|||
|
|
ret.push('old mode ' + patch.oldMode);
|
|||
|
|
ret.push('new mode ' + patch.newMode);
|
|||
|
|
}
|
|||
|
|
if (patch.isRename) {
|
|||
|
|
ret.push('rename from ' + quoteFileNameIfNeeded(((_c = patch.oldFileName) !== null && _c !== void 0 ? _c : '').replace(/^a\//, '')));
|
|||
|
|
ret.push('rename to ' + quoteFileNameIfNeeded(((_d = patch.newFileName) !== null && _d !== void 0 ? _d : '').replace(/^b\//, '')));
|
|||
|
|
}
|
|||
|
|
if (patch.isCopy) {
|
|||
|
|
ret.push('copy from ' + quoteFileNameIfNeeded(((_e = patch.oldFileName) !== null && _e !== void 0 ? _e : '').replace(/^a\//, '')));
|
|||
|
|
ret.push('copy to ' + quoteFileNameIfNeeded(((_f = patch.newFileName) !== null && _f !== void 0 ? _f : '').replace(/^b\//, '')));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName && patch.oldFileName !== undefined) {
|
|||
|
|
ret.push('Index: ' + patch.oldFileName);
|
|||
|
|
}
|
|||
|
|
if (headerOptions.includeUnderline) {
|
|||
|
|
ret.push('===================================================================');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Emit --- / +++ file headers. For Git patches with no hunks (e.g.
|
|||
|
|
// pure renames, mode-only changes), Git omits these, so we do too.
|
|||
|
|
const hasHunks = patch.hunks.length > 0;
|
|||
|
|
if (headerOptions.includeFileHeaders && patch.oldFileName !== undefined && patch.newFileName !== undefined
|
|||
|
|
&& (!patch.isGit || hasHunks)) {
|
|||
|
|
ret.push('--- ' + quoteFileNameIfNeeded(patch.oldFileName) + (patch.oldHeader ? '\t' + patch.oldHeader : ''));
|
|||
|
|
ret.push('+++ ' + quoteFileNameIfNeeded(patch.newFileName) + (patch.newHeader ? '\t' + patch.newHeader : ''));
|
|||
|
|
}
|
|||
|
|
for (let i = 0; i < patch.hunks.length; i++) {
|
|||
|
|
const hunk = patch.hunks[i];
|
|||
|
|
// Unified Diff Format quirk: If the chunk size is 0,
|
|||
|
|
// the first number is one lower than one would expect.
|
|||
|
|
// https://www.artima.com/weblogs/viewpost.jsp?thread=164293
|
|||
|
|
const oldStart = hunk.oldLines === 0 ? hunk.oldStart - 1 : hunk.oldStart;
|
|||
|
|
const newStart = hunk.newLines === 0 ? hunk.newStart - 1 : hunk.newStart;
|
|||
|
|
ret.push('@@ -' + oldStart + ',' + hunk.oldLines
|
|||
|
|
+ ' +' + newStart + ',' + hunk.newLines
|
|||
|
|
+ ' @@');
|
|||
|
|
for (const line of hunk.lines) {
|
|||
|
|
ret.push(line);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ret.join('\n') + '\n';
|
|||
|
|
}
|
|||
|
|
function createTwoFilesPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|||
|
|
if (typeof options === 'function') {
|
|||
|
|
options = { callback: options };
|
|||
|
|
}
|
|||
|
|
if (!(options === null || options === void 0 ? void 0 : options.callback)) {
|
|||
|
|
const patchObj = structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options);
|
|||
|
|
if (!patchObj) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
return formatPatch(patchObj, options === null || options === void 0 ? void 0 : options.headerOptions);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
const { callback } = options;
|
|||
|
|
structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, Object.assign(Object.assign({}, options), { callback: patchObj => {
|
|||
|
|
if (!patchObj) {
|
|||
|
|
callback(undefined);
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
callback(formatPatch(patchObj, options.headerOptions));
|
|||
|
|
}
|
|||
|
|
} }));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function createPatch(fileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|||
|
|
return createTwoFilesPatch(fileName, fileName, oldStr, newStr, oldHeader, newHeader, options);
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* Split `text` into an array of lines, including the trailing newline character (where present)
|
|||
|
|
*/
|
|||
|
|
function splitLines(text) {
|
|||
|
|
const hasTrailingNl = text.endsWith('\n');
|
|||
|
|
const result = text.split('\n').map(line => line + '\n');
|
|||
|
|
if (hasTrailingNl) {
|
|||
|
|
result.pop();
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
result.push(result.pop().slice(0, -1));
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* converts a list of change objects to the format returned by Google's [diff-match-patch](https://github.com/google/diff-match-patch) library
|
|||
|
|
*/
|
|||
|
|
function convertChangesToDMP(changes) {
|
|||
|
|
const ret = [];
|
|||
|
|
let change, operation;
|
|||
|
|
for (let i = 0; i < changes.length; i++) {
|
|||
|
|
change = changes[i];
|
|||
|
|
if (change.added) {
|
|||
|
|
operation = 1;
|
|||
|
|
}
|
|||
|
|
else if (change.removed) {
|
|||
|
|
operation = -1;
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
operation = 0;
|
|||
|
|
}
|
|||
|
|
ret.push([operation, change.value]);
|
|||
|
|
}
|
|||
|
|
return ret;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* converts a list of change objects to a serialized XML format
|
|||
|
|
*/
|
|||
|
|
function convertChangesToXML(changes) {
|
|||
|
|
const ret = [];
|
|||
|
|
for (let i = 0; i < changes.length; i++) {
|
|||
|
|
const change = changes[i];
|
|||
|
|
if (change.added) {
|
|||
|
|
ret.push('<ins>');
|
|||
|
|
}
|
|||
|
|
else if (change.removed) {
|
|||
|
|
ret.push('<del>');
|
|||
|
|
}
|
|||
|
|
ret.push(escapeHTML(change.value));
|
|||
|
|
if (change.added) {
|
|||
|
|
ret.push('</ins>');
|
|||
|
|
}
|
|||
|
|
else if (change.removed) {
|
|||
|
|
ret.push('</del>');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ret.join('');
|
|||
|
|
}
|
|||
|
|
function escapeHTML(s) {
|
|||
|
|
let n = s;
|
|||
|
|
n = n.replace(/&/g, '&');
|
|||
|
|
n = n.replace(/</g, '<');
|
|||
|
|
n = n.replace(/>/g, '>');
|
|||
|
|
n = n.replace(/"/g, '"');
|
|||
|
|
return n;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
exports.Diff = Diff;
|
|||
|
|
exports.FILE_HEADERS_ONLY = FILE_HEADERS_ONLY;
|
|||
|
|
exports.INCLUDE_HEADERS = INCLUDE_HEADERS;
|
|||
|
|
exports.OMIT_HEADERS = OMIT_HEADERS;
|
|||
|
|
exports.applyPatch = applyPatch;
|
|||
|
|
exports.applyPatches = applyPatches;
|
|||
|
|
exports.arrayDiff = arrayDiff;
|
|||
|
|
exports.canonicalize = canonicalize;
|
|||
|
|
exports.characterDiff = characterDiff;
|
|||
|
|
exports.convertChangesToDMP = convertChangesToDMP;
|
|||
|
|
exports.convertChangesToXML = convertChangesToXML;
|
|||
|
|
exports.createPatch = createPatch;
|
|||
|
|
exports.createTwoFilesPatch = createTwoFilesPatch;
|
|||
|
|
exports.cssDiff = cssDiff;
|
|||
|
|
exports.diffArrays = diffArrays;
|
|||
|
|
exports.diffChars = diffChars;
|
|||
|
|
exports.diffCss = diffCss;
|
|||
|
|
exports.diffJson = diffJson;
|
|||
|
|
exports.diffLines = diffLines;
|
|||
|
|
exports.diffSentences = diffSentences;
|
|||
|
|
exports.diffTrimmedLines = diffTrimmedLines;
|
|||
|
|
exports.diffWords = diffWords;
|
|||
|
|
exports.diffWordsWithSpace = diffWordsWithSpace;
|
|||
|
|
exports.formatPatch = formatPatch;
|
|||
|
|
exports.jsonDiff = jsonDiff;
|
|||
|
|
exports.lineDiff = lineDiff;
|
|||
|
|
exports.parsePatch = parsePatch;
|
|||
|
|
exports.reversePatch = reversePatch;
|
|||
|
|
exports.sentenceDiff = sentenceDiff;
|
|||
|
|
exports.structuredPatch = structuredPatch;
|
|||
|
|
exports.wordDiff = wordDiff;
|
|||
|
|
exports.wordsWithSpaceDiff = wordsWithSpaceDiff;
|
|||
|
|
|
|||
|
|
}));
|