diff --git a/package.json b/package.json index 1a68f96..9559582 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "node": ">=12.0.0" }, "files": [ - "src/" + "src/*.js", + "!src/*.test.js" ], "repository": { "type": "git", @@ -45,6 +46,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^3.3.1", "jest": "^26.4.2", + "outdent": "^0.8.0", "prettier": "^2.2.1" } } diff --git a/src/generateDiff.js b/src/generateDiff.js index 6820c63..5b86520 100644 --- a/src/generateDiff.js +++ b/src/generateDiff.js @@ -1,8 +1,15 @@ const diff = require('diff'); +function getTrimmedLines(value) { + const trimmedValue = value.endsWith('\n') + ? value.substring(0, value.length - 1) + : value; + return trimmedValue.split('\n'); +} + /** - * Generates a diff between two multi-line strings. The resulting diff shows - * any changes using '-' and '+' to indicate the "old" and "new" version + * Generates a diff between two multi-line string files. The resulting diff + * shows any changes using '-' and '+' to indicate the "old" and "new" version * respectively, and includes 2 lines of unchanged content around each changed * section where possible. * @param {string} before - The string representing the base for the comparison. @@ -11,30 +18,64 @@ const diff = require('diff'); */ function generateDiff(before, after) { const changes = diff.diffLines(before, after); - const diffLines = []; - const preceedingContext = []; - for (const { added, removed, value } of changes) { - const lines = value.split('\n'); - // remove trailing newline - lines.pop(); - if (added || removed) { - if (preceedingContext.length) { - diffLines.push(...preceedingContext); - preceedingContext.splice(0, preceedingContext.length); - } - diffLines.push(...lines.map((line) => `${added ? '+' : '-'}${line}`)); - } else { - // If a changed line has been included already, add up to 2 lines of context - if (diffLines.length) { - diffLines.push(...lines.slice(0, 2).map((line) => ` ${line}`)); - lines.splice(0, 2); + // `diffLines` will always return at least one change + const lastChange = changes[changes.length - 1]; + const penultimateChange = changes[changes.length - 2] || {}; + + // Add notice about newline at end of file + if (!lastChange.value.endsWith('\n')) { + lastChange.noNewline = true; + } + // If the last change is an addition and the penultimate change is a + // removal, then the last line of the file is also in the penultimate change. + // That's why we're checking to see if the newline notice is needed here as + // well. + if ( + lastChange.added && + penultimateChange.removed && + !penultimateChange.value.endsWith('\n') + ) { + penultimateChange.noNewline = true; + } + + const diffLines = changes.flatMap( + ({ added, noNewline, removed, value }, index) => { + const lines = getTrimmedLines(value); + const changedLines = []; + if (added || removed) { + // Add up to 2 lines of context before each change + const previousContext = changes[index - 1]; + if ( + previousContext && + !previousContext.added && + !previousContext.removed + ) { + const hasPreviousChange = index > 1; + const previousContextLines = getTrimmedLines(previousContext.value); + // Avoid repeating context that has already been included in diff + if (!hasPreviousChange || previousContextLines.length >= 3) { + const linesOfContext = + hasPreviousChange && previousContextLines.length === 3 ? 1 : 2; + const previousTwoLines = previousContextLines + .slice(-1 * linesOfContext) + .map((line) => ` ${line}`); + changedLines.push(...previousTwoLines); + } + } + changedLines.push( + ...lines.map((line) => `${added ? '+' : '-'}${line}`), + ); + } else if (index > 0) { + // Add up to 2 lines of context following a change + const nextTwoLines = lines.slice(0, 2).map((line) => ` ${line}`); + changedLines.push(...nextTwoLines); } - // stash last 2 lines for context in case there is another change - if (lines.length) { - preceedingContext.push(...lines.slice(-2)); + if (noNewline) { + changedLines.push('\\ No newline at end of file'); } - } - } + return changedLines; + }, + ); return diffLines.join('\n'); } diff --git a/src/generateDiff.test.js b/src/generateDiff.test.js new file mode 100644 index 0000000..9590dd1 --- /dev/null +++ b/src/generateDiff.test.js @@ -0,0 +1,325 @@ +const _outdent = require('outdent'); + +const outdent = _outdent({ trimTrailingNewline: false }); +const { generateDiff } = require('./generateDiff'); + +const testCases = [ + { + description: 'should return an empty string when comparing empty files', + before: '\n', + after: '\n', + expected: '', + }, + { + description: 'should return an empty string when comparing identical files', + before: 'abc\n', + after: 'abc\n', + expected: '', + }, + { + description: 'should display one-line diff', + before: 'abc\n', + after: '123\n', + expected: '-abc\n+123', + }, + { + description: + 'should display one-line diff of file without trailing newlines', + before: 'abc', + after: '123', + expected: outdent` + -abc + \\ No newline at end of file + +123 + \\ No newline at end of file`, + }, + { + description: 'should display multi-line diff', + before: outdent` + a + b + c + `, + after: outdent` + 1 + 2 + 3 + `, + expected: outdent` + -a + -b + -c + +1 + +2 + +3`, + }, + { + description: 'should display multi-line diff without trailing newline', + before: outdent` + a + b + c`, + after: outdent` + 1 + 2 + 3`, + expected: outdent` + -a + -b + -c + \\ No newline at end of file + +1 + +2 + +3 + \\ No newline at end of file`, + }, + { + description: 'should display multi-line diff with added trailing newline', + before: outdent` + a + b + c`, + after: outdent` + 1 + 2 + 3 + `, + expected: outdent` + -a + -b + -c + \\ No newline at end of file + +1 + +2 + +3`, + }, + { + description: 'should display multi-line diff with removed trailing newline', + before: outdent` + a + b + c + `, + after: outdent` + 1 + 2 + 3`, + expected: outdent` + -a + -b + -c + +1 + +2 + +3 + \\ No newline at end of file`, + }, + { + description: 'should display multi-line diff with removed middle newline', + before: outdent` + a + b + + c + `, + after: outdent` + 1 + 2 + 3 + `, + expected: outdent` + -a + -b + - + -c + +1 + +2 + +3`, + }, + { + description: 'should display multi-line diff with added middle newline', + before: outdent` + a + b + c + `, + after: outdent` + 1 + 2 + + 3 + `, + expected: outdent` + -a + -b + -c + +1 + +2 + + + +3`, + }, + { + description: 'should display diff of added newline in middle of file', + before: outdent` + a + b + c + `, + after: outdent` + a + b + + c + `, + expected: outdent` + ${outdent} + a + b + + + c`, + }, + { + description: 'should display diff of added newline at end of file', + before: outdent` + a + b + c`, + after: outdent` + a + b + c + `, + expected: outdent` + ${outdent} + a + b + -c + \\ No newline at end of file + +c`, + }, + { + description: 'should display diff of removed newline at end of file', + before: outdent` + a + b + c + `, + after: outdent` + a + b + c`, + expected: outdent` + ${outdent} + a + b + -c + +c + \\ No newline at end of file`, + }, + { + description: 'should display one line of context before and after change', + before: outdent` + a + b + c + `, + after: outdent` + a + c + `, + expected: outdent` + ${outdent} + a + -b + c`, + }, + { + description: 'should display two lines of context before and after change', + before: outdent` + a + b + c + d + e + f + g + `, + after: outdent` + a + b + c + e + f + g + `, + expected: outdent` + ${outdent} + b + c + -d + e + f`, + }, + { + description: 'should not repeat context of changes one line apart', + before: outdent` + a + b + c + `, + after: outdent` + b + `, + expected: outdent` + -a + b + -c`, + }, + { + description: 'should not repeat context of changes two lines apart', + before: outdent` + a + b + c + d + `, + after: outdent` + b + c + `, + expected: outdent` + -a + b + c + -d`, + }, + { + description: 'should not repeat context of changes three lines apart', + before: outdent` + a + b + c + d + e + `, + after: outdent` + b + c + d + `, + expected: outdent` + -a + b + c + d + -e`, + }, +]; + +describe('generateDiff', () => { + for (const { description, before, after, expected } of testCases) { + it(`${description}`, () => { + const diff = generateDiff(before, after); + expect(diff).toStrictEqual(expected); + }); + } +}); diff --git a/yarn.lock b/yarn.lock index 630a44e..808d95d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3379,6 +3379,11 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +outdent@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/outdent/-/outdent-0.8.0.tgz#2ebc3e77bf49912543f1008100ff8e7f44428eb0" + integrity sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A== + p-each-series@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48"