@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.
- package/CHANGELOG.md +29 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/config.d.ts +31 -5
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/diff.d.ts +30 -0
- package/dist/types/edit/hashline/execute.d.ts +29 -0
- package/dist/types/edit/hashline/filesystem.d.ts +57 -0
- package/dist/types/edit/hashline/index.d.ts +4 -0
- package/dist/types/edit/hashline/params.d.ts +12 -0
- package/dist/types/edit/index.d.ts +4 -3
- package/dist/types/edit/normalize.d.ts +4 -16
- package/dist/types/index.d.ts +0 -1
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +18 -0
- package/dist/types/utils/changelog.d.ts +8 -3
- package/package.json +8 -15
- package/src/config/settings-schema.ts +32 -0
- package/src/config.ts +42 -15
- package/src/edit/file-snapshot-store.ts +22 -0
- package/src/edit/hashline/diff.ts +88 -0
- package/src/edit/hashline/execute.ts +188 -0
- package/src/edit/hashline/filesystem.ts +129 -0
- package/src/edit/hashline/index.ts +4 -0
- package/src/edit/hashline/params.ts +11 -0
- package/src/edit/index.ts +7 -15
- package/src/edit/normalize.ts +11 -41
- package/src/edit/renderer.ts +1 -1
- package/src/edit/streaming.ts +8 -9
- package/src/index.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/sdk.ts +8 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +3 -3
- package/src/tools/index.ts +6 -5
- package/src/tools/path-utils.ts +81 -0
- package/src/tools/read.ts +14 -72
- package/src/tools/search.ts +136 -17
- package/src/tools/write.ts +3 -3
- package/src/utils/changelog.ts +11 -3
- package/src/utils/file-mentions.ts +1 -1
- package/dist/types/edit/file-read-cache.d.ts +0 -36
- package/dist/types/hashline/anchors.d.ts +0 -26
- package/dist/types/hashline/apply.d.ts +0 -14
- package/dist/types/hashline/constants.d.ts +0 -48
- package/dist/types/hashline/diff-preview.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +0 -16
- package/dist/types/hashline/execute.d.ts +0 -4
- package/dist/types/hashline/executor.d.ts +0 -56
- package/dist/types/hashline/hash.d.ts +0 -76
- package/dist/types/hashline/index.d.ts +0 -14
- package/dist/types/hashline/input.d.ts +0 -4
- package/dist/types/hashline/prefixes.d.ts +0 -7
- package/dist/types/hashline/recovery.d.ts +0 -21
- package/dist/types/hashline/stream.d.ts +0 -2
- package/dist/types/hashline/tokenizer.d.ts +0 -94
- package/dist/types/hashline/types.d.ts +0 -75
- package/src/edit/file-read-cache.ts +0 -138
- package/src/hashline/anchors.ts +0 -104
- package/src/hashline/apply.ts +0 -790
- package/src/hashline/bigrams.json +0 -649
- package/src/hashline/constants.ts +0 -60
- package/src/hashline/diff-preview.ts +0 -42
- package/src/hashline/diff.ts +0 -82
- package/src/hashline/execute.ts +0 -334
- package/src/hashline/executor.ts +0 -347
- package/src/hashline/grammar.lark +0 -22
- package/src/hashline/hash.ts +0 -131
- package/src/hashline/index.ts +0 -14
- package/src/hashline/input.ts +0 -137
- package/src/hashline/prefixes.ts +0 -111
- package/src/hashline/recovery.ts +0 -139
- package/src/hashline/stream.ts +0 -123
- package/src/hashline/tokenizer.ts +0 -473
- package/src/hashline/types.ts +0 -66
- package/src/prompts/tools/hashline.md +0 -83
package/src/hashline/apply.ts
DELETED
|
@@ -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
|
-
}
|