| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- import type { ParsedPatch, PatchChunk, PatchHunk } from './types';
- type ParseMode = 'permissive' | 'strict';
- function normalizeLineEndings(text: string): string {
- return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
- }
- export function normalizeUnicode(text: string): string {
- return text
- .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
- .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
- .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, '-')
- .replace(/\u2026/g, '...')
- .replace(/\u00A0/g, ' ');
- }
- export function stripHeredoc(input: string): string {
- const normalized = normalizeLineEndings(input);
- const match = normalized.match(
- /^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/,
- );
- return match ? match[2] : normalized;
- }
- export function normalizePatchText(patchText: string): string {
- return stripHeredoc(normalizeLineEndings(patchText).trim());
- }
- function parseHeader(lines: string[], index: number) {
- const line = lines[index];
- if (line.startsWith('*** Add File:')) {
- const file = line.slice('*** Add File:'.length).trim();
- return file ? { file, next: index + 1 } : null;
- }
- if (line.startsWith('*** Delete File:')) {
- const file = line.slice('*** Delete File:'.length).trim();
- return file ? { file, next: index + 1 } : null;
- }
- if (line.startsWith('*** Update File:')) {
- const file = line.slice('*** Update File:'.length).trim();
- let move: string | undefined;
- let next = index + 1;
- if (next < lines.length && lines[next].startsWith('*** Move to:')) {
- const moveTarget = lines[next].slice('*** Move to:'.length).trim();
- if (!moveTarget) {
- return null;
- }
- move = moveTarget;
- next += 1;
- }
- return file ? { file, move, next } : null;
- }
- return null;
- }
- function unexpectedPatchLine(context: string, line: string): never {
- const rendered = line.length === 0 ? '<empty>' : line;
- throw new Error(
- `Invalid patch format: unexpected line ${context}: ${rendered}`,
- );
- }
- function parseChangeContext(line: string): string | undefined {
- const context = line.slice(2);
- if (context.length === 0) {
- return undefined;
- }
- return context.startsWith(' ') ? context.slice(1) || undefined : context;
- }
- function isPatchBoundary(line: string, marker: string): boolean {
- return line.trimEnd() === marker;
- }
- function parseChunks(lines: string[], index: number, mode: ParseMode) {
- const chunks: PatchChunk[] = [];
- let at = index;
- while (at < lines.length && !lines[at].startsWith('***')) {
- if (!lines[at].startsWith('@@')) {
- if (mode === 'strict') {
- unexpectedPatchLine('in update body', lines[at]);
- }
- at += 1;
- continue;
- }
- const context = parseChangeContext(lines[at]);
- at += 1;
- const old_lines: string[] = [];
- const new_lines: string[] = [];
- let eof = false;
- while (
- at < lines.length &&
- !lines[at].startsWith('@@') &&
- (!lines[at].startsWith('***') || lines[at] === '*** End of File')
- ) {
- const line = lines[at];
- if (line === '*** End of File') {
- eof = true;
- at += 1;
- break;
- }
- if (line.startsWith(' ')) {
- old_lines.push(line.slice(1));
- new_lines.push(line.slice(1));
- at += 1;
- continue;
- }
- if (line.startsWith('-')) {
- old_lines.push(line.slice(1));
- at += 1;
- continue;
- }
- if (line.startsWith('+')) {
- new_lines.push(line.slice(1));
- at += 1;
- continue;
- }
- if (mode === 'strict') {
- unexpectedPatchLine('in patch chunk', line);
- }
- at += 1;
- }
- chunks.push({
- old_lines,
- new_lines,
- change_context: context,
- is_end_of_file: eof || undefined,
- });
- }
- return { chunks, next: at };
- }
- function parseAdd(lines: string[], index: number, mode: ParseMode) {
- const contents: string[] = [];
- let at = index;
- while (at < lines.length && !lines[at].startsWith('***')) {
- if (lines[at].startsWith('+')) {
- contents.push(lines[at].slice(1));
- at += 1;
- continue;
- }
- if (mode === 'strict') {
- unexpectedPatchLine('in Add File body', lines[at]);
- }
- at += 1;
- }
- return { content: contents.join('\n'), next: at };
- }
- function parsePatchInternal(patchText: string, mode: ParseMode): ParsedPatch {
- const clean = normalizePatchText(patchText);
- const lines = clean.split('\n');
- const begin = lines.findIndex((line) =>
- isPatchBoundary(line, '*** Begin Patch'),
- );
- const end = lines.findIndex(
- (line, index) => index > begin && isPatchBoundary(line, '*** End Patch'),
- );
- if (begin === -1 || end === -1 || begin >= end) {
- throw new Error('Invalid patch format: missing Begin/End markers');
- }
- if (mode === 'strict') {
- for (const line of lines.slice(0, begin)) {
- unexpectedPatchLine('before Begin Patch', line);
- }
- for (const line of lines.slice(end + 1)) {
- unexpectedPatchLine('after End Patch', line);
- }
- }
- const hunks: PatchHunk[] = [];
- let index = begin + 1;
- while (index < end) {
- const header = parseHeader(lines, index);
- if (!header) {
- if (mode === 'strict') {
- unexpectedPatchLine('between hunks', lines[index]);
- }
- index += 1;
- continue;
- }
- if (lines[index].startsWith('*** Add File:')) {
- const next = parseAdd(lines, header.next, mode);
- hunks.push({
- type: 'add',
- path: header.file,
- contents: next.content,
- });
- index = next.next;
- continue;
- }
- if (lines[index].startsWith('*** Delete File:')) {
- hunks.push({ type: 'delete', path: header.file });
- index = header.next;
- continue;
- }
- const next = parseChunks(lines, header.next, mode);
- if (mode === 'strict' && next.chunks.length === 0) {
- throw new Error(
- `Invalid patch format: Update File is missing @@ chunk body: ${header.file}`,
- );
- }
- hunks.push({
- type: 'update',
- path: header.file,
- move_path: header.move,
- chunks: next.chunks,
- });
- index = next.next;
- }
- return { hunks };
- }
- export function parsePatch(patchText: string): ParsedPatch {
- return parsePatchInternal(patchText, 'permissive');
- }
- export function parsePatchStrict(patchText: string): ParsedPatch {
- return parsePatchInternal(patchText, 'strict');
- }
- function diffMatrix(old_lines: string[], new_lines: string[]): number[][] {
- const dp = Array.from({ length: old_lines.length + 1 }, () =>
- Array<number>(new_lines.length + 1).fill(0),
- );
- for (let oldIndex = 1; oldIndex <= old_lines.length; oldIndex += 1) {
- for (let newIndex = 1; newIndex <= new_lines.length; newIndex += 1) {
- dp[oldIndex][newIndex] =
- old_lines[oldIndex - 1] === new_lines[newIndex - 1]
- ? dp[oldIndex - 1][newIndex - 1] + 1
- : Math.max(dp[oldIndex - 1][newIndex], dp[oldIndex][newIndex - 1]);
- }
- }
- return dp;
- }
- function renderChunk(chunk: PatchChunk): string[] {
- const lines = [chunk.change_context ? `@@ ${chunk.change_context}` : '@@'];
- const dp = diffMatrix(chunk.old_lines, chunk.new_lines);
- const body: string[] = [];
- let oldIndex = chunk.old_lines.length;
- let newIndex = chunk.new_lines.length;
- while (oldIndex > 0 && newIndex > 0) {
- if (chunk.old_lines[oldIndex - 1] === chunk.new_lines[newIndex - 1]) {
- body.push(` ${chunk.old_lines[oldIndex - 1]}`);
- oldIndex -= 1;
- newIndex -= 1;
- continue;
- }
- if (dp[oldIndex - 1][newIndex] >= dp[oldIndex][newIndex - 1]) {
- body.push(`-${chunk.old_lines[oldIndex - 1]}`);
- oldIndex -= 1;
- continue;
- }
- body.push(`+${chunk.new_lines[newIndex - 1]}`);
- newIndex -= 1;
- }
- while (oldIndex > 0) {
- body.push(`-${chunk.old_lines[oldIndex - 1]}`);
- oldIndex -= 1;
- }
- while (newIndex > 0) {
- body.push(`+${chunk.new_lines[newIndex - 1]}`);
- newIndex -= 1;
- }
- lines.push(...body.reverse());
- if (chunk.is_end_of_file) {
- lines.push('*** End of File');
- }
- return lines;
- }
- function renderAddContents(contents: string): string[] {
- if (contents.length === 0) {
- return [];
- }
- return contents.split('\n').map((line) => `+${line}`);
- }
- export function formatPatch(patch: ParsedPatch): string {
- const lines = ['*** Begin Patch'];
- for (const hunk of patch.hunks) {
- if (hunk.type === 'add') {
- lines.push(`*** Add File: ${hunk.path}`);
- lines.push(...renderAddContents(hunk.contents));
- continue;
- }
- if (hunk.type === 'delete') {
- lines.push(`*** Delete File: ${hunk.path}`);
- continue;
- }
- lines.push(`*** Update File: ${hunk.path}`);
- if (hunk.move_path) {
- lines.push(`*** Move to: ${hunk.move_path}`);
- }
- for (const chunk of hunk.chunks) {
- lines.push(...renderChunk(chunk));
- }
- }
- lines.push('*** End Patch');
- return lines.join('\n');
- }
|