Files
oh-my-pi/packages/coding-agent/src/patch/parser.ts
T
can1357 7bb2e1aa58 feat(port): ported pi-mono improvements + worker & patch improvements
- added Azure OpenAI Responses support and OpenRouter routing compat
- improved patch applicator diagnostics and subagent context propagation
- updated editor cursor handling, keybindings, and working message API
- refreshed porting sync metadata
2026-01-25 13:50:08 +01:00

533 lines
18 KiB
TypeScript

/**
* Diff/patch parsing for the edit tool.
*
* Supports multiple input formats:
* - Simple +/- diffs
* - Unified diff format (@@ -X,Y +A,B @@)
* - Codex-style wrapped patches (*** Begin Patch / *** End Patch)
*/
import type { DiffHunk } from "./types";
import { ApplyPatchError, ParseError } from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════════════════
const EOF_MARKER = "*** End of File";
const CHANGE_CONTEXT_MARKER = "@@ ";
const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
/** Regex to match unified diff hunk headers: @@ -OLD,COUNT +NEW,COUNT @@ optional-context */
const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/;
/** Regex to match @@ line/lines N or N-M pattern (model-generated line hints) */
const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i;
const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i;
/**
* Check if a line is a diff content line (context, addition, or removal).
* These should never be treated as metadata even if their content looks like it.
* Note: `--- ` and `+++ ` are metadata headers, not content lines.
*/
function isDiffContentLine(line: string): boolean {
const firstChar = line[0];
if (firstChar === " ") return true;
if (firstChar === "+") {
// `+++ ` is metadata, single `+` followed by content is addition
return !line.startsWith("+++ ");
}
if (firstChar === "-") {
// `--- ` is metadata, single `-` followed by content is removal
return !line.startsWith("--- ");
}
return false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Normalization
// ═══════════════════════════════════════════════════════════════════════════
/**
* Normalize a diff by stripping various wrapper formats and metadata.
*
* Handles:
* - `*** Begin Patch` / `*** End Patch` markers (partial or complete)
* - Codex file markers: `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** End of File`
* - Unified diff metadata: `diff --git`, `index`, `---`, `+++`, mode changes, rename markers
*/
export function normalizeDiff(diff: string): string {
let lines = diff.split("\n");
// Strip trailing truly empty lines (not diff content lines like " " which represent blank context)
while (lines.length > 0) {
const lastLine = lines[lines.length - 1];
// Only strip if line is completely empty (no characters) OR
// if it's whitespace-only but NOT a diff content line (space prefix = context line)
if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) {
lines = lines.slice(0, -1);
} else {
break;
}
}
// Layer 1: Strip *** Begin Patch / *** End Patch (may have only one or both)
if (lines[0]?.trim().startsWith("*** Begin Patch")) {
lines = lines.slice(1);
}
// Also strip bare *** at the beginning (model hallucination)
if (lines[0]?.trim() === "***") {
lines = lines.slice(1);
}
if (lines.length > 0 && lines[lines.length - 1]?.trim().startsWith("*** End Patch")) {
lines = lines.slice(0, -1);
}
// Also strip bare *** terminator (model hallucination)
if (lines.length > 0 && lines[lines.length - 1]?.trim() === "***") {
lines = lines.slice(0, -1);
}
// Layer 2: Strip Codex-style file operation markers and unified diff metadata
// NOTE: Do NOT strip "*** End of File" - that's a valid marker within hunks, not a wrapper
// IMPORTANT: Only strip actual metadata lines, NOT diff content lines (starting with space, +, or -)
lines = lines.filter(line => {
// Preserve diff content lines even if their content looks like metadata
// Note: `--- ` and `+++ ` are metadata, not content lines
if (isDiffContentLine(line)) {
return true;
}
const trimmed = line.trim();
// Codex file operation markers (these wrap multiple file changes)
if (trimmed.startsWith("*** Update File:")) return false;
if (trimmed.startsWith("*** Add File:")) return false;
if (trimmed.startsWith("*** Delete File:")) return false;
// Unified diff metadata
if (trimmed.startsWith("diff --git ")) return false;
if (trimmed.startsWith("index ")) return false;
if (trimmed.startsWith("--- ")) return false;
if (trimmed.startsWith("+++ ")) return false;
if (trimmed.startsWith("new file mode ")) return false;
if (trimmed.startsWith("deleted file mode ")) return false;
if (trimmed.startsWith("rename from ")) return false;
if (trimmed.startsWith("rename to ")) return false;
if (trimmed.startsWith("similarity index ")) return false;
if (trimmed.startsWith("dissimilarity index ")) return false;
if (trimmed.startsWith("old mode ")) return false;
if (trimmed.startsWith("new mode ")) return false;
return true;
});
return lines.join("\n");
}
/**
* Strip `+ ` prefix from file creation content if all non-empty lines have it.
* This handles diffs where file content is formatted as additions.
*/
export function normalizeCreateContent(content: string): string {
const lines = content.split("\n");
const nonEmptyLines = lines.filter(l => l.length > 0);
// Check if all non-empty lines start with "+ " or "+"
if (nonEmptyLines.length > 0 && nonEmptyLines.every(l => l.startsWith("+ ") || l.startsWith("+"))) {
return lines
.map(l => {
if (l.startsWith("+ ")) return l.slice(2);
if (l.startsWith("+")) return l.slice(1);
return l;
})
.join("\n");
}
return content;
}
// ═══════════════════════════════════════════════════════════════════════════
// Header Parsing
// ═══════════════════════════════════════════════════════════════════════════
interface UnifiedHunkHeader {
oldStartLine: number;
oldLineCount: number;
newStartLine: number;
newLineCount: number;
changeContext?: string;
}
function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined {
const match = line.match(UNIFIED_HUNK_HEADER_REGEX);
if (!match) return undefined;
const oldStartLine = Number(match[1]);
const oldLineCount = match[2] ? Number(match[2]) : 1;
const newStartLine = Number(match[3]);
const newLineCount = match[4] ? Number(match[4]) : 1;
const changeContext = match[5]?.trim();
return {
oldStartLine,
oldLineCount,
newStartLine,
newLineCount,
changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined,
};
}
function isUnifiedDiffMetadataLine(line: string): boolean {
return (
line.startsWith("diff --git ") ||
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ") ||
line.startsWith("new file mode ") ||
line.startsWith("deleted file mode ") ||
line.startsWith("rename from ") ||
line.startsWith("rename to ") ||
line.startsWith("similarity index ") ||
line.startsWith("dissimilarity index ") ||
line.startsWith("old mode ") ||
line.startsWith("new mode ")
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Hunk Parsing
// ═══════════════════════════════════════════════════════════════════════════
interface ParseHunkResult {
hunk: DiffHunk;
linesConsumed: number;
}
/**
* Parse a single hunk from lines starting at the current position.
*
* Handles several context formats:
* - Empty: `@@` (no context, match from current position)
* - Unified: `@@ -10,3 +10,3 @@` (line numbers as hints)
* - Context: `@@ function foo` (search for context line)
* - Line hint: `@@ line 125` (use line 125 as starting position)
* - Nested: `@@ class Foo\n@@ method` (hierarchical context search)
*/
function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult {
if (lines.length === 0) {
throw new ParseError("Diff does not contain any lines", lineNumber);
}
const changeContexts: string[] = [];
let oldStartLine: number | undefined;
let newStartLine: number | undefined;
let startIndex: number;
const headerLine = lines[0];
const headerTrimmed = headerLine.trimEnd();
const isHeaderLine = headerLine.startsWith("@@");
const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
// Check for context marker
if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
startIndex = 1;
} else if (unifiedHeader) {
if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) {
throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber);
}
if (unifiedHeader.changeContext) {
changeContexts.push(unifiedHeader.changeContext);
}
oldStartLine = unifiedHeader.oldStartLine;
newStartLine = unifiedHeader.newStartLine;
startIndex = 1;
} else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length);
const trimmedContextValue = contextValue.trim();
const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, "");
const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX);
if (lineHintMatch) {
oldStartLine = Number(lineHintMatch[1]);
newStartLine = oldStartLine;
if (oldStartLine < 1) {
throw new ParseError("Line hint must be >= 1", lineNumber);
}
} else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) {
oldStartLine = 1;
newStartLine = 1;
} else if (trimmedContextValue.length > 0) {
changeContexts.push(contextValue);
}
startIndex = 1;
} else if (isHeaderLine) {
const contextValue = headerTrimmed.slice(2).trim();
if (contextValue.length > 0) {
changeContexts.push(contextValue);
}
startIndex = 1;
} else {
if (!allowMissingContext) {
throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber);
}
startIndex = 0;
}
if (oldStartLine !== undefined && oldStartLine < 1) {
throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber);
}
if (newStartLine !== undefined && newStartLine < 1) {
throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
}
// Check for nested @@ anchors on subsequent lines
// Format: @@ class Foo
// @@ method
while (startIndex < lines.length) {
const nextLine = lines[startIndex];
if (!nextLine.startsWith("@@")) {
break;
}
const trimmed = nextLine.trimEnd();
// Check if it's another @@ line (nested anchor)
if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
if (nestedContext.trim().length > 0) {
changeContexts.push(nestedContext);
}
startIndex++;
} else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) {
// Empty @@ as separator - skip it
startIndex++;
} else {
// Not an @@ line, stop accumulating
break;
}
}
if (startIndex >= lines.length) {
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
}
// Combine contexts: if multiple, join with newline for hierarchical matching
const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined;
const hunk: DiffHunk = {
changeContext,
oldStartLine,
newStartLine,
hasContextLines: false,
oldLines: [],
newLines: [],
isEndOfFile: false,
};
let parsedLines = 0;
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
const nextLine = lines[i + 1];
if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) {
break;
}
if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
if (parsedLines === 0) {
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
}
hunk.isEndOfFile = true;
parsedLines++;
break;
}
if (trimmed === "..." || trimmed === "…") {
hunk.hasContextLines = true;
parsedLines++;
continue;
}
const firstChar = line[0];
if (firstChar === undefined || firstChar === "") {
// Empty line - treat as context
hunk.hasContextLines = true;
hunk.oldLines.push("");
hunk.newLines.push("");
} else if (firstChar === " ") {
// Context line
hunk.hasContextLines = true;
hunk.oldLines.push(line.slice(1));
hunk.newLines.push(line.slice(1));
} else if (firstChar === "+") {
// Added line
hunk.newLines.push(line.slice(1));
} else if (firstChar === "-") {
// Removed line
hunk.oldLines.push(line.slice(1));
} else if (!line.startsWith("@@")) {
// Implicit context line (model omitted leading space)
hunk.hasContextLines = true;
hunk.oldLines.push(line);
hunk.newLines.push(line);
} else {
if (parsedLines === 0) {
throw new ParseError(
`Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`,
lineNumber + 1,
);
}
// Assume start of next hunk
break;
}
parsedLines++;
}
if (parsedLines === 0) {
throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex);
}
stripLineNumberPrefixes(hunk);
return { hunk, linesConsumed: parsedLines + startIndex };
}
function stripLineNumberPrefixes(hunk: DiffHunk): void {
const allLines = [...hunk.oldLines, ...hunk.newLines].filter(line => line.trim().length > 0);
if (allLines.length < 2) return;
const numberMatches = allLines
.map(line => line.match(/^\s*(\d{1,6})\s+(.+)$/u))
.filter((match): match is RegExpMatchArray => match !== null);
if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) {
return;
}
const numbers = numberMatches.map(match => Number(match[1]));
let sequential = 0;
for (let i = 1; i < numbers.length; i++) {
if (numbers[i] === numbers[i - 1] + 1) {
sequential++;
}
}
if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) {
return;
}
const strip = (line: string): string => {
const match = line.match(/^\s*\d{1,6}\s+(.+)$/u);
return match ? match[1] : line;
};
hunk.oldLines = hunk.oldLines.map(strip);
hunk.newLines = hunk.newLines.map(strip);
}
/** Multi-file patch markers that indicate this is not a single-file patch */
const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "];
/**
* Count multi-file markers in a diff.
* Returns the count of file-level markers found.
* Only counts lines that are actual metadata (not diff content lines).
*/
function countMultiFileMarkers(diff: string): number {
const counts = new Map<string, number>();
const paths = new Set<string>();
const lines = diff.split("\n");
for (const line of lines) {
if (isDiffContentLine(line)) {
continue;
}
const trimmed = line.trim();
for (const marker of MULTI_FILE_MARKERS) {
if (trimmed.startsWith(marker)) {
const path = extractMarkerPath(trimmed);
if (path) {
paths.add(path);
}
counts.set(marker, (counts.get(marker) ?? 0) + 1);
break;
}
}
}
if (paths.size > 0) {
return paths.size;
}
let maxCount = 0;
for (const count of counts.values()) {
if (count > maxCount) {
maxCount = count;
}
}
return maxCount;
}
function extractMarkerPath(line: string): string | undefined {
if (line.startsWith("diff --git ")) {
const parts = line.split(/\s+/);
const candidate = parts[3] ?? parts[2];
if (!candidate) return undefined;
return candidate.replace(/^(a|b)\//, "");
}
if (line.startsWith("*** Update File:")) {
return line.slice("*** Update File:".length).trim();
}
if (line.startsWith("*** Add File:")) {
return line.slice("*** Add File:".length).trim();
}
if (line.startsWith("*** Delete File:")) {
return line.slice("*** Delete File:".length).trim();
}
return undefined;
}
/**
* Parse all diff hunks from a diff string.
*/
export function parseHunks(diff: string): DiffHunk[] {
const multiFileCount = countMultiFileMarkers(diff);
if (multiFileCount > 1) {
throw new ApplyPatchError(
`Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`,
);
}
const normalizedDiff = normalizeDiff(diff);
const lines = normalizedDiff.split("\n");
const hunks: DiffHunk[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// Skip blank lines between hunks
if (trimmed === "") {
i++;
continue;
}
// Skip unified diff metadata lines, but only if they're not diff content lines
const firstChar = line[0];
const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-";
if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) {
i++;
continue;
}
if (trimmed.startsWith("@@") && lines.slice(i + 1).every(l => l.trim() === "")) {
break;
}
const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true);
hunks.push(hunk);
i += linesConsumed;
}
return hunks;
}