codec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import type { ParsedPatch, PatchChunk, PatchHunk } from './types';
  2. type ParseMode = 'permissive' | 'strict';
  3. function normalizeLineEndings(text: string): string {
  4. return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  5. }
  6. export function normalizeUnicode(text: string): string {
  7. return text
  8. .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
  9. .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
  10. .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, '-')
  11. .replace(/\u2026/g, '...')
  12. .replace(/\u00A0/g, ' ');
  13. }
  14. export function stripHeredoc(input: string): string {
  15. const normalized = normalizeLineEndings(input);
  16. const match = normalized.match(
  17. /^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/,
  18. );
  19. return match ? match[2] : normalized;
  20. }
  21. export function normalizePatchText(patchText: string): string {
  22. return stripHeredoc(normalizeLineEndings(patchText).trim());
  23. }
  24. function parseHeader(lines: string[], index: number) {
  25. const line = lines[index];
  26. if (line.startsWith('*** Add File:')) {
  27. const file = line.slice('*** Add File:'.length).trim();
  28. return file ? { file, next: index + 1 } : null;
  29. }
  30. if (line.startsWith('*** Delete File:')) {
  31. const file = line.slice('*** Delete File:'.length).trim();
  32. return file ? { file, next: index + 1 } : null;
  33. }
  34. if (line.startsWith('*** Update File:')) {
  35. const file = line.slice('*** Update File:'.length).trim();
  36. let move: string | undefined;
  37. let next = index + 1;
  38. if (next < lines.length && lines[next].startsWith('*** Move to:')) {
  39. const moveTarget = lines[next].slice('*** Move to:'.length).trim();
  40. if (!moveTarget) {
  41. return null;
  42. }
  43. move = moveTarget;
  44. next += 1;
  45. }
  46. return file ? { file, move, next } : null;
  47. }
  48. return null;
  49. }
  50. function unexpectedPatchLine(context: string, line: string): never {
  51. const rendered = line.length === 0 ? '<empty>' : line;
  52. throw new Error(
  53. `Invalid patch format: unexpected line ${context}: ${rendered}`,
  54. );
  55. }
  56. function parseChangeContext(line: string): string | undefined {
  57. const context = line.slice(2);
  58. if (context.length === 0) {
  59. return undefined;
  60. }
  61. return context.startsWith(' ') ? context.slice(1) || undefined : context;
  62. }
  63. function isPatchBoundary(line: string, marker: string): boolean {
  64. return line.trimEnd() === marker;
  65. }
  66. function parseChunks(lines: string[], index: number, mode: ParseMode) {
  67. const chunks: PatchChunk[] = [];
  68. let at = index;
  69. while (at < lines.length && !lines[at].startsWith('***')) {
  70. if (!lines[at].startsWith('@@')) {
  71. if (mode === 'strict') {
  72. unexpectedPatchLine('in update body', lines[at]);
  73. }
  74. at += 1;
  75. continue;
  76. }
  77. const context = parseChangeContext(lines[at]);
  78. at += 1;
  79. const old_lines: string[] = [];
  80. const new_lines: string[] = [];
  81. let eof = false;
  82. while (
  83. at < lines.length &&
  84. !lines[at].startsWith('@@') &&
  85. (!lines[at].startsWith('***') || lines[at] === '*** End of File')
  86. ) {
  87. const line = lines[at];
  88. if (line === '*** End of File') {
  89. eof = true;
  90. at += 1;
  91. break;
  92. }
  93. if (line.startsWith(' ')) {
  94. old_lines.push(line.slice(1));
  95. new_lines.push(line.slice(1));
  96. at += 1;
  97. continue;
  98. }
  99. if (line.startsWith('-')) {
  100. old_lines.push(line.slice(1));
  101. at += 1;
  102. continue;
  103. }
  104. if (line.startsWith('+')) {
  105. new_lines.push(line.slice(1));
  106. at += 1;
  107. continue;
  108. }
  109. if (mode === 'strict') {
  110. unexpectedPatchLine('in patch chunk', line);
  111. }
  112. at += 1;
  113. }
  114. chunks.push({
  115. old_lines,
  116. new_lines,
  117. change_context: context,
  118. is_end_of_file: eof || undefined,
  119. });
  120. }
  121. return { chunks, next: at };
  122. }
  123. function parseAdd(lines: string[], index: number, mode: ParseMode) {
  124. const contents: string[] = [];
  125. let at = index;
  126. while (at < lines.length && !lines[at].startsWith('***')) {
  127. if (lines[at].startsWith('+')) {
  128. contents.push(lines[at].slice(1));
  129. at += 1;
  130. continue;
  131. }
  132. if (mode === 'strict') {
  133. unexpectedPatchLine('in Add File body', lines[at]);
  134. }
  135. at += 1;
  136. }
  137. return { content: contents.join('\n'), next: at };
  138. }
  139. function parsePatchInternal(patchText: string, mode: ParseMode): ParsedPatch {
  140. const clean = normalizePatchText(patchText);
  141. const lines = clean.split('\n');
  142. const begin = lines.findIndex((line) =>
  143. isPatchBoundary(line, '*** Begin Patch'),
  144. );
  145. const end = lines.findIndex(
  146. (line, index) => index > begin && isPatchBoundary(line, '*** End Patch'),
  147. );
  148. if (begin === -1 || end === -1 || begin >= end) {
  149. throw new Error('Invalid patch format: missing Begin/End markers');
  150. }
  151. if (mode === 'strict') {
  152. for (const line of lines.slice(0, begin)) {
  153. unexpectedPatchLine('before Begin Patch', line);
  154. }
  155. for (const line of lines.slice(end + 1)) {
  156. unexpectedPatchLine('after End Patch', line);
  157. }
  158. }
  159. const hunks: PatchHunk[] = [];
  160. let index = begin + 1;
  161. while (index < end) {
  162. const header = parseHeader(lines, index);
  163. if (!header) {
  164. if (mode === 'strict') {
  165. unexpectedPatchLine('between hunks', lines[index]);
  166. }
  167. index += 1;
  168. continue;
  169. }
  170. if (lines[index].startsWith('*** Add File:')) {
  171. const next = parseAdd(lines, header.next, mode);
  172. hunks.push({
  173. type: 'add',
  174. path: header.file,
  175. contents: next.content,
  176. });
  177. index = next.next;
  178. continue;
  179. }
  180. if (lines[index].startsWith('*** Delete File:')) {
  181. hunks.push({ type: 'delete', path: header.file });
  182. index = header.next;
  183. continue;
  184. }
  185. const next = parseChunks(lines, header.next, mode);
  186. if (mode === 'strict' && next.chunks.length === 0) {
  187. throw new Error(
  188. `Invalid patch format: Update File is missing @@ chunk body: ${header.file}`,
  189. );
  190. }
  191. hunks.push({
  192. type: 'update',
  193. path: header.file,
  194. move_path: header.move,
  195. chunks: next.chunks,
  196. });
  197. index = next.next;
  198. }
  199. return { hunks };
  200. }
  201. export function parsePatch(patchText: string): ParsedPatch {
  202. return parsePatchInternal(patchText, 'permissive');
  203. }
  204. export function parsePatchStrict(patchText: string): ParsedPatch {
  205. return parsePatchInternal(patchText, 'strict');
  206. }
  207. function diffMatrix(old_lines: string[], new_lines: string[]): number[][] {
  208. const dp = Array.from({ length: old_lines.length + 1 }, () =>
  209. Array<number>(new_lines.length + 1).fill(0),
  210. );
  211. for (let oldIndex = 1; oldIndex <= old_lines.length; oldIndex += 1) {
  212. for (let newIndex = 1; newIndex <= new_lines.length; newIndex += 1) {
  213. dp[oldIndex][newIndex] =
  214. old_lines[oldIndex - 1] === new_lines[newIndex - 1]
  215. ? dp[oldIndex - 1][newIndex - 1] + 1
  216. : Math.max(dp[oldIndex - 1][newIndex], dp[oldIndex][newIndex - 1]);
  217. }
  218. }
  219. return dp;
  220. }
  221. function renderChunk(chunk: PatchChunk): string[] {
  222. const lines = [chunk.change_context ? `@@ ${chunk.change_context}` : '@@'];
  223. const dp = diffMatrix(chunk.old_lines, chunk.new_lines);
  224. const body: string[] = [];
  225. let oldIndex = chunk.old_lines.length;
  226. let newIndex = chunk.new_lines.length;
  227. while (oldIndex > 0 && newIndex > 0) {
  228. if (chunk.old_lines[oldIndex - 1] === chunk.new_lines[newIndex - 1]) {
  229. body.push(` ${chunk.old_lines[oldIndex - 1]}`);
  230. oldIndex -= 1;
  231. newIndex -= 1;
  232. continue;
  233. }
  234. if (dp[oldIndex - 1][newIndex] >= dp[oldIndex][newIndex - 1]) {
  235. body.push(`-${chunk.old_lines[oldIndex - 1]}`);
  236. oldIndex -= 1;
  237. continue;
  238. }
  239. body.push(`+${chunk.new_lines[newIndex - 1]}`);
  240. newIndex -= 1;
  241. }
  242. while (oldIndex > 0) {
  243. body.push(`-${chunk.old_lines[oldIndex - 1]}`);
  244. oldIndex -= 1;
  245. }
  246. while (newIndex > 0) {
  247. body.push(`+${chunk.new_lines[newIndex - 1]}`);
  248. newIndex -= 1;
  249. }
  250. lines.push(...body.reverse());
  251. if (chunk.is_end_of_file) {
  252. lines.push('*** End of File');
  253. }
  254. return lines;
  255. }
  256. function renderAddContents(contents: string): string[] {
  257. if (contents.length === 0) {
  258. return [];
  259. }
  260. return contents.split('\n').map((line) => `+${line}`);
  261. }
  262. export function formatPatch(patch: ParsedPatch): string {
  263. const lines = ['*** Begin Patch'];
  264. for (const hunk of patch.hunks) {
  265. if (hunk.type === 'add') {
  266. lines.push(`*** Add File: ${hunk.path}`);
  267. lines.push(...renderAddContents(hunk.contents));
  268. continue;
  269. }
  270. if (hunk.type === 'delete') {
  271. lines.push(`*** Delete File: ${hunk.path}`);
  272. continue;
  273. }
  274. lines.push(`*** Update File: ${hunk.path}`);
  275. if (hunk.move_path) {
  276. lines.push(`*** Move to: ${hunk.move_path}`);
  277. }
  278. for (const chunk of hunk.chunks) {
  279. lines.push(...renderChunk(chunk));
  280. }
  281. }
  282. lines.push('*** End Patch');
  283. return lines.join('\n');
  284. }