UNPKG

@pierre/diffs

Version:

501 lines (499 loc) 21.8 kB
import { ALTERNATE_FILE_NAMES_GIT, COMMIT_METADATA_SPLIT, FILENAME_HEADER_REGEX, FILENAME_HEADER_REGEX_GIT, GIT_DIFF_FILE_BREAK_REGEX, INDEX_LINE_METADATA } from "../constants.js"; import { cleanLastNewline } from "./cleanLastNewline.js"; import { detachString, releaseStringDetachBuffer } from "./detachString.js"; //#region src/utils/parsePatchFiles.ts function processPatch(data, cacheKeyPrefix, throwOnError) { try { return _processPatch(data, cacheKeyPrefix, throwOnError); } finally { releaseStringDetachBuffer(); } } function _processPatch(data, cacheKeyPrefix, throwOnError = false) { const isGitDiff = isGitDiffPatch(data); const rawFiles = isGitDiff ? splitGitDiffFiles(data) : splitUnifiedDiffFiles(data); let patchMetadata; const files = []; for (const fileOrPatchMetadata of rawFiles) { if (isGitDiff && !GIT_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) { if (patchMetadata == null) patchMetadata = detachString(fileOrPatchMetadata); else if (throwOnError) throw Error("parsePatchContent: unknown file blob"); else console.error("parsePatchContent: unknown file blob:", fileOrPatchMetadata); continue; } else if (!isGitDiff && !startsWithUnifiedDiffFileHeader(fileOrPatchMetadata)) { if (patchMetadata == null) patchMetadata = detachString(fileOrPatchMetadata); else if (throwOnError) throw Error("parsePatchContent: unknown file blob"); else console.error("parsePatchContent: unknown file blob:", fileOrPatchMetadata); continue; } const currentFile = _processFile(fileOrPatchMetadata, { cacheKey: cacheKeyPrefix != null ? `${cacheKeyPrefix}-${files.length}` : void 0, isGitDiff, throwOnError }); if (currentFile != null) files.push(currentFile); } return { patchMetadata, files }; } function processFile(fileDiffString, options) { try { return _processFile(fileDiffString, options); } finally { releaseStringDetachBuffer(); } } function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREAK_REGEX.test(fileDiffString), oldFile, newFile, throwOnError = false } = {}) { let lastHunkEnd = 0; const hunks = splitAtLinePrefix(fileDiffString, "@@ "); let currentFile; const isPartial = oldFile == null || newFile == null; let deletionLineIndex = 0; let additionLineIndex = 0; for (const hunk of hunks) { const lines = splitWithNewlines(hunk); const firstLine = lines[0]; if (firstLine == null) { if (throwOnError) throw Error("parsePatchContent: invalid hunk"); else console.error("parsePatchContent: invalid hunk", hunk); continue; } const fileHeader = parseHunkHeader(firstLine); let additionLines = 0; let deletionLines = 0; if (fileHeader == null || currentFile == null) { if (currentFile != null) { if (throwOnError) throw Error("parsePatchContent: Invalid hunk"); else console.error("parsePatchContent: Invalid hunk", hunk); continue; } currentFile = { name: "", type: "change", hunks: [], splitLineCount: 0, unifiedLineCount: 0, isPartial, additionLines: !isPartial && oldFile != null && newFile != null ? splitFileContents(newFile.contents) : [], deletionLines: !isPartial && oldFile != null && newFile != null ? splitFileContents(oldFile.contents) : [], cacheKey: maybeDetachOptionalString(cacheKey) }; if (currentFile.additionLines.length === 1 && newFile?.contents === "") currentFile.additionLines.length = 0; if (currentFile.deletionLines.length === 1 && oldFile?.contents === "") currentFile.deletionLines.length = 0; for (const line of lines) { if (line.startsWith("diff --git")) { const filenameMatch$1 = line.trim().match(ALTERNATE_FILE_NAMES_GIT); const prevName = filenameMatch$1?.[1] ?? filenameMatch$1?.[2]; const name = filenameMatch$1?.[3] ?? filenameMatch$1?.[4]; if (prevName == null || name == null) { if (throwOnError) throw Error("parsePatchContent: invalid git diff header"); else console.error("parsePatchContent: invalid git diff header", line); continue; } currentFile.name = detachString(name.trim()); if (prevName !== name) currentFile.prevName = detachString(prevName.trim()); continue; } const filenameMatch = line.startsWith("---") || line.startsWith("+++") ? line.match(isGitDiff ? FILENAME_HEADER_REGEX_GIT : FILENAME_HEADER_REGEX) : null; if (filenameMatch != null) { const [, type, fileName] = filenameMatch; if (type === "---" && fileName !== "/dev/null") { const detachedFileName = detachString(fileName.trim()); currentFile.prevName = detachedFileName; currentFile.name = detachedFileName; } else if (type === "+++" && fileName !== "/dev/null") currentFile.name = detachString(fileName.trim()); } else if (isGitDiff) { if (line.startsWith("new mode ")) currentFile.mode = detachString(line.slice(8).trim()); if (line.startsWith("old mode ")) currentFile.prevMode = detachString(line.slice(8).trim()); if (line.startsWith("new file mode")) { currentFile.type = "new"; currentFile.mode = detachString(line.slice(13).trim()); } if (line.startsWith("deleted file mode")) { currentFile.type = "deleted"; currentFile.mode = detachString(line.slice(17).trim()); } if (line.startsWith("similarity index")) if (line.startsWith("similarity index 100%")) currentFile.type = "rename-pure"; else currentFile.type = "rename-changed"; if (line.startsWith("index ")) { const [, prevObjectId, newObjectId, mode] = line.trim().match(INDEX_LINE_METADATA) ?? []; if (prevObjectId != null) currentFile.prevObjectId = detachString(prevObjectId); if (newObjectId != null) currentFile.newObjectId = detachString(newObjectId); if (mode != null) currentFile.mode = detachString(mode); } if (line.startsWith("rename from ")) currentFile.prevName = detachString(line.slice(12).trim()); if (line.startsWith("rename to ")) currentFile.name = detachString(line.slice(10).trim()); } } continue; } let currentContent; let lastLineType; while (lines.length > 0 && (lines[lines.length - 1] === "\n" || lines[lines.length - 1] === "\r" || lines[lines.length - 1] === "\r\n" || lines[lines.length - 1] === "")) lines.pop(); const { additionStart, deletionStart } = fileHeader; deletionLineIndex = isPartial ? deletionLineIndex : deletionStart - 1; additionLineIndex = isPartial ? additionLineIndex : additionStart - 1; const hunkData = { collapsedBefore: 0, splitLineCount: 0, splitLineStart: 0, unifiedLineCount: 0, unifiedLineStart: 0, additionCount: fileHeader.additionCount, additionStart, additionLines, deletionCount: fileHeader.deletionCount, deletionStart, deletionLines, deletionLineIndex, additionLineIndex, hunkContent: [], hunkContext: maybeDetachOptionalString(fileHeader.hunkContext), hunkSpecs: detachString(firstLine), noEOFCRAdditions: false, noEOFCRDeletions: false }; let parsedAdditionLines = 0; let parsedDeletionLines = 0; for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) { const rawLine = lines[lineIndex]; if (parsedAdditionLines >= hunkData.additionCount && parsedDeletionLines >= hunkData.deletionCount && !rawLine.startsWith("\\")) { if (throwOnError && isHunkBodyLine(rawLine) && !isFormatPatchVersionSeparator(rawLine)) throw Error("parsePatchContent: hunk has more lines than expected"); break; } const firstChar = rawLine[0]; if (firstChar !== "+" && firstChar !== "-" && firstChar !== " " && firstChar !== "\\") { if (throwOnError) throw Error("parsePatchContent: invalid hunk line"); console.error(`parseLineType: Invalid firstChar: "${firstChar}", full line: "${rawLine}"`); console.error("processFile: invalid rawLine:", rawLine); continue; } const type = parseRawLineType(firstChar); if (type === "addition") { if (throwOnError && parsedAdditionLines >= hunkData.additionCount) throw Error("parsePatchContent: hunk has too many addition lines"); const line = getParsedLineContent(rawLine); if (currentContent == null || currentContent.type !== "change") { currentContent = createContentGroup("change", deletionLineIndex, additionLineIndex); hunkData.hunkContent.push(currentContent); } additionLineIndex++; parsedAdditionLines++; if (isPartial) currentFile.additionLines.push(line); currentContent.additions++; additionLines++; lastLineType = "addition"; } else if (type === "deletion") { if (throwOnError && parsedDeletionLines >= hunkData.deletionCount) throw Error("parsePatchContent: hunk has too many deletion lines"); const line = getParsedLineContent(rawLine); if (currentContent == null || currentContent.type !== "change") { currentContent = createContentGroup("change", deletionLineIndex, additionLineIndex); hunkData.hunkContent.push(currentContent); } deletionLineIndex++; parsedDeletionLines++; if (isPartial) currentFile.deletionLines.push(line); currentContent.deletions++; deletionLines++; lastLineType = "deletion"; } else if (type === "context") { if (throwOnError && (parsedDeletionLines >= hunkData.deletionCount || parsedAdditionLines >= hunkData.additionCount)) throw Error("parsePatchContent: hunk has too many context lines"); const line = getParsedLineContent(rawLine); if (currentContent == null || currentContent.type !== "context") { currentContent = createContentGroup("context", deletionLineIndex, additionLineIndex); hunkData.hunkContent.push(currentContent); } additionLineIndex++; deletionLineIndex++; parsedAdditionLines++; parsedDeletionLines++; if (isPartial) { currentFile.deletionLines.push(line); currentFile.additionLines.push(line); } currentContent.lines++; lastLineType = "context"; } else if (type === "metadata" && currentContent != null) { if (currentContent.type === "context") { hunkData.noEOFCRAdditions = true; hunkData.noEOFCRDeletions = true; } else if (lastLineType === "deletion") hunkData.noEOFCRDeletions = true; else if (lastLineType === "addition") hunkData.noEOFCRAdditions = true; if (isPartial && (lastLineType === "addition" || lastLineType === "context")) { const lastIndex = currentFile.additionLines.length - 1; if (lastIndex >= 0) currentFile.additionLines[lastIndex] = cleanLastNewline(currentFile.additionLines[lastIndex]); } if (isPartial && (lastLineType === "deletion" || lastLineType === "context")) { const lastIndex = currentFile.deletionLines.length - 1; if (lastIndex >= 0) currentFile.deletionLines[lastIndex] = cleanLastNewline(currentFile.deletionLines[lastIndex]); } } } if (throwOnError && (parsedAdditionLines !== hunkData.additionCount || parsedDeletionLines !== hunkData.deletionCount)) throw Error("parsePatchContent: hunk line count mismatch"); hunkData.additionLines = additionLines; hunkData.deletionLines = deletionLines; hunkData.collapsedBefore = Math.max(hunkData.additionStart - 1 - lastHunkEnd, 0); currentFile.hunks.push(hunkData); lastHunkEnd = hunkData.additionStart + hunkData.additionCount - 1; for (const content of hunkData.hunkContent) if (content.type === "context") { hunkData.splitLineCount += content.lines; hunkData.unifiedLineCount += content.lines; } else { hunkData.splitLineCount += Math.max(content.additions, content.deletions); hunkData.unifiedLineCount += content.deletions + content.additions; } hunkData.splitLineStart = currentFile.splitLineCount + hunkData.collapsedBefore; hunkData.unifiedLineStart = currentFile.unifiedLineCount + hunkData.collapsedBefore; currentFile.splitLineCount += hunkData.collapsedBefore + hunkData.splitLineCount; currentFile.unifiedLineCount += hunkData.collapsedBefore + hunkData.unifiedLineCount; } if (currentFile == null) return; if (throwOnError && isPartial && !isGitDiff && currentFile.hunks.length === 0) throw Error("parsePatchContent: unified file has no hunks"); if (currentFile.hunks.length > 0 && !isPartial && currentFile.additionLines.length > 0 && currentFile.deletionLines.length > 0) { const lastHunk = currentFile.hunks[currentFile.hunks.length - 1]; const lastHunkEnd$1 = lastHunk.additionStart + lastHunk.additionCount - 1; const totalFileLines = currentFile.additionLines.length; const collapsedAfter = Math.max(totalFileLines - lastHunkEnd$1, 0); currentFile.splitLineCount += collapsedAfter; currentFile.unifiedLineCount += collapsedAfter; } if (!isGitDiff) { if (currentFile.prevName != null && currentFile.name !== currentFile.prevName) if (currentFile.hunks.length > 0) currentFile.type = "rename-changed"; else currentFile.type = "rename-pure"; else if ((oldFile == null || oldFile.contents === "") && newFile != null && newFile.contents !== "") currentFile.type = "new"; else if (oldFile != null && oldFile.contents !== "" && (newFile == null || newFile.contents === "")) currentFile.type = "deleted"; } if (currentFile.type !== "rename-pure" && currentFile.type !== "rename-changed") currentFile.prevName = void 0; return currentFile; } /** * Parses a patch file string into an array of parsed patches. * * @param data - The raw patch file content (supports multi-commit patches) * @param cacheKeyPrefix - Optional prefix for generating cache keys. When provided, * each file in the patch will get a cache key in the format `prefix-patchIndex-fileIndex`. * This enables caching of rendered diff results in the worker pool. */ function parsePatchFiles(data, cacheKeyPrefix, throwOnError = false) { const patches = []; const rawPatches = hasCommitMetadataBoundary(data) ? data.split(COMMIT_METADATA_SPLIT) : [data]; for (const patch of rawPatches) try { patches.push(processPatch(patch, cacheKeyPrefix != null ? `${cacheKeyPrefix}-${patches.length}` : void 0, throwOnError)); } catch (error) { if (throwOnError) throw error; else console.error(error); } return patches; } function hasCommitMetadataBoundary(data) { return data.startsWith("From ") || data.includes("\nFrom "); } function splitFileContents(contents) { const lines = splitWithNewlines(contents); for (let index = 0; index < lines.length; index++) lines[index] = detachString(lines[index]); return lines; } function splitWithNewlines(contents) { if (contents.length === 0) return [""]; const lines = []; let startIndex = 0; for (;;) { const newlineIndex = contents.indexOf("\n", startIndex); if (newlineIndex === -1) break; lines.push(contents.slice(startIndex, newlineIndex + 1)); startIndex = newlineIndex + 1; } if (startIndex < contents.length) lines.push(contents.slice(startIndex)); return lines; } function splitGitDiffFiles(contents) { return splitAtLinePrefix(contents, "diff --git"); } function splitUnifiedDiffFiles(contents) { if (contents.length === 0) return [""]; const parts = []; let partStartIndex = 0; let lineStartIndex = 0; let remainingDeletionLines = 0; let remainingAdditionLines = 0; let hasOpenedUnifiedFile = false; while (lineStartIndex < contents.length) { const nextLineStartIndex = getNextLineStartIndex(contents, lineStartIndex); if (remainingDeletionLines <= 0 && remainingAdditionLines <= 0) { if (isUnifiedDiffFileHeaderAt(contents, lineStartIndex)) { if (lineStartIndex > partStartIndex) parts.push(contents.slice(partStartIndex, lineStartIndex)); partStartIndex = lineStartIndex; hasOpenedUnifiedFile = true; lineStartIndex = getNextLineStartIndex(contents, nextLineStartIndex); continue; } if (hasOpenedUnifiedFile && contents.startsWith("@@ -", lineStartIndex)) { const fileHeader = parseHunkHeader(contents.slice(lineStartIndex, nextLineStartIndex)); if (fileHeader != null) { remainingDeletionLines = fileHeader.deletionCount; remainingAdditionLines = fileHeader.additionCount; } } lineStartIndex = nextLineStartIndex; continue; } const firstChar = contents[lineStartIndex]; if (firstChar === "\\") { lineStartIndex = nextLineStartIndex; continue; } if (firstChar === " ") { remainingDeletionLines = Math.max(remainingDeletionLines - 1, 0); remainingAdditionLines = Math.max(remainingAdditionLines - 1, 0); } else if (firstChar === "-") remainingDeletionLines = Math.max(remainingDeletionLines - 1, 0); else if (firstChar === "+") remainingAdditionLines = Math.max(remainingAdditionLines - 1, 0); lineStartIndex = nextLineStartIndex; } parts.push(contents.slice(partStartIndex)); return parts; } function startsWithUnifiedDiffFileHeader(contents) { return isUnifiedDiffFileHeaderAt(contents, 0); } function isUnifiedDiffFileHeaderAt(contents, lineStartIndex) { const nextLineStartIndex = getNextLineStartIndex(contents, lineStartIndex); return isUnifiedDiffHeaderLineAt(contents, lineStartIndex, "---") && isUnifiedDiffHeaderLineAt(contents, nextLineStartIndex, "+++"); } function isUnifiedDiffHeaderLineAt(contents, lineStartIndex, prefix) { if (!contents.startsWith(prefix, lineStartIndex)) return false; const separator = contents[lineStartIndex + prefix.length]; if (separator !== " " && separator !== " ") return false; for (let index = lineStartIndex + prefix.length + 1; index < contents.length; index++) { const char = contents[index]; if (char === "\n" || char === "\r") break; if (char !== " " && char !== " ") return true; } return false; } function getNextLineStartIndex(contents, lineStartIndex) { const newlineIndex = contents.indexOf("\n", lineStartIndex); return newlineIndex === -1 ? contents.length : newlineIndex + 1; } function isHunkBodyLine(line) { const firstChar = line[0]; return firstChar === "+" || firstChar === "-" || firstChar === " "; } function isFormatPatchVersionSeparator(line) { if (!line.startsWith("--")) return false; for (let index = 2; index < line.length; index++) { const char = line[index]; if (char !== " " && char !== " " && char !== "\n" && char !== "\r") return false; } return true; } function parseHunkHeader(line) { if (!line.startsWith("@@ -")) return; let index = 4; const deletionStartResult = readPositiveInteger(line, index); if (deletionStartResult == null) return; const deletionStart = deletionStartResult.value; index = deletionStartResult.endIndex; let deletionCount = 1; if (line[index] === ",") { const deletionCountResult = readPositiveInteger(line, index + 1); if (deletionCountResult == null) return; deletionCount = deletionCountResult.value; index = deletionCountResult.endIndex; } if (line[index] !== " " || line[index + 1] !== "+") return; index += 2; const additionStartResult = readPositiveInteger(line, index); if (additionStartResult == null) return; const additionStart = additionStartResult.value; index = additionStartResult.endIndex; let additionCount = 1; if (line[index] === ",") { const additionCountResult = readPositiveInteger(line, index + 1); if (additionCountResult == null) return; additionCount = additionCountResult.value; index = additionCountResult.endIndex; } if (line[index] !== " " || line[index + 1] !== "@" || line[index + 2] !== "@") return; let hunkContext; const contextStartIndex = index + 3; if (line[contextStartIndex] === " ") hunkContext = trimLineEnd(line.slice(contextStartIndex + 1)); return { additionCount, additionStart, deletionCount, deletionStart, hunkContext }; } function readPositiveInteger(value, startIndex) { let index = startIndex; let parsedValue = 0; for (; index < value.length; index++) { const digit = value.charCodeAt(index) - 48; if (digit < 0 || digit > 9) break; parsedValue = parsedValue * 10 + digit; } if (index === startIndex) return; return { value: parsedValue, endIndex: index }; } function trimLineEnd(value) { if (value.endsWith("\r\n")) return value.slice(0, -2); if (value.endsWith("\n")) return value.slice(0, -1); return value; } function isGitDiffPatch(data) { return data.startsWith("diff --git") || data.includes("\ndiff --git"); } function splitAtLinePrefix(contents, prefix) { if (contents.length === 0) return [""]; const newlinePrefix = `\n${prefix}`; const firstBoundaryIndex = contents.startsWith(prefix) ? 0 : findLinePrefixIndex(contents, newlinePrefix, 0); if (firstBoundaryIndex === -1) return [contents]; const parts = []; if (firstBoundaryIndex > 0) parts.push(contents.slice(0, firstBoundaryIndex)); let startIndex = firstBoundaryIndex; for (;;) { const nextBoundaryIndex = findLinePrefixIndex(contents, newlinePrefix, startIndex + 1); if (nextBoundaryIndex === -1) break; parts.push(contents.slice(startIndex, nextBoundaryIndex)); startIndex = nextBoundaryIndex; } parts.push(contents.slice(startIndex)); return parts; } function findLinePrefixIndex(contents, newlinePrefix, fromIndex) { const index = contents.indexOf(newlinePrefix, fromIndex); return index === -1 ? -1 : index + 1; } function maybeDetachOptionalString(value) { return value == null ? value : detachString(value); } function parseRawLineType(firstChar) { return firstChar === " " ? "context" : firstChar === "\\" ? "metadata" : firstChar === "+" ? "addition" : "deletion"; } function getParsedLineContent(rawLine) { const processedLine = rawLine.slice(1); return detachString(processedLine === "" ? "\n" : processedLine); } function createContentGroup(type, deletionLineIndex, additionLineIndex) { if (type === "change") return { type: "change", additions: 0, deletions: 0, additionLineIndex, deletionLineIndex }; return { type: "context", lines: 0, additionLineIndex, deletionLineIndex }; } //#endregion export { parsePatchFiles, processFile, processPatch }; //# sourceMappingURL=parsePatchFiles.js.map