decorated-pi 0.7.3 → 0.8.1

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.
@@ -11,7 +11,12 @@
11
11
 
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
- import * as fsPromises from "node:fs/promises";
14
+ import {
15
+ detectFileEncoding,
16
+ readFileDecoded,
17
+ writeFileEncoded,
18
+ type FileEncoding,
19
+ } from "./encoding.js";
15
20
 
16
21
  // ═══════════════════════════════════════════════════════════════════════════
17
22
  // Types
@@ -63,6 +68,14 @@ export interface ReplacementInfo {
63
68
  oldLines: string[];
64
69
  /** The new lines that replaced them */
65
70
  newLines: string[];
71
+ /** Verbatim normalized replacement text (for byte-faithful writeback). */
72
+ newStr?: string;
73
+ /** Offset in normalized content where the matched region starts (writeback). */
74
+ normStart?: number;
75
+ /** Offset one past the matched region in normalized content (writeback). */
76
+ normEnd?: number;
77
+ /** Anchor was provided but appeared multiple times; matcher fell back to a global old_str search. */
78
+ anchorNotUnique?: boolean;
66
79
  /** Optional anchor text (first line only, for hunk display) */
67
80
  anchor?: string;
68
81
  /** Anchor was provided but not found, and patch fell back to global old_str search */
@@ -156,13 +169,20 @@ function applyOverwrite(
156
169
  content: string,
157
170
  result: PatchResult,
158
171
  ): void {
159
- const oldContent = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
172
+ // Detect encoding from the existing file so we round-trip in the same
173
+ // bytes. New files default to UTF-8 (no BOM).
174
+ const enc: FileEncoding | null = fs.existsSync(absPath)
175
+ ? detectFileEncoding(absPath)
176
+ : null;
177
+ const oldContent = enc ? readFileDecoded(absPath, enc) : "";
160
178
 
161
179
  // Write to temp file in the same directory (same filesystem → mv is atomic)
162
180
  ensureParentDir(absPath);
163
181
  const dir = path.dirname(absPath);
164
182
  const tmpName = path.join(dir, `.pi-patch-${randomId()}.tmp`);
165
- fs.writeFileSync(tmpName, content, "utf8");
183
+ // Overwrite semantics: write exactly what the caller passed, in the
184
+ // detected encoding (UTF-8 for new files).
185
+ writeFileEncoded(tmpName, content, enc ?? { encoding: "utf-8", hasBOM: false, isUtf8: true });
166
186
  fs.renameSync(tmpName, absPath);
167
187
 
168
188
  if (oldContent) {
@@ -184,6 +204,7 @@ type LocateEditResult =
184
204
  matchIdx: number;
185
205
  displayAnchor?: string;
186
206
  anchorMissing: boolean;
207
+ anchorNotUnique: boolean;
187
208
  }
188
209
  | {
189
210
  found: false;
@@ -207,6 +228,7 @@ function locateEdit(
207
228
  let searchFrom = 0;
208
229
  let displayAnchor: string | undefined;
209
230
  let anchorMissing = false;
231
+ let anchorNotUnique = false;
210
232
  let anchorState: "ok" | "missing" | "not_unique" = "ok";
211
233
  let anchorMessage: string | undefined;
212
234
 
@@ -235,7 +257,11 @@ function locateEdit(
235
257
  // ── Global exact match fallback (when anchor was missing/unusable) ──
236
258
  if (matchIdx === -1 && anchorMessage) {
237
259
  displayAnchor = edit.anchor;
238
- anchorMissing = true;
260
+ // Distinguish the two degradation modes: a not-unique anchor still
261
+ // appeared in the file (just more than once), whereas a missing anchor
262
+ // did not appear at all. The diff label differs accordingly.
263
+ if (anchorState === "not_unique") anchorNotUnique = true;
264
+ else anchorMissing = true;
239
265
  matchIdx = content.indexOf(oldNorm, 0);
240
266
  if (matchIdx !== -1) {
241
267
  const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
@@ -270,7 +296,7 @@ function locateEdit(
270
296
  }
271
297
  }
272
298
 
273
- return { found: true, oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing };
299
+ return { found: true, oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing, anchorNotUnique };
274
300
  }
275
301
 
276
302
  async function applyEdits(
@@ -287,8 +313,8 @@ async function applyEdits(
287
313
  throw new ApplyError(`Cannot patch directory: ${displayPath}`);
288
314
  }
289
315
 
290
- const rawContent = fs.readFileSync(absPath, "utf8");
291
- const lineEnding = detectLineEnding(rawContent);
316
+ const enc = detectFileEncoding(absPath);
317
+ const rawContent = readFileDecoded(absPath, enc);
292
318
  const originalContent = normalizeLineEndings(rawContent);
293
319
 
294
320
  // Precompute line offsets for the original file (used throughout)
@@ -345,10 +371,12 @@ async function applyEdits(
345
371
  );
346
372
  }
347
373
 
348
- const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing } = located;
374
+ const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing, anchorNotUnique } = located;
349
375
 
350
376
  const oldStartLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset);
351
377
  const oldEndLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset + oldNorm.length - 1);
378
+ const normStart = matchIdx - cumulativeOffset;
379
+ const normEnd = normStart + oldNorm.length;
352
380
 
353
381
  content =
354
382
  content.substring(0, matchIdx) +
@@ -364,8 +392,12 @@ async function applyEdits(
364
392
  newEndLine: 0,
365
393
  oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
366
394
  newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
395
+ newStr: newNorm,
396
+ normStart,
397
+ normEnd,
367
398
  anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
368
399
  anchorMissing,
400
+ anchorNotUnique,
369
401
  });
370
402
  }
371
403
 
@@ -392,12 +424,18 @@ async function applyEdits(
392
424
  result.diff = fileDiff;
393
425
  }
394
426
 
395
- const finalContent = restoreLineEndings(content, lineEnding);
396
- if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
397
- result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
398
- }
427
+ const finalContent = spliceOntoRaw(
428
+ rawContent,
429
+ cleanReplacements
430
+ .map((r) => ({
431
+ normStart: r.normStart ?? 0,
432
+ normEnd: r.normEnd ?? 0,
433
+ newStr: r.newStr ?? r.newLines.join("\n"),
434
+ }))
435
+ .sort((a, b) => a.normStart - b.normStart),
436
+ );
399
437
 
400
- fs.writeFileSync(absPath, finalContent, "utf8");
438
+ writeFileEncoded(absPath, finalContent, enc);
401
439
  result.modified.push(displayPath);
402
440
  result.replacements.set(displayPath, cleanReplacements);
403
441
  return;
@@ -461,6 +499,7 @@ async function applyEdits(
461
499
  newLines: p.newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
462
500
  anchor: p.displayAnchor ? p.displayAnchor.split("\n")[0] : undefined,
463
501
  anchorMissing: p.anchorMissing,
502
+ anchorNotUnique: p.anchorNotUnique,
464
503
  });
465
504
 
466
505
  neededRanges.push({
@@ -495,14 +534,16 @@ async function applyEdits(
495
534
  result.diff = fileDiff;
496
535
  }
497
536
 
498
- // Restore line endings
499
- const finalContent = restoreLineEndings(content, lineEnding);
500
-
501
- if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
502
- result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
503
- }
537
+ const finalContent = spliceOntoRaw(
538
+ rawContent,
539
+ sorted.map((p) => ({
540
+ normStart: p.matchIdx,
541
+ normEnd: p.matchIdx + p.oldNorm.length,
542
+ newStr: p.newNorm,
543
+ })),
544
+ );
504
545
 
505
- fs.writeFileSync(absPath, finalContent, "utf8");
546
+ writeFileEncoded(absPath, finalContent, enc);
506
547
  result.modified.push(displayPath);
507
548
  result.replacements.set(displayPath, replacements);
508
549
  }
@@ -541,7 +582,8 @@ export async function computePatchPreview(
541
582
  return { error: "File not found" };
542
583
  }
543
584
 
544
- const rawContent = await fsPromises.readFile(absPath, "utf8");
585
+ const enc = detectFileEncoding(absPath);
586
+ const rawContent = readFileDecoded(absPath, enc);
545
587
  const lineOffsets = buildLineOffsets(rawContent);
546
588
  const totalLines = lineOffsets.length - 1;
547
589
  let content = normalizeLineEndings(rawContent);
@@ -556,60 +598,26 @@ export async function computePatchPreview(
556
598
 
557
599
  for (const edit of patch.edits) {
558
600
  if (!edit.old_str) continue;
559
- let oldNorm = normalizeLineEndings(edit.old_str);
560
- let newNorm = normalizeLineEndings(edit.new_str);
561
-
562
- let searchFrom = 0;
563
- let displayAnchor: string | undefined;
564
- let anchorMissing = false;
565
- let anchorNotFoundMessage: string | undefined;
566
- if (edit.anchor) {
567
- const anchorNorm = normalizeLineEndings(edit.anchor);
568
- const idx = content.indexOf(anchorNorm);
569
- if (idx === -1) {
570
- anchorNotFoundMessage = `Anchor not found: "${truncate(edit.anchor)}"`;
571
- } else {
572
- const secondAnchor = content.indexOf(anchorNorm, idx + 1);
573
- if (secondAnchor !== -1) {
574
- anchorNotFoundMessage = `Anchor is not unique: "${truncate(edit.anchor)}"`;
575
- } else {
576
- searchFrom = Math.max(0, idx - (oldNorm.length - 1));
577
- displayAnchor = edit.anchor;
578
- anchorMissing = false;
579
- }
580
- }
581
- }
582
601
 
583
- let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
584
- if (matchIdx === -1 && anchorNotFoundMessage) {
585
- displayAnchor = edit.anchor;
586
- anchorMissing = true;
587
- matchIdx = content.indexOf(oldNorm, 0);
588
- if (matchIdx !== -1) {
589
- const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
590
- if (secondGlobalMatch !== -1) {
591
- const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
592
- return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
593
- }
594
- }
602
+ // Reuse the shared locator so preview and apply can never drift
603
+ // apart on anchor / fuzzy / uniqueness semantics. The only
604
+ // difference is error reporting: apply throws, preview returns.
605
+ let located: LocateEditResult;
606
+ try {
607
+ located = locateEdit(edit, content, patch.path);
608
+ } catch (e) {
609
+ return { error: e instanceof Error ? e.message : String(e) };
595
610
  }
596
-
597
- if (matchIdx === -1) {
598
- const searchLine = 0;
599
- const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
600
- if (fuzzy) {
601
- oldNorm = fuzzy.matched;
602
- matchIdx = fuzzy.idx;
603
- newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
604
- } else if (anchorNotFoundMessage) {
605
- const diag = diagnoseOldStrMismatch(oldNorm, content);
606
- return { error: `${anchorNotFoundMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
607
- } else {
608
- const diag = diagnoseOldStrMismatch(oldNorm, content);
609
- return { error: `old_str not found: "${truncate(edit.old_str)}".\n${diag}` };
611
+ if (!located.found) {
612
+ const diag = diagnoseOldStrMismatch(located.oldNorm, content);
613
+ if (located.anchorState === "missing" || located.anchorState === "not_unique") {
614
+ return { error: `${located.anchorMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
610
615
  }
616
+ return { error: `old_str not found: "${truncate(edit.old_str)}".${edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : ""}\n${diag}` };
611
617
  }
612
618
 
619
+ const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing, anchorNotUnique } = located;
620
+
613
621
  const origMatchIdx = matchIdx - cumulativeOffset;
614
622
  const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
615
623
  const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
@@ -623,7 +631,7 @@ export async function computePatchPreview(
623
631
  startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
624
632
  endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
625
633
  });
626
- allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing });
634
+ allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing, anchorNotUnique });
627
635
  cumulativeOffset += newNorm.length - oldNorm.length;
628
636
  }
629
637
 
@@ -712,6 +720,7 @@ function buildReplacementChunks(
712
720
  interface ChunkAnchor {
713
721
  text: string;
714
722
  missing: boolean;
723
+ notUnique?: boolean;
715
724
  }
716
725
 
717
726
  function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
@@ -724,9 +733,13 @@ function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
724
733
  for (const text of texts) {
725
734
  const existing = byText.get(text);
726
735
  if (!existing) {
727
- byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
728
- } else if (!rep.anchorMissing) {
729
- existing.missing = false;
736
+ byText.set(text, { text, missing: Boolean(rep.anchorMissing), notUnique: Boolean(rep.anchorNotUnique) });
737
+ } else {
738
+ // A later rep with the same anchor text that did NOT degrade clears
739
+ // the stale flag — the anchor is usable in at least one rep, so
740
+ // don't keep a stale warning for the other.
741
+ if (!rep.anchorMissing) existing.missing = false;
742
+ if (!rep.anchorNotUnique) existing.notUnique = false;
730
743
  }
731
744
  }
732
745
  }
@@ -734,7 +747,9 @@ function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
734
747
  }
735
748
 
736
749
  function formatAnchorLabel(anchor: ChunkAnchor): string {
737
- return anchor.text + (anchor.missing ? " (missing)" : "");
750
+ if (anchor.notUnique) return anchor.text + " (not unique)";
751
+ if (anchor.missing) return anchor.text + " (missing)";
752
+ return anchor.text;
738
753
  }
739
754
 
740
755
  function formatChunkHeader(chunk: ReplacementChunk): string {
@@ -1053,16 +1068,68 @@ function ensureParentDir(absPath: string): void {
1053
1068
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1054
1069
  }
1055
1070
 
1056
- function detectLineEnding(content: string): string {
1057
- return content.includes("\r\n") ? "\r\n" : "\n";
1058
- }
1059
-
1060
1071
  function normalizeLineEndings(text: string): string {
1061
1072
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1062
1073
  }
1063
1074
 
1064
- function restoreLineEndings(text: string, ending: string): string {
1065
- return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
1075
+ /** A replacement expressed in coordinates of the normalized (\n-only) content,
1076
+ * paired with the exact new_str to drop in. Used by spliceOntoRaw to rebuild
1077
+ * the file byte-for-byte on the original rawContent. */
1078
+ interface RawSplice {
1079
+ /** Offset in the normalized content where the matched text starts. */
1080
+ normStart: number;
1081
+ /** Offset in the normalized content one past the matched text. */
1082
+ normEnd: number;
1083
+ /** Verbatim replacement text (already normalized to \n). */
1084
+ newStr: string;
1085
+ }
1086
+
1087
+ /** Map every index of the normalized content to its offset in rawContent.
1088
+ * The two strings differ only by `\r` bytes (CRLF→LF normalization removed
1089
+ * them), so we walk both in lockstep. O(n). */
1090
+ function buildNormToRawMap(raw: string, norm: string): Int32Array {
1091
+ const map = new Int32Array(norm.length + 1);
1092
+ let ri = 0;
1093
+ for (let ni = 0; ni <= norm.length; ni++) {
1094
+ // Skip any `\r` in raw that the normalization folded into `\n`.
1095
+ // norm[ni] corresponds to raw[ri]; when norm advances past a `\n` that
1096
+ // came from `\r\n`, raw must skip the `\r` first.
1097
+ if (ni < norm.length) {
1098
+ map[ni] = ri;
1099
+ const ch = norm.charCodeAt(ni);
1100
+ const rawCh = raw.charCodeAt(ri);
1101
+ if (ch === 10 /* \n */ && rawCh === 13 /* \r */) {
1102
+ // raw had \r\n; advance past \r then \n
1103
+ ri += 2;
1104
+ } else {
1105
+ ri += 1;
1106
+ }
1107
+ } else {
1108
+ map[ni] = raw.length;
1109
+ }
1110
+ }
1111
+ return map;
1112
+ }
1113
+
1114
+ /** Rebuild the file on top of the original rawContent: untouched regions keep
1115
+ * their original bytes (including CRLF / mixed endings), edited regions get
1116
+ * the verbatim newStr the caller supplied. Splices must be sorted by
1117
+ * normStart and non-overlapping. */
1118
+ function spliceOntoRaw(rawContent: string, splices: RawSplice[]): string {
1119
+ if (splices.length === 0) return rawContent;
1120
+ const norm = normalizeLineEndings(rawContent);
1121
+ const map = buildNormToRawMap(rawContent, norm);
1122
+ let out = "";
1123
+ let rawCursor = 0;
1124
+ for (const s of splices) {
1125
+ const rawStart = map[s.normStart] ?? 0;
1126
+ const rawEnd = map[s.normEnd] ?? rawContent.length;
1127
+ if (rawStart > rawCursor) out += rawContent.substring(rawCursor, rawStart);
1128
+ out += s.newStr;
1129
+ rawCursor = rawEnd;
1130
+ }
1131
+ if (rawCursor < rawContent.length) out += rawContent.substring(rawCursor);
1132
+ return out;
1066
1133
  }
1067
1134
 
1068
1135
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1203,8 +1270,12 @@ function collapseSequentialReplacements(
1203
1270
  newEndLine: merged.oldStartLine + next.newLines.length - 1,
1204
1271
  oldLines: merged.oldLines,
1205
1272
  newLines: next.newLines,
1273
+ newStr: next.newStr,
1274
+ normStart: merged.normStart,
1275
+ normEnd: merged.normEnd,
1206
1276
  anchor: undefined,
1207
1277
  anchorMissing: merged.anchorMissing || next.anchorMissing,
1278
+ anchorNotUnique: merged.anchorNotUnique || next.anchorNotUnique,
1208
1279
  };
1209
1280
  j++;
1210
1281
  }
@@ -1530,4 +1601,6 @@ export const __patchCoreTest = {
1530
1601
  truncate,
1531
1602
  collapseSequentialReplacements,
1532
1603
  generateReplacementDiff,
1604
+ spliceOntoRaw,
1605
+ buildNormToRawMap,
1533
1606
  };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Encoding detection and conversion for patch.
3
+ *
4
+ * - chardet analyses raw bytes (BOM + histogram heuristics) → encoding name.
5
+ * - iconv-lite decodes/encodes between Buffer and string.
6
+ *
7
+ * For UTF-8 (the overwhelmingly common case) we bypass iconv-lite and use
8
+ * Node's native string<->Buffer conversion — zero behaviour change vs the
9
+ * pre-encoding-aware tool, and no iconv runtime cost for ASCII/UTF-8 files.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import chardet, { type Match } from "chardet";
14
+ import * as iconv from "iconv-lite";
15
+
16
+ export interface FileEncoding {
17
+ /** iconv-lite-compatible encoding name (e.g. "utf-8", "gb18030", "utf-16le"). */
18
+ encoding: string;
19
+ /** True when the file starts with a BOM that should be preserved on write. */
20
+ hasBOM: boolean;
21
+ /** True when the file is UTF-8 (with or without BOM). Native path is used. */
22
+ isUtf8: boolean;
23
+ }
24
+
25
+ // chardet emits "UTF-8" for plain UTF-8. iconv-lite accepts any case.
26
+ const UTF8_ALIASES = new Set(["utf-8", "utf8", "ascii"]);
27
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
28
+ const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
29
+ const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
30
+
31
+ /** Returns true if `buf` is valid UTF-8 (every byte parses, no U+FFFD
32
+ * replacement). Pure-ASCII and any well-formed UTF-8 qualify. */
33
+ function isValidUtf8(buf: Buffer): boolean {
34
+ // Writing then reading back would corrupt invalid sequences into U+FFFD;
35
+ // detect that by comparing round-trip byte lengths. The cheap, correct
36
+ // check is Node's built-in: toString('utf8') replaces bad sequences with
37
+ // U+FFFD, so we scan the decoded string for it.
38
+ if (buf.length === 0) return true;
39
+ const s = buf.toString("utf8");
40
+ return !s.includes("\uFFFD");
41
+ }
42
+
43
+ function looksLikeUtf8Bom(buf: Buffer): boolean {
44
+ return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
45
+ }
46
+
47
+ function looksLikeUtf16LeBom(buf: Buffer): boolean {
48
+ return buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe;
49
+ }
50
+
51
+ function looksLikeUtf16BeBom(buf: Buffer): boolean {
52
+ return buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff;
53
+ }
54
+
55
+ /**
56
+ * Detect a file's encoding by reading its bytes.
57
+ * Falls back to UTF-8 if detection fails or the detected encoding is not
58
+ * supported by iconv-lite.
59
+ */
60
+ export function detectFileEncoding(filePath: string): FileEncoding {
61
+ const buf = fs.readFileSync(filePath);
62
+
63
+ // BOM takes precedence for the UTF-16/UTF-32 family (endianness matters).
64
+ if (looksLikeUtf8Bom(buf)) {
65
+ return { encoding: "utf-8", hasBOM: true, isUtf8: true };
66
+ }
67
+ if (looksLikeUtf16LeBom(buf)) {
68
+ return { encoding: "utf-16le", hasBOM: true, isUtf8: false };
69
+ }
70
+ if (looksLikeUtf16BeBom(buf)) {
71
+ return { encoding: "utf-16be", hasBOM: true, isUtf8: false };
72
+ }
73
+
74
+ // Any well-formed UTF-8 (including pure ASCII) is UTF-8. This short-circuits
75
+ // chardet's tendency to mis-classify short or ASCII-only buffers as exotic
76
+ // encodings (e.g. 2-byte "x\n" as utf-32le).
77
+ if (isValidUtf8(buf)) {
78
+ return { encoding: "utf-8", hasBOM: false, isUtf8: true };
79
+ }
80
+
81
+ // Not valid UTF-8 — trust chardet's heuristic for the legacy encoding.
82
+ // chardet mis-classifies short or mixed CJK samples (it often ties GBK,
83
+ // Big5, Shift_JIS, EUC-JP, EUC-KR at the same low confidence). Use the
84
+ // full ranked list and prefer Chinese encodings — GB18030 is a superset
85
+ // of GBK/GB2312 and the most common non-UTF-8 encoding for Chinese text,
86
+ // which is the primary use case for this tool.
87
+ const candidates = chardet.analyse(buf);
88
+ let encoding = pickLegacyEncoding(candidates);
89
+ let hasBOM = false;
90
+
91
+ // Verify iconv-lite actually ships a codec for this label; otherwise fall
92
+ // back to UTF-8 (the previous behaviour) instead of throwing mid-edit.
93
+ if (!iconv.encodingExists(encoding)) {
94
+ encoding = "utf-8";
95
+ hasBOM = false;
96
+ }
97
+
98
+ // Last-resort safety net: if the chosen encoding still produces U+FFFD
99
+ // replacement chars on decode, the file is not actually in that encoding
100
+ // and we would corrupt it on write-back. Fall back to ISO-8859-1 (Latin1),
101
+ // which is a lossless 1:1 byte<->code-point mapping for 0x00–0xFF and can
102
+ // never introduce U+FFFD — round-tripping is byte-identical.
103
+ if (!UTF8_ALIASES.has(encoding) && iconv.decode(buf, encoding).includes("\uFFFD")) {
104
+ encoding = "iso-8859-1";
105
+ hasBOM = false;
106
+ }
107
+
108
+ return {
109
+ encoding,
110
+ hasBOM,
111
+ isUtf8: UTF8_ALIASES.has(encoding),
112
+ };
113
+ }
114
+
115
+ /** Preference order for breaking chardet ties. Chinese first (GB18030 is a
116
+ * superset of GBK/GB2312), then other CJK, then everything else. */
117
+ const ENCODING_PRIORITY: string[] = [
118
+ "gb18030", "gbk", "gb2312", // Chinese (mainland)
119
+ "big5", // Chinese (traditional)
120
+ "shift_jis", "euc-jp", // Japanese
121
+ "euc-kr", "windows-949", // Korean
122
+ "windows-1252", "iso-8859-1", // Western (rarely reached: isValidUtf8 short-circuits)
123
+ ];
124
+
125
+ function pickLegacyEncoding(candidates: Match[]): string {
126
+ if (candidates.length === 0) return "iso-8859-1";
127
+ const top = candidates[0]!.confidence;
128
+ // Keep only candidates tied with the top confidence (±5 tolerance —
129
+ // chardet's scores are coarse).
130
+ const tied = candidates.filter((c) => Math.abs(c.confidence - top) <= 5);
131
+ for (const pref of ENCODING_PRIORITY) {
132
+ const hit = tied.find((c) => c.name.toLowerCase() === pref);
133
+ if (hit) return pref;
134
+ }
135
+ // We only reach here when isValidUtf8 already failed (there are high
136
+ // bytes), so "ascii" / "UTF-8" from chardet are wrong. ISO-8859-1 is the
137
+ // safe single-byte fallback — lossless for 0x00–0xFF, no U+FFFD.
138
+ return "iso-8859-1";
139
+ }
140
+
141
+ /** Read a file as a string, decoding via the detected encoding. */
142
+ export function readFileDecoded(filePath: string, enc: FileEncoding): string {
143
+ const buf = fs.readFileSync(filePath);
144
+ if (enc.isUtf8) {
145
+ // Native path: identical to the old fs.readFileSync(p, "utf8").
146
+ // For UTF-8 with BOM, slice off the BOM so it does not leak into content
147
+ // matching (it would prefix the first line and break old_str lookups).
148
+ const slice = enc.hasBOM ? buf.subarray(3) : buf;
149
+ return slice.toString("utf8");
150
+ }
151
+ return iconv.decode(buf, enc.encoding, { stripBOM: true });
152
+ }
153
+
154
+ /** Write a string back to a file, encoding via the detected encoding. */
155
+ export function writeFileEncoded(
156
+ filePath: string,
157
+ content: string,
158
+ enc: FileEncoding,
159
+ ): void {
160
+ if (enc.isUtf8) {
161
+ if (enc.hasBOM) {
162
+ const body = Buffer.from(content, "utf8");
163
+ const out = Buffer.concat([UTF8_BOM, body]);
164
+ fs.writeFileSync(filePath, out);
165
+ return;
166
+ }
167
+ fs.writeFileSync(filePath, content, "utf8");
168
+ return;
169
+ }
170
+ const buf = iconv.encode(content, enc.encoding, { addBOM: enc.hasBOM });
171
+ fs.writeFileSync(filePath, buf);
172
+ }
@@ -162,13 +162,17 @@ function createSingleLineComponent(text: string) {
162
162
  };
163
163
  }
164
164
 
165
- function formatPatchMetaLine(line: string, theme: any): string {
166
- const missingSuffix = " (missing)";
167
- if (line.endsWith(missingSuffix)) {
168
- return (
169
- theme.fg("accent", line.slice(0, -missingSuffix.length)) +
170
- theme.fg("warning", missingSuffix)
171
- );
165
+ export function formatPatchMetaLine(line: string, theme: any): string {
166
+ // Anchor status suffixes share the same warning color so a degraded
167
+ // anchor (missing or not-unique) stands out from the diff body.
168
+ const suffixes = [" (missing)", " (not unique)"];
169
+ for (const suffix of suffixes) {
170
+ if (line.endsWith(suffix)) {
171
+ return (
172
+ theme.fg("accent", line.slice(0, -suffix.length)) +
173
+ theme.fg("warning", suffix)
174
+ );
175
+ }
172
176
  }
173
177
  return theme.fg("accent", line);
174
178
  }