@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.
@@ -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
- * base36 hash derived from the normalized line content (xxHash32, truncated to 4
6
- * base36 chars).
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|CONTENT`
12
- * Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
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 ParsedRefs =
19
- | { kind: "single"; ref: { line: number; hash: string } }
20
- | { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
21
- | { kind: "insertAfter"; after: { line: number; hash: string } }
22
- | { kind: "insertBefore"; before: { line: number; hash: string } }
23
- | { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
24
- | { kind: "insertAtEof" };
25
-
26
- function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
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: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0"));
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 base36 hash of a single line.
187
+ * Compute a short hexadecimal hash of a single line.
305
188
  *
306
- * Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN}
307
- * base36 characters. The `idx` parameter is accepted for compatibility with older
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) % HASH_MOD];
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|CONTENT` where LINENUM is 1-indexed.
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|function hi() {\n2#HH| return;\n3#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
- const hash = computeLineHash(num, line);
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)}|${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)}|${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 parseLineRef(ref: string): { line: number; hash: string } {
564
- // Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
565
- // Models often copy the full display format from read output.
566
- const cleaned = ref
567
- .replace(/\|.*$/, "")
568
- .replace(/ {2}.*$/, "")
569
- .replace(/^>+\s*/, "")
570
- .trim();
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}|${content}`);
536
+ lines.push(`>>> ${prefix}:${content}`);
652
537
  } else {
653
- lines.push(` ${prefix}|${content}`);
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.toLowerCase()) {
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 parseLineRef}
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 { spec } of parsed) {
726
- switch (spec.kind) {
727
- case "single":
728
- touched.add(spec.ref.line);
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 "insertAfter":
734
- touched.add(spec.after.line);
606
+ case "replace":
607
+ for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
735
608
  break;
736
- case "insertBefore":
737
- touched.add(spec.before.line);
609
+ case "append":
610
+ if (edit.after) {
611
+ touched.add(edit.after.line);
612
+ }
738
613
  break;
739
- case "insertBetween":
740
- touched.add(spec.after.line);
741
- touched.add(spec.before.line);
614
+ case "prepend":
615
+ if (edit.before) {
616
+ touched.add(edit.before.line);
617
+ }
742
618
  break;
743
- case "insertAtEof":
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.toLowerCase()) {
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 { spec, dstLines } of parsed) {
765
- switch (spec.kind) {
766
- case "single": {
767
- if (!validateRef(spec.ref)) continue;
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 "insertAfter": {
771
- if (dstLines.length === 0) {
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(spec.after)) continue;
652
+ if (edit.after && !validateRef(edit.after)) continue;
775
653
  break;
776
654
  }
777
- case "insertBefore": {
778
- if (dstLines.length === 0) {
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(spec.before)) continue;
659
+ if (edit.before && !validateRef(edit.before)) continue;
782
660
  break;
783
661
  }
784
- case "insertBetween": {
785
- if (dstLines.length === 0) {
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 (spec.before.line !== spec.after.line + 1) {
666
+ if (edit.before.line !== edit.after.line + 1) {
789
667
  throw new Error(
790
- `insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
668
+ `insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`,
791
669
  );
792
670
  }
793
- const afterValid = validateRef(spec.after);
794
- const beforeValid = validateRef(spec.before);
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 "insertAtEof": {
799
- if (dstLines.length === 0) {
800
- throw new Error("Insert-at-EOF edit requires non-empty dst");
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(spec.start);
810
- const endValid = validateRef(spec.end);
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 < parsed.length; i++) {
823
- const p = parsed[i];
694
+ for (let i = 0; i < edits.length; i++) {
695
+ const edit = edits[i];
824
696
  let lineKey: string;
825
- switch (p.spec.kind) {
826
- case "single":
827
- lineKey = `s:${p.spec.ref.line}`;
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 "insertAfter":
833
- lineKey = `i:${p.spec.after.line}`;
701
+ case "replace":
702
+ lineKey = `r:${edit.first.line}:${edit.last.line}`;
834
703
  break;
835
- case "insertBefore":
836
- lineKey = `ib:${p.spec.before.line}`;
704
+ case "append":
705
+ if (edit.after) {
706
+ lineKey = `i:${edit.after.line}`;
707
+ break;
708
+ }
709
+ lineKey = "ieof";
837
710
  break;
838
- case "insertBetween":
839
- lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
711
+ case "prepend":
712
+ if (edit.before) {
713
+ lineKey = `ib:${edit.before.line}`;
714
+ break;
715
+ }
716
+ lineKey = "ibef";
840
717
  break;
841
- case "insertAtEof":
842
- lineKey = "ieof";
718
+ case "insert":
719
+ lineKey = `ix:${edit.after.line}:${edit.before.line}`;
843
720
  break;
844
721
  }
845
- const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
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 = parsed.length - 1; i >= 0; i--) {
854
- if (dedupIndices.has(i)) parsed.splice(i, 1);
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 = parsed.map((p, idx) => {
736
+ const annotated = edits.map((edit, idx) => {
860
737
  let sortLine: number;
861
738
  let precedence: number;
862
- switch (p.spec.kind) {
863
- case "single":
864
- sortLine = p.spec.ref.line;
739
+ switch (edit.op) {
740
+ case "set":
741
+ sortLine = edit.tag.line;
865
742
  precedence = 0;
866
743
  break;
867
- case "range":
868
- sortLine = p.spec.end.line;
744
+ case "replace":
745
+ sortLine = edit.last.line;
869
746
  precedence = 0;
870
747
  break;
871
- case "insertAfter":
872
- sortLine = p.spec.after.line;
748
+ case "append":
749
+ sortLine = edit.after ? edit.after.line : fileLines.length + 1;
873
750
  precedence = 1;
874
751
  break;
875
- case "insertBefore":
876
- sortLine = p.spec.before.line;
752
+ case "prepend":
753
+ sortLine = edit.before ? edit.before.line : 0;
877
754
  precedence = 2;
878
755
  break;
879
- case "insertBetween":
880
- sortLine = p.spec.before.line;
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 { ...p, idx, sortLine, precedence };
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 { spec, dstLines, idx } of annotated) {
895
- switch (spec.kind) {
896
- case "single": {
897
- const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
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
- if (
906
- origLines.join("\n") === nextLines.join("\n") &&
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: `${spec.ref.line}#${spec.ref.hash}`,
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(spec.ref.line - 1, spec.ref.line);
793
+ const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
926
794
  let stripped = autocorrect
927
- ? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
928
- : dstLines;
795
+ ? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
796
+ : edit.content;
929
797
  stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
930
- let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
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: `${spec.ref.line}#${spec.ref.hash}`,
802
+ loc: `${edit.tag.line}#${edit.tag.hash}`,
942
803
  currentContent: origLines.join("\n"),
943
804
  });
944
805
  break;
945
806
  }
946
- fileLines.splice(spec.ref.line - 1, count, ...newLines);
947
- trackFirstChanged(spec.ref.line);
807
+ fileLines.splice(edit.tag.line - 1, count, ...newLines);
808
+ trackFirstChanged(edit.tag.line);
948
809
  break;
949
810
  }
950
- case "range": {
951
- const count = spec.end.line - spec.start.line + 1;
952
- const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
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, spec.start.line, spec.end.line, dstLines)
955
- : dstLines;
815
+ ? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
816
+ : edit.content;
956
817
  stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
957
- let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
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: `${spec.start.line}#${spec.start.hash}`,
822
+ loc: `${edit.first.line}#${edit.first.hash}`,
969
823
  currentContent: origLines.join("\n"),
970
824
  });
971
825
  break;
972
826
  }
973
- fileLines.splice(spec.start.line - 1, count, ...newLines);
974
- trackFirstChanged(spec.start.line);
827
+ fileLines.splice(edit.first.line - 1, count, ...newLines);
828
+ trackFirstChanged(edit.first.line);
975
829
  break;
976
830
  }
977
- case "insertAfter": {
978
- const anchorLine = originalFileLines[spec.after.line - 1];
979
- const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
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: `${spec.after.line}#${spec.after.hash}`,
984
- currentContent: originalFileLines[spec.after.line - 1],
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
- fileLines.splice(spec.after.line, 0, ...inserted);
989
- trackFirstChanged(spec.after.line + 1);
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 "insertBefore": {
993
- const anchorLine = originalFileLines[spec.before.line - 1];
994
- const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
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: `${spec.before.line}#${spec.before.hash}`,
999
- currentContent: originalFileLines[spec.before.line - 1],
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
- fileLines.splice(spec.before.line - 1, 0, ...inserted);
1004
- trackFirstChanged(spec.before.line);
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 "insertBetween": {
1008
- const afterLine = originalFileLines[spec.after.line - 1];
1009
- const beforeLine = originalFileLines[spec.before.line - 1];
1010
- const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
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: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
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(spec.before.line - 1, 0, ...inserted);
1020
- trackFirstChanged(spec.before.line);
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
- dst: string[],
919
+ content: string[],
1051
920
  ): { startLine: number; deleteCount: number; newLines: string[] } | null {
1052
- if (dst.length !== 1) return null;
921
+ if (content.length !== 1) return null;
1053
922
  if (line < 1 || line > fileLines.length) return null;
1054
923
 
1055
- const newLine = dst[0];
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;