diff --git a/README.md b/README.md index a8aefc97..0916884e 100644 --- a/README.md +++ b/README.md @@ -119,13 +119,21 @@ Broadly, jsdiff's diff functions all take an old text and a new text and perform * `Diff.applyPatch(source, patch[, options])` - attempts to apply a unified diff patch. - 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`), returns false. + 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. `patch` may be a string diff or the output from the `parsePatch` or `structuredPatch` methods. The optional `options` object may have the following keys: - - `fuzzFactor`: Number of lines that are allowed to differ before rejecting a patch. Defaults to 0. + - `fuzzFactor`: Maximum Levenshtein distance (in lines deleted, added, or subtituted) between the context shown in a patch hunk and the lines found in the file. Defaults to 0. - `autoConvertLineEndings`: If `true`, and if the file to be patched consistently uses different line endings to the patch (i.e. either the file always uses Unix line endings while the patch uses Windows ones, or vice versa), then `applyPatch` will behave as if the line endings in the patch were the same as those in the source file. (If `false`, the patch will usually fail to apply in such circumstances since lines deleted in the patch won't be considered to match those in the source file.) Defaults to `true`. - `compareLine(lineNumber, line, operation, patchContent)`: Callback used to compare to given lines to determine if they should be considered equal when patching. Defaults to strict equality but may be overridden to provide fuzzier comparison. Should return false if the lines should be rejected. diff --git a/release-notes.md b/release-notes.md index 9fa328f1..e254a056 100644 --- a/release-notes.md +++ b/release-notes.md @@ -28,6 +28,10 @@ - [#521](https://github.com/kpdecker/jsdiff/pull/521) **the `callback` option is now supported by `structuredPatch`, `createPatch - [#529](https://github.com/kpdecker/jsdiff/pull/529) **`parsePatch` can now parse patches where lines starting with `--` or `++` are deleted/inserted**; previously, there were edge cases where the parser would choke on valid patches or give wrong results. - [#530](https://github.com/kpdecker/jsdiff/pull/530) **Added `ignoreNewlineAtEof` option` to `diffLines`** +- [#533](https://github.com/kpdecker/jsdiff/pull/533) **`applyPatch` uses an entirely new algorithm for fuzzy matching.** Differences between the old and new algorithm are as follows: + * The `fuzzFactor` now indicates the maximum [*Levenshtein* distance](https://en.wikipedia.org/wiki/Levenshtein_distance) that there can be between the context shown in a hunk and the actual file content at a location where we try to apply the hunk. (Previously, it represented a maximum [*Hamming* distance](https://en.wikipedia.org/wiki/Hamming_distance), meaning that a single insertion or deletion in the source file could stop a hunk from applying even with a high `fuzzFactor`.) + * A hunk containing a deletion can now only be applied in a context where the line to be deleted actually appears verbatim. (Previously, as long as enough context lines in the hunk matched, `applyPatch` would apply the hunk anyway and delete a completely different line.) + * The context line immediately before and immediately after an insertion must match exactly between the hunk and the file for a hunk to apply. (Previously this was not required.) ## v5.2.0 diff --git a/src/patch/apply.js b/src/patch/apply.js index 151ecfc1..6980f904 100644 --- a/src/patch/apply.js +++ b/src/patch/apply.js @@ -29,104 +29,228 @@ export function applyPatch(source, uniDiff, options = {}) { hunks = uniDiff.hunks, compareLine = options.compareLine || ((lineNumber, line, operation, patchContent) => line === patchContent), - errorCount = 0, fuzzFactor = options.fuzzFactor || 0, - minLine = 0, - offset = 0, + minLine = 0; - removeEOFNL, - addEOFNL; + 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; + } + break; + } + prevLine = line; + } + if (removeEOFNL) { + 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 exactly fits on the provided location + * 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 hunkFits(hunk, toPos) { - for (let j = 0; j < hunk.lines.length; j++) { - let line = hunk.lines[j], - operation = (line.length > 0 ? line[0] : ' '), - content = (line.length > 0 ? line.substr(1) : line); - - if (operation === ' ' || operation === '-') { - // Context sanity check - if (!compareLine(toPos + 1, lines[toPos], operation, content)) { - errorCount++; - - if (errorCount > fuzzFactor) { - return false; + function applyHunk( + hunkLines, + toPos, + maxErrors, + hunkLinesI = 0, + lastContextLineMatched = true, + patchedLines = [], + patchedLinesLength = 0, + ) { + let nConsecutiveOldContextLines = 0; + let nextContextLineMustMatch = false; + for (; hunkLinesI < hunkLines.length; hunkLinesI++) { + let 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 + ) + ); } - toPos++; } } - return true; + // 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++) { - let hunk = hunks[i], - maxLine = lines.length - hunk.oldLines, - localOffset = 0, - toPos = offset + hunk.oldStart - 1; - - let iterator = distanceIterator(toPos, minLine, maxLine); - - for (; localOffset !== undefined; localOffset = iterator()) { - if (hunkFits(hunk, toPos + localOffset)) { - hunk.offset = offset += localOffset; + const hunk = hunks[i]; + let hunkResult; + let maxLine = lines.length - hunk.oldLines + fuzzFactor; + let toPos; + for (let maxErrors = 0; maxErrors <= fuzzFactor; maxErrors++) { + toPos = hunk.oldStart + prevHunkOffset - 1; + let iterator = distanceIterator(toPos, minLine, maxLine); + for (; toPos !== undefined; toPos = iterator()) { + hunkResult = applyHunk(hunk.lines, toPos, maxErrors); + if (hunkResult) { + break; + } + } + if (hunkResult) { break; } } - if (localOffset === undefined) { + if (!hunkResult) { return false; } - // Set lower text limit to end of the current hunk, so next ones don't try - // to fit over already patched text - minLine = hunk.offset + hunk.oldStart + hunk.oldLines; - } + // 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]); + } - // Apply patch hunks - let diffOffset = 0; - for (let i = 0; i < hunks.length; i++) { - let hunk = hunks[i], - toPos = hunk.oldStart + hunk.offset + diffOffset - 1; - diffOffset += hunk.newLines - hunk.oldLines; + // 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); + } - for (let j = 0; j < hunk.lines.length; j++) { - let line = hunk.lines[j], - operation = (line.length > 0 ? line[0] : ' '), - content = (line.length > 0 ? line.substr(1) : 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; - if (operation === ' ') { - toPos++; - } else if (operation === '-') { - lines.splice(toPos, 1); - /* istanbul ignore else */ - } else if (operation === '+') { - lines.splice(toPos, 0, content); - toPos++; - } else if (operation === '\\') { - let previousOperation = hunk.lines[j - 1] ? hunk.lines[j - 1][0] : null; - if (previousOperation === '+') { - removeEOFNL = true; - } else if (previousOperation === '-') { - addEOFNL = true; - } - } - } + // 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; } - // Handle EOFNL insertion/removal - if (removeEOFNL) { - while (!lines[lines.length - 1]) { - lines.pop(); - } - } else if (addEOFNL) { - lines.push(''); + // Copy over the rest of the lines from the old text + for (let i = minLine; i < lines.length; i++) { + resultLines.push(lines[i]); } - return lines.join('\n'); + + return resultLines.join('\n'); } // Wrapper that supports multiple file patches via callbacks. diff --git a/src/util/distance-iterator.js b/src/util/distance-iterator.js index 73be1f80..1ffe6413 100644 --- a/src/util/distance-iterator.js +++ b/src/util/distance-iterator.js @@ -18,7 +18,7 @@ export default function(start, minLine, maxLine) { // 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 localOffset; + return start + localOffset; } forwardExhausted = true; @@ -32,7 +32,7 @@ export default function(start, minLine, maxLine) { // Check if trying to fit before text beginning, and if not, check it fits // before offset location if (minLine <= start - localOffset) { - return -localOffset++; + return start - localOffset++; } backwardExhausted = true; diff --git a/test/patch/apply.js b/test/patch/apply.js index d8c0711f..a05b7133 100755 --- a/test/patch/apply.js +++ b/test/patch/apply.js @@ -525,40 +525,707 @@ describe('patch/apply', function() { .to.equal(false); }); - it('should succeed within fuzz factor', function() { + it("should fail if a line to delete doesn't match, even with fuzz factor", function() { + const patch = 'Index: foo.txt\n' + + '===================================================================\n' + + '--- foo.txt\n' + + '+++ foo.txt\n' + + '@@ -1,4 +1,3 @@\n' + + ' foo\n' + + '-bar\n' + + ' baz\n' + + ' qux\n'; + + // Sanity-check - patch should apply fine to this: + const result1 = applyPatch('foo\nbar\nbaz\nqux\n', patch, {fuzzFactor: 99}); + expect(result1).to.equal('foo\nbaz\nqux\n'); + + // ... but not to this: + const result2 = applyPatch('foo\nSOMETHING ENTIRELY DIFFERENT\nbaz\nqux\n', patch, {fuzzFactor: 99}); + expect(result2).to.equal(false); + }); + + it("should fail if either line immediately next to an insertion doesn't match, regardless of fuzz factor", function() { expect(applyPatch( - 'line2\n' + 'lineA\n' + + 'lineB\n' + + 'lineC\n' + + 'lineD\n' + + 'lineE\n', + + '--- test\theader1\n' + + '+++ test\theader2\n' + + '@@ -1,5 +1,6 @@\n' + + ' lineA\n' + + ' lineB\n' + + ' lineC\n' + + '+lineNEW\n' + + ' lineX\n' + + ' lineE\n', + {fuzzFactor: 10})) + .to.equal(false); + + expect(applyPatch( + 'lineA\n' + + 'lineB\n' + + 'lineC\n' + + 'lineD\n' + + 'lineE\n', + + '--- test\theader1\n' + + '+++ test\theader2\n' + + '@@ -1,5 +1,6 @@\n' + + ' lineA\n' + + ' lineB\n' + + ' lineX\n' + + '+lineNEW\n' + + ' lineD\n' + + ' lineE\n', + {fuzzFactor: 10})) + .to.equal(false); + }); + + it('should, given a fuzz factor, allow mismatches caused by presence of extra lines', function() { + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line2.5\n' + + 'line3\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'line8\n' + + 'line8.5\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 2} + )).to.equal( + 'line1\n' + + 'line2\n' + + 'line2.5\n' + + 'line3\n' + + 'line4\n' + + 'line5\n' + + 'line6\n' + + 'line7\n' + + 'line8\n' + + 'line8.5\n' + + 'line10\n', + ); + }); + + it('should, given a fuzz factor, allow mismatches due to missing lines', function() { + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 2} + )).to.equal( + 'line1\n' + + 'line2\n' + + 'line4\n' + + 'line5\n' + + 'line6\n' + + 'line7\n' + + 'line10\n', + ); + }); + + it('should, given a fuzz factor, allow mismatches caused by lines being changed', function() { + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'lineTHREE\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'lineEIGHT\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 2} + )).to.equal( + 'line1\n' + + 'line2\n' + + 'lineTHREE\n' + + 'line4\n' + + 'line5\n' + + 'line6\n' + + 'line7\n' + + 'lineEIGHT\n' + + 'line10\n', + ); + }); + + it('should, given a fuzz factor, allow mismatches caused by a mixture of ins/sub/del', function() { + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line2.5\n' + + 'lineTHREE\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 3} + )).to.equal( + 'line1\n' + + 'line2\n' + + 'line2.5\n' + + 'lineTHREE\n' + + 'line4\n' + + 'line5\n' + + 'line6\n' + + 'line7\n' + + 'line10\n', + ); + }); + + it('should fail if number of lines of context mismatch is greater than fuzz factor', function() { + // 3 extra lines of context, but fuzzFactor: 2 + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line2.5\n' + + 'line3\n' + + 'line4\n' + + 'line6\n' + + 'line6.5\n' + + 'line7\n' + + 'line8\n' + + 'line8.5\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 2} + )).to.equal( + false + ); + + // 2 lines of context missing from file to patch, fuzz factor 1 + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 1} + )).to.equal(false); + + // 3 changed context lines, but fuzzFactor of 2 + expect(applyPatch( + 'line1\n' + + 'lineTWO\n' + + 'lineTHREE\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'lineEIGHT\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 2} + )).to.equal(false); + + // 3 total changes, fuzzFactor 2 + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line2.5\n' + + 'lineTHREE\n' + + 'line4\n' + + 'line6\n' + + 'line7\n' + + 'line9\n' + + 'line10\n', + + '--- foo.txt\t2024-07-19 09:58:02.489059795 +0100\n' + + '+++ bar.txt\t2024-07-19 09:58:24.768153252 +0100\n' + + '@@ -2,8 +2,8 @@\n' + + ' line2\n' + + ' line3\n' + + ' line4\n' + + '+line5\n' + + ' line6\n' + + ' line7\n' + + ' line8\n' + + '-line9\n' + + ' line10\n', + + {fuzzFactor: 2} + )).to.equal(false); + }); + + it('should adjust where it starts looking to apply the hunk based on offsets of prior hunks', function() { + const patch = '--- foo.txt\t2024-07-19 12:28:25.056182029 +0100\n' + + '+++ bar.txt\t2024-07-19 12:28:13.036639136 +0100\n' + + '@@ -9,7 +9,6 @@\n' + + ' 1 2 3 introductory text\n' + + ' Baa oink moo introductory text\n' + + ' Probably enough introductory text\n' + + '-Incy wincy mincy introductory text\n' + + ' \n' + + ' Three repeated verses:\n' + + ' \n' + + '@@ -28,7 +27,7 @@\n' + + ' The wind came along and blew them in again\n' + + ' Poor old Michael Finnegan, begin again\n' + + ' \n' + + '-There was an old man named Michael Finnegan\n' + + '+There was an old man named Bob\n' + + ' He had whiskers on his chinnegan\n' + + ' The wind came along and blew them in again\n' + + ' Poor old Michael Finnegan, begin again\n'; + + + // First we try applying the text to the original text I used to generate the patch. + // The patch was generated by modifying the fourth of the six occurrences of the repeated + // verse, and that's what we should see when we apply it... + expect(applyPatch( + 'Bla bla bla introductory text\n' + + 'Foo bar baz introductory text\n' + + 'Fworble worble glorble introductory text\n' + + 'Need to be at least 6 lines of introductory text\n' + + 'Jingle jangle jungle introductory text\n' + + 'Horgle worgle borgle introductory text\n' + + 'Wiggly jiggly piggly introductory text\n' + + 'A B C introductory text\n' + + '1 2 3 introductory text\n' + + 'Baa oink moo introductory text\n' + + 'Probably enough introductory text\n' + + 'Incy wincy mincy introductory text\n' + + '\n' + + 'Three repeated verses:\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n', + + patch + )).to.equal( + 'Bla bla bla introductory text\n' + + 'Foo bar baz introductory text\n' + + 'Fworble worble glorble introductory text\n' + + 'Need to be at least 6 lines of introductory text\n' + + 'Jingle jangle jungle introductory text\n' + + 'Horgle worgle borgle introductory text\n' + + 'Wiggly jiggly piggly introductory text\n' + + 'A B C introductory text\n' + + '1 2 3 introductory text\n' + + 'Baa oink moo introductory text\n' + + 'Probably enough introductory text\n' + + '\n' + + 'Three repeated verses:\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Bob\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + ); + + // But what if we apply the patch to a source file where the first 5 lines are deleted? + // Then we expect applyPatch to still modify the fourth occurrence of the repeated verse, + // NOT the fifth (which is now the one at the line number indicated by the hunk header). This + // is because it should be able to tell when it applied the previous hunk that 5 lines at the + // beginning of the file had been deleted, and to adjust where it tries to apply the second + // hunk accordingly. + expect(applyPatch( + 'Horgle worgle borgle introductory text\n' + + 'Wiggly jiggly piggly introductory text\n' + + 'A B C introductory text\n' + + '1 2 3 introductory text\n' + + 'Baa oink moo introductory text\n' + + 'Probably enough introductory text\n' + + 'Incy wincy mincy introductory text\n' + + '\n' + + 'Three repeated verses:\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n', + + patch + )).to.equal( + 'Horgle worgle borgle introductory text\n' + + 'Wiggly jiggly piggly introductory text\n' + + 'A B C introductory text\n' + + '1 2 3 introductory text\n' + + 'Baa oink moo introductory text\n' + + 'Probably enough introductory text\n' + + '\n' + + 'Three repeated verses:\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Bob\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + ); + + // What if we instead ADD five lines? Same thing - we still expect verse 4 to be the one + // changed + expect(applyPatch( + 'line1\n' + + 'line2\n' + + 'line3\n' + + 'line4\n' + + 'line5\n' + + 'Bla bla bla introductory text\n' + + 'Foo bar baz introductory text\n' + + 'Fworble worble glorble introductory text\n' + + 'Need to be at least 6 lines of introductory text\n' + + 'Jingle jangle jungle introductory text\n' + + 'Horgle worgle borgle introductory text\n' + + 'Wiggly jiggly piggly introductory text\n' + + 'A B C introductory text\n' + + '1 2 3 introductory text\n' + + 'Baa oink moo introductory text\n' + + 'Probably enough introductory text\n' + + 'Incy wincy mincy introductory text\n' + + '\n' + + 'Three repeated verses:\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n', + + patch + )).to.equal( + 'line1\n' + + 'line2\n' + + 'line3\n' + + 'line4\n' + + 'line5\n' + + 'Bla bla bla introductory text\n' + + 'Foo bar baz introductory text\n' + + 'Fworble worble glorble introductory text\n' + + 'Need to be at least 6 lines of introductory text\n' + + 'Jingle jangle jungle introductory text\n' + + 'Horgle worgle borgle introductory text\n' + + 'Wiggly jiggly piggly introductory text\n' + + 'A B C introductory text\n' + + '1 2 3 introductory text\n' + + 'Baa oink moo introductory text\n' + + 'Probably enough introductory text\n' + + '\n' + + 'Three repeated verses:\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Bob\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + + '\n' + + 'There was an old man named Michael Finnegan\n' + + 'He had whiskers on his chinnegan\n' + + 'The wind came along and blew them in again\n' + + 'Poor old Michael Finnegan, begin again\n' + ); + }); + + it('should succeed when hunk needs a negative offset', function() { + expect(applyPatch( + 'line1\n' + + 'line3\n' + + 'line4\n' + + 'line5\n', + + '--- test\theader1\n' + + '+++ test\theader2\n' + + '@@ -3,2 +3,3 @@\n' + + ' line1\n' + + '+line2\n' + + ' line3\n')) + .to.equal( + 'line1\n' + 'line2\n' + + 'line3\n' + + 'line4\n' + + 'line5\n'); + }); + + it('can handle an insertion before the first line', function() { + expect(applyPatch( + 'line2\n' + + 'line3\n' + + 'line4\n' + 'line5\n', '--- test\theader1\n' + '+++ test\theader2\n' - + '@@ -1,3 +1,4 @@\n' + + '@@ -1,2 +1,3 @@\n' + + '+line1\n' + ' line2\n' - + ' line3\n' - + '+line4\n' - + ' line5\n', - {fuzzFactor: 1})) + + ' line3\n')) .to.equal( - 'line2\n' + 'line1\n' + 'line2\n' + + 'line3\n' + 'line4\n' + 'line5\n'); }); - it('should succeed when hunk needs a negative offset', function() { + it('can handle an insertion after the first line', function() { expect(applyPatch( 'line1\n' + + 'line2\n' + 'line3\n' - + 'line4\n' - + 'line5\n', + + 'line4\n', '--- test\theader1\n' + '+++ test\theader2\n' + '@@ -3,2 +3,3 @@\n' - + ' line1\n' - + '+line2\n' - + ' line3\n')) + + ' line3\n' + + ' line4\n' + + '+line5\n')) .to.equal( 'line1\n' + 'line2\n' @@ -824,6 +1491,108 @@ describe('patch/apply', function() { expect(applyPatch(oldFile, diffFile, {autoConvertLineEndings: false})).to.equal(false); }); + + it('fails if asked to remove a non-existent trailing newline with fuzzFactor 0', () => { + const oldFile = 'foo\nbar\nbaz\nqux'; + const diffFile = + 'Index: bla\n' + + '===================================================================\n' + + '--- bla\tOld Header\n' + + '+++ bla\tNew Header\n' + + '@@ -4,1 +4,1 @@\n' + + '-qux\n' + + '+qux\n' + + '\\ No newline at end of file\n'; + + expect(applyPatch(oldFile, diffFile)).to.equal(false); + }); + + it('fails if asked to add an EOF newline, with fuzzFactor 0, when one already exists', () => { + const oldFile = 'foo\nbar\nbaz\nqux\n'; + const diffFile = + 'Index: bla\n' + + '===================================================================\n' + + '--- bla\tOld Header\n' + + '+++ bla\tNew Header\n' + + '@@ -4,1 +4,1 @@\n' + + '-qux\n' + + '\\ No newline at end of file\n' + + '+qux\n'; + + expect(applyPatch(oldFile, diffFile)).to.equal(false); + }); + + it('ignores being asked to remove a non-existent trailing newline if fuzzFactor >0', () => { + const oldFile = 'foo\nbar\nbaz\nqux'; + const diffFile = + 'Index: bla\n' + + '===================================================================\n' + + '--- bla\tOld Header\n' + + '+++ bla\tNew Header\n' + + '@@ -4,1 +4,1 @@\n' + + '-qux\n' + + '+qux\n' + + '\\ No newline at end of file\n'; + + expect(applyPatch(oldFile, diffFile, {fuzzFactor: 1})).to.equal(oldFile); + }); + + it('ignores being asked to add an EOF newline when one already exists if fuzzFactor>0', () => { + const oldFile = 'foo\nbar\nbaz\nqux\n'; + const diffFile = + 'Index: bla\n' + + '===================================================================\n' + + '--- bla\tOld Header\n' + + '+++ bla\tNew Header\n' + + '@@ -4,1 +4,1 @@\n' + + '-qux\n' + + '\\ No newline at end of file\n' + + '+qux\n'; + + expect(applyPatch(oldFile, diffFile, {fuzzFactor: 1})).to.equal(oldFile); + }); + + it('rejects negative or non-integer fuzz factors', () => { + expect(() => { + applyPatch( + 'line2\n' + + 'line3\n' + + 'line5\n', + + 'Index: test\n' + + '===================================================================\n' + + '--- test\theader1\n' + + '+++ test\theader2\n' + + '@@ -1,3 +1,4 @@\n' + + ' line2\n' + + ' line3\n' + + '+line4\n' + + ' line5\n', + + {fuzzFactor: -1} + ); + }).to['throw']('fuzzFactor must be a non-negative integer'); + + expect(() => { + applyPatch( + 'line2\n' + + 'line3\n' + + 'line5\n', + + 'Index: test\n' + + '===================================================================\n' + + '--- test\theader1\n' + + '+++ test\theader2\n' + + '@@ -1,3 +1,4 @@\n' + + ' line2\n' + + ' line3\n' + + '+line4\n' + + ' line5\n', + + {fuzzFactor: 1.5} + ); + }).to['throw']('fuzzFactor must be a non-negative integer'); + }); }); describe('#applyPatches', function() {