| /** | |
| * Parses a unified diff format patch into a structured patch object. | |
| * | |
| * `parsePatch` has some understanding of Git's particular dialect of unified diff format. | |
| * When parsing a Git patch, each index in the result may contain additional | |
| * fields (`isRename`, `isBinary`, etc) not included in the data structure returned by | |
| * `structuredPatch`; see the `StructuredPatch` interface for a full list. | |
| * | |
| * @return a JSON object representation of the patch, suitable for use with the `applyPatch` | |
| * method. This parses to the same structure returned by `structuredPatch`, except that | |
| * `oldFileName` and `newFileName` may be `undefined` if the patch doesn't contain enough | |
| * information to determine them (e.g. a hunk-only patch with no file headers). | |
| */ | |
| export function parsePatch(uniDiff) { | |
| const diffstr = uniDiff.split(/\n/), list = []; | |
| let i = 0; | |
| // These helper functions identify line types that can appear between files | |
| // in a multi-file patch. Keeping them in one place avoids subtle | |
| // inconsistencies from having the same regexes duplicated in multiple places. | |
| // Matches `diff --git ...` lines specifically. | |
| function isGitDiffHeader(line) { | |
| return (/^diff --git /).test(line); | |
| } | |
| // Matches lines that denote the start of a new diff's section in a | |
| // multi-file patch: `diff --git ...`, `Index: ...`, or `diff -r ...`. | |
| function isDiffHeader(line) { | |
| return isGitDiffHeader(line) | |
| || (/^Index:\s/).test(line) | |
| || (/^diff(?: -r \w+)+\s/).test(line); | |
| } | |
| // Matches `--- ...` and `+++ ...` file header lines. | |
| function isFileHeader(line) { | |
| return (/^(---|\+\+\+)\s/).test(line); | |
| } | |
| // Matches `@@ ...` hunk header lines. | |
| function isHunkHeader(line) { | |
| return (/^@@\s/).test(line); | |
| } | |
| function parseIndex() { | |
| var _a; | |
| const index = {}; | |
| index.hunks = []; | |
| list.push(index); | |
| // Parse diff metadata | |
| let seenDiffHeader = false; | |
| while (i < diffstr.length) { | |
| const line = diffstr[i]; | |
| // File header (---, +++) or hunk header (@@) found; end parsing diff metadata | |
| if (isFileHeader(line) || isHunkHeader(line)) { | |
| break; | |
| } | |
| // The next two branches handle recognized diff headers. Note that | |
| // isDiffHeader deliberately does NOT match arbitrary `diff` | |
| // commands like `diff -u -p -r1.1 -r1.2`, because in some | |
| // formats (e.g. CVS diffs) such lines appear as metadata within | |
| // a single file's header section, after an `Index:` line. See the | |
| // diffx documentation (https://diffx.org) for examples. | |
| // | |
| // In both branches: if we've already seen a diff header for *this* | |
| // file and now we encounter another one, it must belong to the | |
| // next file, so break. | |
| if (isGitDiffHeader(line)) { | |
| if (seenDiffHeader) { | |
| return; | |
| } | |
| seenDiffHeader = true; | |
| index.isGit = true; | |
| // Parse the old and new filenames from the `diff --git` header and | |
| // tentatively set oldFileName and newFileName from them. These may | |
| // be overridden below by `rename from` / `rename to` or `copy from` / | |
| // `copy to` extended headers, or by --- and +++ lines. But for Git | |
| // diffs that lack all of those (e.g. mode-only changes, binary | |
| // file changes without rename), these are the only filenames we | |
| // get. | |
| // parseGitDiffHeader returns null if the header can't be parsed | |
| // (e.g. unterminated quoted filename, or unexpected format). In | |
| // that case we skip setting filenames here; they may still be | |
| // set from --- / +++ or rename from / rename to lines below. | |
| const paths = parseGitDiffHeader(line); | |
| if (paths) { | |
| index.oldFileName = paths.oldFileName; | |
| index.newFileName = paths.newFileName; | |
| } | |
| // Consume Git extended headers (`old mode`, `new mode`, `rename from`, | |
| // `rename to`, `similarity index`, `index`, `Binary files ... differ`, | |
| // etc.) | |
| i++; | |
| while (i < diffstr.length) { | |
| const extLine = diffstr[i]; | |
| // Stop consuming extended headers if we hit a file header, | |
| // hunk header, or another diff header. | |
| if (isFileHeader(extLine) || isHunkHeader(extLine) || isDiffHeader(extLine)) { | |
| break; | |
| } | |
| // Parse `rename from` / `rename to` lines - these give us | |
| // unambiguous filenames. These lines don't include the | |
| // a/ and b/ prefixes that appear in the `diff --git` header | |
| // and --- / +++ lines, so we add them for consistency. | |
| // Git C-style quotes filenames containing special characters | |
| // (tabs, newlines, backslashes, double quotes), so we must | |
| // unquote them when present. | |
| const renameFromMatch = (/^rename from (.*)/).exec(extLine); | |
| if (renameFromMatch) { | |
| index.oldFileName = 'a/' + unquoteIfQuoted(renameFromMatch[1]); | |
| index.isRename = true; | |
| } | |
| const renameToMatch = (/^rename to (.*)/).exec(extLine); | |
| if (renameToMatch) { | |
| index.newFileName = 'b/' + unquoteIfQuoted(renameToMatch[1]); | |
| index.isRename = true; | |
| } | |
| // Parse copy from / copy to lines similarly | |
| const copyFromMatch = (/^copy from (.*)/).exec(extLine); | |
| if (copyFromMatch) { | |
| index.oldFileName = 'a/' + unquoteIfQuoted(copyFromMatch[1]); | |
| index.isCopy = true; | |
| } | |
| const copyToMatch = (/^copy to (.*)/).exec(extLine); | |
| if (copyToMatch) { | |
| index.newFileName = 'b/' + unquoteIfQuoted(copyToMatch[1]); | |
| index.isCopy = true; | |
| } | |
| const newFileModeMatch = (/^new file mode (\d+)/).exec(extLine); | |
| if (newFileModeMatch) { | |
| index.isCreate = true; | |
| index.newMode = newFileModeMatch[1]; | |
| } | |
| const deletedFileModeMatch = (/^deleted file mode (\d+)/).exec(extLine); | |
| if (deletedFileModeMatch) { | |
| index.isDelete = true; | |
| index.oldMode = deletedFileModeMatch[1]; | |
| } | |
| const oldModeMatch = (/^old mode (\d+)/).exec(extLine); | |
| if (oldModeMatch) { | |
| index.oldMode = oldModeMatch[1]; | |
| } | |
| const newModeMatch = (/^new mode (\d+)/).exec(extLine); | |
| if (newModeMatch) { | |
| index.newMode = newModeMatch[1]; | |
| } | |
| if ((/^Binary files /).test(extLine)) { | |
| index.isBinary = true; | |
| } | |
| i++; | |
| } | |
| continue; | |
| } | |
| else if (isDiffHeader(line)) { | |
| if (seenDiffHeader) { | |
| return; | |
| } | |
| seenDiffHeader = true; | |
| // For Mercurial-style headers like | |
| // diff -r 9117c6561b0b -r 273ce12ad8f1 .hgignore | |
| // or Index: headers like | |
| // Index: something with multiple words | |
| // we extract the trailing filename as the index. | |
| // | |
| // TODO: It seems awkward that we indiscriminately trim off | |
| // trailing whitespace here. Theoretically, couldn't that | |
| // be meaningful - e.g. if the patch represents a diff of a | |
| // file whose name ends with a space? Seems wrong to nuke | |
| // it. But this behaviour has been around since v2.2.1 in | |
| // 2015, so if it's going to change, it should be done | |
| // cautiously and in a new major release, for | |
| // backwards-compat reasons. | |
| // -- ExplodingCabbage | |
| const headerMatch = (/^(?:Index:|diff(?: -r \w+)+)\s+/).exec(line); | |
| if (headerMatch) { | |
| index.index = line.substring(headerMatch[0].length).trim(); | |
| } | |
| } | |
| i++; | |
| } | |
| // Parse file headers if they are defined. Unified diff requires them, but | |
| // there's no technical issues to have an isolated hunk without file header | |
| parseFileHeader(index); | |
| parseFileHeader(index); | |
| // If we got one file header but not the other, that's a malformed patch. | |
| if ((index.oldFileName === undefined) !== (index.newFileName === undefined)) { | |
| throw new Error('Missing ' + (index.oldFileName !== undefined ? '"+++ ..."' : '"--- ..."') | |
| + ' file header for ' + ((_a = index.oldFileName) !== null && _a !== void 0 ? _a : index.newFileName)); | |
| } | |
| while (i < diffstr.length) { | |
| const line = diffstr[i]; | |
| if (isDiffHeader(line) || isFileHeader(line) || (/^===================================================================/).test(line)) { | |
| break; | |
| } | |
| else if (isHunkHeader(line)) { | |
| index.hunks.push(parseHunk()); | |
| } | |
| else { | |
| // Skip blank lines and any other unrecognized content between | |
| // or after hunks. Real-world examples of such content include: | |
| // - `Only in <dir>: <file>` from GNU `diff -r` | |
| // - `Property changes on:` sections from `svn diff` | |
| // - Trailing prose or commentary in email patches | |
| // GNU `patch` tolerates all of these, and so do we. | |
| i++; | |
| } | |
| } | |
| } | |
| /** | |
| * Parses the old and new filenames from a `diff --git` header line. | |
| * | |
| * The format is: | |
| * diff --git a/<old-path> b/<new-path> | |
| * | |
| * When filenames contain special characters (including newlines, tabs, | |
| * backslashes, or double quotes), Git quotes them with C-style escaping: | |
| * diff --git "a/file\twith\ttabs.txt" "b/file\twith\ttabs.txt" | |
| * | |
| * When filenames don't contain special characters and the old and new names | |
| * are the same, we can unambiguously split on ` b/` by finding where the | |
| * two halves (including their a/ and b/ prefixes) yield matching bare names. | |
| * | |
| * A pathological case exists in which we cannot reliably determine the paths | |
| * from the `diff --git` header. This case is when the following are true: | |
| * - the old and new file paths differ | |
| * - they are both unquoted (i.e. contain no special characters) | |
| * - at least one of the underlying file paths includes the substring ` b/` | |
| * In this scenario, we do not know which occurrence of ` b/` indicates the | |
| * start of the new file path, so the header is inherently ambiguous. We thus | |
| * select a possible interpretation arbitrarily and return that. | |
| * | |
| * Fortunately, this ambiguity should never matter, because in any patch | |
| * genuinely output by Git in which this pathological scenario occurs, there | |
| * must also be `rename from`/`rename to` or `copy from`/`copy to` extended | |
| * headers present below the `diff --git` header. `parseIndex` will parse | |
| * THOSE headers, from which we CAN unambiguously determine the filenames, | |
| * and will discard the result returned by this function. | |
| * | |
| * Returns null if the header can't be parsed at all — e.g. a quoted filename | |
| * has an unterminated quote, or if the unquoted header doesn't match the | |
| * expected `a/... b/...` format. In that case, the caller (parseIndex) | |
| * skips setting oldFileName/newFileName from this header, but they may | |
| * still be set later from `---`/`+++` lines or `rename from`/`rename to` | |
| * extended headers; if none of those are present either, they'll remain | |
| * undefined in the output. | |
| */ | |
| function parseGitDiffHeader(line) { | |
| // Strip the "diff --git " prefix | |
| const rest = line.substring('diff --git '.length); | |
| // Handle quoted paths: "a/path" "b/path" | |
| // Git quotes paths when they contain characters like newlines, tabs, | |
| // backslashes, or double quotes (but notably not spaces). | |
| if (rest.startsWith('"')) { | |
| const oldPath = parseQuotedFileName(rest); | |
| if (oldPath === null) { | |
| return null; | |
| } | |
| const afterOld = rest.substring(oldPath.rawLength + 1); // +1 for space | |
| let newFileName; | |
| if (afterOld.startsWith('"')) { | |
| const newPath = parseQuotedFileName(afterOld); | |
| if (newPath === null) { | |
| return null; | |
| } | |
| newFileName = newPath.fileName; | |
| } | |
| else { | |
| newFileName = afterOld; | |
| } | |
| return { | |
| oldFileName: oldPath.fileName, | |
| newFileName | |
| }; | |
| } | |
| // Check if the second path is quoted | |
| // e.g. diff --git a/simple "b/renamed\nnewline.txt" | |
| const quoteIdx = rest.indexOf('"'); | |
| if (quoteIdx > 0) { | |
| const oldFileName = rest.substring(0, quoteIdx - 1); | |
| const newPath = parseQuotedFileName(rest.substring(quoteIdx)); | |
| if (newPath === null) { | |
| return null; | |
| } | |
| return { | |
| oldFileName, | |
| newFileName: newPath.fileName | |
| }; | |
| } | |
| // Unquoted paths. Try to find the split point. | |
| // The format is: a/<old-path> b/<new-path> | |
| // | |
| // Note the potential ambiguity caused by the possibility of the file paths | |
| // themselves containing the substring ` b/`, plus the pathological case | |
| // described in the comment above. | |
| // | |
| // Strategy: find all occurrences of " b/" and split on the middle | |
| // one. When old and new names are the same (which is the only case where | |
| // we can't rely on extended headers later in the patch so HAVE to get | |
| // this right), this will always be the correct split. | |
| if (rest.startsWith('a/')) { | |
| const splits = []; | |
| let idx = 0; | |
| while (true) { | |
| idx = rest.indexOf(' b/', idx + 1); | |
| if (idx === -1) { | |
| break; | |
| } | |
| splits.push(idx); | |
| } | |
| if (splits.length > 0) { | |
| const mid = splits[Math.floor(splits.length / 2)]; | |
| return { | |
| oldFileName: rest.substring(0, mid), | |
| newFileName: rest.substring(mid + 1) | |
| }; | |
| } | |
| } | |
| // Fallback: can't parse, return null | |
| return null; | |
| } | |
| /** | |
| * If `s` starts with a double quote, unquotes it using C-style escape | |
| * rules (as used by Git). Otherwise returns `s` as-is. | |
| */ | |
| function unquoteIfQuoted(s) { | |
| if (s.startsWith('"')) { | |
| const parsed = parseQuotedFileName(s); | |
| if (parsed) { | |
| return parsed.fileName; | |
| } | |
| } | |
| return s; | |
| } | |
| /** | |
| * Parses a C-style quoted filename as used by Git or GNU `diff -u`. | |
| * Returns the unescaped filename and the raw length consumed (including quotes). | |
| */ | |
| function parseQuotedFileName(s) { | |
| if (!s.startsWith('"')) { | |
| return null; | |
| } | |
| let result = ''; | |
| let j = 1; // skip opening quote | |
| while (j < s.length) { | |
| if (s[j] === '"') { | |
| return { fileName: result, rawLength: j + 1 }; | |
| } | |
| if (s[j] === '\\' && j + 1 < s.length) { | |
| j++; | |
| switch (s[j]) { | |
| case 'a': | |
| result += '\x07'; | |
| break; | |
| case 'b': | |
| result += '\b'; | |
| break; | |
| case 'f': | |
| result += '\f'; | |
| break; | |
| case 'n': | |
| result += '\n'; | |
| break; | |
| case 'r': | |
| result += '\r'; | |
| break; | |
| case 't': | |
| result += '\t'; | |
| break; | |
| case 'v': | |
| result += '\v'; | |
| break; | |
| case '\\': | |
| result += '\\'; | |
| break; | |
| case '"': | |
| result += '"'; | |
| break; | |
| case '0': | |
| case '1': | |
| case '2': | |
| case '3': | |
| case '4': | |
| case '5': | |
| case '6': | |
| case '7': { | |
| // C-style octal escapes represent raw bytes. Collect | |
| // consecutive octal-escaped bytes and decode as UTF-8. | |
| // Validate that we have a full 3-digit octal escape | |
| if (j + 2 >= s.length || s[j + 1] < '0' || s[j + 1] > '7' || s[j + 2] < '0' || s[j + 2] > '7') { | |
| return null; | |
| } | |
| const bytes = [parseInt(s.substring(j, j + 3), 8)]; | |
| j += 3; | |
| while (s[j] === '\\' && s[j + 1] >= '0' && s[j + 1] <= '7') { | |
| if (j + 3 >= s.length || s[j + 2] < '0' || s[j + 2] > '7' || s[j + 3] < '0' || s[j + 3] > '7') { | |
| return null; | |
| } | |
| bytes.push(parseInt(s.substring(j + 1, j + 4), 8)); | |
| j += 4; | |
| } | |
| result += new TextDecoder('utf-8').decode(new Uint8Array(bytes)); | |
| continue; // j already points at the next character | |
| } | |
| // Note that in C, there are also three kinds of hex escape sequences: | |
| // - \xhh | |
| // - \uhhhh | |
| // - \Uhhhhhhhh | |
| // We do not bother to parse them here because, so far as we know, | |
| // they are never emitted by any tools that generate unified diff | |
| // format diffs, and so for now jsdiff does not consider them legal. | |
| default: return null; | |
| } | |
| } | |
| else { | |
| result += s[j]; | |
| } | |
| j++; | |
| } | |
| // Unterminated quote | |
| return null; | |
| } | |
| // Parses the --- and +++ headers, if none are found, no lines | |
| // are consumed. | |
| function parseFileHeader(index) { | |
| const fileHeaderMatch = (/^(---|\+\+\+)\s+/).exec(diffstr[i]); | |
| if (fileHeaderMatch) { | |
| const prefix = fileHeaderMatch[1], data = diffstr[i].substring(3).trim().split('\t', 2), header = (data[1] || '').trim(); | |
| let fileName = data[0]; | |
| if (fileName.startsWith('"')) { | |
| fileName = unquoteIfQuoted(fileName); | |
| } | |
| else { | |
| fileName = fileName.replace(/\\\\/g, '\\'); | |
| } | |
| if (prefix === '---') { | |
| index.oldFileName = fileName; | |
| index.oldHeader = header; | |
| } | |
| else { | |
| index.newFileName = fileName; | |
| index.newHeader = header; | |
| } | |
| i++; | |
| } | |
| } | |
| // Parses a hunk | |
| // This assumes that we are at the start of a hunk. | |
| function parseHunk() { | |
| var _a; | |
| const chunkHeaderIndex = i, chunkHeaderLine = diffstr[i++], chunkHeader = chunkHeaderLine.split(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); | |
| const hunk = { | |
| oldStart: +chunkHeader[1], | |
| oldLines: typeof chunkHeader[2] === 'undefined' ? 1 : +chunkHeader[2], | |
| newStart: +chunkHeader[3], | |
| newLines: typeof chunkHeader[4] === 'undefined' ? 1 : +chunkHeader[4], | |
| lines: [] | |
| }; | |
| // 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 | |
| if (hunk.oldLines === 0) { | |
| hunk.oldStart += 1; | |
| } | |
| if (hunk.newLines === 0) { | |
| hunk.newStart += 1; | |
| } | |
| let addCount = 0, removeCount = 0; | |
| for (; i < diffstr.length && (removeCount < hunk.oldLines || addCount < hunk.newLines || ((_a = diffstr[i]) === null || _a === void 0 ? void 0 : _a.startsWith('\\'))); i++) { | |
| const operation = (diffstr[i].length == 0 && i != (diffstr.length - 1)) ? ' ' : diffstr[i][0]; | |
| if (operation === '+' || operation === '-' || operation === ' ' || operation === '\\') { | |
| hunk.lines.push(diffstr[i]); | |
| if (operation === '+') { | |
| addCount++; | |
| } | |
| else if (operation === '-') { | |
| removeCount++; | |
| } | |
| else if (operation === ' ') { | |
| addCount++; | |
| removeCount++; | |
| } | |
| } | |
| else { | |
| throw new Error(`Hunk at line ${chunkHeaderIndex + 1} contained invalid line ${diffstr[i]}`); | |
| } | |
| } | |
| // Handle the empty block count case | |
| if (!addCount && hunk.newLines === 1) { | |
| hunk.newLines = 0; | |
| } | |
| if (!removeCount && hunk.oldLines === 1) { | |
| hunk.oldLines = 0; | |
| } | |
| // Perform sanity checking | |
| if (addCount !== hunk.newLines) { | |
| throw new Error('Added line count did not match for hunk at line ' + (chunkHeaderIndex + 1)); | |
| } | |
| if (removeCount !== hunk.oldLines) { | |
| throw new Error('Removed line count did not match for hunk at line ' + (chunkHeaderIndex + 1)); | |
| } | |
| // Check for extra hunk-body-like lines after the declared line counts | |
| // were exhausted. If the very next line starts with ' ', '+', or '-', | |
| // the hunk's line counts were probably wrong — unless it's a file | |
| // header (--- or +++), which legitimately appears immediately after a | |
| // hunk in multi-file diffs without Index lines. | |
| if (i < diffstr.length && diffstr[i] && (/^[+ -]/).test(diffstr[i]) | |
| && !isFileHeader(diffstr[i])) { | |
| throw new Error('Hunk at line ' + (chunkHeaderIndex + 1) | |
| + ' has more lines than expected (expected ' | |
| + hunk.oldLines + ' old lines and ' + hunk.newLines + ' new lines)'); | |
| } | |
| return hunk; | |
| } | |
| while (i < diffstr.length) { | |
| parseIndex(); | |
| } | |
| return list; | |
| } |