@oh-my-pi/pi-coding-agent 15.5.3 → 15.5.4

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/settings-schema.d.ts +27 -0
  3. package/dist/types/config.d.ts +31 -5
  4. package/dist/types/edit/file-snapshot-store.d.ts +18 -0
  5. package/dist/types/edit/hashline/diff.d.ts +30 -0
  6. package/dist/types/edit/hashline/execute.d.ts +29 -0
  7. package/dist/types/edit/hashline/filesystem.d.ts +57 -0
  8. package/dist/types/edit/hashline/index.d.ts +4 -0
  9. package/dist/types/edit/hashline/params.d.ts +12 -0
  10. package/dist/types/edit/index.d.ts +4 -3
  11. package/dist/types/edit/normalize.d.ts +4 -16
  12. package/dist/types/index.d.ts +0 -1
  13. package/dist/types/tools/index.d.ts +6 -5
  14. package/dist/types/tools/path-utils.d.ts +18 -0
  15. package/dist/types/utils/changelog.d.ts +8 -3
  16. package/package.json +8 -15
  17. package/src/config/settings-schema.ts +32 -0
  18. package/src/config.ts +42 -15
  19. package/src/edit/file-snapshot-store.ts +22 -0
  20. package/src/edit/hashline/diff.ts +88 -0
  21. package/src/edit/hashline/execute.ts +188 -0
  22. package/src/edit/hashline/filesystem.ts +129 -0
  23. package/src/edit/hashline/index.ts +4 -0
  24. package/src/edit/hashline/params.ts +11 -0
  25. package/src/edit/index.ts +7 -15
  26. package/src/edit/normalize.ts +11 -41
  27. package/src/edit/renderer.ts +1 -1
  28. package/src/edit/streaming.ts +8 -9
  29. package/src/index.ts +0 -1
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/sdk.ts +8 -1
  32. package/src/tools/ast-edit.ts +1 -1
  33. package/src/tools/ast-grep.ts +3 -3
  34. package/src/tools/index.ts +6 -5
  35. package/src/tools/path-utils.ts +81 -0
  36. package/src/tools/read.ts +14 -72
  37. package/src/tools/search.ts +136 -17
  38. package/src/tools/write.ts +3 -3
  39. package/src/utils/changelog.ts +11 -3
  40. package/src/utils/file-mentions.ts +1 -1
  41. package/dist/types/edit/file-read-cache.d.ts +0 -36
  42. package/dist/types/hashline/anchors.d.ts +0 -26
  43. package/dist/types/hashline/apply.d.ts +0 -14
  44. package/dist/types/hashline/constants.d.ts +0 -48
  45. package/dist/types/hashline/diff-preview.d.ts +0 -2
  46. package/dist/types/hashline/diff.d.ts +0 -16
  47. package/dist/types/hashline/execute.d.ts +0 -4
  48. package/dist/types/hashline/executor.d.ts +0 -56
  49. package/dist/types/hashline/hash.d.ts +0 -76
  50. package/dist/types/hashline/index.d.ts +0 -14
  51. package/dist/types/hashline/input.d.ts +0 -4
  52. package/dist/types/hashline/prefixes.d.ts +0 -7
  53. package/dist/types/hashline/recovery.d.ts +0 -21
  54. package/dist/types/hashline/stream.d.ts +0 -2
  55. package/dist/types/hashline/tokenizer.d.ts +0 -94
  56. package/dist/types/hashline/types.d.ts +0 -75
  57. package/src/edit/file-read-cache.ts +0 -138
  58. package/src/hashline/anchors.ts +0 -104
  59. package/src/hashline/apply.ts +0 -790
  60. package/src/hashline/bigrams.json +0 -649
  61. package/src/hashline/constants.ts +0 -60
  62. package/src/hashline/diff-preview.ts +0 -42
  63. package/src/hashline/diff.ts +0 -82
  64. package/src/hashline/execute.ts +0 -334
  65. package/src/hashline/executor.ts +0 -347
  66. package/src/hashline/grammar.lark +0 -22
  67. package/src/hashline/hash.ts +0 -131
  68. package/src/hashline/index.ts +0 -14
  69. package/src/hashline/input.ts +0 -137
  70. package/src/hashline/prefixes.ts +0 -111
  71. package/src/hashline/recovery.ts +0 -139
  72. package/src/hashline/stream.ts +0 -123
  73. package/src/hashline/tokenizer.ts +0 -473
  74. package/src/hashline/types.ts +0 -66
  75. package/src/prompts/tools/hashline.md +0 -83
@@ -1,790 +0,0 @@
1
- import { cloneCursor } from "./tokenizer";
2
- import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit } from "./types";
3
-
4
- export interface HashlineApplyResult {
5
- lines: string;
6
- firstChangedLine?: number;
7
- warnings?: string[];
8
- noopEdits?: HashlineNoopEdit[];
9
- }
10
-
11
- export interface HashlineNoopEdit {
12
- editIndex: number;
13
- loc: string;
14
- reason: string;
15
- current: string;
16
- }
17
-
18
- type HashlineLineOrigin = "original" | "insert" | "replacement";
19
-
20
- interface IndexedEdit {
21
- edit: HashlineEdit;
22
- idx: number;
23
- }
24
-
25
- type HashlineDeleteEdit = Extract<HashlineEdit, { kind: "delete" }>;
26
-
27
- interface HashlineReplacementGroup {
28
- startIndex: number;
29
- endIndex: number;
30
- sourceLineNum: number;
31
- replacement: string[];
32
- deletes: HashlineDeleteEdit[];
33
- }
34
-
35
- function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
36
- if (edit.kind === "delete") return [edit.anchor];
37
- if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
38
- if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
39
- return [];
40
- }
41
-
42
- /**
43
- * Verify every anchored edit points at an existing line. File-version binding is
44
- * checked once per section via the header hash before this function runs.
45
- */
46
- function validateHashlineLineBounds(edits: HashlineEdit[], fileLines: string[]): void {
47
- for (const edit of edits) {
48
- for (const anchor of getHashlineEditAnchors(edit)) {
49
- if (anchor.line < 1 || anchor.line > fileLines.length) {
50
- throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
51
- }
52
- }
53
- }
54
- }
55
-
56
- /**
57
- * Refuse a single-line replace whose target line is blank and whose payload is
58
- * non-empty. The model is almost certainly miscounting: `A:CONTENT` overwrites
59
- * the existing line, so applying it to a blank target deletes the blank cadence
60
- * and inserts content in its place. To insert content at a blank line, use
61
- * `A↑` (insert before) or `A↓` (insert after) instead.
62
- *
63
- * Only fires for the simple shape: exactly one `insert(before_anchor A)` + one
64
- * `delete(A)` sharing the same source op line, no other inserts/deletes from
65
- * that op.
66
- */
67
- function detectReplaceOnBlankTarget(edits: HashlineEdit[], fileLines: string[]): string | null {
68
- type Pair = {
69
- insert?: Extract<HashlineEdit, { kind: "insert" }>;
70
- delete?: Extract<HashlineEdit, { kind: "delete" }>;
71
- multi?: boolean;
72
- };
73
- const byOpLine = new Map<number, Pair>();
74
- for (const edit of edits) {
75
- const pair = byOpLine.get(edit.lineNum) ?? {};
76
- if (pair.multi) continue;
77
- if (edit.kind === "insert") {
78
- if (pair.insert) pair.multi = true;
79
- else pair.insert = edit;
80
- } else {
81
- if (pair.delete) pair.multi = true;
82
- else pair.delete = edit;
83
- }
84
- byOpLine.set(edit.lineNum, pair);
85
- }
86
- for (const pair of byOpLine.values()) {
87
- if (pair.multi || !pair.insert || !pair.delete) continue;
88
- const insert = pair.insert;
89
- const del = pair.delete;
90
- if (insert.cursor.kind !== "before_anchor") continue;
91
- if (insert.cursor.anchor.line !== del.anchor.line) continue;
92
- if (insert.text.includes("\n")) continue;
93
- if (insert.text.trim().length === 0) continue;
94
- const targetLine = del.anchor.line;
95
- const oldLine = fileLines[targetLine - 1];
96
- if (oldLine === undefined || oldLine.trim().length !== 0) continue;
97
- return (
98
- `Edit rejected: replace at line ${targetLine} targets a blank line but the payload is non-empty. ` +
99
- `'A:CONTENT' overwrites the line at A; to insert content next to a blank line, use 'A${"\u2191"}' (insert before) ` +
100
- `or 'A${"\u2193"}' (insert after) instead. If you really meant to replace this blank with content, ` +
101
- `widen the range to include surrounding non-blank lines so the intent is explicit.`
102
- );
103
- }
104
- return null;
105
- }
106
-
107
- function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
108
- if (lines.length === 0) return;
109
- const origins = lines.map((): HashlineLineOrigin => "insert");
110
- if (fileLines.length === 1 && fileLines[0] === "") {
111
- fileLines.splice(0, 1, ...lines);
112
- lineOrigins.splice(0, 1, ...origins);
113
- return;
114
- }
115
- fileLines.splice(0, 0, ...lines);
116
- lineOrigins.splice(0, 0, ...origins);
117
- }
118
-
119
- function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined {
120
- if (lines.length === 0) return undefined;
121
- const origins = lines.map((): HashlineLineOrigin => "insert");
122
- if (fileLines.length === 1 && fileLines[0] === "") {
123
- fileLines.splice(0, 1, ...lines);
124
- lineOrigins.splice(0, 1, ...origins);
125
- return 1;
126
- }
127
- const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
128
- const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
129
- fileLines.splice(insertIndex, 0, ...lines);
130
- lineOrigins.splice(insertIndex, 0, ...origins);
131
- return insertIndex + 1;
132
- }
133
-
134
- /** Bucket edits by the line they target so we can apply each line's group in one splice. */
135
-
136
- function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
137
- if (edit.kind === "delete") return edit.anchor.line;
138
- if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
139
- return undefined;
140
- }
141
-
142
- function collectAnchorTargetLines(edits: HashlineEdit[]): Set<number> {
143
- const lines = new Set<number>();
144
- for (const edit of edits) {
145
- const line = getAnchorTargetLine(edit);
146
- if (line !== undefined) lines.add(line);
147
- }
148
- return lines;
149
- }
150
-
151
- function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined {
152
- const first = edits[startIndex];
153
- if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
154
-
155
- const sourceLineNum = first.lineNum;
156
- const replacement: string[] = [];
157
- let index = startIndex;
158
- while (index < edits.length) {
159
- const edit = edits[index];
160
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
161
- replacement.push(edit.text);
162
- index++;
163
- }
164
-
165
- const deletes: HashlineDeleteEdit[] = [];
166
- while (index < edits.length) {
167
- const edit = edits[index];
168
- if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
169
- deletes.push(edit);
170
- index++;
171
- }
172
- if (deletes.length === 0) return undefined;
173
-
174
- const startLine = deletes[0].anchor.line;
175
- for (let offset = 0; offset < deletes.length; offset++) {
176
- if (deletes[offset].anchor.line !== startLine + offset) return undefined;
177
- }
178
- const cursorLine = first.cursor.anchor.line;
179
- if (cursorLine !== startLine) return undefined;
180
-
181
- return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
182
- }
183
-
184
- function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
185
- const max = Math.min(replacement.length, startLine - 1);
186
- for (let count = max; count >= 2; count--) {
187
- let matches = true;
188
- for (let offset = 0; offset < count; offset++) {
189
- if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
190
- matches = false;
191
- break;
192
- }
193
- }
194
- if (matches) return count;
195
- }
196
- return 0;
197
- }
198
-
199
- function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
200
- const max = Math.min(replacement.length, fileLines.length - endLine);
201
- for (let count = max; count >= 2; count--) {
202
- let matches = true;
203
- for (let offset = 0; offset < count; offset++) {
204
- if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
205
- matches = false;
206
- break;
207
- }
208
- }
209
- if (matches) return count;
210
- }
211
- return 0;
212
- }
213
-
214
- // Single-line replacement-boundary absorption is limited to structural closing
215
- // delimiters. General one-line context is too easy to delete incorrectly, but
216
- // duplicated `};` / `)` / `]` boundaries often mean a replacement range stopped
217
- // one line early and would otherwise produce a syntax error.
218
- const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
219
-
220
- function isStructuralClosingBoundaryLine(line: string): boolean {
221
- return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
222
- }
223
-
224
- interface DelimiterBalance {
225
- paren: number;
226
- bracket: number;
227
- brace: number;
228
- }
229
-
230
- /**
231
- * Naive bracket counter — does NOT skip string/template/comment contents. The
232
- * single-line structural absorb relies on this being safe-by-asymmetry: the
233
- * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
234
- * to be pure delimiters, so noise in deleted lines or non-boundary kept payload
235
- * tends to push `expected !== kept` and biases the heuristic toward NOT
236
- * absorbing (the safe direction). If we ever extend this to opening boundaries
237
- * or non-structural single lines, swap this for a real tokenizer.
238
- */
239
- function computeDelimiterBalance(lines: string[]): DelimiterBalance {
240
- const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
241
- for (const line of lines) {
242
- for (const char of line) {
243
- switch (char) {
244
- case "(":
245
- balance.paren++;
246
- break;
247
- case ")":
248
- balance.paren--;
249
- break;
250
- case "[":
251
- balance.bracket++;
252
- break;
253
- case "]":
254
- balance.bracket--;
255
- break;
256
- case "{":
257
- balance.brace++;
258
- break;
259
- case "}":
260
- balance.brace--;
261
- break;
262
- }
263
- }
264
- }
265
- return balance;
266
- }
267
-
268
- function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
269
- return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
270
- }
271
-
272
- /**
273
- * Decides whether the structural-boundary candidate should be dropped: the
274
- * `keptPayload` (full payload with the boundary line removed) must restore the
275
- * caller's `expectedBalance`, while the `fullPayload` (boundary line still
276
- * present) must NOT. For replacements `expectedBalance` is the deleted
277
- * region's net delimiter balance; for pure inserts it is zero.
278
- */
279
- function shouldDropSingleStructuralBoundary(
280
- fullPayload: string[],
281
- keptPayload: string[],
282
- expectedBalance: DelimiterBalance,
283
- ): boolean {
284
- return (
285
- delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
286
- !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
287
- );
288
- }
289
-
290
- function countMatchingSingleStructuralPrefixBoundary(
291
- fileLines: string[],
292
- startLine: number,
293
- replacement: string[],
294
- expectedBalance: DelimiterBalance,
295
- ): number {
296
- if (replacement.length === 0 || startLine <= 1) return 0;
297
- const line = replacement[0];
298
- if (!isStructuralClosingBoundaryLine(line)) return 0;
299
- if (fileLines[startLine - 2] !== line) return 0;
300
- return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
301
- }
302
-
303
- function countMatchingSingleStructuralSuffixBoundary(
304
- fileLines: string[],
305
- endLine: number,
306
- replacement: string[],
307
- expectedBalance: DelimiterBalance,
308
- ): number {
309
- if (replacement.length === 0 || endLine >= fileLines.length) return 0;
310
- const line = replacement[replacement.length - 1];
311
- if (!isStructuralClosingBoundaryLine(line)) return 0;
312
- if (fileLines[endLine] !== line) return 0;
313
- return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
314
- }
315
-
316
- /**
317
- * Single-line non-structural boundary duplicate detector for replacement
318
- * groups. Mirrors the same boundary check the pure-insert absorber uses for
319
- * `ANCHOR↓` (leading) / `ANCHOR↑` (trailing) inserts, but applied to the
320
- * top/bottom edges of an `A-B:payload` range. Catches mistakes like
321
- * `103-138:const X = …` where line 102 already reads `const X = …` and the
322
- * user really meant `103-138!` (delete only).
323
- *
324
- * Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
325
- * absorb already runs unconditionally, and the structural single-line
326
- * absorber is balance-validated; a non-structural single-line duplicate is
327
- * ambiguous (could be an intentional `2:foo` over a line that happens to
328
- * sit next to another `foo`), so we only fire when the user has opted in.
329
- */
330
- function countMatchingSingleNonStructuralPrefixDuplicate(
331
- fileLines: string[],
332
- startLine: number,
333
- replacement: string[],
334
- ): number {
335
- if (replacement.length === 0 || startLine <= 1) return 0;
336
- const line = replacement[0];
337
- if (line.trim().length === 0) return 0;
338
- if (isStructuralClosingBoundaryLine(line)) return 0;
339
- if (fileLines[startLine - 2] !== line) return 0;
340
- return 1;
341
- }
342
-
343
- function countMatchingSingleNonStructuralSuffixDuplicate(
344
- fileLines: string[],
345
- endLine: number,
346
- replacement: string[],
347
- ): number {
348
- if (replacement.length === 0 || endLine >= fileLines.length) return 0;
349
- const line = replacement[replacement.length - 1];
350
- if (line.trim().length === 0) return 0;
351
- if (isStructuralClosingBoundaryLine(line)) return 0;
352
- if (fileLines[endLine] !== line) return 0;
353
- return 1;
354
- }
355
-
356
- function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
357
- for (const line of lines) {
358
- if (externalTargetLines.has(line)) return true;
359
- }
360
- return false;
361
- }
362
-
363
- function contiguousRange(start: number, count: number): number[] {
364
- return Array.from({ length: count }, (_, offset) => start + offset);
365
- }
366
-
367
- function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): HashlineEdit {
368
- return {
369
- kind: "delete",
370
- anchor: { line },
371
- lineNum: sourceLineNum,
372
- index,
373
- };
374
- }
375
-
376
- interface HashlinePureInsertGroup {
377
- startIndex: number;
378
- endIndex: number;
379
- sourceLineNum: number;
380
- cursor: HashlineCursor;
381
- payload: string[];
382
- }
383
-
384
- function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
385
- if (a.kind !== b.kind) return false;
386
- if (a.kind === "bof" || a.kind === "eof") return true;
387
- const aAnchor = (a as { anchor: Anchor }).anchor;
388
- const bAnchor = (b as { anchor: Anchor }).anchor;
389
- return aAnchor.line === bAnchor.line;
390
- }
391
-
392
- /**
393
- * Collects a run of consecutive `insert` edits that all share the same
394
- * `lineNum` and `cursor`, IFF that run is not immediately followed by a
395
- * `delete` at the same `lineNum` (which would make it a replacement group
396
- * instead). Returns the contiguous payload so we can check it for boundary
397
- * duplicates against the file.
398
- */
399
- function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
400
- const first = edits[startIndex];
401
- if (first?.kind !== "insert") return undefined;
402
-
403
- const sourceLineNum = first.lineNum;
404
- const cursor = first.cursor;
405
- const payload: string[] = [];
406
- let index = startIndex;
407
- while (index < edits.length) {
408
- const edit = edits[index];
409
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
410
- if (!cursorMatches(edit.cursor, cursor)) break;
411
- payload.push(edit.text);
412
- index++;
413
- }
414
-
415
- // If the run is followed by a delete at the same source lineNum, this is a
416
- // replacement group (handled by absorbReplacement…). Decline.
417
- if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
418
- return undefined;
419
- }
420
-
421
- return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
422
- }
423
-
424
- /**
425
- * For a pure-insert group, locate the file region adjacent to the insertion
426
- * point. Returns 0-indexed bounds:
427
- * - `aboveEndIdx`: index of the last file line strictly above the insertion
428
- * point (-1 if none).
429
- * - `belowStartIdx`: index of the first file line strictly below the
430
- * insertion point (`fileLines.length` if none).
431
- */
432
- function pureInsertNeighborhood(
433
- cursor: HashlineCursor,
434
- fileLines: string[],
435
- ): { aboveEndIdx: number; belowStartIdx: number } {
436
- if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
437
- if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
438
- if (cursor.kind === "before_anchor") {
439
- return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
440
- }
441
- // after_anchor
442
- return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
443
- }
444
-
445
- interface PureInsertAbsorbResult {
446
- keptPayload: string[];
447
- absorbedLeading: number;
448
- absorbedTrailing: number;
449
- leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
450
- trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
451
- }
452
-
453
- /**
454
- * For a pure-insert group, drop only multi-line context echoes that exactly
455
- * duplicate the file lines adjacent to the insertion point. Single-line pure
456
- * insert duplicates are ambiguous (`N↓}` may be an accidental anchor echo or an
457
- * intentional inserted delimiter), so they are left literal even when generic
458
- * duplicate absorption is enabled.
459
- */
460
- function tryAbsorbPureInsertGroup(
461
- group: HashlinePureInsertGroup,
462
- fileLines: string[],
463
- allowGenericBoundaryAbsorb: boolean,
464
- ): PureInsertAbsorbResult {
465
- const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
466
- if (group.payload.length === 0) return empty;
467
-
468
- const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
469
-
470
- // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
471
- let absorbedLeading = 0;
472
- if (allowGenericBoundaryAbsorb) {
473
- const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
474
- for (let count = maxLead; count >= 2; count--) {
475
- let ok = true;
476
- for (let offset = 0; offset < count; offset++) {
477
- if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
478
- ok = false;
479
- break;
480
- }
481
- }
482
- if (ok) {
483
- absorbedLeading = count;
484
- break;
485
- }
486
- }
487
- }
488
-
489
- // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
490
- // Don't double-count payload lines already absorbed as leading.
491
- let absorbedTrailing = 0;
492
- const remaining = group.payload.length - absorbedLeading;
493
- if (allowGenericBoundaryAbsorb) {
494
- const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
495
- for (let count = maxTrail; count >= 2; count--) {
496
- let ok = true;
497
- for (let offset = 0; offset < count; offset++) {
498
- if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
499
- ok = false;
500
- break;
501
- }
502
- }
503
- if (ok) {
504
- absorbedTrailing = count;
505
- break;
506
- }
507
- }
508
- }
509
-
510
- if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
511
-
512
- return {
513
- keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
514
- absorbedLeading,
515
- absorbedTrailing,
516
- leadingFileRange:
517
- absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
518
- trailingFileRange:
519
- absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
520
- };
521
- }
522
-
523
- function absorbReplacementBoundaryDuplicates(
524
- edits: HashlineEdit[],
525
- fileLines: string[],
526
- warnings: string[],
527
- options: HashlineApplyOptions,
528
- ): HashlineEdit[] {
529
- let nextSyntheticIndex = edits.length;
530
- const absorbed: HashlineEdit[] = [];
531
-
532
- // Anchor targets are stable across the loop because we only ever append
533
- // synthetic deletes (never mutate originals). A line in this set that
534
- // falls outside the current group's range is necessarily owned by another
535
- // op, so absorbing it would silently steal its target.
536
- const allTargetLines = collectAnchorTargetLines(edits);
537
- const emittedAbsorbKeys = new Set<string>();
538
-
539
- for (let index = 0; index < edits.length; index++) {
540
- const group = findReplacementGroup(edits, index);
541
- if (!group) {
542
- const pureInsert = findPureInsertGroup(edits, index);
543
- if (pureInsert) {
544
- const result = tryAbsorbPureInsertGroup(
545
- pureInsert,
546
- fileLines,
547
- options.autoDropPureInsertDuplicates === true,
548
- );
549
- if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
550
- if (result.leadingFileRange) {
551
- const { start, end } = result.leadingFileRange;
552
- const key = `pure-insert-leading:${start}..${end}`;
553
- if (!emittedAbsorbKeys.has(key)) {
554
- emittedAbsorbKeys.add(key);
555
- warnings.push(
556
- `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
557
- `(file lines ${start}..${end} already match the payload's leading lines).`,
558
- );
559
- }
560
- }
561
- if (result.trailingFileRange) {
562
- const { start, end } = result.trailingFileRange;
563
- const key = `pure-insert-trailing:${start}..${end}`;
564
- if (!emittedAbsorbKeys.has(key)) {
565
- emittedAbsorbKeys.add(key);
566
- warnings.push(
567
- `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
568
- `(file lines ${start}..${end} already match the payload's trailing lines).`,
569
- );
570
- }
571
- }
572
- for (const text of result.keptPayload) {
573
- absorbed.push({
574
- kind: "insert",
575
- cursor: cloneCursor(pureInsert.cursor),
576
- text,
577
- lineNum: pureInsert.sourceLineNum,
578
- index: nextSyntheticIndex++,
579
- });
580
- }
581
- index = pureInsert.endIndex;
582
- continue;
583
- }
584
- for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
585
- absorbed.push(edits[groupIndex]);
586
- }
587
- index = pureInsert.endIndex;
588
- continue;
589
- }
590
- absorbed.push(edits[index]);
591
- continue;
592
- }
593
-
594
- const startLine = group.deletes[0].anchor.line;
595
- const endLine = group.deletes[group.deletes.length - 1].anchor.line;
596
-
597
- const deletedBalance = computeDelimiterBalance(
598
- group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
599
- );
600
- const optInSingleLineAbsorb = options.autoDropPureInsertDuplicates === true;
601
- const prefixCount =
602
- countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
603
- countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance) ||
604
- (optInSingleLineAbsorb
605
- ? countMatchingSingleNonStructuralPrefixDuplicate(fileLines, startLine, group.replacement)
606
- : 0);
607
- const suffixCount =
608
- countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
609
- countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance) ||
610
- (optInSingleLineAbsorb
611
- ? countMatchingSingleNonStructuralSuffixDuplicate(fileLines, endLine, group.replacement)
612
- : 0);
613
- const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
614
- const suffixLines = contiguousRange(endLine + 1, suffixCount);
615
- const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
616
- const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
617
-
618
- if (safePrefixCount > 0) {
619
- const absorbStart = startLine - safePrefixCount;
620
- const key = `prefix:${absorbStart}..${startLine - 1}`;
621
- if (!emittedAbsorbKeys.has(key)) {
622
- emittedAbsorbKeys.add(key);
623
- warnings.push(
624
- `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
625
- `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
626
- `widened the deletion to absorb them).`,
627
- );
628
- }
629
- }
630
- if (safeSuffixCount > 0) {
631
- const absorbEnd = endLine + safeSuffixCount;
632
- const key = `suffix:${endLine + 1}..${absorbEnd}`;
633
- if (!emittedAbsorbKeys.has(key)) {
634
- emittedAbsorbKeys.add(key);
635
- warnings.push(
636
- `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
637
- `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
638
- `widened the deletion to absorb them).`,
639
- );
640
- }
641
- }
642
-
643
- for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
644
- absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
645
- }
646
- for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
647
- absorbed.push(edits[groupIndex]);
648
- }
649
- for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
650
- absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
651
- }
652
-
653
- index = group.endIndex;
654
- }
655
-
656
- return absorbed;
657
- }
658
-
659
- function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
660
- const byLine = new Map<number, IndexedEdit[]>();
661
- for (const entry of edits) {
662
- const line =
663
- entry.edit.kind === "delete"
664
- ? entry.edit.anchor.line
665
- : entry.edit.cursor.kind === "before_anchor"
666
- ? entry.edit.cursor.anchor.line
667
- : 0;
668
- const bucket = byLine.get(line);
669
- if (bucket) bucket.push(entry);
670
- else byLine.set(line, [entry]);
671
- }
672
- return byLine;
673
- }
674
-
675
- export function applyHashlineEdits(
676
- text: string,
677
- edits: HashlineEdit[],
678
- options: HashlineApplyOptions = {},
679
- ): HashlineApplyResult {
680
- if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
681
-
682
- const fileLines = text.split("\n");
683
- const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original");
684
- const warnings: string[] = [];
685
-
686
- let firstChangedLine: number | undefined;
687
- const trackFirstChanged = (line: number) => {
688
- if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
689
- };
690
-
691
- validateHashlineLineBounds(edits, fileLines);
692
-
693
- const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
694
- if (blankTargetError !== null) throw new Error(blankTargetError);
695
-
696
- const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
697
-
698
- // Normalize after_anchor inserts to before_anchor of the next line, or EOF
699
- // when the anchor is the final line. This keeps the bucketing logic below
700
- // (which only knows about before_anchor / bof / eof) untouched.
701
- for (const edit of normalizedEdits) {
702
- if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
703
- const anchorLine = edit.cursor.anchor.line;
704
- if (anchorLine >= fileLines.length) {
705
- edit.cursor = { kind: "eof" };
706
- continue;
707
- }
708
- const nextLineNum = anchorLine + 1;
709
- edit.cursor = {
710
- kind: "before_anchor",
711
- anchor: { line: nextLineNum },
712
- };
713
- }
714
-
715
- // Partition edits into BOF, EOF, and anchor-targeted buckets.
716
- const bofLines: string[] = [];
717
- const eofLines: string[] = [];
718
- const anchorEdits: IndexedEdit[] = [];
719
- normalizedEdits.forEach((edit, idx) => {
720
- if (edit.kind === "insert" && edit.cursor.kind === "bof") {
721
- bofLines.push(edit.text);
722
- } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
723
- eofLines.push(edit.text);
724
- } else {
725
- anchorEdits.push({ edit, idx });
726
- }
727
- });
728
-
729
- // Apply per-line buckets bottom-up so earlier indices stay valid.
730
- const byLine = bucketAnchorEditsByLine(anchorEdits);
731
- for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
732
- const bucket = byLine.get(line);
733
- if (!bucket) continue;
734
- bucket.sort((a, b) => a.idx - b.idx);
735
-
736
- const idx = line - 1;
737
- const currentLine = fileLines[idx] ?? "";
738
- const beforeLines: string[] = [];
739
- let deleteLine = false;
740
-
741
- for (const { edit } of bucket) {
742
- if (edit.kind === "insert") {
743
- beforeLines.push(edit.text);
744
- } else if (edit.kind === "delete") {
745
- deleteLine = true;
746
- }
747
- }
748
- if (beforeLines.length === 0 && !deleteLine) continue;
749
-
750
- const replaceMode = beforeLines.length > 0;
751
- if (deleteLine && !replaceMode) {
752
- const balance = computeDelimiterBalance([currentLine]);
753
- const trimmedCurrentLine = currentLine.trim();
754
- const touchesStructuralBoundary =
755
- trimmedCurrentLine.startsWith(")") ||
756
- trimmedCurrentLine.startsWith("]") ||
757
- trimmedCurrentLine.startsWith("}") ||
758
- trimmedCurrentLine.endsWith("(") ||
759
- trimmedCurrentLine.endsWith("[") ||
760
- trimmedCurrentLine.endsWith("{");
761
- if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
762
- warnings.push(
763
- `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
764
- );
765
- }
766
- }
767
- const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
768
- const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
769
- if (!deleteLine) {
770
- origins[origins.length - 1] = lineOrigins[idx] ?? "original";
771
- }
772
-
773
- fileLines.splice(idx, 1, ...replacement);
774
- lineOrigins.splice(idx, 1, ...origins);
775
- trackFirstChanged(line);
776
- }
777
-
778
- if (bofLines.length > 0) {
779
- insertAtStart(fileLines, lineOrigins, bofLines);
780
- trackFirstChanged(1);
781
- }
782
- const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
783
- if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
784
-
785
- return {
786
- lines: fileLines.join("\n"),
787
- firstChangedLine,
788
- ...(warnings.length > 0 ? { warnings } : {}),
789
- };
790
- }