diff --git a/README.md b/README.md index 82d1ac41..c127d914 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Certain options can be provided in the `options` object of *any* method that cal (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.) * `maxEditLength`: a number specifying the maximum edit distance to consider between the old and new texts. If the edit distance is higher than this, jsdiff will return `undefined` instead of a diff. You can use this to limit the computational cost of diffing large, very different texts by giving up early if the cost will be huge. Works for functions that return change objects and also for `structuredPatch`, but not other patch-generation functions. +* `oneChangePerToken`: if `true`, the array of change objects returned will contain one change object per token (e.g. one per line if calling `diffLines`), instead of runs of consecutive tokens that are all added / all removed / all conserved being combined into a single change object. ### Defining custom diffing behaviors diff --git a/release-notes.md b/release-notes.md index e22311ce..d543cdf1 100644 --- a/release-notes.md +++ b/release-notes.md @@ -8,6 +8,7 @@ - [#439](https://github.com/kpdecker/jsdiff/pull/439) Prefer diffs that order deletions before insertions. When faced with a choice between two diffs with an equal total edit distance, the Myers diff algorithm generally prefers one that does deletions before insertions rather than insertions before deletions. For instance, when diffing `abcd` against `acbd`, it will prefer a diff that says to delete the `b` and then insert a new `b` after the `c`, over a diff that says to insert a `c` before the `b` and then delete the existing `c`. JsDiff deviated from the published Myers algorithm in a way that led to it having the opposite preference in many cases, including that example. This is now fixed, meaning diffs output by JsDiff will more accurately reflect what the published Myers diff algorithm would output. - [#455](https://github.com/kpdecker/jsdiff/pull/455) The `added` and `removed` properties of change objects are now guaranteed to be set to a boolean value. (Previously, they would be set to `undefined` or omitted entirely instead of setting them to false.) - [#464](https://github.com/kpdecker/jsdiff/pull/464) Specifying `{maxEditLength: 0}` now sets a max edit length of 0 instead of no maximum. +- [#460][https://github.com/kpdecker/jsdiff/pull/460] Added `oneChangePerToken` option. ## Development diff --git a/src/diff/base.js b/src/diff/base.js index 9b5ecb78..a5b6c665 100644 --- a/src/diff/base.js +++ b/src/diff/base.js @@ -40,7 +40,7 @@ Diff.prototype = { let newPos = this.extractCommon(bestPath[0], newString, oldString, 0); if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) { // Identity per the equality and tokenizer - return done([{value: this.join(newString), count: newString.length, added: false, removed: false}]); + return done(buildValues(self, bestPath[0].lastComponent, newString, oldString, self.useLongestToken)); } // Once we hit the right edge of the edit graph on some diagonal k, we can @@ -147,7 +147,7 @@ Diff.prototype = { addToPath(path, added, removed, oldPosInc) { let last = path.lastComponent; - if (last && last.added === added && last.removed === removed) { + if (last && !this.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 } @@ -170,9 +170,12 @@ Diff.prototype = { newPos++; oldPos++; commonCount++; + if (this.options.oneChangePerToken) { + basePath.lastComponent = {count: 1, previousComponent: basePath.lastComponent, added: false, removed: false}; + } } - if (commonCount) { + if (commonCount && !this.options.oneChangePerToken) { basePath.lastComponent = {count: commonCount, previousComponent: basePath.lastComponent, added: false, removed: false}; } diff --git a/test/diff/character.js b/test/diff/character.js index 9061e69b..d2ccf9a4 100644 --- a/test/diff/character.js +++ b/test/diff/character.js @@ -6,8 +6,25 @@ import {expect} from 'chai'; describe('diff/character', function() { describe('#diffChars', function() { it('Should diff chars', function() { - const diffResult = diffChars('New Value.', 'New ValueMoreData.'); - expect(convertChangesToXML(diffResult)).to.equal('New ValueMoreData.'); + const diffResult = diffChars('Old Value.', 'New ValueMoreData.'); + expect(convertChangesToXML(diffResult)).to.equal('OldNew ValueMoreData.'); + }); + + describe('oneChangePerToken option', function() { + it('emits one change per character', function() { + const diffResult = diffChars('Old Value.', 'New ValueMoreData.', {oneChangePerToken: true}); + expect(diffResult.length).to.equal(21); + expect(convertChangesToXML(diffResult)).to.equal('OldNew ValueMoreData.'); + }); + + it('correctly handles the case where the texts are identical', function() { + const diffResult = diffChars('foo bar baz qux', 'foo bar baz qux', {oneChangePerToken: true}); + expect(diffResult).to.deep.equal( + ['f', 'o', 'o', ' ', 'b', 'a', 'r', ' ', 'b', 'a', 'z', ' ', 'q', 'u', 'x'].map( + char => ({value: char, count: 1, added: false, removed: false}) + ) + ); + }); }); describe('case insensitivity', function() { diff --git a/test/diff/line.js b/test/diff/line.js index 7052b2a1..abd0ede9 100644 --- a/test/diff/line.js +++ b/test/diff/line.js @@ -101,6 +101,25 @@ describe('diff/line', function() { }); }); + describe('oneChangePerToken option', function() { + it('emits one change per line', function() { + const diffResult = diffLines( + 'foo\nbar\nbaz\nqux\n', + 'fox\nbar\nbaz\nqux\n', + { oneChangePerToken: true } + ); + expect(diffResult).to.deep.equal( + [ + {value: 'foo\n', count: 1, added: false, removed: true}, + {value: 'fox\n', count: 1, added: true, removed: false}, + {value: 'bar\n', count: 1, added: false, removed: false}, + {value: 'baz\n', count: 1, added: false, removed: false}, + {value: 'qux\n', count: 1, added: false, removed: false} + ] + ); + }); + }); + // Trimmed Line Diff describe('#TrimmedLineDiff', function() { it('should diff lines', function() {