364 lines
15 KiB
JavaScript
364 lines
15 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.OMIT_HEADERS = exports.FILE_HEADERS_ONLY = exports.INCLUDE_HEADERS = void 0;
|
|
exports.structuredPatch = structuredPatch;
|
|
exports.formatPatch = formatPatch;
|
|
exports.createTwoFilesPatch = createTwoFilesPatch;
|
|
exports.createPatch = createPatch;
|
|
const line_js_1 = require("../diff/line.js");
|
|
/**
|
|
* Returns true if the filename contains characters that require C-style
|
|
* quoting (as used by Git and GNU diffutils in diff output).
|
|
*/
|
|
function needsQuoting(s) {
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] < '\x20' || s[i] > '\x7e' || s[i] === '"' || s[i] === '\\') {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* C-style quotes a filename, encoding special characters as escape sequences
|
|
* and non-ASCII bytes as octal escapes. This is the inverse of
|
|
* `parseQuotedFileName` in parse.ts.
|
|
*
|
|
* Non-ASCII bytes are encoded as UTF-8 before being emitted as octal escapes.
|
|
* This matches the behaviour of both Git and GNU diffutils, which always emit
|
|
* UTF-8 octal escapes regardless of the underlying filesystem encoding (e.g.
|
|
* Git for Windows converts from NTFS's UTF-16 to UTF-8 internally).
|
|
*
|
|
* If the filename doesn't need quoting, returns it as-is.
|
|
*/
|
|
function quoteFileNameIfNeeded(s) {
|
|
if (!needsQuoting(s)) {
|
|
return s;
|
|
}
|
|
let result = '"';
|
|
const bytes = new TextEncoder().encode(s);
|
|
let i = 0;
|
|
while (i < bytes.length) {
|
|
const b = bytes[i];
|
|
// See https://en.wikipedia.org/wiki/Escape_sequences_in_C#Escape_sequences
|
|
if (b === 0x07) {
|
|
result += '\\a';
|
|
}
|
|
else if (b === 0x08) {
|
|
result += '\\b';
|
|
}
|
|
else if (b === 0x09) {
|
|
result += '\\t';
|
|
}
|
|
else if (b === 0x0a) {
|
|
result += '\\n';
|
|
}
|
|
else if (b === 0x0b) {
|
|
result += '\\v';
|
|
}
|
|
else if (b === 0x0c) {
|
|
result += '\\f';
|
|
}
|
|
else if (b === 0x0d) {
|
|
result += '\\r';
|
|
}
|
|
else if (b === 0x22) {
|
|
result += '\\"';
|
|
}
|
|
else if (b === 0x5c) {
|
|
result += '\\\\';
|
|
}
|
|
else if (b >= 0x20 && b <= 0x7e) {
|
|
// Just a printable ASCII character that is neither a double quote nor a
|
|
// backslash; no need to escape it.
|
|
result += String.fromCharCode(b);
|
|
}
|
|
else {
|
|
// Either part of a non-ASCII character or a control character without a
|
|
// special escape sequence; needs escaping as a 3-digit octal escape
|
|
result += '\\' + b.toString(8).padStart(3, '0');
|
|
}
|
|
i++;
|
|
}
|
|
result += '"';
|
|
return result;
|
|
}
|
|
exports.INCLUDE_HEADERS = {
|
|
includeIndex: true,
|
|
includeUnderline: true,
|
|
includeFileHeaders: true
|
|
};
|
|
exports.FILE_HEADERS_ONLY = {
|
|
includeIndex: false,
|
|
includeUnderline: false,
|
|
includeFileHeaders: true
|
|
};
|
|
exports.OMIT_HEADERS = {
|
|
includeIndex: false,
|
|
includeUnderline: false,
|
|
includeFileHeaders: false
|
|
};
|
|
function structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|
let optionsObj;
|
|
if (!options) {
|
|
optionsObj = {};
|
|
}
|
|
else if (typeof options === 'function') {
|
|
optionsObj = { callback: options };
|
|
}
|
|
else {
|
|
optionsObj = options;
|
|
}
|
|
if (typeof optionsObj.context === 'undefined') {
|
|
optionsObj.context = 4;
|
|
}
|
|
// We copy this into its own variable to placate TypeScript, which thinks
|
|
// optionsObj.context might be undefined in the callbacks below.
|
|
const context = optionsObj.context;
|
|
// @ts-expect-error (runtime check for something that is correctly a static type error)
|
|
if (optionsObj.newlineIsToken) {
|
|
throw new Error('newlineIsToken may not be used with patch-generation functions, only with diffing functions');
|
|
}
|
|
if (!optionsObj.callback) {
|
|
return diffLinesResultToPatch((0, line_js_1.diffLines)(oldStr, newStr, optionsObj));
|
|
}
|
|
else {
|
|
const { callback } = optionsObj;
|
|
(0, line_js_1.diffLines)(oldStr, newStr, Object.assign(Object.assign({}, optionsObj), { callback: (diff) => {
|
|
const patch = diffLinesResultToPatch(diff);
|
|
// TypeScript is unhappy without the cast because it does not understand that `patch` may
|
|
// be undefined here only if `callback` is StructuredPatchCallbackAbortable:
|
|
callback(patch);
|
|
} }));
|
|
}
|
|
function diffLinesResultToPatch(diff) {
|
|
// STEP 1: Build up the patch with no "\ No newline at end of file" lines and with the arrays
|
|
// of lines containing trailing newline characters. We'll tidy up later...
|
|
if (!diff) {
|
|
return;
|
|
}
|
|
diff.push({ value: '', lines: [] }); // Append an empty value to make cleanup easier
|
|
function contextLines(lines) {
|
|
return lines.map(function (entry) { return ' ' + entry; });
|
|
}
|
|
const 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 || splitLines(current.value);
|
|
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 = context > 0 ? contextLines(prev.lines.slice(-context)) : [];
|
|
oldRangeStart -= curRange.length;
|
|
newRangeStart -= curRange.length;
|
|
}
|
|
}
|
|
// Output our changes
|
|
for (const line of lines) {
|
|
curRange.push((current.added ? '+' : '-') + line);
|
|
}
|
|
// 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 <= context * 2 && i < diff.length - 2) {
|
|
// Overlapping
|
|
for (const line of contextLines(lines)) {
|
|
curRange.push(line);
|
|
}
|
|
}
|
|
else {
|
|
// end the range and output
|
|
const contextSize = Math.min(lines.length, context);
|
|
for (const line of contextLines(lines.slice(0, contextSize))) {
|
|
curRange.push(line);
|
|
}
|
|
const hunk = {
|
|
oldStart: oldRangeStart,
|
|
oldLines: (oldLine - oldRangeStart + contextSize),
|
|
newStart: newRangeStart,
|
|
newLines: (newLine - newRangeStart + contextSize),
|
|
lines: curRange
|
|
};
|
|
hunks.push(hunk);
|
|
oldRangeStart = 0;
|
|
newRangeStart = 0;
|
|
curRange = [];
|
|
}
|
|
}
|
|
oldLine += lines.length;
|
|
newLine += lines.length;
|
|
}
|
|
}
|
|
// Step 2: eliminate the trailing `\n` from each line of each hunk, and, where needed, add
|
|
// "\ No newline at end of file".
|
|
for (const hunk of hunks) {
|
|
for (let i = 0; i < hunk.lines.length; i++) {
|
|
if (hunk.lines[i].endsWith('\n')) {
|
|
hunk.lines[i] = hunk.lines[i].slice(0, -1);
|
|
}
|
|
else {
|
|
hunk.lines.splice(i + 1, 0, '\\ No newline at end of file');
|
|
i++; // Skip the line we just added, then continue iterating
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
oldFileName: oldFileName, newFileName: newFileName,
|
|
oldHeader: oldHeader, newHeader: newHeader,
|
|
hunks: hunks
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* 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`).
|
|
* @param headerOptions behaves the same as the `headerOptions` option of `createTwoFilesPatch`.
|
|
* Ignored for patches where `isGit` is `true`.
|
|
*
|
|
* When a patch has `isGit: true`, `formatPatch` output is changed to more closely match Git's
|
|
* output: it emits a `diff --git` header, emits Git extended headers as appropriate based on
|
|
* properties like `isRename`, `isCreate`, `newMode`, etc, and will omit `---`/`+++` file
|
|
* headers for patches with no hunks (e.g. renames without content changes).
|
|
*/
|
|
function formatPatch(patch, headerOptions) {
|
|
var _a, _b, _c, _d, _e, _f;
|
|
if (!headerOptions) {
|
|
headerOptions = exports.INCLUDE_HEADERS;
|
|
}
|
|
if (Array.isArray(patch)) {
|
|
if (patch.length > 1 && !headerOptions.includeFileHeaders && !patch.every(p => p.isGit)) {
|
|
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 = [];
|
|
// Git patches have a fixed header format (diff --git, extended headers,
|
|
// and ---/+++ when hunks are present), so headerOptions is ignored.
|
|
if (patch.isGit) {
|
|
headerOptions = exports.INCLUDE_HEADERS;
|
|
// Emit Git-style diff --git header and extended headers.
|
|
// Git never puts /dev/null in the "diff --git" line; for file
|
|
// creations/deletions it uses the real filename on both sides.
|
|
if (!patch.oldFileName) {
|
|
throw new Error('oldFileName must be specified for Git patches');
|
|
}
|
|
if (!patch.newFileName) {
|
|
throw new Error('newFileName must be specified for Git patches');
|
|
}
|
|
let gitOldName = patch.oldFileName;
|
|
let gitNewName = patch.newFileName;
|
|
if (patch.isCreate && gitOldName === '/dev/null') {
|
|
gitOldName = gitNewName.replace(/^b\//, 'a/');
|
|
}
|
|
else if (patch.isDelete && gitNewName === '/dev/null') {
|
|
gitNewName = gitOldName.replace(/^a\//, 'b/');
|
|
}
|
|
ret.push('diff --git ' + quoteFileNameIfNeeded(gitOldName) + ' ' + quoteFileNameIfNeeded(gitNewName));
|
|
if (patch.isDelete) {
|
|
ret.push('deleted file mode ' + ((_a = patch.oldMode) !== null && _a !== void 0 ? _a : '100644'));
|
|
}
|
|
if (patch.isCreate) {
|
|
ret.push('new file mode ' + ((_b = patch.newMode) !== null && _b !== void 0 ? _b : '100644'));
|
|
}
|
|
if (patch.oldMode && patch.newMode && !patch.isDelete && !patch.isCreate) {
|
|
ret.push('old mode ' + patch.oldMode);
|
|
ret.push('new mode ' + patch.newMode);
|
|
}
|
|
if (patch.isRename) {
|
|
ret.push('rename from ' + quoteFileNameIfNeeded(((_c = patch.oldFileName) !== null && _c !== void 0 ? _c : '').replace(/^a\//, '')));
|
|
ret.push('rename to ' + quoteFileNameIfNeeded(((_d = patch.newFileName) !== null && _d !== void 0 ? _d : '').replace(/^b\//, '')));
|
|
}
|
|
if (patch.isCopy) {
|
|
ret.push('copy from ' + quoteFileNameIfNeeded(((_e = patch.oldFileName) !== null && _e !== void 0 ? _e : '').replace(/^a\//, '')));
|
|
ret.push('copy to ' + quoteFileNameIfNeeded(((_f = patch.newFileName) !== null && _f !== void 0 ? _f : '').replace(/^b\//, '')));
|
|
}
|
|
}
|
|
else {
|
|
if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName && patch.oldFileName !== undefined) {
|
|
ret.push('Index: ' + patch.oldFileName);
|
|
}
|
|
if (headerOptions.includeUnderline) {
|
|
ret.push('===================================================================');
|
|
}
|
|
}
|
|
// Emit --- / +++ file headers. For Git patches with no hunks (e.g.
|
|
// pure renames, mode-only changes), Git omits these, so we do too.
|
|
const hasHunks = patch.hunks.length > 0;
|
|
if (headerOptions.includeFileHeaders && patch.oldFileName !== undefined && patch.newFileName !== undefined
|
|
&& (!patch.isGit || hasHunks)) {
|
|
ret.push('--- ' + quoteFileNameIfNeeded(patch.oldFileName) + (patch.oldHeader ? '\t' + patch.oldHeader : ''));
|
|
ret.push('+++ ' + quoteFileNameIfNeeded(patch.newFileName) + (patch.newHeader ? '\t' + patch.newHeader : ''));
|
|
}
|
|
for (let i = 0; i < patch.hunks.length; i++) {
|
|
const hunk = patch.hunks[i];
|
|
// Unified Diff Format quirk: If the chunk size is 0,
|
|
// the first number is one lower than one would expect.
|
|
// https://www.artima.com/weblogs/viewpost.jsp?thread=164293
|
|
const oldStart = hunk.oldLines === 0 ? hunk.oldStart - 1 : hunk.oldStart;
|
|
const newStart = hunk.newLines === 0 ? hunk.newStart - 1 : hunk.newStart;
|
|
ret.push('@@ -' + oldStart + ',' + hunk.oldLines
|
|
+ ' +' + newStart + ',' + hunk.newLines
|
|
+ ' @@');
|
|
for (const line of hunk.lines) {
|
|
ret.push(line);
|
|
}
|
|
}
|
|
return ret.join('\n') + '\n';
|
|
}
|
|
function createTwoFilesPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|
if (typeof options === 'function') {
|
|
options = { callback: options };
|
|
}
|
|
if (!(options === null || options === void 0 ? void 0 : options.callback)) {
|
|
const patchObj = structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options);
|
|
if (!patchObj) {
|
|
return;
|
|
}
|
|
return formatPatch(patchObj, options === null || options === void 0 ? void 0 : options.headerOptions);
|
|
}
|
|
else {
|
|
const { callback } = options;
|
|
structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, Object.assign(Object.assign({}, options), { callback: patchObj => {
|
|
if (!patchObj) {
|
|
callback(undefined);
|
|
}
|
|
else {
|
|
callback(formatPatch(patchObj, options.headerOptions));
|
|
}
|
|
} }));
|
|
}
|
|
}
|
|
function createPatch(fileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|
return createTwoFilesPatch(fileName, fileName, oldStr, newStr, oldHeader, newHeader, options);
|
|
}
|
|
/**
|
|
* Split `text` into an array of lines, including the trailing newline character (where present)
|
|
*/
|
|
function splitLines(text) {
|
|
const hasTrailingNl = text.endsWith('\n');
|
|
const result = text.split('\n').map(line => line + '\n');
|
|
if (hasTrailingNl) {
|
|
result.pop();
|
|
}
|
|
else {
|
|
result.push(result.pop().slice(0, -1));
|
|
}
|
|
return result;
|
|
}
|