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.
- package/README.md +3 -3
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +11 -146
- package/hooks/image-vision.ts +127 -5
- package/hooks/mcp.ts +10 -2
- package/hooks/pi-tool-filter.ts +2 -6
- package/hooks/skeleton.ts +4 -1
- package/hooks/smart-at.ts +18 -23
- package/hooks/thinking-label-strip.ts +79 -0
- package/index.ts +207 -176
- package/package.json +3 -1
- package/skills/pi-docs/SKILL.md +3 -4
- package/tools/ask/index.ts +1 -1
- package/tools/patch/core.ts +154 -81
- package/tools/patch/encoding.ts +172 -0
- package/tools/patch/index.ts +11 -7
- package/ui/ask.ts +71 -31
package/tools/patch/core.ts
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
const
|
|
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 =
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
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
|
+
}
|
package/tools/patch/index.ts
CHANGED
|
@@ -162,13 +162,17 @@ function createSingleLineComponent(text: string) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
function formatPatchMetaLine(line: string, theme: any): string {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
}
|