Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ Broadly, jsdiff's diff functions all take an old text and a new text and perform

#### Universal `options`

Certain options can be provided in the `options` object of *any* method that calculates a diff:
Certain options can be provided in the `options` object of *any* method that calculates a diff (including `diffChars`, `diffLines` etc. as well as `structuredPatch`, `createPatch`, and `createTwoFilesPatch`):

* `callback`: if provided, the diff will be computed in async mode to avoid blocking the event loop while the diff is calculated. The value of the `callback` option should be a function and will be passed the result of the diff as its first argument. Only works with functions that return change objects, like `diffLines`, not those that return patches, like `structuredPatch` or `createPatch`.
* `callback`: if provided, the diff will be computed in async mode to avoid blocking the event loop while the diff is calculated. The value of the `callback` option should be a function and will be passed the computed diff or patch as its first argument.

(Note that if the ONLY option you want to provide is a callback, you can pass the callback function directly as the `options` parameter instead of passing an object with a `callback` property.)

Expand Down
1 change: 1 addition & 0 deletions release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- [#490](https://github.com/kpdecker/jsdiff/pull/490) **When calling diffing functions in async mode by passing a `callback` option, the diff result will now be passed as the *first* argument to the callback instead of the second.** (Previously, the first argument was never used at all and would always have value `undefined`.)
- [#489](github.com/kpdecker/jsdiff/pull/489) **`this.options` no longer exists on `Diff` objects.** Instead, `options` is now passed as an argument to methods that rely on options, like `equals(left, right, options)`. This fixes a race condition in async mode, where diffing behaviour could be changed mid-execution if a concurrent usage of the same `Diff` instances overwrote its `options`.
- [#518](https://github.com/kpdecker/jsdiff/pull/518) **`linedelimiters` no longer exists** on patch objects; instead, when a patch with Windows-style CRLF line endings is parsed, **the lines in `lines` will end with `\r`**. There is now a **new `autoConvertLineEndings` option, on by default**, which makes it so that when a patch with Windows-style line endings is applied to a source file with Unix style line endings, the patch gets autoconverted to use Unix-style line endings, and when a patch with Unix-style line endings is applied to a source file with Windows-style line endings, it gets autoconverted to use Windows-style line endings.
- [#521](https://github.com/kpdecker/jsdiff/pull/521) **the `callback` option is now supported by `structuredPatch`, `createPatch`, and `createTwoFilesPatch`**

## v5.2.0

Expand Down
215 changes: 131 additions & 84 deletions src/patch/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,104 +4,125 @@ export function structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHea
if (!options) {
options = {};
}
if (typeof options === 'function') {
options = {callback: options};
}
if (typeof options.context === 'undefined') {
options.context = 4;
}

const diff = diffLines(oldStr, newStr, options);
if(!diff) {
return;
if (!options.callback) {
return diffLinesResultToPatch(diffLines(oldStr, newStr, options));
} else {
const {callback} = options;
diffLines(
oldStr,
newStr,
{
...options,
callback: (diff) => {
const patch = diffLinesResultToPatch(diff);
callback(patch);
}
}
);
}

diff.push({value: '', lines: []}); // Append an empty value to make cleanup easier
function diffLinesResultToPatch(diff) {
if(!diff) {
return;
}

function contextLines(lines) {
return lines.map(function(entry) { return ' ' + entry; });
}
diff.push({value: '', lines: []}); // Append an empty value to make cleanup easier

function contextLines(lines) {
return lines.map(function(entry) { return ' ' + entry; });
}

let 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 || current.value.replace(/\n$/, '').split('\n');
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 = options.context > 0 ? contextLines(prev.lines.slice(-options.context)) : [];
oldRangeStart -= curRange.length;
newRangeStart -= curRange.length;
let 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 || current.value.replace(/\n$/, '').split('\n');
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 = options.context > 0 ? contextLines(prev.lines.slice(-options.context)) : [];
oldRangeStart -= curRange.length;
newRangeStart -= curRange.length;
}
}
}

// Output our changes
curRange.push(... lines.map(function(entry) {
return (current.added ? '+' : '-') + entry;
}));
// Output our changes
curRange.push(... lines.map(function(entry) {
return (current.added ? '+' : '-') + entry;
}));

// 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 <= options.context * 2 && i < diff.length - 2) {
// Overlapping
curRange.push(... contextLines(lines));
// Track the updated file position
if (current.added) {
newLine += lines.length;
} else {
// end the range and output
let contextSize = Math.min(lines.length, options.context);
curRange.push(... contextLines(lines.slice(0, contextSize)));

let hunk = {
oldStart: oldRangeStart,
oldLines: (oldLine - oldRangeStart + contextSize),
newStart: newRangeStart,
newLines: (newLine - newRangeStart + contextSize),
lines: curRange
};
if (i >= diff.length - 2 && lines.length <= options.context) {
// EOF is inside this hunk
let oldEOFNewline = ((/\n$/).test(oldStr));
let newEOFNewline = ((/\n$/).test(newStr));
let noNlBeforeAdds = lines.length == 0 && curRange.length > hunk.oldLines;
if (!oldEOFNewline && noNlBeforeAdds && oldStr.length > 0) {
// special case: old has no eol and no trailing context; no-nl can end up before adds
// however, if the old file is empty, do not output the no-nl line
curRange.splice(hunk.oldLines, 0, '\\ No newline at end of file');
}
if ((!oldEOFNewline && !noNlBeforeAdds) || !newEOFNewline) {
curRange.push('\\ No newline at end of file');
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 <= options.context * 2 && i < diff.length - 2) {
// Overlapping
curRange.push(... contextLines(lines));
} else {
// end the range and output
let contextSize = Math.min(lines.length, options.context);
curRange.push(... contextLines(lines.slice(0, contextSize)));

let hunk = {
oldStart: oldRangeStart,
oldLines: (oldLine - oldRangeStart + contextSize),
newStart: newRangeStart,
newLines: (newLine - newRangeStart + contextSize),
lines: curRange
};
if (i >= diff.length - 2 && lines.length <= options.context) {
// EOF is inside this hunk
let oldEOFNewline = ((/\n$/).test(oldStr));
let newEOFNewline = ((/\n$/).test(newStr));
let noNlBeforeAdds = lines.length == 0 && curRange.length > hunk.oldLines;
if (!oldEOFNewline && noNlBeforeAdds && oldStr.length > 0) {
// special case: old has no eol and no trailing context; no-nl can end up before adds
// however, if the old file is empty, do not output the no-nl line
curRange.splice(hunk.oldLines, 0, '\\ No newline at end of file');
}
if ((!oldEOFNewline && !noNlBeforeAdds) || !newEOFNewline) {
curRange.push('\\ No newline at end of file');
}
}
}
hunks.push(hunk);
hunks.push(hunk);

oldRangeStart = 0;
newRangeStart = 0;
curRange = [];
oldRangeStart = 0;
newRangeStart = 0;
curRange = [];
}
}
oldLine += lines.length;
newLine += lines.length;
}
oldLine += lines.length;
newLine += lines.length;
}
}

return {
oldFileName: oldFileName, newFileName: newFileName,
oldHeader: oldHeader, newHeader: newHeader,
hunks: hunks
};
return {
oldFileName: oldFileName, newFileName: newFileName,
oldHeader: oldHeader, newHeader: newHeader,
hunks: hunks
};
}
}

export function formatPatch(diff) {
Expand Down Expand Up @@ -140,11 +161,37 @@ export function formatPatch(diff) {
}

export function createTwoFilesPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
const patchObj = structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options);
if (!patchObj) {
return;
if (typeof options === 'function') {
options = {callback: options};
}

if (!options?.callback) {
const patchObj = structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options);
if (!patchObj) {
return;
}
return formatPatch(patchObj);
} else {
const {callback} = options;
structuredPatch(
oldFileName,
newFileName,
oldStr,
newStr,
oldHeader,
newHeader,
{
...options,
callback: patchObj => {
if (!patchObj) {
callback();
} else {
callback(formatPatch(patchObj));
}
}
}
);
}
return formatPatch(patchObj);
}

export function createPatch(fileName, oldStr, newStr, oldHeader, newHeader, options) {
Expand Down
98 changes: 98 additions & 0 deletions test/patch/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,66 @@ describe('patch/create', function() {
expect(diffResult).to.equal(expectedResult);
});
});


it('takes an optional callback option', function(done) {
createPatch(
'test',
'foo\nbar\nbaz\n', 'foo\nbarcelona\nbaz\n',
'header1', 'header2',
{callback: (res) => {
expect(res).to.eql(
'Index: test\n'
+ '===================================================================\n'
+ '--- test\theader1\n'
+ '+++ test\theader2\n'
+ '@@ -1,3 +1,3 @@\n'
+ ' foo\n'
+ '-bar\n'
+ '+barcelona\n'
+ ' baz\n'
);
done();
}}
);
});

it('lets you provide a callback by passing a function as the `options` parameter', function(done) {
createPatch(
'test',
'foo\nbar\nbaz\n', 'foo\nbarcelona\nbaz\n',
'header1', 'header2',
res => {
expect(res).to.eql(
'Index: test\n'
+ '===================================================================\n'
+ '--- test\theader1\n'
+ '+++ test\theader2\n'
+ '@@ -1,3 +1,3 @@\n'
+ ' foo\n'
+ '-bar\n'
+ '+barcelona\n'
+ ' baz\n'
);
done();
}
);
});

it('still supports early termination when in async mode', function(done) {
createPatch(
'test',
'foo\nbar\nbaz\n', 'food\nbarcelona\nbaz\n',
'header1', 'header2',
{
maxEditLength: 1,
callback: (res) => {
expect(res).to.eql(undefined);
done();
}
}
);
});
});

describe('stripTrailingCr', function() {
Expand Down Expand Up @@ -766,6 +826,44 @@ describe('patch/create', function() {
});
});

it('takes an optional callback option', function(done) {
structuredPatch(
'oldfile', 'newfile',
'foo\nbar\nbaz\n', 'foo\nbarcelona\nbaz\n',
'header1', 'header2',
{callback: (res) => {
expect(res).to.eql({
oldFileName: 'oldfile', newFileName: 'newfile',
oldHeader: 'header1', newHeader: 'header2',
hunks: [{
oldStart: 1, oldLines: 3, newStart: 1, newLines: 3,
lines: [' foo', '-bar', '+barcelona', ' baz']
}]
});
done();
}}
);
});

it('lets you provide a callback by passing a function as the `options` parameter', function(done) {
structuredPatch(
'oldfile', 'newfile',
'foo\nbar\nbaz\n', 'foo\nbarcelona\nbaz\n',
'header1', 'header2',
res => {
expect(res).to.eql({
oldFileName: 'oldfile', newFileName: 'newfile',
oldHeader: 'header1', newHeader: 'header2',
hunks: [{
oldStart: 1, oldLines: 3, newStart: 1, newLines: 3,
lines: [' foo', '-bar', '+barcelona', ' baz']
}]
});
done();
}
);
});

describe('given options.maxEditLength', function() {
const options = { maxEditLength: 1 };

Expand Down