@oh-my-pi/pi-coding-agent 12.13.0 → 12.14.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.
- package/CHANGELOG.md +62 -0
- package/package.json +8 -7
- package/scripts/generate-docs-index.ts +56 -0
- package/src/config/prompt-templates.ts +2 -2
- package/src/config/settings-schema.ts +10 -1
- package/src/discovery/builtin.ts +14 -4
- package/src/extensibility/extensions/types.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +101 -0
- package/src/internal-urls/docs-protocol.ts +84 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +1 -1
- package/src/modes/controllers/event-controller.ts +20 -0
- package/src/patch/diff.ts +1 -1
- package/src/patch/hashline.ts +197 -328
- package/src/patch/index.ts +325 -102
- package/src/patch/shared.ts +23 -40
- package/src/prompts/system/system-prompt.md +13 -2
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/prompts/tools/hashline.md +192 -90
- package/src/prompts/tools/read.md +3 -1
- package/src/sdk.ts +17 -0
- package/src/session/agent-session.ts +1 -0
- package/src/tools/fetch.ts +4 -3
- package/src/tools/grep.ts +13 -3
- package/src/tools/read.ts +2 -2
- package/src/web/search/render.ts +2 -2
package/src/patch/hashline.ts
CHANGED
|
@@ -2,103 +2,27 @@
|
|
|
2
2
|
* Hashline edit mode — a line-addressable edit format using content hashes.
|
|
3
3
|
*
|
|
4
4
|
* Each line in a file is identified by its 1-indexed line number and a short
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* hexadecimal hash derived from the normalized line content (xxHash32, truncated to 2
|
|
6
|
+
* hex chars).
|
|
7
7
|
* The combined `LINE#ID` reference acts as both an address and a staleness check:
|
|
8
8
|
* if the file has changed since the caller last read it, hash mismatches are caught
|
|
9
9
|
* before any mutation occurs.
|
|
10
10
|
*
|
|
11
|
-
* Displayed format: `LINENUM#HASH
|
|
12
|
-
* Reference format: `"LINENUM#HASH"` (e.g. `"5#
|
|
11
|
+
* Displayed format: `LINENUM#HASH:CONTENT`
|
|
12
|
+
* Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { HashlineEdit } from "./index";
|
|
16
15
|
import type { HashMismatch } from "./types";
|
|
17
16
|
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
| {
|
|
21
|
-
| {
|
|
22
|
-
| {
|
|
23
|
-
| {
|
|
24
|
-
| {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if ("set" in edit) {
|
|
28
|
-
return {
|
|
29
|
-
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
|
|
30
|
-
dstLines: edit.set.body,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
if ("set_range" in edit) {
|
|
34
|
-
const r = edit.set_range as Record<string, unknown>;
|
|
35
|
-
const start = parseLineRef(r.beg as string);
|
|
36
|
-
if (!r.end) {
|
|
37
|
-
return {
|
|
38
|
-
spec: { kind: "single", ref: start },
|
|
39
|
-
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
const end = parseLineRef(r.end as string);
|
|
43
|
-
return {
|
|
44
|
-
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
|
|
45
|
-
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
if ("insert" in edit) {
|
|
49
|
-
const r = edit.insert as Record<string, unknown>;
|
|
50
|
-
const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
|
|
51
|
-
const hasAfterField = "after" in r;
|
|
52
|
-
const hasBeforeField = "before" in r;
|
|
53
|
-
const afterRef = r.after;
|
|
54
|
-
const beforeRef = r.before;
|
|
55
|
-
if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
|
|
56
|
-
throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
|
|
57
|
-
}
|
|
58
|
-
if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
|
|
59
|
-
throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
|
|
60
|
-
}
|
|
61
|
-
const hasAfter = hasAfterField && typeof afterRef === "string";
|
|
62
|
-
const hasBefore = hasBeforeField && typeof beforeRef === "string";
|
|
63
|
-
if (hasAfter && hasBefore) {
|
|
64
|
-
return {
|
|
65
|
-
spec: {
|
|
66
|
-
kind: "insertBetween",
|
|
67
|
-
after: parseLineRef(afterRef),
|
|
68
|
-
before: parseLineRef(beforeRef),
|
|
69
|
-
},
|
|
70
|
-
dstLines,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
if (hasAfter) {
|
|
74
|
-
return {
|
|
75
|
-
spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
|
|
76
|
-
dstLines,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
if (hasBefore) {
|
|
80
|
-
return {
|
|
81
|
-
spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
|
|
82
|
-
dstLines,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
return { spec: { kind: "insertAtEof" }, dstLines };
|
|
86
|
-
}
|
|
87
|
-
if ("replace" in edit) {
|
|
88
|
-
throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
|
|
89
|
-
}
|
|
90
|
-
throw new Error("Unknown hashline edit operation");
|
|
91
|
-
}
|
|
92
|
-
/** Split dst into lines; empty string means delete (no lines). */
|
|
93
|
-
function splitDstLines(dst: string): string[] {
|
|
94
|
-
return dst === "" ? [] : dst.split("\n");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Pattern matching hashline display format: `LINE#ID|CONTENT` */
|
|
98
|
-
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
|
|
99
|
-
|
|
100
|
-
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
|
|
101
|
-
const DIFF_PLUS_RE = /^\+(?!\+)/;
|
|
17
|
+
export type LineTag = { line: number; hash: string };
|
|
18
|
+
export type HashlineEdit =
|
|
19
|
+
| { op: "set"; tag: LineTag; content: string[] }
|
|
20
|
+
| { op: "replace"; first: LineTag; last: LineTag; content: string[] }
|
|
21
|
+
| { op: "append"; after?: LineTag; content: string[] }
|
|
22
|
+
| { op: "prepend"; before?: LineTag; content: string[] }
|
|
23
|
+
| { op: "insert"; after: LineTag; before: LineTag; content: string[] };
|
|
24
|
+
export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
|
|
25
|
+
export type EditSpec = HashlineEdit | ReplaceTextEdit;
|
|
102
26
|
|
|
103
27
|
/**
|
|
104
28
|
* Compare two strings ignoring all whitespace differences.
|
|
@@ -144,16 +68,6 @@ function restoreLeadingIndent(templateLine: string, line: string): string {
|
|
|
144
68
|
return templateIndent + line;
|
|
145
69
|
}
|
|
146
70
|
|
|
147
|
-
const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
|
|
148
|
-
|
|
149
|
-
function normalizeConfusableHyphens(s: string): string {
|
|
150
|
-
return s.replace(CONFUSABLE_HYPHENS_RE, "-");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function normalizeConfusableHyphensInLines(lines: string[]): string[] {
|
|
154
|
-
return lines.map(l => normalizeConfusableHyphens(l));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
71
|
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
|
|
158
72
|
if (oldLines.length !== newLines.length) return newLines;
|
|
159
73
|
let changed = false;
|
|
@@ -261,50 +175,19 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
|
|
|
261
175
|
return out;
|
|
262
176
|
}
|
|
263
177
|
|
|
264
|
-
|
|
265
|
-
* Strip hashline display prefixes and diff `+` markers from replacement lines.
|
|
266
|
-
*
|
|
267
|
-
* Models frequently copy the `LINE#ID ` prefix from read output into their
|
|
268
|
-
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
|
|
269
|
-
* output file. This strips them heuristically before application.
|
|
270
|
-
*/
|
|
271
|
-
function stripNewLinePrefixes(lines: string[]): string[] {
|
|
272
|
-
// Detect whether the *majority* of non-empty lines carry a prefix —
|
|
273
|
-
// if only one line out of many has a match it's likely real content.
|
|
274
|
-
let hashPrefixCount = 0;
|
|
275
|
-
let diffPlusCount = 0;
|
|
276
|
-
let nonEmpty = 0;
|
|
277
|
-
for (const l of lines) {
|
|
278
|
-
if (l.length === 0) continue;
|
|
279
|
-
nonEmpty++;
|
|
280
|
-
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
|
|
281
|
-
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
|
|
282
|
-
}
|
|
283
|
-
if (nonEmpty === 0) return lines;
|
|
284
|
-
|
|
285
|
-
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
|
|
286
|
-
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
|
|
287
|
-
|
|
288
|
-
if (!stripHash && !stripPlus) return lines;
|
|
289
|
-
|
|
290
|
-
return lines.map(l => {
|
|
291
|
-
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
|
|
292
|
-
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
|
|
293
|
-
return l;
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const HASH_LEN = 2;
|
|
298
|
-
const RADIX = 16;
|
|
299
|
-
const HASH_MOD = RADIX ** HASH_LEN;
|
|
178
|
+
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
|
|
300
179
|
|
|
301
|
-
const DICT = Array.from({ length:
|
|
180
|
+
const DICT = Array.from({ length: 256 }, (_, i) => {
|
|
181
|
+
const h = i >>> 4;
|
|
182
|
+
const l = i & 0x0f;
|
|
183
|
+
return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`;
|
|
184
|
+
});
|
|
302
185
|
|
|
303
186
|
/**
|
|
304
|
-
* Compute a short
|
|
187
|
+
* Compute a short hexadecimal hash of a single line.
|
|
305
188
|
*
|
|
306
|
-
* Uses
|
|
307
|
-
*
|
|
189
|
+
* Uses xxHash32 on a whitespace-normalized line, truncated to {@link HASH_LEN}
|
|
190
|
+
* hex characters. The `idx` parameter is accepted for compatibility with older
|
|
308
191
|
* call sites, but is not currently mixed into the hash.
|
|
309
192
|
* The line input should not include a trailing newline.
|
|
310
193
|
*/
|
|
@@ -314,13 +197,20 @@ export function computeLineHash(idx: number, line: string): string {
|
|
|
314
197
|
}
|
|
315
198
|
line = line.replace(/\s+/g, "");
|
|
316
199
|
void idx; // Might use line, but for now, let's not.
|
|
317
|
-
return DICT[Bun.hash.xxHash32(line)
|
|
200
|
+
return DICT[Bun.hash.xxHash32(line) & 0xff];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Formats a tag given the line number and content.
|
|
205
|
+
*/
|
|
206
|
+
export function formatLineTag(line: number, content: string): string {
|
|
207
|
+
return `${line}#${computeLineHash(line, content)}`;
|
|
318
208
|
}
|
|
319
209
|
|
|
320
210
|
/**
|
|
321
211
|
* Format file content with hashline prefixes for display.
|
|
322
212
|
*
|
|
323
|
-
* Each line becomes `LINENUM#HASH
|
|
213
|
+
* Each line becomes `LINENUM#HASH:CONTENT` where LINENUM is 1-indexed.
|
|
324
214
|
*
|
|
325
215
|
* @param content - Raw file content string
|
|
326
216
|
* @param startLine - First line number (1-indexed, defaults to 1)
|
|
@@ -329,7 +219,7 @@ export function computeLineHash(idx: number, line: string): string {
|
|
|
329
219
|
* @example
|
|
330
220
|
* ```
|
|
331
221
|
* formatHashLines("function hi() {\n return;\n}")
|
|
332
|
-
* // "1#HH
|
|
222
|
+
* // "1#HH:function hi() {\n2#HH: return;\n3#HH:}"
|
|
333
223
|
* ```
|
|
334
224
|
*/
|
|
335
225
|
export function formatHashLines(content: string, startLine = 1): string {
|
|
@@ -337,8 +227,7 @@ export function formatHashLines(content: string, startLine = 1): string {
|
|
|
337
227
|
return lines
|
|
338
228
|
.map((line, i) => {
|
|
339
229
|
const num = startLine + i;
|
|
340
|
-
|
|
341
|
-
return `${num}#${hash}|${line}`;
|
|
230
|
+
return `${formatLineTag(num, line)}:${line}`;
|
|
342
231
|
})
|
|
343
232
|
.join("\n");
|
|
344
233
|
}
|
|
@@ -410,7 +299,7 @@ export async function* streamHashLinesFromUtf8(
|
|
|
410
299
|
};
|
|
411
300
|
|
|
412
301
|
const pushLine = (line: string): string[] => {
|
|
413
|
-
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}
|
|
302
|
+
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
|
414
303
|
lineNum++;
|
|
415
304
|
|
|
416
305
|
const chunksToYield: string[] = [];
|
|
@@ -504,7 +393,7 @@ export async function* streamHashLinesFromLines(
|
|
|
504
393
|
|
|
505
394
|
const pushLine = (line: string): string[] => {
|
|
506
395
|
sawAnyLine = true;
|
|
507
|
-
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}
|
|
396
|
+
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
|
508
397
|
lineNum++;
|
|
509
398
|
|
|
510
399
|
const chunksToYield: string[] = [];
|
|
@@ -560,18 +449,14 @@ export async function* streamHashLinesFromLines(
|
|
|
560
449
|
*
|
|
561
450
|
* @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
|
|
562
451
|
*/
|
|
563
|
-
export function
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const normalized = cleaned.replace(/\s*#\s*/, "#");
|
|
572
|
-
const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
|
|
573
|
-
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
|
|
574
|
-
const match = strictMatch ?? prefixMatch;
|
|
452
|
+
export function parseTag(ref: string): { line: number; hash: string } {
|
|
453
|
+
// This regex captures:
|
|
454
|
+
// 1. optional leading ">+" and whitespace
|
|
455
|
+
// 2. line number (1+ digits)
|
|
456
|
+
// 3. "#" with optional surrounding spaces
|
|
457
|
+
// 4. hash (2 hex chars)
|
|
458
|
+
// 5. optional trailing display suffix (":..." or " ...")
|
|
459
|
+
const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
|
|
575
460
|
if (!match) {
|
|
576
461
|
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
|
|
577
462
|
}
|
|
@@ -648,9 +533,9 @@ export class HashlineMismatchError extends Error {
|
|
|
648
533
|
const prefix = `${lineNum}#${hash}`;
|
|
649
534
|
|
|
650
535
|
if (mismatchSet.has(lineNum)) {
|
|
651
|
-
lines.push(`>>> ${prefix}
|
|
536
|
+
lines.push(`>>> ${prefix}:${content}`);
|
|
652
537
|
} else {
|
|
653
|
-
lines.push(` ${prefix}
|
|
538
|
+
lines.push(` ${prefix}:${content}`);
|
|
654
539
|
}
|
|
655
540
|
}
|
|
656
541
|
return lines.join("\n");
|
|
@@ -670,7 +555,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
|
|
670
555
|
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
671
556
|
}
|
|
672
557
|
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
|
673
|
-
if (actualHash !== ref.hash
|
|
558
|
+
if (actualHash !== ref.hash) {
|
|
674
559
|
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
|
|
675
560
|
}
|
|
676
561
|
}
|
|
@@ -683,7 +568,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
|
|
683
568
|
* Apply an array of hashline edits to file content.
|
|
684
569
|
*
|
|
685
570
|
* Each edit operation identifies target lines directly (`set`, `set_range`,
|
|
686
|
-
* `insert`). Line references are resolved via {@link
|
|
571
|
+
* `insert`). Line references are resolved via {@link parseTag}
|
|
687
572
|
* and hashes validated before any mutation.
|
|
688
573
|
*
|
|
689
574
|
* Edits are sorted bottom-up (highest effective line first) so earlier
|
|
@@ -711,36 +596,29 @@ export function applyHashlineEdits(
|
|
|
711
596
|
|
|
712
597
|
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
|
|
713
598
|
|
|
714
|
-
// Parse src specs and dst lines up front
|
|
715
|
-
const parsed = edits.map(edit => {
|
|
716
|
-
const parsedEdit = parseHashlineEdit(edit);
|
|
717
|
-
return {
|
|
718
|
-
spec: parsedEdit.spec,
|
|
719
|
-
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
|
|
720
|
-
};
|
|
721
|
-
});
|
|
722
|
-
|
|
723
599
|
function collectExplicitlyTouchedLines(): Set<number> {
|
|
724
600
|
const touched = new Set<number>();
|
|
725
|
-
for (const
|
|
726
|
-
switch (
|
|
727
|
-
case "
|
|
728
|
-
touched.add(
|
|
729
|
-
break;
|
|
730
|
-
case "range":
|
|
731
|
-
for (let ln = spec.start.line; ln <= spec.end.line; ln++) touched.add(ln);
|
|
601
|
+
for (const edit of edits) {
|
|
602
|
+
switch (edit.op) {
|
|
603
|
+
case "set":
|
|
604
|
+
touched.add(edit.tag.line);
|
|
732
605
|
break;
|
|
733
|
-
case "
|
|
734
|
-
|
|
606
|
+
case "replace":
|
|
607
|
+
for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
|
|
735
608
|
break;
|
|
736
|
-
case "
|
|
737
|
-
|
|
609
|
+
case "append":
|
|
610
|
+
if (edit.after) {
|
|
611
|
+
touched.add(edit.after.line);
|
|
612
|
+
}
|
|
738
613
|
break;
|
|
739
|
-
case "
|
|
740
|
-
|
|
741
|
-
|
|
614
|
+
case "prepend":
|
|
615
|
+
if (edit.before) {
|
|
616
|
+
touched.add(edit.before.line);
|
|
617
|
+
}
|
|
742
618
|
break;
|
|
743
|
-
case "
|
|
619
|
+
case "insert":
|
|
620
|
+
touched.add(edit.after.line);
|
|
621
|
+
touched.add(edit.before.line);
|
|
744
622
|
break;
|
|
745
623
|
}
|
|
746
624
|
}
|
|
@@ -755,59 +633,53 @@ export function applyHashlineEdits(
|
|
|
755
633
|
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
756
634
|
}
|
|
757
635
|
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
|
758
|
-
if (actualHash === ref.hash
|
|
636
|
+
if (actualHash === ref.hash) {
|
|
759
637
|
return true;
|
|
760
638
|
}
|
|
761
639
|
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
762
640
|
return false;
|
|
763
641
|
}
|
|
764
|
-
for (const
|
|
765
|
-
switch (
|
|
766
|
-
case "
|
|
767
|
-
if (!validateRef(
|
|
642
|
+
for (const edit of edits) {
|
|
643
|
+
switch (edit.op) {
|
|
644
|
+
case "set": {
|
|
645
|
+
if (!validateRef(edit.tag)) continue;
|
|
768
646
|
break;
|
|
769
647
|
}
|
|
770
|
-
case "
|
|
771
|
-
if (
|
|
648
|
+
case "append": {
|
|
649
|
+
if (edit.content.length === 0) {
|
|
772
650
|
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
|
|
773
651
|
}
|
|
774
|
-
if (!validateRef(
|
|
652
|
+
if (edit.after && !validateRef(edit.after)) continue;
|
|
775
653
|
break;
|
|
776
654
|
}
|
|
777
|
-
case "
|
|
778
|
-
if (
|
|
655
|
+
case "prepend": {
|
|
656
|
+
if (edit.content.length === 0) {
|
|
779
657
|
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
|
|
780
658
|
}
|
|
781
|
-
if (!validateRef(
|
|
659
|
+
if (edit.before && !validateRef(edit.before)) continue;
|
|
782
660
|
break;
|
|
783
661
|
}
|
|
784
|
-
case "
|
|
785
|
-
if (
|
|
662
|
+
case "insert": {
|
|
663
|
+
if (edit.content.length === 0) {
|
|
786
664
|
throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
|
|
787
665
|
}
|
|
788
|
-
if (
|
|
666
|
+
if (edit.before.line !== edit.after.line + 1) {
|
|
789
667
|
throw new Error(
|
|
790
|
-
`insert requires adjacent anchors (after ${
|
|
668
|
+
`insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`,
|
|
791
669
|
);
|
|
792
670
|
}
|
|
793
|
-
const afterValid = validateRef(
|
|
794
|
-
const beforeValid = validateRef(
|
|
671
|
+
const afterValid = validateRef(edit.after);
|
|
672
|
+
const beforeValid = validateRef(edit.before);
|
|
795
673
|
if (!afterValid || !beforeValid) continue;
|
|
796
674
|
break;
|
|
797
675
|
}
|
|
798
|
-
case "
|
|
799
|
-
if (
|
|
800
|
-
throw new Error(
|
|
801
|
-
}
|
|
802
|
-
break;
|
|
803
|
-
}
|
|
804
|
-
case "range": {
|
|
805
|
-
if (spec.start.line > spec.end.line) {
|
|
806
|
-
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
|
|
676
|
+
case "replace": {
|
|
677
|
+
if (edit.first.line > edit.last.line) {
|
|
678
|
+
throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`);
|
|
807
679
|
}
|
|
808
680
|
|
|
809
|
-
const startValid = validateRef(
|
|
810
|
-
const endValid = validateRef(
|
|
681
|
+
const startValid = validateRef(edit.first);
|
|
682
|
+
const endValid = validateRef(edit.last);
|
|
811
683
|
if (!startValid || !endValid) continue;
|
|
812
684
|
break;
|
|
813
685
|
}
|
|
@@ -819,30 +691,35 @@ export function applyHashlineEdits(
|
|
|
819
691
|
// Deduplicate identical edits targeting the same line(s)
|
|
820
692
|
const seenEditKeys = new Map<string, number>();
|
|
821
693
|
const dedupIndices = new Set<number>();
|
|
822
|
-
for (let i = 0; i <
|
|
823
|
-
const
|
|
694
|
+
for (let i = 0; i < edits.length; i++) {
|
|
695
|
+
const edit = edits[i];
|
|
824
696
|
let lineKey: string;
|
|
825
|
-
switch (
|
|
826
|
-
case "
|
|
827
|
-
lineKey = `s:${
|
|
828
|
-
break;
|
|
829
|
-
case "range":
|
|
830
|
-
lineKey = `r:${p.spec.start.line}:${p.spec.end.line}`;
|
|
697
|
+
switch (edit.op) {
|
|
698
|
+
case "set":
|
|
699
|
+
lineKey = `s:${edit.tag.line}`;
|
|
831
700
|
break;
|
|
832
|
-
case "
|
|
833
|
-
lineKey = `
|
|
701
|
+
case "replace":
|
|
702
|
+
lineKey = `r:${edit.first.line}:${edit.last.line}`;
|
|
834
703
|
break;
|
|
835
|
-
case "
|
|
836
|
-
|
|
704
|
+
case "append":
|
|
705
|
+
if (edit.after) {
|
|
706
|
+
lineKey = `i:${edit.after.line}`;
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
lineKey = "ieof";
|
|
837
710
|
break;
|
|
838
|
-
case "
|
|
839
|
-
|
|
711
|
+
case "prepend":
|
|
712
|
+
if (edit.before) {
|
|
713
|
+
lineKey = `ib:${edit.before.line}`;
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
lineKey = "ibef";
|
|
840
717
|
break;
|
|
841
|
-
case "
|
|
842
|
-
lineKey =
|
|
718
|
+
case "insert":
|
|
719
|
+
lineKey = `ix:${edit.after.line}:${edit.before.line}`;
|
|
843
720
|
break;
|
|
844
721
|
}
|
|
845
|
-
const dstKey = `${lineKey}
|
|
722
|
+
const dstKey = `${lineKey}:${edit.content.join("\n")}`;
|
|
846
723
|
if (seenEditKeys.has(dstKey)) {
|
|
847
724
|
dedupIndices.add(i);
|
|
848
725
|
} else {
|
|
@@ -850,51 +727,47 @@ export function applyHashlineEdits(
|
|
|
850
727
|
}
|
|
851
728
|
}
|
|
852
729
|
if (dedupIndices.size > 0) {
|
|
853
|
-
for (let i =
|
|
854
|
-
if (dedupIndices.has(i))
|
|
730
|
+
for (let i = edits.length - 1; i >= 0; i--) {
|
|
731
|
+
if (dedupIndices.has(i)) edits.splice(i, 1);
|
|
855
732
|
}
|
|
856
733
|
}
|
|
857
734
|
|
|
858
735
|
// Compute sort key (descending) — bottom-up application
|
|
859
|
-
const annotated =
|
|
736
|
+
const annotated = edits.map((edit, idx) => {
|
|
860
737
|
let sortLine: number;
|
|
861
738
|
let precedence: number;
|
|
862
|
-
switch (
|
|
863
|
-
case "
|
|
864
|
-
sortLine =
|
|
739
|
+
switch (edit.op) {
|
|
740
|
+
case "set":
|
|
741
|
+
sortLine = edit.tag.line;
|
|
865
742
|
precedence = 0;
|
|
866
743
|
break;
|
|
867
|
-
case "
|
|
868
|
-
sortLine =
|
|
744
|
+
case "replace":
|
|
745
|
+
sortLine = edit.last.line;
|
|
869
746
|
precedence = 0;
|
|
870
747
|
break;
|
|
871
|
-
case "
|
|
872
|
-
sortLine =
|
|
748
|
+
case "append":
|
|
749
|
+
sortLine = edit.after ? edit.after.line : fileLines.length + 1;
|
|
873
750
|
precedence = 1;
|
|
874
751
|
break;
|
|
875
|
-
case "
|
|
876
|
-
sortLine =
|
|
752
|
+
case "prepend":
|
|
753
|
+
sortLine = edit.before ? edit.before.line : 0;
|
|
877
754
|
precedence = 2;
|
|
878
755
|
break;
|
|
879
|
-
case "
|
|
880
|
-
sortLine =
|
|
756
|
+
case "insert":
|
|
757
|
+
sortLine = edit.before.line;
|
|
881
758
|
precedence = 3;
|
|
882
759
|
break;
|
|
883
|
-
case "insertAtEof":
|
|
884
|
-
sortLine = fileLines.length + 1;
|
|
885
|
-
precedence = 4;
|
|
886
|
-
break;
|
|
887
760
|
}
|
|
888
|
-
return {
|
|
761
|
+
return { edit, idx, sortLine, precedence };
|
|
889
762
|
});
|
|
890
763
|
|
|
891
764
|
annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
|
892
765
|
|
|
893
766
|
// Apply edits bottom-up
|
|
894
|
-
for (const {
|
|
895
|
-
switch (
|
|
896
|
-
case "
|
|
897
|
-
const merged = autocorrect ? maybeExpandSingleLineMerge(
|
|
767
|
+
for (const { edit, idx } of annotated) {
|
|
768
|
+
switch (edit.op) {
|
|
769
|
+
case "set": {
|
|
770
|
+
const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null;
|
|
898
771
|
if (merged) {
|
|
899
772
|
const origLines = originalFileLines.slice(
|
|
900
773
|
merged.startLine - 1,
|
|
@@ -902,16 +775,11 @@ export function applyHashlineEdits(
|
|
|
902
775
|
);
|
|
903
776
|
let nextLines = merged.newLines;
|
|
904
777
|
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
|
908
|
-
) {
|
|
909
|
-
nextLines = normalizeConfusableHyphensInLines(nextLines);
|
|
910
|
-
}
|
|
911
|
-
if (origLines.join("\n") === nextLines.join("\n")) {
|
|
778
|
+
|
|
779
|
+
if (origLines.every((line, i) => line === nextLines[i])) {
|
|
912
780
|
noopEdits.push({
|
|
913
781
|
editIndex: idx,
|
|
914
|
-
loc: `${
|
|
782
|
+
loc: `${edit.tag.line}#${edit.tag.hash}`,
|
|
915
783
|
currentContent: origLines.join("\n"),
|
|
916
784
|
});
|
|
917
785
|
break;
|
|
@@ -922,112 +790,113 @@ export function applyHashlineEdits(
|
|
|
922
790
|
}
|
|
923
791
|
|
|
924
792
|
const count = 1;
|
|
925
|
-
const origLines = originalFileLines.slice(
|
|
793
|
+
const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
|
|
926
794
|
let stripped = autocorrect
|
|
927
|
-
? stripRangeBoundaryEcho(originalFileLines,
|
|
928
|
-
:
|
|
795
|
+
? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
|
|
796
|
+
: edit.content;
|
|
929
797
|
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
|
930
|
-
|
|
931
|
-
if (
|
|
932
|
-
autocorrect &&
|
|
933
|
-
origLines.join("\n") === newLines.join("\n") &&
|
|
934
|
-
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
|
935
|
-
) {
|
|
936
|
-
newLines = normalizeConfusableHyphensInLines(newLines);
|
|
937
|
-
}
|
|
938
|
-
if (origLines.join("\n") === newLines.join("\n")) {
|
|
798
|
+
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
|
799
|
+
if (origLines.every((line, i) => line === newLines[i])) {
|
|
939
800
|
noopEdits.push({
|
|
940
801
|
editIndex: idx,
|
|
941
|
-
loc: `${
|
|
802
|
+
loc: `${edit.tag.line}#${edit.tag.hash}`,
|
|
942
803
|
currentContent: origLines.join("\n"),
|
|
943
804
|
});
|
|
944
805
|
break;
|
|
945
806
|
}
|
|
946
|
-
fileLines.splice(
|
|
947
|
-
trackFirstChanged(
|
|
807
|
+
fileLines.splice(edit.tag.line - 1, count, ...newLines);
|
|
808
|
+
trackFirstChanged(edit.tag.line);
|
|
948
809
|
break;
|
|
949
810
|
}
|
|
950
|
-
case "
|
|
951
|
-
const count =
|
|
952
|
-
const origLines = originalFileLines.slice(
|
|
811
|
+
case "replace": {
|
|
812
|
+
const count = edit.last.line - edit.first.line + 1;
|
|
813
|
+
const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count);
|
|
953
814
|
let stripped = autocorrect
|
|
954
|
-
? stripRangeBoundaryEcho(originalFileLines,
|
|
955
|
-
:
|
|
815
|
+
? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
|
|
816
|
+
: edit.content;
|
|
956
817
|
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
|
957
|
-
|
|
958
|
-
if (
|
|
959
|
-
autocorrect &&
|
|
960
|
-
origLines.join("\n") === newLines.join("\n") &&
|
|
961
|
-
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
|
962
|
-
) {
|
|
963
|
-
newLines = normalizeConfusableHyphensInLines(newLines);
|
|
964
|
-
}
|
|
965
|
-
if (origLines.join("\n") === newLines.join("\n")) {
|
|
818
|
+
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
|
819
|
+
if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
|
|
966
820
|
noopEdits.push({
|
|
967
821
|
editIndex: idx,
|
|
968
|
-
loc: `${
|
|
822
|
+
loc: `${edit.first.line}#${edit.first.hash}`,
|
|
969
823
|
currentContent: origLines.join("\n"),
|
|
970
824
|
});
|
|
971
825
|
break;
|
|
972
826
|
}
|
|
973
|
-
fileLines.splice(
|
|
974
|
-
trackFirstChanged(
|
|
827
|
+
fileLines.splice(edit.first.line - 1, count, ...newLines);
|
|
828
|
+
trackFirstChanged(edit.first.line);
|
|
975
829
|
break;
|
|
976
830
|
}
|
|
977
|
-
case "
|
|
978
|
-
const
|
|
979
|
-
|
|
831
|
+
case "append": {
|
|
832
|
+
const inserted = edit.after
|
|
833
|
+
? autocorrect
|
|
834
|
+
? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
|
|
835
|
+
: edit.content
|
|
836
|
+
: edit.content;
|
|
980
837
|
if (inserted.length === 0) {
|
|
981
838
|
noopEdits.push({
|
|
982
839
|
editIndex: idx,
|
|
983
|
-
loc: `${
|
|
984
|
-
currentContent: originalFileLines[
|
|
840
|
+
loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF",
|
|
841
|
+
currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "",
|
|
985
842
|
});
|
|
986
843
|
break;
|
|
987
844
|
}
|
|
988
|
-
|
|
989
|
-
|
|
845
|
+
if (edit.after) {
|
|
846
|
+
fileLines.splice(edit.after.line, 0, ...inserted);
|
|
847
|
+
trackFirstChanged(edit.after.line + 1);
|
|
848
|
+
} else {
|
|
849
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
850
|
+
fileLines.splice(0, 1, ...inserted);
|
|
851
|
+
trackFirstChanged(1);
|
|
852
|
+
} else {
|
|
853
|
+
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
854
|
+
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
990
857
|
break;
|
|
991
858
|
}
|
|
992
|
-
case "
|
|
993
|
-
const
|
|
994
|
-
|
|
859
|
+
case "prepend": {
|
|
860
|
+
const inserted = edit.before
|
|
861
|
+
? autocorrect
|
|
862
|
+
? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
|
|
863
|
+
: edit.content
|
|
864
|
+
: edit.content;
|
|
995
865
|
if (inserted.length === 0) {
|
|
996
866
|
noopEdits.push({
|
|
997
867
|
editIndex: idx,
|
|
998
|
-
loc: `${
|
|
999
|
-
currentContent: originalFileLines[
|
|
868
|
+
loc: edit.before ? `${edit.before.line}#${edit.before.hash}` : "BOF",
|
|
869
|
+
currentContent: edit.before ? originalFileLines[edit.before.line - 1] : "",
|
|
1000
870
|
});
|
|
1001
871
|
break;
|
|
1002
872
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
873
|
+
if (edit.before) {
|
|
874
|
+
fileLines.splice(edit.before.line - 1, 0, ...inserted);
|
|
875
|
+
trackFirstChanged(edit.before.line);
|
|
876
|
+
} else {
|
|
877
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
878
|
+
fileLines.splice(0, 1, ...inserted);
|
|
879
|
+
} else {
|
|
880
|
+
fileLines.splice(0, 0, ...inserted);
|
|
881
|
+
}
|
|
882
|
+
trackFirstChanged(1);
|
|
883
|
+
}
|
|
1005
884
|
break;
|
|
1006
885
|
}
|
|
1007
|
-
case "
|
|
1008
|
-
const afterLine = originalFileLines[
|
|
1009
|
-
const beforeLine = originalFileLines[
|
|
1010
|
-
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine,
|
|
886
|
+
case "insert": {
|
|
887
|
+
const afterLine = originalFileLines[edit.after.line - 1];
|
|
888
|
+
const beforeLine = originalFileLines[edit.before.line - 1];
|
|
889
|
+
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
|
|
1011
890
|
if (inserted.length === 0) {
|
|
1012
891
|
noopEdits.push({
|
|
1013
892
|
editIndex: idx,
|
|
1014
|
-
loc: `${
|
|
893
|
+
loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.before.hash}`,
|
|
1015
894
|
currentContent: `${afterLine}\n${beforeLine}`,
|
|
1016
895
|
});
|
|
1017
896
|
break;
|
|
1018
897
|
}
|
|
1019
|
-
fileLines.splice(
|
|
1020
|
-
trackFirstChanged(
|
|
1021
|
-
break;
|
|
1022
|
-
}
|
|
1023
|
-
case "insertAtEof": {
|
|
1024
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
1025
|
-
fileLines.splice(0, 1, ...dstLines);
|
|
1026
|
-
trackFirstChanged(1);
|
|
1027
|
-
break;
|
|
1028
|
-
}
|
|
1029
|
-
fileLines.splice(fileLines.length, 0, ...dstLines);
|
|
1030
|
-
trackFirstChanged(fileLines.length - dstLines.length + 1);
|
|
898
|
+
fileLines.splice(edit.before.line - 1, 0, ...inserted);
|
|
899
|
+
trackFirstChanged(edit.before.line);
|
|
1031
900
|
break;
|
|
1032
901
|
}
|
|
1033
902
|
}
|
|
@@ -1047,12 +916,12 @@ export function applyHashlineEdits(
|
|
|
1047
916
|
|
|
1048
917
|
function maybeExpandSingleLineMerge(
|
|
1049
918
|
line: number,
|
|
1050
|
-
|
|
919
|
+
content: string[],
|
|
1051
920
|
): { startLine: number; deleteCount: number; newLines: string[] } | null {
|
|
1052
|
-
if (
|
|
921
|
+
if (content.length !== 1) return null;
|
|
1053
922
|
if (line < 1 || line > fileLines.length) return null;
|
|
1054
923
|
|
|
1055
|
-
const newLine =
|
|
924
|
+
const newLine = content[0];
|
|
1056
925
|
const newCanon = stripAllWhitespace(newLine);
|
|
1057
926
|
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
|
|
1058
927
|
if (newCanon.length === 0) return null;
|