decorated-pi 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,842 @@
1
+ /**
2
+ * Patch — Exact string replacement for pi
3
+ *
4
+ * Replaces diff-based format with old_str/new_str matching.
5
+ * No fuzzy matching, no similarity — only exact string matching.
6
+ *
7
+ * Per-file operations:
8
+ * { path, edits: [{ old_str, new_str, anchor? }] } — targeted replacements
9
+ * { path, overwrite: true, new_str } — atomic full-file overwrite
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as fsPromises from "node:fs/promises";
15
+
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+ // Types
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+
20
+ export interface Edit {
21
+ /** Optional anchor to narrow search range (exact string, searched from file start) */
22
+ anchor?: string;
23
+ /** Exact text to find in the file */
24
+ old_str: string;
25
+ /** Replacement text */
26
+ new_str: string;
27
+ }
28
+
29
+ export interface FilePatch {
30
+ /** File path (relative to cwd or absolute) */
31
+ path: string;
32
+ /** Targeted edits to apply sequentially */
33
+ edits?: Edit[];
34
+ /** If true, replace the entire file content atomically */
35
+ overwrite?: boolean;
36
+ /** New file content when overwriting */
37
+ new_str?: string;
38
+ }
39
+
40
+ export interface PatchResult {
41
+ modified: string[];
42
+ created: string[];
43
+ warnings: string[];
44
+ /** Per-file replacement info for diff generation */
45
+ replacements: Map<string, ReplacementInfo[]>;
46
+ /** Original file lines per file, for diff context generation */
47
+ originalLines: Map<string, string[]>;
48
+ /** Pre-generated diff string (set by applyEdits to avoid re-reading files) */
49
+ diff: string;
50
+ }
51
+
52
+ /** Records a single old_str→new_str replacement within a file */
53
+ export interface ReplacementInfo {
54
+ /** 1-based line number where old_str starts in the original file */
55
+ oldStartLine: number;
56
+ /** 1-based line number where old_str ends in the original file */
57
+ oldEndLine: number;
58
+ /** 1-based line number where new_str starts in the result file */
59
+ newStartLine: number;
60
+ /** 1-based line number where new_str ends in the result file */
61
+ newEndLine: number;
62
+ /** The original lines that were replaced */
63
+ oldLines: string[];
64
+ /** The new lines that replaced them */
65
+ newLines: string[];
66
+ /** Optional anchor text (first line only, for hunk display) */
67
+ anchor?: string;
68
+ /** Anchor was provided but not found, and patch fell back to global old_str search */
69
+ anchorMissing?: boolean;
70
+ }
71
+
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+ // Errors
74
+ // ═══════════════════════════════════════════════════════════════════════════
75
+
76
+ export class ParseError extends Error {
77
+ constructor(message: string) { super(message); this.name = "ParseError"; }
78
+ }
79
+
80
+ export class ApplyError extends Error {
81
+ constructor(message: string) { super(message); this.name = "ApplyError"; }
82
+ }
83
+
84
+ // ═══════════════════════════════════════════════════════════════════════════
85
+ // Main API
86
+ // ═══════════════════════════════════════════════════════════════════════════
87
+
88
+ export async function applyPatch(patch: FilePatch, cwd: string): Promise<PatchResult> {
89
+ if (!patch.path?.trim()) throw new ParseError("File path cannot be empty.");
90
+
91
+ const result: PatchResult = {
92
+ modified: [],
93
+ created: [],
94
+ warnings: [],
95
+ replacements: new Map(),
96
+ originalLines: new Map(),
97
+ diff: "",
98
+ };
99
+
100
+ const absPath = resolveAbsPath(cwd, patch.path);
101
+
102
+ if (patch.overwrite) {
103
+ applyOverwrite(absPath, patch.path, patch.new_str ?? "", result);
104
+ } else if (patch.edits && patch.edits.length > 0) {
105
+ await applyEdits(absPath, patch.path, patch.edits, result);
106
+ } else {
107
+ throw new ParseError(
108
+ `File ${patch.path}: must provide either edits[] or overwrite:true with new_str.`
109
+ );
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ /** @deprecated Use applyPatch instead. Kept for backward compatibility with tests. */
116
+ export async function applyPatches(patches: FilePatch[], cwd: string): Promise<PatchResult> {
117
+ if (!Array.isArray(patches) || patches.length === 0) {
118
+ throw new ParseError("Patch is empty — no files specified.");
119
+ }
120
+
121
+ const result: PatchResult = {
122
+ modified: [],
123
+ created: [],
124
+ warnings: [],
125
+ replacements: new Map(),
126
+ originalLines: new Map(),
127
+ diff: "",
128
+ };
129
+
130
+ for (const p of patches) {
131
+ if (!p.path?.trim()) throw new ParseError("File path cannot be empty.");
132
+
133
+ const absPath = resolveAbsPath(cwd, p.path);
134
+
135
+ if (p.overwrite) {
136
+ applyOverwrite(absPath, p.path, p.new_str ?? "", result);
137
+ } else if (p.edits && p.edits.length > 0) {
138
+ await applyEdits(absPath, p.path, p.edits, result);
139
+ } else {
140
+ throw new ParseError(
141
+ `File ${p.path}: must provide either edits[] or overwrite:true with new_str.`
142
+ );
143
+ }
144
+ }
145
+
146
+ return result;
147
+ }
148
+
149
+ // ═══════════════════════════════════════════════════════════════════════════
150
+ // Overwrite (atomic mv)
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+
153
+ function applyOverwrite(
154
+ absPath: string,
155
+ displayPath: string,
156
+ content: string,
157
+ result: PatchResult,
158
+ ): void {
159
+ const oldContent = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
160
+
161
+ // Write to temp file in the same directory (same filesystem → mv is atomic)
162
+ ensureParentDir(absPath);
163
+ const dir = path.dirname(absPath);
164
+ const tmpName = path.join(dir, `.pi-patch-${randomId()}.tmp`);
165
+ fs.writeFileSync(tmpName, content, "utf8");
166
+ fs.renameSync(tmpName, absPath);
167
+
168
+ if (oldContent) {
169
+ result.modified.push(displayPath);
170
+ } else {
171
+ result.created.push(displayPath);
172
+ }
173
+ }
174
+
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+ // Edits (exact string replacement)
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+
179
+ async function applyEdits(
180
+ absPath: string,
181
+ displayPath: string,
182
+ edits: Edit[],
183
+ result: PatchResult,
184
+ ): Promise<void> {
185
+ if (!fs.existsSync(absPath)) {
186
+ throw new ApplyError(`File not found: ${displayPath}`);
187
+ }
188
+ const stat = fs.statSync(absPath);
189
+ if (stat.isDirectory()) {
190
+ throw new ApplyError(`Cannot patch directory: ${displayPath}`);
191
+ }
192
+
193
+ const rawContent = fs.readFileSync(absPath, "utf8");
194
+ const lineEnding = detectLineEnding(rawContent);
195
+ let content = normalizeLineEndings(rawContent);
196
+
197
+ // Precompute line offsets for O(log n) line number lookups
198
+ const lineOffsets = buildLineOffsets(rawContent);
199
+ const totalLines = lineOffsets.length - 1;
200
+
201
+ // Track cumulative offset for mapping current positions back to original
202
+ let cumulativeOffset = 0;
203
+ const replacements: ReplacementInfo[] = [];
204
+ const neededRanges: LineRange[] = [];
205
+
206
+ for (const edit of edits) {
207
+ if (!edit.old_str) {
208
+ throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
209
+ }
210
+
211
+ const oldNorm = normalizeLineEndings(edit.old_str);
212
+ const newNorm = normalizeLineEndings(edit.new_str);
213
+
214
+ // Determine search range
215
+ let searchFrom = 0;
216
+ let displayAnchor: string | undefined;
217
+ let anchorMissing = false;
218
+ let anchorNotFoundMessage: string | undefined;
219
+
220
+ if (edit.anchor) {
221
+ const anchorNorm = normalizeLineEndings(edit.anchor);
222
+
223
+ // Find anchor — must be unique when present
224
+ const anchorIdx = content.indexOf(anchorNorm);
225
+ if (anchorIdx === -1) {
226
+ anchorNotFoundMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
227
+ } else {
228
+ const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
229
+ if (secondAnchor !== -1) {
230
+ throw new ApplyError(
231
+ `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}" ` +
232
+ `found at multiple locations. Choose a more specific anchor.`
233
+ );
234
+ }
235
+ // Search from anchor start position onward (anchor narrows search range)
236
+ // old_str may start at anchor position (anchor is a prefix of old_str)
237
+ searchFrom = anchorIdx;
238
+ displayAnchor = edit.anchor;
239
+ anchorMissing = false;
240
+ }
241
+ }
242
+
243
+ // Find old_str in range — must be unique
244
+ let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
245
+ if (matchIdx === -1) {
246
+ if (anchorNotFoundMessage) {
247
+ displayAnchor = edit.anchor;
248
+ anchorMissing = true;
249
+ const globalMatchIdx = content.indexOf(oldNorm, 0);
250
+ if (globalMatchIdx === -1) {
251
+ throw new ApplyError(
252
+ `${anchorNotFoundMessage} old_str not found in ${displayPath}: "${truncate(edit.old_str)}". ` +
253
+ `The file may have changed — re-read it and try again.`
254
+ );
255
+ }
256
+ const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
257
+ if (secondGlobalMatch !== -1) {
258
+ throw new ApplyError(
259
+ `${anchorNotFoundMessage} old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
260
+ `Add more context to old_str or use a more specific anchor.`
261
+ );
262
+ }
263
+ matchIdx = globalMatchIdx;
264
+ } else {
265
+ throw new ApplyError(
266
+ `old_str not found in ${displayPath}` +
267
+ (edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
268
+ `: "${truncate(edit.old_str)}". ` +
269
+ `The file may have changed — re-read it and try again.`
270
+ );
271
+ }
272
+ }
273
+
274
+ // Check uniqueness in anchor-narrowed / plain search path only when anchor was used normally
275
+ if (!anchorNotFoundMessage) {
276
+ const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
277
+ if (secondMatch !== -1) {
278
+ throw new ApplyError(
279
+ `old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
280
+ `Add more context to old_str or use an anchor to narrow the search.`
281
+ );
282
+ }
283
+ }
284
+
285
+ // Compute line numbers in the original file for diff generation (O(log n) via binary search)
286
+ // matchIdx is in the modified content; subtract cumulative offset to map back to original
287
+ const origMatchIdx = matchIdx - cumulativeOffset;
288
+ const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
289
+ const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
290
+
291
+ // Apply replacement
292
+ content =
293
+ content.substring(0, matchIdx) +
294
+ newNorm +
295
+ content.substring(matchIdx + oldNorm.length);
296
+
297
+ // Track the offset shift for subsequent edits
298
+ cumulativeOffset += newNorm.length - oldNorm.length;
299
+
300
+ // Compute new_str line numbers in the result
301
+ const newStartLine = charOffsetToLine(content, matchIdx);
302
+ const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
303
+
304
+ // Record replacement info
305
+ replacements.push({
306
+ oldStartLine,
307
+ oldEndLine,
308
+ newStartLine,
309
+ newEndLine,
310
+ oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
311
+ newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
312
+ anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
313
+ anchorMissing,
314
+ });
315
+
316
+ // Collect context range for this edit
317
+ neededRanges.push({
318
+ startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
319
+ endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
320
+ });
321
+ }
322
+
323
+ // Generate diff using only needed context lines (no full-file split)
324
+ const mergedRanges = mergeRanges(neededRanges);
325
+ const neededLines: Map<number, string> = new Map();
326
+ for (const range of mergedRanges) {
327
+ const lines = extractLineRange(content, lineOffsets, range.startLine, range.endLine);
328
+ for (let i = 0; i < lines.length; i++) {
329
+ neededLines.set(range.startLine + i, lines[i]);
330
+ }
331
+ }
332
+
333
+ // Build diff for this file and append to result
334
+ const fileDiff = generateLocalDiff(displayPath, replacements, neededLines, totalLines);
335
+ if (result.diff) {
336
+ result.diff += "\n" + fileDiff;
337
+ } else {
338
+ result.diff = fileDiff;
339
+ }
340
+
341
+ // Restore line endings
342
+ const finalContent = restoreLineEndings(content, lineEnding);
343
+
344
+ // Warn if line endings were normalized (CRLF → LF)
345
+ if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
346
+ result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
347
+ }
348
+
349
+ fs.writeFileSync(absPath, finalContent, "utf8");
350
+ result.modified.push(displayPath);
351
+ result.replacements.set(displayPath, replacements);
352
+ }
353
+
354
+ // ═══════════════════════════════════════════════════════════════════════════
355
+ // Diff generation (for TUI preview and result display)
356
+ // ═══════════════════════════════════════════════════════════════════════════
357
+
358
+ /**
359
+ * Patch preview without writing to disk.
360
+ * Returns unified diff for edits, or truncated content for overwrites.
361
+ */
362
+ export interface PatchPreview {
363
+ diff?: string;
364
+ error?: string;
365
+ /** Truncated new content preview for overwrite mode */
366
+ preview?: string;
367
+ isOverwrite?: boolean;
368
+ }
369
+
370
+ export async function computePatchPreview(
371
+ patch: FilePatch,
372
+ cwd: string,
373
+ ): Promise<PatchPreview> {
374
+ try {
375
+ if (!patch.path?.trim()) {
376
+ return { error: "File path cannot be empty." };
377
+ }
378
+
379
+ const absPath = resolveAbsPath(cwd, patch.path);
380
+
381
+ if (patch.overwrite) {
382
+ return { preview: patch.new_str ?? "", isOverwrite: true };
383
+ } else if (patch.edits && patch.edits.length > 0) {
384
+ if (!fs.existsSync(absPath)) {
385
+ return { error: "File not found" };
386
+ }
387
+
388
+ const rawContent = await fsPromises.readFile(absPath, "utf8");
389
+ const lineOffsets = buildLineOffsets(rawContent);
390
+ const totalLines = lineOffsets.length - 1;
391
+ let content = normalizeLineEndings(rawContent);
392
+ const allReplacements: ReplacementInfo[] = [];
393
+ const neededRanges: LineRange[] = [];
394
+ let cumulativeOffset = 0;
395
+
396
+ for (const edit of patch.edits) {
397
+ if (!edit.old_str) continue;
398
+ const oldNorm = normalizeLineEndings(edit.old_str);
399
+ const newNorm = normalizeLineEndings(edit.new_str);
400
+
401
+ let searchFrom = 0;
402
+ let displayAnchor: string | undefined;
403
+ let anchorMissing = false;
404
+ let anchorNotFoundMessage: string | undefined;
405
+ if (edit.anchor) {
406
+ const anchorNorm = normalizeLineEndings(edit.anchor);
407
+ const idx = content.indexOf(anchorNorm);
408
+ if (idx === -1) {
409
+ anchorNotFoundMessage = `Anchor not found: "${truncate(edit.anchor)}"`;
410
+ } else {
411
+ const secondAnchor = content.indexOf(anchorNorm, idx + 1);
412
+ if (secondAnchor !== -1) {
413
+ return { error: `Anchor is not unique: "${truncate(edit.anchor)}"` };
414
+ }
415
+ searchFrom = idx;
416
+ displayAnchor = edit.anchor;
417
+ anchorMissing = false;
418
+ }
419
+ }
420
+
421
+ let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
422
+ if (matchIdx === -1) {
423
+ if (anchorNotFoundMessage) {
424
+ displayAnchor = edit.anchor;
425
+ anchorMissing = true;
426
+ const globalMatchIdx = content.indexOf(oldNorm, 0);
427
+ if (globalMatchIdx === -1) {
428
+ return { error: `${anchorNotFoundMessage}; old_str not found: "${truncate(edit.old_str)}"` };
429
+ }
430
+ const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
431
+ if (secondGlobalMatch !== -1) {
432
+ return { error: `${anchorNotFoundMessage}; old_str is not unique: "${truncate(edit.old_str)}"` };
433
+ }
434
+ matchIdx = globalMatchIdx;
435
+ } else {
436
+ return { error: `old_str not found: "${truncate(edit.old_str)}"` };
437
+ }
438
+ }
439
+
440
+ const origMatchIdx = matchIdx - cumulativeOffset;
441
+ const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
442
+ const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
443
+ const oldLines = oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
444
+ const newLines = newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
445
+ content = content.substring(0, matchIdx) + newNorm + content.substring(matchIdx + oldNorm.length);
446
+ const newStartLine = charOffsetToLine(content, matchIdx);
447
+ const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
448
+ // Record needed context range around this edit
449
+ neededRanges.push({
450
+ startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
451
+ endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
452
+ });
453
+ allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing });
454
+ cumulativeOffset += newNorm.length - oldNorm.length;
455
+ }
456
+
457
+ // Merge needed ranges and extract only those lines
458
+ const mergedRanges = mergeRanges(neededRanges);
459
+ const neededLines: Map<number, string> = new Map();
460
+ for (const range of mergedRanges) {
461
+ const lines = extractLineRange(content, lineOffsets, range.startLine, range.endLine);
462
+ for (let i = 0; i < lines.length; i++) {
463
+ neededLines.set(range.startLine + i, lines[i]);
464
+ }
465
+ }
466
+
467
+ const diff = generateLocalDiff(patch.path, allReplacements, neededLines, totalLines);
468
+ return { diff };
469
+ } else {
470
+ return { error: "Must provide edits[] or overwrite:true" };
471
+ }
472
+ } catch (err) {
473
+ return { error: err instanceof Error ? err.message : String(err) };
474
+ }
475
+ }
476
+
477
+ /** @deprecated Use computePatchPreview(single) instead. Kept for backward compatibility. */
478
+ export async function computePatchPreviewMulti(
479
+ patches: FilePatch[],
480
+ cwd: string,
481
+ ): Promise<Map<string, PatchPreview>> {
482
+ const results = new Map<string, PatchPreview>();
483
+ for (const p of patches) {
484
+ const preview = await computePatchPreview(p, cwd);
485
+ results.set(p.path || "_parse", preview);
486
+ }
487
+ return results;
488
+ }
489
+
490
+
491
+
492
+ export function generatePatchDiff(result: PatchResult): string {
493
+ // If applyEdits pre-generated the diff, use it directly (avoids re-reading files)
494
+ if (result.diff) {
495
+ return result.diff;
496
+ }
497
+
498
+ // Fallback: reconstruct diff from stored originalLines (legacy path)
499
+ const parts: string[] = [];
500
+ for (const [filePath, reps] of result.replacements) {
501
+ const origLines = result.originalLines.get(filePath) ?? [];
502
+ parts.push(generateReplacementDiff(filePath, reps, origLines));
503
+ }
504
+ return parts.join("\n");
505
+ }
506
+
507
+ interface ReplacementChunk {
508
+ startLine: number;
509
+ endLine: number;
510
+ reps: ReplacementInfo[];
511
+ }
512
+
513
+ function buildReplacementChunks(
514
+ reps: ReplacementInfo[],
515
+ totalLines: number,
516
+ contextLines: number,
517
+ ): ReplacementChunk[] {
518
+ const sorted = [...reps].sort((a, b) => a.oldStartLine - b.oldStartLine);
519
+ const chunks: ReplacementChunk[] = [];
520
+
521
+ for (const rep of sorted) {
522
+ const startLine = Math.max(1, rep.oldStartLine - contextLines);
523
+ const endLine = Math.min(totalLines, rep.oldEndLine + contextLines);
524
+ const current = chunks[chunks.length - 1];
525
+
526
+ if (current && startLine <= current.endLine + 1) {
527
+ current.endLine = Math.max(current.endLine, endLine);
528
+ current.reps.push(rep);
529
+ } else {
530
+ chunks.push({ startLine, endLine, reps: [rep] });
531
+ }
532
+ }
533
+
534
+ return chunks;
535
+ }
536
+
537
+ interface ChunkAnchor {
538
+ text: string;
539
+ missing: boolean;
540
+ }
541
+
542
+ function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
543
+ const byText = new Map<string, ChunkAnchor>();
544
+ for (const rep of chunk.reps) {
545
+ const text = rep.anchor?.trim();
546
+ if (!text) continue;
547
+ const existing = byText.get(text);
548
+ if (!existing) {
549
+ byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
550
+ } else if (!rep.anchorMissing) {
551
+ // If any replacement successfully used this anchor, do not mark it missing.
552
+ existing.missing = false;
553
+ }
554
+ }
555
+ return [...byText.values()];
556
+ }
557
+
558
+ function formatAnchorLabel(anchor: ChunkAnchor): string {
559
+ return anchor.text + (anchor.missing ? " (missing)" : "");
560
+ }
561
+
562
+ function formatChunkHeader(chunk: ReplacementChunk): string {
563
+ const range = chunk.startLine === chunk.endLine
564
+ ? String(chunk.startLine)
565
+ : `${chunk.startLine}-${chunk.endLine}`;
566
+
567
+ const anchors = getChunkAnchors(chunk);
568
+ if (anchors.length === 0) {
569
+ return `@@ lines ${range} @@`;
570
+ }
571
+
572
+ if (anchors.length === 1) {
573
+ return `@@ lines ${range} @@ anchor: ${formatAnchorLabel(anchors[0]!)}`;
574
+ }
575
+
576
+ return `@@ lines ${range} @@`;
577
+ }
578
+
579
+ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
580
+ const anchors = getChunkAnchors(chunk);
581
+ if (anchors.length <= 1) return [];
582
+
583
+ const shown = anchors.slice(0, 2);
584
+ const remaining = anchors.length - shown.length;
585
+ const lines = ["anchors:", ...shown.map((anchor) => ` - ${formatAnchorLabel(anchor)}`)];
586
+ if (remaining > 0) {
587
+ lines.push(` - +${remaining} more`);
588
+ }
589
+ return lines;
590
+ }
591
+
592
+ /**
593
+ * Generate diff as visual chunks merged by overlapping/adjacent context windows.
594
+ * This keeps spacing stable when multiple nearby edits would otherwise create
595
+ * repeated context and oversized gaps between chunks.
596
+ */
597
+ function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], originalLines: string[]): string {
598
+ const parts: string[] = [];
599
+ parts.push(`--- ${filePath}`);
600
+ parts.push(`+++ ${filePath}`);
601
+
602
+ if (reps.length === 0) {
603
+ parts.push("");
604
+ return parts.join("\n");
605
+ }
606
+
607
+ const maxLineNum = Math.max(originalLines.length, ...reps.map(r => r.oldEndLine));
608
+ const numWidth = String(maxLineNum).length;
609
+ const CONTEXT = 3;
610
+ const chunks = buildReplacementChunks(reps, originalLines.length, CONTEXT);
611
+
612
+ for (let c = 0; c < chunks.length; c++) {
613
+ const chunk = chunks[c]!;
614
+ if (c > 0) parts.push("");
615
+ parts.push(formatChunkHeader(chunk));
616
+ parts.push(...formatChunkMetadataLines(chunk));
617
+
618
+ let cursor = chunk.startLine;
619
+
620
+ for (const rep of chunk.reps) {
621
+ // Context before this replacement (only once per original line)
622
+ for (let i = cursor; i < rep.oldStartLine; i++) {
623
+ const num = String(i).padStart(numWidth, " ");
624
+ parts.push(` ${num} ${originalLines[i - 1]}`);
625
+ }
626
+
627
+ // Removed lines (from original)
628
+ for (let i = 0; i < rep.oldLines.length; i++) {
629
+ const num = String(rep.oldStartLine + i).padStart(numWidth, " ");
630
+ parts.push(`-${num} ${rep.oldLines[i]}`);
631
+ }
632
+
633
+ // Added lines
634
+ for (let i = 0; i < rep.newLines.length; i++) {
635
+ const num = String(rep.oldStartLine + i).padStart(numWidth, " ");
636
+ parts.push(`+${num} ${rep.newLines[i]}`);
637
+ }
638
+
639
+ cursor = rep.oldEndLine + 1;
640
+ }
641
+
642
+ // Trailing context for the merged chunk
643
+ for (let i = cursor; i <= chunk.endLine; i++) {
644
+ const num = String(i).padStart(numWidth, " ");
645
+ parts.push(` ${num} ${originalLines[i - 1]}`);
646
+ }
647
+ }
648
+
649
+ if (parts[parts.length - 1] !== "") parts.push("");
650
+ return parts.join("\n");
651
+ }
652
+
653
+ // ═══════════════════════════════════════════════════════════════════════════
654
+ // Formatting
655
+ // ═══════════════════════════════════════════════════════════════════════════
656
+
657
+ export function formatPatchResult(result: PatchResult): string {
658
+ const lines: string[] = [];
659
+ for (const p of result.created) lines.push(`A ${p}`);
660
+ for (const p of result.modified) lines.push(`M ${p}`);
661
+ let output = lines.length > 0
662
+ ? "Updated the following files:\n" + lines.join("\n")
663
+ : "No files were modified.";
664
+ if (result.warnings.length > 0) {
665
+ output += "\n\n" + result.warnings.join("\n");
666
+ }
667
+ return output;
668
+ }
669
+
670
+ // ═══════════════════════════════════════════════════════════════════════════
671
+ // Helpers
672
+ // ═══════════════════════════════════════════════════════════════════════════
673
+
674
+ function resolveAbsPath(cwd: string, filePath: string): string {
675
+ return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
676
+ }
677
+
678
+ function ensureParentDir(absPath: string): void {
679
+ const dir = path.dirname(absPath);
680
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
681
+ }
682
+
683
+ function detectLineEnding(content: string): string {
684
+ return content.includes("\r\n") ? "\r\n" : "\n";
685
+ }
686
+
687
+ function normalizeLineEndings(text: string): string {
688
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
689
+ }
690
+
691
+ function restoreLineEndings(text: string, ending: string): string {
692
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
693
+ }
694
+
695
+ // ═══════════════════════════════════════════════════════════════════════════
696
+ // Line range utilities (for partial file reading)
697
+ // ═══════════════════════════════════════════════════════════════════════════
698
+
699
+ const CONTEXT_LINES = 3;
700
+
701
+ interface LineRange {
702
+ startLine: number;
703
+ endLine: number;
704
+ }
705
+
706
+ /** Build line offset table: offsets[i] = character offset of line i+1 (1-based) */
707
+ function buildLineOffsets(content: string): number[] {
708
+ const offsets = [0];
709
+ for (let i = 0; i < content.length; i++) {
710
+ if (content[i] === "\n") offsets.push(i + 1);
711
+ }
712
+ return offsets;
713
+ }
714
+
715
+
716
+ /** Binary search: find 1-based line number containing charOffset */
717
+ function lineAtOffset(lineOffsets: number[], charOffset: number): number {
718
+ let lo = 0, hi = lineOffsets.length - 1;
719
+ while (lo < hi) {
720
+ const mid = (lo + hi + 1) >> 1;
721
+ if (lineOffsets[mid] <= charOffset) lo = mid;
722
+ else hi = mid - 1;
723
+ }
724
+ return lo + 1;
725
+ }
726
+
727
+ /** Binary search: find line start offset given 1-based line number */
728
+ function offsetAtLine(lineOffsets: number[], lineNum: number): number {
729
+ if (lineNum <= 1) return 0;
730
+ if (lineNum > lineOffsets.length) return lineOffsets[lineOffsets.length - 1];
731
+ return lineOffsets[lineNum - 1];
732
+ }
733
+
734
+ /** Extract a range of lines from content (1-based, inclusive) */
735
+ function extractLineRange(content: string, lineOffsets: number[], startLine: number, endLine: number): string[] {
736
+ const lines: string[] = [];
737
+ for (let i = startLine; i <= endLine; i++) {
738
+ const start = offsetAtLine(lineOffsets, i);
739
+ const end = offsetAtLine(lineOffsets, i + 1);
740
+ // Remove trailing \n from last line if present
741
+ const lineText = content.slice(start, end).replace(/\n$/, "");
742
+ lines.push(lineText);
743
+ }
744
+ return lines;
745
+ }
746
+
747
+
748
+ /** Merge overlapping/adjacent line ranges */
749
+ function mergeRanges(ranges: LineRange[]): LineRange[] {
750
+ if (ranges.length === 0) return [];
751
+ const sorted = [...ranges].sort((a, b) => a.startLine - b.startLine);
752
+ const merged: LineRange[] = [];
753
+ for (const r of sorted) {
754
+ const last = merged[merged.length - 1];
755
+ if (last && r.startLine <= last.endLine + 1) {
756
+ last.endLine = Math.max(last.endLine, r.endLine);
757
+ } else {
758
+ merged.push({ ...r });
759
+ }
760
+ }
761
+ return merged;
762
+ }
763
+
764
+ function randomId(): string {
765
+ return Math.random().toString(36).slice(2, 10);
766
+ }
767
+
768
+ /** Convert a character offset to a 1-based line number. */
769
+ function charOffsetToLine(content: string, offset: number): number {
770
+ let line = 1;
771
+ for (let i = 0; i < offset && i < content.length; i++) {
772
+ if (content[i] === "\n") line++;
773
+ }
774
+ return line;
775
+ }
776
+
777
+ /**
778
+ * Generate diff using only the needed lines (partial file context).
779
+ */
780
+ function generateLocalDiff(
781
+ filePath: string,
782
+ reps: ReplacementInfo[],
783
+ neededLines: Map<number, string>,
784
+ totalLines: number,
785
+ ): string {
786
+ if (reps.length === 0) return "";
787
+
788
+ const parts: string[] = [];
789
+ parts.push(`--- ${filePath}`);
790
+ parts.push(`+++ ${filePath}`);
791
+
792
+ // Calculate dynamic width based on max line number
793
+ const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
794
+ const numWidth = String(maxLineNum).length;
795
+
796
+ // Merge replacement chunks
797
+ const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
798
+ for (let c = 0; c < chunks.length; c++) {
799
+ if (c > 0) parts.push("");
800
+ const chunk = chunks[c]!;
801
+ parts.push(formatChunkHeader(chunk));
802
+ parts.push(...formatChunkMetadataLines(chunk));
803
+
804
+ // Output context + removed + added
805
+ let cursor = chunk.startLine;
806
+ for (const rep of chunk.reps) {
807
+ // Context before this replacement
808
+ for (let i = cursor; i < rep.oldStartLine; i++) {
809
+ const lineText = neededLines.get(i);
810
+ if (lineText !== undefined) {
811
+ parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
812
+ }
813
+ }
814
+ // Removed lines
815
+ for (let i = 0; i < rep.oldLines.length; i++) {
816
+ parts.push(`-${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.oldLines[i]}`);
817
+ }
818
+ // Added lines
819
+ for (let i = 0; i < rep.newLines.length; i++) {
820
+ parts.push(`+${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.newLines[i]}`);
821
+ }
822
+ cursor = rep.oldEndLine + 1;
823
+ }
824
+ // Trailing context
825
+ for (let i = cursor; i <= chunk.endLine; i++) {
826
+ const lineText = neededLines.get(i);
827
+ if (lineText !== undefined) {
828
+ parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
829
+ }
830
+ }
831
+ }
832
+
833
+ return parts.join("\n");
834
+ }
835
+
836
+ function truncate(s: string, maxLen = 60): string {
837
+ if (s.length <= maxLen) return s;
838
+ // Show first line only
839
+ const firstLine = s.split("\n")[0];
840
+ if (firstLine.length <= maxLen) return firstLine;
841
+ return firstLine.slice(0, maxLen - 3) + "...";
842
+ }