diff --git a/README.md b/README.md index 8d650fc3..8d7db61b 100644 --- a/README.md +++ b/README.md @@ -108,24 +108,34 @@ jsdiff's diff functions all take an old text and a new text and perform three st * `createTwoFilesPatch(oldFileName, newFileName, oldStr, newStr[, oldHeader[, newHeader[, options]]])` - creates a unified diff patch by first computing a diff with `diffLines` and then serializing it to unified diff format. Parameters: - * `oldFileName` : String to be output in the filename section of the patch for the removals - * `newFileName` : String to be output in the filename section of the patch for the additions - * `oldStr` : Original string value - * `newStr` : New string value - * `oldHeader` : Optional additional information to include in the old file header. Default: `undefined`. - * `newHeader` : Optional additional information to include in the new file header. Default: `undefined`. - * `options` : An object with options. - - `context` describes how many lines of context should be included. You can set this to `Number.MAX_SAFE_INTEGER` or `Infinity` to include the entire file content in one hunk. + * `oldFileName`: String to be output in the filename section of the patch for the removals + * `newFileName`: String to be output in the filename section of the patch for the additions + * `oldStr`: Original string value + * `newStr`: New string value + * `oldHeader`: Optional additional information to include in the old file header. Default: `undefined`. + * `newHeader`: Optional additional information to include in the new file header. Default: `undefined`. + * `options`: An object with options. + - `context`: describes how many lines of context should be included. You can set this to `Number.MAX_SAFE_INTEGER` or `Infinity` to include the entire file content in one hunk. - `ignoreWhitespace`: Same as in `diffLines`. Defaults to `false`. - `stripTrailingCr`: Same as in `diffLines`. Defaults to `false`. + - `headerOptions`: Configures the format of patch headers in the returned patch. (Note these are distinct from *hunk* headers, which are a mandatory part of the unified diff format and not configurable.) Has three subfields (all default to `true`): + - `includeIndex`: whether to include a line like `Index: filename.txt` at the start of the patch header. (Even if this is `true`, this line will be omitted if `oldFileName` and `newFileName` are not identical.) + - `includeUnderline`: whether to include `===================================================================`. + - `includeFileHeaders`: whether to include two lines indicating the old and new filename, formatted like `--- old.txt` and `+++ new.txt`. + + Note further that jsdiff exports three top-level constants that can be used as `headerOptions` values, named `INCLUDE_HEADERS` (the default), `FILE_HEADERS_ONLY`, and `OMIT_HEADERS`. + + (Note that in the case where `includeIndex` and `includeFileHeaders` are both false, the `oldFileName` and `newFileName` parameters are ignored entirely.) + + The GNU `patch` util will accept patches produced with any configuration of these header options (and refers to patch headers as "leading garbage", which in typical usage it makes no attempt to parse or use in any way). However, other tools for working with unified diff format patches may be less liberal (and are not unambiguously wrong to be so, since the format has no real standard). Tinkering with the `headerOptions` setting thus provides a way to help make patches produced by jsdiff compatible with other tools. * `createPatch(fileName, oldStr, newStr[, oldHeader[, newHeader[, options]]])` - creates a unified diff patch. Just like createTwoFilesPatch, but with oldFileName being equal to newFileName. -* `formatPatch(patch)` - creates a unified diff patch. +* `formatPatch(patch[, headerOptions])` - creates a unified diff patch. - `patch` may be either a single structured patch object (as returned by `structuredPatch`) or an array of them (as returned by `parsePatch`). + `patch` may be either a single structured patch object (as returned by `structuredPatch`) or an array of them (as returned by `parsePatch`). The optional `headerOptions` argument behaves the same as the `headerOptions` option of `createTwoFilesPatch`. * `structuredPatch(oldFileName, newFileName, oldStr, newStr[, oldHeader[, newHeader[, options]]])` - returns an object with an array of hunk objects. diff --git a/release-notes.md b/release-notes.md index 2bb2108f..815fbc9f 100644 --- a/release-notes.md +++ b/release-notes.md @@ -4,6 +4,7 @@ - [#631](https://github.com/kpdecker/jsdiff/pull/631) - **fix support for using an `Intl.Segmenter` with `diffWords`**. This has been almost completely broken since the feature was added in v6.0.0, since it would outright crash on any text that featured two consecutive newlines between a pair of words (a very common case). - [#635](https://github.com/kpdecker/jsdiff/pull/635) - **small tweaks to tokenization behaviour of `diffWords`** when used *without* an `Intl.Segmenter`. Specifically, the soft hyphen (U+00AD) is no longer considered to be a word break, and the multiplication and division signs (`×` and `÷`) are now treated as punctuation instead of as letters / word characters. +- [#641](https://github.com/kpdecker/jsdiff/pull/641) - **the format of file headers in `createPatch` etc. patches can now be customised somewhat**. It now takes a `headerOptions` option that can be used to disable the file headers entirely, or omit the `Index:` line and/or the underline. In particular, this was motivated by a request to make jsdiff patches compatible with react-diff-view, which they now are if produced with `headerOptions: FILE_HEADERS_ONLY`. ## 8.0.2 diff --git a/src/index.ts b/src/index.ts index a21c29b5..526f3a13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,13 +33,17 @@ import { structuredPatch, createTwoFilesPatch, createPatch, - formatPatch + formatPatch, + INCLUDE_HEADERS, + FILE_HEADERS_ONLY, + OMIT_HEADERS } from './patch/create.js'; import type { StructuredPatchOptionsAbortable, StructuredPatchOptionsNonabortable, CreatePatchOptionsAbortable, - CreatePatchOptionsNonabortable + CreatePatchOptionsNonabortable, + HeaderOptions } from './patch/create.js'; import {convertChangesToDMP} from './convert/dmp.js'; @@ -91,6 +95,9 @@ export { createTwoFilesPatch, createPatch, formatPatch, + INCLUDE_HEADERS, + FILE_HEADERS_ONLY, + OMIT_HEADERS, applyPatch, applyPatches, parsePatch, @@ -127,5 +134,6 @@ export type { StructuredPatchOptionsAbortable, StructuredPatchOptionsNonabortable, CreatePatchOptionsAbortable, - CreatePatchOptionsNonabortable + CreatePatchOptionsNonabortable, + HeaderOptions }; diff --git a/src/patch/create.ts b/src/patch/create.ts index 138f9544..13c2a2b1 100644 --- a/src/patch/create.ts +++ b/src/patch/create.ts @@ -4,6 +4,28 @@ import type { StructuredPatch, DiffLinesOptionsAbortable, DiffLinesOptionsNonabo type StructuredPatchCallbackAbortable = (patch: StructuredPatch | undefined) => void; type StructuredPatchCallbackNonabortable = (patch: StructuredPatch) => void; +export interface HeaderOptions { + includeIndex: boolean; + includeUnderline: boolean; + includeFileHeaders: boolean; +} + +export const INCLUDE_HEADERS = { + includeIndex: true, + includeUnderline: true, + includeFileHeaders: true +}; +export const FILE_HEADERS_ONLY = { + includeIndex: false, + includeUnderline: false, + includeFileHeaders: true +}; +export const OMIT_HEADERS = { + includeIndex: false, + includeUnderline: false, + includeFileHeaders: false +}; + interface _StructuredPatchOptionsAbortable extends Pick { /** * describes how many lines of context should be included. @@ -254,18 +276,32 @@ export function structuredPatch( * 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`) */ -export function formatPatch(patch: StructuredPatch | StructuredPatch[]): string { +export function formatPatch(patch: StructuredPatch | StructuredPatch[], headerOptions?: HeaderOptions): string { + if (!headerOptions) { + headerOptions = INCLUDE_HEADERS; + } if (Array.isArray(patch)) { - return patch.map(formatPatch).join('\n'); + if (patch.length > 1 && !headerOptions.includeFileHeaders) { + 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 = []; - if (patch.oldFileName == patch.newFileName) { + if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName) { ret.push('Index: ' + patch.oldFileName); } - ret.push('==================================================================='); - ret.push('--- ' + patch.oldFileName + (typeof patch.oldHeader === 'undefined' ? '' : '\t' + patch.oldHeader)); - ret.push('+++ ' + patch.newFileName + (typeof patch.newHeader === 'undefined' ? '' : '\t' + patch.newHeader)); + if (headerOptions.includeUnderline) { + ret.push('==================================================================='); + } + if (headerOptions.includeFileHeaders) { + ret.push('--- ' + patch.oldFileName + (typeof patch.oldHeader === 'undefined' ? '' : '\t' + patch.oldHeader)); + ret.push('+++ ' + patch.newFileName + (typeof patch.newHeader === 'undefined' ? '' : '\t' + patch.newHeader)); + } for (let i = 0; i < patch.hunks.length; i++) { const hunk = patch.hunks[i]; @@ -297,11 +333,13 @@ type CreatePatchCallbackNonabortable = (patch: string) => void; interface _CreatePatchOptionsAbortable extends Pick { context?: number, callback?: CreatePatchCallbackAbortable, + headerOptions?: HeaderOptions, } export type CreatePatchOptionsAbortable = _CreatePatchOptionsAbortable & AbortableDiffOptions; export interface CreatePatchOptionsNonabortable extends Pick { context?: number, callback?: CreatePatchCallbackNonabortable, + headerOptions?: HeaderOptions, } interface CreatePatchCallbackOptionAbortable { callback: CreatePatchCallbackAbortable; @@ -382,7 +420,7 @@ export function createTwoFilesPatch( if (!patchObj) { return; } - return formatPatch(patchObj); + return formatPatch(patchObj, options?.headerOptions); } else { const {callback} = options; structuredPatch( @@ -398,7 +436,7 @@ export function createTwoFilesPatch( if (!patchObj) { (callback as CreatePatchCallbackAbortable)(undefined); } else { - callback(formatPatch(patchObj)); + callback(formatPatch(patchObj, options.headerOptions)); } } } diff --git a/test/patch/create.js b/test/patch/create.js index 862b3b7b..204818d8 100644 --- a/test/patch/create.js +++ b/test/patch/create.js @@ -1,5 +1,5 @@ import {diffWords} from 'diff'; -import {createPatch, createTwoFilesPatch, formatPatch, structuredPatch} from '../../libesm/patch/create.js'; +import {createPatch, createTwoFilesPatch, FILE_HEADERS_ONLY, formatPatch, INCLUDE_HEADERS, OMIT_HEADERS, structuredPatch} from '../../libesm/patch/create.js'; import {parsePatch} from '../../libesm/patch/parse.js'; import {expect} from 'chai'; @@ -628,43 +628,82 @@ describe('patch/create', function() { expect(diffResult).to.equal(expectedResult); }); - it('should output headers only for identical files', function() { - const expectedResult = - 'Index: testFileName\n' - + '===================================================================\n' - + '--- testFileName\tOld Header\n' - + '+++ testFileName\tNew Header\n'; - const diffResult = createPatch('testFileName', oldFile, oldFile, 'Old Header', 'New Header'); - expect(diffResult).to.equal(expectedResult); - }); + describe('headers handling', function() { + it('should output headers only for identical files', function() { + const expectedResult = + 'Index: testFileName\n' + + '===================================================================\n' + + '--- testFileName\tOld Header\n' + + '+++ testFileName\tNew Header\n'; + const diffResult = createPatch('testFileName', oldFile, oldFile, 'Old Header', 'New Header'); + expect(diffResult).to.equal(expectedResult); + }); - it('should omit headers if undefined', function() { - const expectedResult = - 'Index: testFileName\n' - + '===================================================================\n' - + '--- testFileName\n' - + '+++ testFileName\n'; - const diffResult = createPatch('testFileName', oldFile, oldFile); - expect(diffResult).to.equal(expectedResult); - }); + it('should omit headers if undefined', function() { + const expectedResult = + 'Index: testFileName\n' + + '===================================================================\n' + + '--- testFileName\n' + + '+++ testFileName\n'; + const diffResult = createPatch('testFileName', oldFile, oldFile); + expect(diffResult).to.equal(expectedResult); + }); - it('should safely handle empty inputs', function() { - const expectedResult = - 'Index: testFileName\n' - + '===================================================================\n' - + '--- testFileName\n' - + '+++ testFileName\n'; - const diffResult = createPatch('testFileName', '', ''); - expect(diffResult).to.equal(expectedResult); - }); + it('should safely handle empty inputs', function() { + const expectedResult = + 'Index: testFileName\n' + + '===================================================================\n' + + '--- testFileName\n' + + '+++ testFileName\n'; + const diffResult = createPatch('testFileName', '', ''); + expect(diffResult).to.equal(expectedResult); + }); - it('should omit index with multiple file names', function() { - const expectedResult = - '===================================================================\n' - + '--- foo\n' - + '+++ bar\n'; - const diffResult = createTwoFilesPatch('foo', 'bar', '', ''); - expect(diffResult).to.equal(expectedResult); + it('should omit index with multiple file names', function() { + const expectedResult = + '===================================================================\n' + + '--- foo\n' + + '+++ bar\n'; + const diffResult = createTwoFilesPatch('foo', 'bar', '', ''); + expect(diffResult).to.equal(expectedResult); + }); + + it('should handle INCLUDE_HEADERS the same as not specifying options regarding headers', function() { + const expectedResult = + 'Index: testFileName\n' + + '===================================================================\n' + + '--- testFileName\n' + + '+++ testFileName\n' + + '@@ -1,1 +1,1 @@\n' + + '-foo\n' + + '+bar\n'; + const diffResult1 = createTwoFilesPatch('testFileName', 'testFileName', 'foo\n', 'bar\n'); + const diffResult2 = createTwoFilesPatch('testFileName', 'testFileName', 'foo\n', 'bar\n', undefined, undefined, {}); + const diffResult3 = createTwoFilesPatch('testFileName', 'testFileName', 'foo\n', 'bar\n', undefined, undefined, { headerOptions: INCLUDE_HEADERS }); + expect(diffResult1).to.equal(expectedResult); + expect(diffResult2).to.equal(expectedResult); + expect(diffResult3).to.equal(expectedResult); + }); + + it('should respect FILE_HEADERS_ONLY', function() { + const expectedResult = + '--- testFileName\n' + + '+++ testFileName\n' + + '@@ -1,1 +1,1 @@\n' + + '-foo\n' + + '+bar\n'; + const diffResult = createTwoFilesPatch('testFileName', 'testFileName', 'foo\n', 'bar\n', undefined, undefined, {headerOptions: FILE_HEADERS_ONLY}); + expect(diffResult).to.equal(expectedResult); + }); + + it('should respect OMIT_HEADERS', function() { + const expectedResult = + '@@ -1,1 +1,1 @@\n' + + '-foo\n' + + '+bar\n'; + const diffResult = createTwoFilesPatch('testFileName', 'testFileName', 'foo\n', 'bar\n', undefined, undefined, {headerOptions: OMIT_HEADERS}); + expect(diffResult).to.equal(expectedResult); + }); }); it('should respect maxEditLength', function() { @@ -1008,5 +1047,134 @@ describe('patch/create', function() { expect(roundTrippedPatch).to.deep.equal([patchObj]); }); + + describe('with headerOptions parameter', function() { + const patch = { + oldFileName: 'oldfile', + oldHeader: 'old-timestamp', + newFileName: 'newfile', + newHeader: 'new-timestamp', + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + '-old line', + '+new line' + ] + } + ] + }; + + const patchArray = [ + { + oldFileName: 'file1', + oldHeader: 'timestamp1', + newFileName: 'file1', + newHeader: 'timestamp2', + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: ['-a', '+b'] + } + ] + }, + { + oldFileName: 'file2', + oldHeader: 'timestamp3', + newFileName: 'file2', + newHeader: 'timestamp4', + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: ['-x', '+y'] + } + ] + } + ]; + + it('should include all headers with INCLUDE_HEADERS', function() { + const result = formatPatch(patch, INCLUDE_HEADERS); + const expected = + '===================================================================\n' + + '--- oldfile\told-timestamp\n' + + '+++ newfile\tnew-timestamp\n' + + '@@ -1,1 +1,1 @@\n' + + '-old line\n' + + '+new line\n'; + expect(result).to.equal(expected); + }); + + it('should include only file headers with FILE_HEADERS_ONLY', function() { + const result = formatPatch(patch, FILE_HEADERS_ONLY); + const expected = + '--- oldfile\told-timestamp\n' + + '+++ newfile\tnew-timestamp\n' + + '@@ -1,1 +1,1 @@\n' + + '-old line\n' + + '+new line\n'; + expect(result).to.equal(expected); + }); + + it('should omit all headers with OMIT_HEADERS', function() { + const result = formatPatch(patch, OMIT_HEADERS); + const expected = + '@@ -1,1 +1,1 @@\n' + + '-old line\n' + + '+new line\n'; + expect(result).to.equal(expected); + }); + + it('should work with array of patches and INCLUDE_HEADERS', function() { + const result = formatPatch(patchArray, INCLUDE_HEADERS); + const expected = + 'Index: file1\n' + + '===================================================================\n' + + '--- file1\ttimestamp1\n' + + '+++ file1\ttimestamp2\n' + + '@@ -1,1 +1,1 @@\n' + + '-a\n' + + '+b\n' + + '\n' + + 'Index: file2\n' + + '===================================================================\n' + + '--- file2\ttimestamp3\n' + + '+++ file2\ttimestamp4\n' + + '@@ -1,1 +1,1 @@\n' + + '-x\n' + + '+y\n'; + expect(result).to.equal(expected); + }); + + it('should work with array of patches and FILE_HEADERS_ONLY', function() { + const result = formatPatch(patchArray, FILE_HEADERS_ONLY); + const expected = + '--- file1\ttimestamp1\n' + + '+++ file1\ttimestamp2\n' + + '@@ -1,1 +1,1 @@\n' + + '-a\n' + + '+b\n' + + '\n' + + '--- file2\ttimestamp3\n' + + '+++ file2\ttimestamp4\n' + + '@@ -1,1 +1,1 @@\n' + + '-x\n' + + '+y\n'; + expect(result).to.equal(expected); + }); + + it('should throw an error when given an array of patches and OMIT_HEADERS', function() { + // eslint-disable-next-line dot-notation + expect(() => formatPatch(patchArray, OMIT_HEADERS)).to.throw(); + }); + }); }); });