@oh-my-pi/hashline 17.2.14 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/types/apply.d.ts +13 -15
- package/dist/types/format.d.ts +5 -0
- package/dist/types/messages.d.ts +25 -27
- package/dist/types/syntax.d.ts +8 -7
- package/package.json +3 -3
- package/src/apply.ts +541 -966
- package/src/format.ts +14 -2
- package/src/messages.ts +52 -52
- package/src/prompt.md +33 -31
- package/src/syntax.ts +33 -7
package/src/apply.ts
CHANGED
|
@@ -3,23 +3,26 @@
|
|
|
3
3
|
* post-edit lines plus any diagnostic warnings. Pure function: no FS, no
|
|
4
4
|
* mutation of the input.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Mis-set replacement range boundaries are repaired by bounded candidate
|
|
7
|
+
* search. Exact line equality, indentation, tree-sitter structure, and a
|
|
8
|
+
* narrow pure-closer shape gate constrain candidates; tree-sitter validates
|
|
9
|
+
* the selected result.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { resolveClipboardEdits } from "./clipboard";
|
|
12
13
|
import {
|
|
13
14
|
afterInsertLandingShiftWarning,
|
|
14
15
|
ambiguousBoundaryEchoMessage,
|
|
15
|
-
|
|
16
|
-
ambiguousLeadingCloserSpareMessage,
|
|
16
|
+
ambiguousBoundaryPlacementMessage,
|
|
17
17
|
blockInsertLandingShiftWarning,
|
|
18
|
-
|
|
18
|
+
boundaryVariantRepairWarning,
|
|
19
|
+
editBrokeParseWarning,
|
|
19
20
|
REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
|
|
21
|
+
textualBoundaryEchoWarning,
|
|
20
22
|
UNRESOLVED_BLOCK_INTERNAL,
|
|
23
|
+
UNRESOLVED_CLIPBOARD_INTERNAL,
|
|
21
24
|
} from "./messages";
|
|
22
|
-
import { parsesCleanly } from "./syntax";
|
|
25
|
+
import { enclosingBoundaries, parsesCleanly } from "./syntax";
|
|
23
26
|
import { cloneCursor } from "./tokenizer";
|
|
24
27
|
import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
|
|
25
28
|
|
|
@@ -29,6 +32,14 @@ type InsertEdit = Extract<Edit, { kind: "insert" }>;
|
|
|
29
32
|
type DeleteEdit = Extract<Edit, { kind: "delete" }>;
|
|
30
33
|
type AppliedEdit = InsertEdit | DeleteEdit;
|
|
31
34
|
|
|
35
|
+
function insertEditAt(edits: readonly AppliedEdit[], index: number): InsertEdit {
|
|
36
|
+
const edit = edits[index];
|
|
37
|
+
if (edit?.kind !== "insert") {
|
|
38
|
+
throw new Error("internal error: after-insert group contains a non-insert edit");
|
|
39
|
+
}
|
|
40
|
+
return edit;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
interface IndexedEdit {
|
|
33
44
|
edit: AppliedEdit;
|
|
34
45
|
idx: number;
|
|
@@ -123,237 +134,25 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
123
134
|
}
|
|
124
135
|
return byLine;
|
|
125
136
|
}
|
|
126
|
-
/**
|
|
127
|
-
* A closer-spare repair could not tell which side of a spared delimiter the
|
|
128
|
-
* payload belongs on. Distinct from the evidence-complete textual rejections
|
|
129
|
-
* (a one-sided boundary echo) so {@link applyEdits} can withhold *only* this
|
|
130
|
-
* delimiter-semantics verdict on a file the parser cannot vouch for, while
|
|
131
|
-
* every other rejection propagates unconditionally.
|
|
132
|
-
*/
|
|
133
|
-
class CloserSpareAmbiguityError extends Error {
|
|
134
|
-
constructor(message: string) {
|
|
135
|
-
super(message);
|
|
136
|
-
this.name = "CloserSpareAmbiguityError";
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
137
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
141
138
|
// Replacement-boundary repair
|
|
142
139
|
//
|
|
143
|
-
// Models routinely miscount
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
140
|
+
// Models routinely miscount replacement edges: the range swallows an unchanged
|
|
141
|
+
// boundary row, or the payload restates rows that survive just outside it.
|
|
142
|
+
// Exact outside echoes are normalized from line equality alone. If the authored
|
|
143
|
+
// result still does not parse, a bounded whole-patch search may retain the
|
|
144
|
+
// selected range's first or effective-last row and may combine that retention
|
|
145
|
+
// with exact echo removal.
|
|
147
146
|
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// wrapper" mistake.
|
|
147
|
+
// Retention never follows parse success alone. On a valid baseline, deleting
|
|
148
|
+
// the row must itself break syntax; every candidate also requires source-range
|
|
149
|
+
// structure and indentation evidence. Distinct candidate texts tied at the
|
|
150
|
+
// minimum repair cost are rejected rather than guessed. Pure structural-closer
|
|
151
|
+
// rows are recognized only to verify sibling-depth placement.
|
|
154
152
|
|
|
155
153
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
156
154
|
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
157
155
|
|
|
158
|
-
/** A JSX/XML closing boundary that carries structure but no bracket tokens. */
|
|
159
|
-
const JSX_CLOSER_RE = /^\s*(?:<\/>|<\/[A-Za-z][\w.:-]*>|\/>)\s*[;,]?\s*$/;
|
|
160
|
-
const JSX_NAMED_CLOSER_RE = /^\s*<\/([A-Za-z][\w.:-]*)>\s*[;,]?\s*$/;
|
|
161
|
-
const JSX_FRAGMENT_CLOSER_RE = /^\s*<\/>\s*[;,]?\s*$/;
|
|
162
|
-
|
|
163
|
-
function isStructuralCloserLine(text: string): boolean {
|
|
164
|
-
return STRUCTURAL_CLOSER_RE.test(text) || JSX_CLOSER_RE.test(text);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function jsxCloserName(text: string): string | undefined {
|
|
168
|
-
if (JSX_FRAGMENT_CLOSER_RE.test(text)) return "";
|
|
169
|
-
const match = JSX_NAMED_CLOSER_RE.exec(text);
|
|
170
|
-
return match?.[1];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
interface JsxPayloadTag {
|
|
174
|
-
readonly name: string;
|
|
175
|
-
readonly closing: boolean;
|
|
176
|
-
readonly selfClosing: boolean;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function isJsxTagStart(text: string, index: number): boolean {
|
|
180
|
-
const next = text[index + 1];
|
|
181
|
-
return next === ">" || next === "/" || (next >= "A" && next <= "Z") || (next >= "a" && next <= "z");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function findJsxTagEnd(text: string, start: number): number {
|
|
185
|
-
let quote: string | undefined;
|
|
186
|
-
let braces = 0;
|
|
187
|
-
for (let i = start + 1; i < text.length; i++) {
|
|
188
|
-
const ch = text[i];
|
|
189
|
-
if (quote) {
|
|
190
|
-
if (ch === "\\" && i + 1 < text.length) {
|
|
191
|
-
i++;
|
|
192
|
-
} else if (ch === quote) {
|
|
193
|
-
quote = undefined;
|
|
194
|
-
}
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
198
|
-
quote = ch;
|
|
199
|
-
} else if (ch === "{") {
|
|
200
|
-
braces++;
|
|
201
|
-
} else if (ch === "}" && braces > 0) {
|
|
202
|
-
braces--;
|
|
203
|
-
} else if (ch === ">" && braces === 0) {
|
|
204
|
-
return i;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return -1;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function parseJsxPayloadTag(raw: string): JsxPayloadTag | undefined {
|
|
211
|
-
if (raw === "<>") return { name: "", closing: false, selfClosing: false };
|
|
212
|
-
if (raw === "</>") return { name: "", closing: true, selfClosing: false };
|
|
213
|
-
const closing = raw.startsWith("</");
|
|
214
|
-
const nameStart = closing ? 2 : 1;
|
|
215
|
-
let nameEnd = nameStart;
|
|
216
|
-
while (nameEnd < raw.length && /[\w.:-]/.test(raw[nameEnd])) nameEnd++;
|
|
217
|
-
if (nameEnd === nameStart) return undefined;
|
|
218
|
-
return {
|
|
219
|
-
name: raw.slice(nameStart, nameEnd),
|
|
220
|
-
closing,
|
|
221
|
-
selfClosing: !closing && /\/>\s*$/.test(raw),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function readJsxPayloadTags(text: string): JsxPayloadTag[] {
|
|
226
|
-
const tags: JsxPayloadTag[] = [];
|
|
227
|
-
for (let start = text.indexOf("<"); start >= 0; start = text.indexOf("<", start + 1)) {
|
|
228
|
-
if (!isJsxTagStart(text, start)) continue;
|
|
229
|
-
const end = findJsxTagEnd(text, start);
|
|
230
|
-
if (end < 0) break;
|
|
231
|
-
const tag = parseJsxPayloadTag(text.slice(start, end + 1));
|
|
232
|
-
if (tag) tags.push(tag);
|
|
233
|
-
start = end;
|
|
234
|
-
}
|
|
235
|
-
return tags;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function payloadHasJsxOpenerForEcho(payloadPrefix: readonly string[], echoLines: readonly string[]): boolean {
|
|
239
|
-
const openTags: string[] = [];
|
|
240
|
-
for (const tag of readJsxPayloadTags(payloadPrefix.join("\n"))) {
|
|
241
|
-
if (tag.closing) {
|
|
242
|
-
if (openTags[openTags.length - 1] === tag.name) openTags.pop();
|
|
243
|
-
} else if (!tag.selfClosing) {
|
|
244
|
-
openTags.push(tag.name);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
for (const line of echoLines) {
|
|
248
|
-
const name = jsxCloserName(line);
|
|
249
|
-
if (name !== undefined && openTags.includes(name)) return true;
|
|
250
|
-
}
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
interface DelimiterBalance {
|
|
255
|
-
paren: number;
|
|
256
|
-
bracket: number;
|
|
257
|
-
brace: number;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
|
|
262
|
-
* comments (`//`), block comments, and string/template literals. Block-comment
|
|
263
|
-
* and backtick-template state carry across lines; `"` / `'` reset at EOL since
|
|
264
|
-
* they cannot span lines. Deliberately language-light: constructs it cannot
|
|
265
|
-
* classify (e.g. regex literals) are counted naively, which can only suppress a
|
|
266
|
-
* repair (the safe direction), never force one.
|
|
267
|
-
*/
|
|
268
|
-
function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
|
|
269
|
-
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
270
|
-
let inBlockComment = false;
|
|
271
|
-
let quote = "";
|
|
272
|
-
for (const line of lines) {
|
|
273
|
-
for (let i = 0; i < line.length; i++) {
|
|
274
|
-
const ch = line[i];
|
|
275
|
-
if (inBlockComment) {
|
|
276
|
-
if (ch === "*" && line[i + 1] === "/") {
|
|
277
|
-
inBlockComment = false;
|
|
278
|
-
i++;
|
|
279
|
-
}
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (quote) {
|
|
283
|
-
if (ch === "\\") i++;
|
|
284
|
-
else if (ch === quote) quote = "";
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
288
|
-
quote = ch;
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
if (ch === "/" && line[i + 1] === "/") break;
|
|
292
|
-
if (ch === "/" && line[i + 1] === "*") {
|
|
293
|
-
inBlockComment = true;
|
|
294
|
-
i++;
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
switch (ch) {
|
|
298
|
-
case "(":
|
|
299
|
-
balance.paren++;
|
|
300
|
-
break;
|
|
301
|
-
case ")":
|
|
302
|
-
balance.paren--;
|
|
303
|
-
break;
|
|
304
|
-
case "[":
|
|
305
|
-
balance.bracket++;
|
|
306
|
-
break;
|
|
307
|
-
case "]":
|
|
308
|
-
balance.bracket--;
|
|
309
|
-
break;
|
|
310
|
-
case "{":
|
|
311
|
-
balance.brace++;
|
|
312
|
-
break;
|
|
313
|
-
case "}":
|
|
314
|
-
balance.brace--;
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
// `"` / `'` cannot span lines; only backtick templates and block comments do.
|
|
319
|
-
if (quote === '"' || quote === "'") quote = "";
|
|
320
|
-
}
|
|
321
|
-
return balance;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
325
|
-
return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function balanceNegate(a: DelimiterBalance): DelimiterBalance {
|
|
329
|
-
return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
|
|
333
|
-
return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function balanceIsZero(a: DelimiterBalance): boolean {
|
|
337
|
-
return a.paren === 0 && a.bracket === 0 && a.brace === 0;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
341
|
-
return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace };
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function balanceComponentCovers(candidate: number, target: number): boolean {
|
|
345
|
-
if (target === 0) return true;
|
|
346
|
-
return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean {
|
|
350
|
-
return (
|
|
351
|
-
balanceComponentCovers(candidate.paren, target.paren) &&
|
|
352
|
-
balanceComponentCovers(candidate.bracket, target.bracket) &&
|
|
353
|
-
balanceComponentCovers(candidate.brace, target.brace)
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
156
|
interface ReplacementGroup {
|
|
358
157
|
/** Positions in the edit array of the payload inserts, in payload order. */
|
|
359
158
|
insertIndices: number[];
|
|
@@ -467,797 +266,575 @@ function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly
|
|
|
467
266
|
return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : [];
|
|
468
267
|
}
|
|
469
268
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
* the imbalance, so intentional duplicates of ordinary statements stay intact,
|
|
475
|
-
* while duplicated structural lines (closers like `});`, openers like `foo(`)
|
|
476
|
-
* are dropped when they exactly explain the imbalance.
|
|
477
|
-
*/
|
|
478
|
-
function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
479
|
-
if (balanceIsZero(delta)) return 0;
|
|
480
|
-
const { payload, endLine } = group;
|
|
481
|
-
const maxK = Math.min(payload.length, fileLines.length - endLine);
|
|
482
|
-
for (let k = maxK; k >= 1; k--) {
|
|
483
|
-
let matches = true;
|
|
484
|
-
for (let t = 0; t < k; t++) {
|
|
485
|
-
if (payload[payload.length - k + t] !== fileLines[endLine + t]) {
|
|
486
|
-
matches = false;
|
|
487
|
-
break;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
if (!matches) continue;
|
|
491
|
-
if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k;
|
|
269
|
+
function hasNonWhitespace(text: string): boolean {
|
|
270
|
+
for (let i = 0; i < text.length; i++) {
|
|
271
|
+
const code = text.charCodeAt(i);
|
|
272
|
+
if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
|
|
492
273
|
}
|
|
493
|
-
return
|
|
274
|
+
return false;
|
|
494
275
|
}
|
|
495
276
|
|
|
496
|
-
|
|
497
|
-
* Largest `j` such that the payload's first `j` lines exactly equal the `j`
|
|
498
|
-
* surviving file lines just above the range AND dropping them zeroes `delta`.
|
|
499
|
-
* Requires a non-zero `delta`; see {@link findDuplicateSuffix}.
|
|
500
|
-
*/
|
|
501
|
-
function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
502
|
-
if (balanceIsZero(delta)) return 0;
|
|
277
|
+
function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
503
278
|
const { payload, startLine } = group;
|
|
504
|
-
const
|
|
505
|
-
for (let
|
|
279
|
+
const max = Math.min(payload.length, startLine - 1);
|
|
280
|
+
for (let count = max; count >= 1; count--) {
|
|
506
281
|
let matches = true;
|
|
507
|
-
|
|
508
|
-
|
|
282
|
+
let hasContent = false;
|
|
283
|
+
for (let offset = 0; offset < count; offset++) {
|
|
284
|
+
const line = payload[offset];
|
|
285
|
+
if (line !== fileLines[startLine - 1 - count + offset]) {
|
|
509
286
|
matches = false;
|
|
510
287
|
break;
|
|
511
288
|
}
|
|
289
|
+
hasContent ||= hasNonWhitespace(line);
|
|
512
290
|
}
|
|
513
|
-
if (
|
|
514
|
-
if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
|
|
291
|
+
if (matches && hasContent) return count;
|
|
515
292
|
}
|
|
516
293
|
return 0;
|
|
517
294
|
}
|
|
518
|
-
interface DroppedSuffixClosers {
|
|
519
|
-
readonly startLine: number;
|
|
520
|
-
readonly count: number;
|
|
521
|
-
readonly balance: DelimiterBalance;
|
|
522
|
-
}
|
|
523
295
|
|
|
524
|
-
function
|
|
525
|
-
const
|
|
526
|
-
|
|
296
|
+
function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
297
|
+
const { payload, endLine } = group;
|
|
298
|
+
const max = Math.min(payload.length, fileLines.length - endLine);
|
|
299
|
+
for (let count = max; count >= 1; count--) {
|
|
527
300
|
let matches = true;
|
|
301
|
+
let hasContent = false;
|
|
528
302
|
for (let offset = 0; offset < count; offset++) {
|
|
529
|
-
|
|
303
|
+
const line = payload[payload.length - count + offset];
|
|
304
|
+
if (line !== fileLines[endLine + offset]) {
|
|
530
305
|
matches = false;
|
|
531
306
|
break;
|
|
532
307
|
}
|
|
308
|
+
hasContent ||= hasNonWhitespace(line);
|
|
533
309
|
}
|
|
534
|
-
if (matches) return count;
|
|
310
|
+
if (matches && hasContent) return count;
|
|
535
311
|
}
|
|
536
312
|
return 0;
|
|
537
313
|
}
|
|
314
|
+
interface TextualBoundaryAmbiguity {
|
|
315
|
+
readonly startLine: number;
|
|
316
|
+
readonly endLine: number;
|
|
317
|
+
readonly side: "leading" | "trailing";
|
|
318
|
+
readonly count: number;
|
|
319
|
+
}
|
|
538
320
|
|
|
539
|
-
|
|
540
|
-
|
|
321
|
+
interface TextualBoundaryNormalization {
|
|
322
|
+
readonly edits: AppliedEdit[];
|
|
323
|
+
readonly warnings: string[];
|
|
324
|
+
readonly ambiguities: TextualBoundaryAmbiguity[];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Normalize exact boundary echoes without interpreting language tokens.
|
|
329
|
+
*
|
|
330
|
+
* Two-sided echoes are removed when stripping both copies leaves one payload
|
|
331
|
+
* row per deleted range line. One-sided echoes on multi-line ranges are
|
|
332
|
+
* removed when the remaining payload still covers the full range; an
|
|
333
|
+
* under-filled one-sided echo is recorded as ambiguous so the syntax-probe
|
|
334
|
+
* search gets first chance to resolve it, then rejected rather than silently
|
|
335
|
+
* dropping unique range content.
|
|
336
|
+
*/
|
|
337
|
+
function normalizeTextualBoundaryEchoes(
|
|
338
|
+
edits: readonly AppliedEdit[],
|
|
541
339
|
fileLines: readonly string[],
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
return true;
|
|
554
|
-
};
|
|
555
|
-
if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0;
|
|
556
|
-
for (let line = group.endLine + 1; line <= fileLines.length; line++) {
|
|
557
|
-
if (!appendCloserLines(insertedLineMaps.before.get(line))) break;
|
|
558
|
-
if (!deletedLines.has(line)) {
|
|
559
|
-
const text = fileLines[line - 1] ?? "";
|
|
560
|
-
if (!STRUCTURAL_CLOSER_RE.test(text)) break;
|
|
561
|
-
below.push(text);
|
|
340
|
+
): TextualBoundaryNormalization {
|
|
341
|
+
const out: AppliedEdit[] = [];
|
|
342
|
+
const warnings: string[] = [];
|
|
343
|
+
const ambiguities: TextualBoundaryAmbiguity[] = [];
|
|
344
|
+
let i = 0;
|
|
345
|
+
while (i < edits.length) {
|
|
346
|
+
const group = findReplacementGroup(edits, i);
|
|
347
|
+
if (!group) {
|
|
348
|
+
out.push(cloneAppliedEdit(edits[i], i));
|
|
349
|
+
i++;
|
|
350
|
+
continue;
|
|
562
351
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
352
|
+
const inserts = replacementInserts(group, edits);
|
|
353
|
+
const deletes = replacementDeletes(group, edits);
|
|
354
|
+
const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
355
|
+
const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
356
|
+
const rangeLength = group.deleteIndices.length;
|
|
357
|
+
let dropLeading = 0;
|
|
358
|
+
let dropTrailing = 0;
|
|
359
|
+
if (leading > 0 && trailing > 0) {
|
|
360
|
+
if (group.payload.length - leading - trailing === rangeLength) {
|
|
361
|
+
dropLeading = leading;
|
|
362
|
+
dropTrailing = trailing;
|
|
363
|
+
}
|
|
364
|
+
} else if (leading > 0 && rangeLength > 1) {
|
|
365
|
+
if (group.payload.length - leading >= rangeLength) {
|
|
366
|
+
dropLeading = leading;
|
|
367
|
+
} else {
|
|
368
|
+
ambiguities.push({
|
|
369
|
+
startLine: group.startLine,
|
|
370
|
+
endLine: group.endLine,
|
|
371
|
+
side: "leading",
|
|
372
|
+
count: leading,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
} else if (trailing > 0 && rangeLength > 1) {
|
|
376
|
+
if (group.payload.length - trailing >= rangeLength) {
|
|
377
|
+
dropTrailing = trailing;
|
|
378
|
+
} else {
|
|
379
|
+
ambiguities.push({
|
|
380
|
+
startLine: group.startLine,
|
|
381
|
+
endLine: group.endLine,
|
|
382
|
+
side: "trailing",
|
|
383
|
+
count: trailing,
|
|
384
|
+
});
|
|
572
385
|
}
|
|
573
386
|
}
|
|
574
|
-
if (
|
|
387
|
+
if (dropLeading > 0 || dropTrailing > 0) {
|
|
388
|
+
out.push(...inserts.slice(dropLeading, inserts.length - dropTrailing), ...deletes);
|
|
389
|
+
warnings.push(textualBoundaryEchoWarning(group.startLine, dropLeading, dropTrailing));
|
|
390
|
+
} else {
|
|
391
|
+
for (const idx of group.insertIndices) out.push(cloneAppliedEdit(edits[idx], idx));
|
|
392
|
+
for (const idx of group.deleteIndices) out.push(cloneAppliedEdit(edits[idx], idx));
|
|
393
|
+
}
|
|
394
|
+
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
575
395
|
}
|
|
576
|
-
return
|
|
396
|
+
return { edits: out, warnings, ambiguities };
|
|
577
397
|
}
|
|
578
398
|
|
|
579
|
-
interface
|
|
580
|
-
readonly
|
|
581
|
-
readonly
|
|
399
|
+
interface KeepPlan {
|
|
400
|
+
readonly beforeLine?: number;
|
|
401
|
+
readonly afterLine?: number;
|
|
402
|
+
readonly kept: number;
|
|
582
403
|
}
|
|
583
404
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
): DelimiterBalance {
|
|
591
|
-
const prefix: string[] = [];
|
|
592
|
-
for (let line = 1; line < group.startLine; line++) {
|
|
593
|
-
const inserted = insertedByLine.get(line);
|
|
594
|
-
if (inserted) prefix.push(...inserted);
|
|
595
|
-
if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? "");
|
|
596
|
-
}
|
|
597
|
-
const insertedAtStart = insertedLineMaps.before.get(group.startLine);
|
|
598
|
-
if (insertedAtStart) prefix.push(...insertedAtStart);
|
|
599
|
-
prefix.push(...group.payload);
|
|
600
|
-
return computeDelimiterBalance(prefix);
|
|
405
|
+
interface GroupVariant {
|
|
406
|
+
readonly edits: AppliedEdit[];
|
|
407
|
+
/** Original boundary rows retained from the selected range. */
|
|
408
|
+
readonly kept: number;
|
|
409
|
+
/** Exact payload echoes removed from outside the selected range. */
|
|
410
|
+
readonly dropped: number;
|
|
601
411
|
}
|
|
602
412
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
suffixBalance: DelimiterBalance,
|
|
607
|
-
coveredBelowBalance: DelimiterBalance,
|
|
608
|
-
deletedLines: ReadonlySet<number>,
|
|
609
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
610
|
-
insertedLineMaps: InsertedLineMaps,
|
|
611
|
-
): boolean {
|
|
612
|
-
const neededOpeners = balanceNegate(suffixBalance);
|
|
613
|
-
const prefixBalance = computeProjectedPrefixBalance(
|
|
614
|
-
group,
|
|
615
|
-
fileLines,
|
|
616
|
-
deletedLines,
|
|
617
|
-
insertedByLine,
|
|
618
|
-
insertedLineMaps,
|
|
619
|
-
);
|
|
620
|
-
const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance);
|
|
621
|
-
return balanceCovers(uncoveredPrefixBalance, neededOpeners);
|
|
413
|
+
interface GroupVariants {
|
|
414
|
+
readonly variants: GroupVariant[];
|
|
415
|
+
readonly ambiguous: boolean;
|
|
622
416
|
}
|
|
623
417
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
deletedLines: ReadonlySet<number>,
|
|
638
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
639
|
-
insertedLineMaps: InsertedLineMaps,
|
|
640
|
-
): DroppedSuffixClosers | undefined {
|
|
641
|
-
let suffixLength = 0;
|
|
642
|
-
while (
|
|
643
|
-
suffixLength < group.deleteIndices.length &&
|
|
644
|
-
STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "")
|
|
645
|
-
) {
|
|
646
|
-
suffixLength++;
|
|
647
|
-
}
|
|
648
|
-
if (suffixLength === 0) return undefined;
|
|
649
|
-
|
|
650
|
-
const suffixStartLine = group.endLine - suffixLength + 1;
|
|
651
|
-
const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine);
|
|
652
|
-
const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines);
|
|
653
|
-
const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines);
|
|
654
|
-
const keepStart = restatedHead;
|
|
655
|
-
const keepEnd = suffixLength - coveredTail;
|
|
656
|
-
if (keepStart >= keepEnd) return undefined;
|
|
657
|
-
|
|
658
|
-
const keptLines = suffixLines.slice(keepStart, keepEnd);
|
|
659
|
-
const keptBalance = computeDelimiterBalance(keptLines);
|
|
660
|
-
const neededOpeners = balanceNegate(keptBalance);
|
|
661
|
-
const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd));
|
|
662
|
-
if (!balanceCovers(delta, neededOpeners)) return undefined;
|
|
663
|
-
if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
|
|
664
|
-
if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
|
|
665
|
-
if (
|
|
666
|
-
!prefixCanCoverSuffixClosers(
|
|
667
|
-
group,
|
|
668
|
-
fileLines,
|
|
669
|
-
keptBalance,
|
|
670
|
-
coveredBelowBalance,
|
|
671
|
-
deletedLines,
|
|
672
|
-
insertedByLine,
|
|
673
|
-
insertedLineMaps,
|
|
674
|
-
)
|
|
675
|
-
) {
|
|
676
|
-
return undefined;
|
|
418
|
+
const INDENT_TAB_WIDTH = 4;
|
|
419
|
+
|
|
420
|
+
function indentColumns(line: string): number {
|
|
421
|
+
let column = 0;
|
|
422
|
+
for (let i = 0; i < line.length; i++) {
|
|
423
|
+
const code = line.charCodeAt(i);
|
|
424
|
+
if (code === 32) {
|
|
425
|
+
column++;
|
|
426
|
+
} else if (code === 9) {
|
|
427
|
+
column += INDENT_TAB_WIDTH - (column % INDENT_TAB_WIDTH);
|
|
428
|
+
} else {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
677
431
|
}
|
|
678
|
-
return
|
|
432
|
+
return column;
|
|
679
433
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
434
|
+
|
|
435
|
+
function nearestContentLine(fileLines: readonly string[], start: number, step: 1 | -1): string | undefined {
|
|
436
|
+
for (let index = start; index >= 0 && index < fileLines.length; index += step) {
|
|
437
|
+
const line = fileLines[index];
|
|
438
|
+
if (line !== undefined && hasNonWhitespace(line)) return line;
|
|
439
|
+
}
|
|
440
|
+
return undefined;
|
|
683
441
|
}
|
|
684
442
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
* range account for their opener, and dangling opener(s) actually survive
|
|
692
|
-
* above the range in the projected file.
|
|
693
|
-
*/
|
|
694
|
-
function findDroppedPrefixClosers(
|
|
695
|
-
group: ReplacementGroup,
|
|
696
|
-
fileLines: readonly string[],
|
|
697
|
-
delta: DelimiterBalance,
|
|
698
|
-
remainingDelta: DelimiterBalance,
|
|
699
|
-
deletedPrefixBalance: DelimiterBalance,
|
|
700
|
-
deletedLines: ReadonlySet<number>,
|
|
701
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
702
|
-
): DroppedPrefixClosers | undefined {
|
|
703
|
-
let prefixLength = 0;
|
|
704
|
-
while (
|
|
705
|
-
prefixLength < group.deleteIndices.length &&
|
|
706
|
-
STRUCTURAL_CLOSER_RE.test(fileLines[group.startLine + prefixLength - 1] ?? "")
|
|
707
|
-
) {
|
|
708
|
-
prefixLength++;
|
|
443
|
+
function payloadEdge(payload: readonly string[], side: "leading" | "trailing"): string | undefined {
|
|
444
|
+
if (side === "leading") {
|
|
445
|
+
for (const line of payload) {
|
|
446
|
+
if (hasNonWhitespace(line)) return line;
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
709
449
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (group.payload.length === 0 || isStructuralCloserLine(group.payload[0])) return undefined;
|
|
714
|
-
const prefixLines = fileLines.slice(group.startLine - 1, group.startLine - 1 + prefixLength);
|
|
715
|
-
const balance = computeDelimiterBalance(prefixLines);
|
|
716
|
-
if (balanceIsZero(balance)) return undefined;
|
|
717
|
-
const neededOpeners = balanceNegate(balance);
|
|
718
|
-
if (!balanceCovers(delta, neededOpeners)) return undefined;
|
|
719
|
-
if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
|
|
720
|
-
if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
|
|
721
|
-
// The spared closers need dangling opener(s) above the range in the
|
|
722
|
-
// projected file; the payload cannot supply them — it lands below the
|
|
723
|
-
// closers either way.
|
|
724
|
-
const above: string[] = [];
|
|
725
|
-
for (let line = 1; line < group.startLine; line++) {
|
|
726
|
-
const inserted = insertedByLine.get(line);
|
|
727
|
-
if (inserted) above.push(...inserted);
|
|
728
|
-
if (!deletedLines.has(line)) above.push(fileLines[line - 1] ?? "");
|
|
450
|
+
for (let index = payload.length - 1; index >= 0; index--) {
|
|
451
|
+
const line = payload[index];
|
|
452
|
+
if (line !== undefined && hasNonWhitespace(line)) return line;
|
|
729
453
|
}
|
|
730
|
-
|
|
731
|
-
return { count: prefixLength, balance };
|
|
454
|
+
return undefined;
|
|
732
455
|
}
|
|
733
456
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
* than proof: {@link applyEdits} surfaces it only once the tree-sitter probe
|
|
740
|
-
* confirms the authored edit broke the file, which is what separates a real
|
|
741
|
-
* mid-block range from a `}` living in prose or a regex literal. Zero when the
|
|
742
|
-
* payload is itself net-closing (deliberate rebalancing of a broken file) or
|
|
743
|
-
* when another hunk removes the surplus (whole-patch residual clean).
|
|
744
|
-
*/
|
|
745
|
-
function countOrphanedOpeners(
|
|
746
|
-
group: ReplacementGroup,
|
|
747
|
-
delta: DelimiterBalance,
|
|
748
|
-
remainingDelta: DelimiterBalance,
|
|
749
|
-
fileLines: readonly string[],
|
|
750
|
-
): number {
|
|
751
|
-
const deletedBalance = computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine));
|
|
752
|
-
const payloadBalance = computeDelimiterBalance(group.payload);
|
|
753
|
-
let orphaned = 0;
|
|
754
|
-
for (const key of ["paren", "bracket", "brace"] as const) {
|
|
755
|
-
if (payloadBalance[key] < 0) return 0;
|
|
756
|
-
if (delta[key] >= 0 || deletedBalance[key] <= 0 || remainingDelta[key] >= 0) continue;
|
|
757
|
-
orphaned += Math.min(-delta[key], deletedBalance[key], -remainingDelta[key]);
|
|
457
|
+
function replacementInserts(group: ReplacementGroup, edits: readonly AppliedEdit[]): InsertEdit[] {
|
|
458
|
+
const inserts: InsertEdit[] = [];
|
|
459
|
+
for (const index of group.insertIndices) {
|
|
460
|
+
const edit = edits[index];
|
|
461
|
+
if (edit?.kind === "insert") inserts.push(edit);
|
|
758
462
|
}
|
|
759
|
-
return
|
|
760
|
-
}
|
|
761
|
-
interface BoundaryEcho {
|
|
762
|
-
leading: number;
|
|
763
|
-
trailing: number;
|
|
463
|
+
return inserts;
|
|
764
464
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
465
|
+
|
|
466
|
+
function replacementDeletes(group: ReplacementGroup, edits: readonly AppliedEdit[]): DeleteEdit[] {
|
|
467
|
+
const deletes: DeleteEdit[] = [];
|
|
468
|
+
for (const index of group.deleteIndices) {
|
|
469
|
+
const edit = edits[index];
|
|
470
|
+
if (edit?.kind === "delete") deletes.push(edit);
|
|
769
471
|
}
|
|
770
|
-
return
|
|
472
|
+
return deletes;
|
|
771
473
|
}
|
|
772
474
|
|
|
773
|
-
function
|
|
774
|
-
|
|
775
|
-
const max = Math.min(payload.length, startLine - 1);
|
|
776
|
-
for (let count = max; count >= 1; count--) {
|
|
777
|
-
let matches = true;
|
|
778
|
-
let hasContent = false;
|
|
779
|
-
for (let offset = 0; offset < count; offset++) {
|
|
780
|
-
const line = payload[offset];
|
|
781
|
-
if (line !== fileLines[startLine - 1 - count + offset]) {
|
|
782
|
-
matches = false;
|
|
783
|
-
break;
|
|
784
|
-
}
|
|
785
|
-
hasContent ||= hasNonWhitespace(line);
|
|
786
|
-
}
|
|
787
|
-
if (matches && hasContent) return count;
|
|
788
|
-
}
|
|
789
|
-
return 0;
|
|
475
|
+
function isSourceLineDeleted(edits: readonly AppliedEdit[], line: number): boolean {
|
|
476
|
+
return edits.some(edit => edit.kind === "delete" && edit.anchor.line === line);
|
|
790
477
|
}
|
|
791
478
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
479
|
+
/**
|
|
480
|
+
* Ignore a deleted trailing row only when the identical next source row
|
|
481
|
+
* survives every hunk. The preceding deleted row then becomes the effective
|
|
482
|
+
* range edge without resurrecting arbitrary interior content.
|
|
483
|
+
*/
|
|
484
|
+
function effectiveTrailingBoundary(
|
|
485
|
+
group: ReplacementGroup,
|
|
486
|
+
edits: readonly AppliedEdit[],
|
|
487
|
+
fileLines: readonly string[],
|
|
488
|
+
): number {
|
|
489
|
+
let line = group.endLine;
|
|
490
|
+
let survivor = group.endLine + 1;
|
|
491
|
+
while (
|
|
492
|
+
line > group.startLine &&
|
|
493
|
+
survivor <= fileLines.length &&
|
|
494
|
+
!isSourceLineDeleted(edits, survivor) &&
|
|
495
|
+
fileLines[line - 1] === fileLines[survivor - 1]
|
|
496
|
+
) {
|
|
497
|
+
line--;
|
|
498
|
+
survivor++;
|
|
807
499
|
}
|
|
808
|
-
return
|
|
500
|
+
return line;
|
|
809
501
|
}
|
|
810
502
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
// either be delimiter-neutral on their own or exactly cancel the payload/range
|
|
822
|
-
// balance delta. In brace-heavy code where bare closer lines repeat, an
|
|
823
|
-
// "echo" that shifts delimiter balance is structural content the payload
|
|
824
|
-
// placed intentionally — stripping it would corrupt the result.
|
|
825
|
-
const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
|
|
826
|
-
const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
|
|
827
|
-
const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
|
|
828
|
-
if (!balanceIsZero(droppedBalance)) {
|
|
829
|
-
const delta = balanceDelta(
|
|
830
|
-
computeDelimiterBalance(group.payload),
|
|
831
|
-
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
832
|
-
);
|
|
833
|
-
if (!balanceEqual(droppedBalance, delta)) return undefined;
|
|
834
|
-
}
|
|
835
|
-
return { leading: leadingMax, trailing: trailingMax };
|
|
503
|
+
/** Whether deleting one source row from an otherwise valid file breaks it. */
|
|
504
|
+
function isSyntaxEssentialRow(
|
|
505
|
+
fileLines: readonly string[],
|
|
506
|
+
path: string,
|
|
507
|
+
line: number,
|
|
508
|
+
baselineParses: boolean,
|
|
509
|
+
): boolean {
|
|
510
|
+
if (!baselineParses) return true;
|
|
511
|
+
const without = [...fileLines.slice(0, line - 1), ...fileLines.slice(line)].join("\n");
|
|
512
|
+
return !parsesCleanly(path, without);
|
|
836
513
|
}
|
|
837
514
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
`Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
|
|
843
|
-
);
|
|
515
|
+
interface EdgeEvidence {
|
|
516
|
+
readonly first: boolean;
|
|
517
|
+
readonly last: boolean;
|
|
518
|
+
readonly leadingStructure: boolean;
|
|
844
519
|
}
|
|
845
520
|
|
|
846
|
-
function
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
521
|
+
function edgeEvidence(
|
|
522
|
+
fileLines: readonly string[],
|
|
523
|
+
path: string,
|
|
524
|
+
group: ReplacementGroup,
|
|
525
|
+
trailingLine: number,
|
|
526
|
+
baselineParses: boolean,
|
|
527
|
+
): EdgeEvidence {
|
|
528
|
+
if (!baselineParses) {
|
|
529
|
+
return { first: true, last: true, leadingStructure: false };
|
|
530
|
+
}
|
|
531
|
+
const first = isSyntaxEssentialRow(fileLines, path, group.startLine, true);
|
|
532
|
+
const last = trailingLine === group.startLine ? first : isSyntaxEssentialRow(fileLines, path, trailingLine, true);
|
|
533
|
+
const innerStart = group.startLine + 1;
|
|
534
|
+
const leadingStructure =
|
|
535
|
+
innerStart <= trailingLine &&
|
|
536
|
+
enclosingBoundaries(fileLines, path, innerStart, trailingLine).includes(group.startLine);
|
|
537
|
+
return { first, last, leadingStructure };
|
|
851
538
|
}
|
|
852
539
|
|
|
853
540
|
/**
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* short of the keeper I retyped" mistake (e.g. att: payload ends with
|
|
858
|
-
* `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
|
|
859
|
-
* are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
|
|
860
|
-
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
|
861
|
-
*
|
|
862
|
-
* Scoped broadly for multi-line ranges (a construct rewrite) because retouched
|
|
863
|
-
* neutral keepers are usually boundary mistakes there. Single-line expansions
|
|
864
|
-
* are riskier — ordinary duplicated statements may be intentional — so they are
|
|
865
|
-
* only repaired when the duplicated edge is a structural closer line that
|
|
866
|
-
* carries no delimiter-balance signal itself, such as a JSX `</section>` close.
|
|
867
|
-
* The dropped lines must keep the already-balanced result balanced, and must
|
|
868
|
-
* not consume the whole payload.
|
|
869
|
-
*
|
|
870
|
-
* A detected echo is only *repairable* when the payload is long enough to be
|
|
871
|
-
* the widened range's full content (`payload ≥ range + echo`). Shorter
|
|
872
|
-
* payloads are ambiguous — the echo may instead mean the range itself was
|
|
873
|
-
* shifted by the echo, which keeps the far boundary line(s) the repair would
|
|
874
|
-
* delete — and the caller rejects the edit instead of guessing.
|
|
541
|
+
* Retention is limited to the selected range's first and effective-last rows.
|
|
542
|
+
* On a valid baseline, deleting the row must break syntax; every candidate
|
|
543
|
+
* must also satisfy source-range structure or indentation evidence.
|
|
875
544
|
*/
|
|
876
|
-
function
|
|
545
|
+
function buildKeepPlans(
|
|
877
546
|
group: ReplacementGroup,
|
|
547
|
+
trailingLine: number,
|
|
548
|
+
payload: readonly string[],
|
|
878
549
|
fileLines: readonly string[],
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
550
|
+
evidence: EdgeEvidence,
|
|
551
|
+
path: string,
|
|
552
|
+
baselineParses: boolean,
|
|
553
|
+
): { plans: KeepPlan[]; ambiguous: boolean } {
|
|
554
|
+
const leadingPayload = payloadEdge(payload, "leading");
|
|
555
|
+
const trailingPayload = payloadEdge(payload, "trailing");
|
|
556
|
+
const plans: KeepPlan[] = [{ kept: 0 }];
|
|
557
|
+
if (leadingPayload === undefined || trailingPayload === undefined) return { plans, ambiguous: false };
|
|
558
|
+
|
|
559
|
+
const first = fileLines[group.startLine - 1] ?? "";
|
|
560
|
+
const last = fileLines[trailingLine - 1] ?? "";
|
|
561
|
+
const leadingIndent = indentColumns(leadingPayload);
|
|
562
|
+
const trailingIndent = indentColumns(trailingPayload);
|
|
563
|
+
const firstIndent = indentColumns(first);
|
|
564
|
+
const lastIndent = indentColumns(last);
|
|
565
|
+
let ambiguous = false;
|
|
566
|
+
|
|
567
|
+
if (group.startLine === trailingLine) {
|
|
568
|
+
const previous = nearestContentLine(fileLines, group.startLine - 2, -1);
|
|
569
|
+
const fitsBefore = previous === undefined || indentColumns(previous) === trailingIndent;
|
|
570
|
+
if (evidence.first && fitsBefore && trailingIndent > firstIndent) {
|
|
571
|
+
plans.push({ afterLine: group.startLine, kept: 1 });
|
|
572
|
+
} else if (baselineParses && evidence.first && trailingIndent === firstIndent) {
|
|
573
|
+
ambiguous = true;
|
|
574
|
+
}
|
|
575
|
+
return { plans, ambiguous };
|
|
893
576
|
}
|
|
894
|
-
return { side, count };
|
|
895
|
-
}
|
|
896
577
|
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
`The range was one line short of the content you retyped — issue the payload as the final content for the ` +
|
|
903
|
-
`selected range only, and widen the range to consume any keeper you restate.`
|
|
578
|
+
const next = nearestContentLine(fileLines, group.startLine, 1);
|
|
579
|
+
const previous = nearestContentLine(fileLines, trailingLine - 2, -1);
|
|
580
|
+
const beforeFirst = nearestContentLine(fileLines, group.startLine - 2, -1);
|
|
581
|
+
const selectedLeadingBoundary = enclosingBoundaries(fileLines, path, group.startLine + 1, group.endLine).includes(
|
|
582
|
+
group.startLine,
|
|
904
583
|
);
|
|
584
|
+
const firstText = (fileLines[group.startLine - 1] ?? "").trim();
|
|
585
|
+
const selectedStructuralEdge =
|
|
586
|
+
STRUCTURAL_CLOSER_RE.test(firstText) &&
|
|
587
|
+
firstIndent === leadingIndent &&
|
|
588
|
+
firstIndent === indentColumns(fileLines[group.endLine - 1] ?? "");
|
|
589
|
+
const underfilledEffectiveEdge =
|
|
590
|
+
trailingLine < group.endLine && payload.length < group.endLine - group.startLine + 1;
|
|
591
|
+
const keepsLeading =
|
|
592
|
+
evidence.first &&
|
|
593
|
+
(evidence.leadingStructure || selectedLeadingBoundary || selectedStructuralEdge || underfilledEffectiveEdge) &&
|
|
594
|
+
(next === undefined || selectedStructuralEdge
|
|
595
|
+
? leadingIndent >= firstIndent
|
|
596
|
+
: indentColumns(next) === leadingIndent);
|
|
597
|
+
const keepsTrailing =
|
|
598
|
+
(evidence.last || underfilledEffectiveEdge) &&
|
|
599
|
+
!keepsLeading &&
|
|
600
|
+
trailingIndent > lastIndent &&
|
|
601
|
+
(previous === undefined || indentColumns(previous) === trailingIndent);
|
|
602
|
+
if (keepsLeading) plans.push({ beforeLine: group.startLine, kept: 1 });
|
|
603
|
+
if (keepsTrailing) plans.push({ afterLine: trailingLine, kept: 1 });
|
|
604
|
+
// Retaining both edges can silently resurrect an intentionally removed
|
|
605
|
+
// wrapper or signature; parsing cannot distinguish that from omission.
|
|
606
|
+
if (
|
|
607
|
+
baselineParses &&
|
|
608
|
+
evidence.first &&
|
|
609
|
+
beforeFirst !== undefined &&
|
|
610
|
+
firstIndent < indentColumns(beforeFirst) &&
|
|
611
|
+
leadingIndent > firstIndent
|
|
612
|
+
) {
|
|
613
|
+
ambiguous = true;
|
|
614
|
+
}
|
|
615
|
+
return { plans, ambiguous };
|
|
905
616
|
}
|
|
906
617
|
|
|
907
618
|
/**
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
619
|
+
* Enumerate repair hypotheses for one replacement group. Exact outside echoes
|
|
620
|
+
* may be removed; edge retention requires syntax, source structure,
|
|
621
|
+
* indentation, or the narrow pure-closer sibling-depth shape. Tree-sitter
|
|
622
|
+
* validates every candidate result.
|
|
911
623
|
*/
|
|
912
|
-
|
|
913
|
-
| { kind: "edits"; edits: AppliedEdit[]; warning?: string }
|
|
914
|
-
| {
|
|
915
|
-
kind: "candidate";
|
|
916
|
-
group: ReplacementGroup;
|
|
917
|
-
inserts: AppliedEdit[];
|
|
918
|
-
deletes: AppliedEdit[];
|
|
919
|
-
delta: DelimiterBalance;
|
|
920
|
-
};
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* Delimiter balance of the lines immediately above a group's range that are
|
|
924
|
-
* themselves deleted by other hunks, netted against any payload inserted at
|
|
925
|
-
* those lines. When this covers the group's own delta the matching opener was
|
|
926
|
-
* deleted (or replaced by an opener of the same shape) just above — a deliberate
|
|
927
|
-
* wrapper removal — so the range's deleted closer must stay deleted, not be
|
|
928
|
-
* "kept". Scanned over its own contiguous lines so quote/comment state never
|
|
929
|
-
* bleeds in from elsewhere in the patch.
|
|
930
|
-
*/
|
|
931
|
-
function netDeletedPrefixBalance(
|
|
624
|
+
function buildGroupVariants(
|
|
932
625
|
group: ReplacementGroup,
|
|
933
|
-
|
|
934
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
626
|
+
edits: readonly AppliedEdit[],
|
|
935
627
|
fileLines: readonly string[],
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
628
|
+
path: string,
|
|
629
|
+
baselineParses: boolean,
|
|
630
|
+
): GroupVariants {
|
|
631
|
+
const inserts = replacementInserts(group, edits);
|
|
632
|
+
const deletes = replacementDeletes(group, edits);
|
|
633
|
+
const trailingLine = effectiveTrailingBoundary(group, edits, fileLines);
|
|
634
|
+
const evidence = edgeEvidence(fileLines, path, group, trailingLine, baselineParses);
|
|
635
|
+
const dropJ = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
636
|
+
const dropK = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
637
|
+
const leadingDrops = dropJ > 0 ? [0, dropJ] : [0];
|
|
638
|
+
const trailingDrops = dropK > 0 ? [0, dropK] : [0];
|
|
639
|
+
const variants: GroupVariant[] = [];
|
|
640
|
+
let ambiguous = false;
|
|
641
|
+
|
|
642
|
+
for (const leadingDrop of leadingDrops) {
|
|
643
|
+
for (const trailingDrop of trailingDrops) {
|
|
644
|
+
const dropped = leadingDrop + trailingDrop;
|
|
645
|
+
if (dropped >= inserts.length) continue;
|
|
646
|
+
const payload = group.payload.slice(leadingDrop, group.payload.length - trailingDrop);
|
|
647
|
+
const keepResult = buildKeepPlans(group, trailingLine, payload, fileLines, evidence, path, baselineParses);
|
|
648
|
+
ambiguous ||= keepResult.ambiguous;
|
|
649
|
+
for (const keep of keepResult.plans) {
|
|
650
|
+
if (keep.kept === 0 && dropped === 0) continue;
|
|
651
|
+
if (keep.kept > 0 && group.deleteIndices.length > 1 && payload.length > group.deleteIndices.length) {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
variants.push({
|
|
655
|
+
kept: keep.kept,
|
|
656
|
+
dropped,
|
|
657
|
+
edits: applyGroupVariant(
|
|
658
|
+
inserts,
|
|
659
|
+
deletes,
|
|
660
|
+
keep.beforeLine,
|
|
661
|
+
keep.afterLine,
|
|
662
|
+
leadingDrop,
|
|
663
|
+
trailingDrop,
|
|
664
|
+
fileLines.length,
|
|
665
|
+
),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
variants.sort(compareGroupVariant);
|
|
671
|
+
return { variants, ambiguous };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function compareGroupVariant(a: GroupVariant, b: GroupVariant): number {
|
|
675
|
+
return a.kept - b.kept || a.dropped - b.dropped;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function applyGroupVariant(
|
|
679
|
+
inserts: readonly InsertEdit[],
|
|
680
|
+
deletes: readonly DeleteEdit[],
|
|
681
|
+
beforeLine: number | undefined,
|
|
682
|
+
afterLine: number | undefined,
|
|
683
|
+
dropLeading: number,
|
|
684
|
+
dropTrailing: number,
|
|
685
|
+
fileLineCount: number,
|
|
686
|
+
): AppliedEdit[] {
|
|
687
|
+
let retainedInserts = inserts.slice(dropLeading, inserts.length - dropTrailing);
|
|
688
|
+
const retainedDeletes = deletes.filter(edit => edit.anchor.line !== beforeLine && edit.anchor.line !== afterLine);
|
|
689
|
+
if (beforeLine !== undefined) {
|
|
690
|
+
const cursor: Cursor =
|
|
691
|
+
beforeLine >= fileLineCount ? { kind: "eof" } : { kind: "before_anchor", anchor: { line: beforeLine + 1 } };
|
|
692
|
+
retainedInserts = retainedInserts.map(edit => ({ ...edit, cursor }));
|
|
943
693
|
}
|
|
944
|
-
return
|
|
694
|
+
return [...retainedInserts, ...retainedDeletes];
|
|
945
695
|
}
|
|
946
696
|
|
|
947
|
-
/**
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
else deleted.push(fileLines[edit.anchor.line - 1] ?? "");
|
|
961
|
-
}
|
|
962
|
-
return balanceDelta(computeDelimiterBalance(inserted), computeDelimiterBalance(deleted));
|
|
697
|
+
/** One choice per broken group; `null` keeps the group as authored. */
|
|
698
|
+
interface BoundaryCombo {
|
|
699
|
+
readonly variants: readonly (GroupVariant | null)[];
|
|
700
|
+
readonly touched: number;
|
|
701
|
+
readonly kept: number;
|
|
702
|
+
readonly dropped: number;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Combinatorial cap after each group is added to the candidate beam. */
|
|
706
|
+
const MAX_BOUNDARY_COMBOS = 512;
|
|
707
|
+
|
|
708
|
+
function compareBoundaryCombo(a: BoundaryCombo, b: BoundaryCombo): number {
|
|
709
|
+
return a.touched - b.touched || a.kept - b.kept || a.dropped - b.dropped;
|
|
963
710
|
}
|
|
964
711
|
|
|
965
712
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
* against the whole-patch delimiter residual, so a closer is only kept when
|
|
971
|
-
* the patch as a whole is missing it — never when another hunk already
|
|
972
|
-
* removed the matching opener.
|
|
973
|
-
*
|
|
974
|
-
* Textual repairs (boundary echoes, payload lines duplicated from just outside
|
|
975
|
-
* the range) are evidence-complete on their own and always applied. The
|
|
976
|
-
* closer-spare repairs are not: they claim a lone `}` is syntax. They run only
|
|
977
|
-
* when `applySpares` is set, which {@link applyEdits} does only after the
|
|
978
|
-
* tree-sitter probe shows the authored edits broke a file that previously
|
|
979
|
-
* parsed. With `applySpares` false the same detections are reported through
|
|
980
|
-
* `suspicious` / `advisories` and nothing is rewritten.
|
|
981
|
-
*
|
|
982
|
-
* When the spares do run, they fire only if exactly one reading explains the
|
|
983
|
-
* mistake; ambiguous evidence — a one-sided echo whose payload is too short
|
|
984
|
-
* for the widened range, a spared trailing closer the payload neither opens
|
|
985
|
-
* nor indents into, or a spared leading closer whose payload claims the block
|
|
986
|
-
* interior — throws instead of guessing, so the author re-issues the edit
|
|
987
|
-
* rather than shipping silently corrupted content.
|
|
713
|
+
* Search boundary hypotheses across the whole patch. Parsing is the semantic
|
|
714
|
+
* filter; the deterministic cost prefers fewer touched groups, retained rows,
|
|
715
|
+
* then exact echo drops. Different texts tied at that full cost are not
|
|
716
|
+
* guessed.
|
|
988
717
|
*/
|
|
989
|
-
function
|
|
718
|
+
function repairBoundaryVariants(
|
|
990
719
|
edits: readonly AppliedEdit[],
|
|
991
720
|
fileLines: readonly string[],
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
sparesProposed: boolean;
|
|
1000
|
-
/** Diagnostics to surface only if the result is kept unrepaired. */
|
|
1001
|
-
advisories: string[];
|
|
1002
|
-
} {
|
|
1003
|
-
// Pass 1: apply every repair whose correctness is local to one group
|
|
1004
|
-
// (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
|
|
1005
|
-
// it must weigh a group's imbalance against the whole patch, which is only
|
|
1006
|
-
// known once the local repairs above have settled.
|
|
1007
|
-
const slots: RepairSlot[] = [];
|
|
721
|
+
path: string | undefined,
|
|
722
|
+
baselineParses: boolean,
|
|
723
|
+
): { edits: AppliedEdit[]; warnings: string[] } | undefined {
|
|
724
|
+
if (path === undefined) return undefined;
|
|
725
|
+
|
|
726
|
+
const groups: { group: ReplacementGroup; variants: GroupVariant[] }[] = [];
|
|
727
|
+
let ambiguousGroup: ReplacementGroup | undefined;
|
|
1008
728
|
let i = 0;
|
|
1009
729
|
while (i < edits.length) {
|
|
1010
730
|
const group = findReplacementGroup(edits, i);
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
731
|
+
if (group) {
|
|
732
|
+
const built = buildGroupVariants(group, edits, fileLines, path, baselineParses);
|
|
733
|
+
if (built.ambiguous && ambiguousGroup === undefined) ambiguousGroup = group;
|
|
734
|
+
if (built.variants.length > 0) groups.push({ group, variants: built.variants });
|
|
735
|
+
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
736
|
+
} else {
|
|
1013
737
|
i++;
|
|
1014
|
-
continue;
|
|
1015
738
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const boundaryEcho = findBoundaryEcho(group, fileLines);
|
|
1021
|
-
if (boundaryEcho) {
|
|
1022
|
-
slots.push({
|
|
1023
|
-
kind: "edits",
|
|
1024
|
-
edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes],
|
|
1025
|
-
warning: describeBoundaryEchoRepair(group, boundaryEcho),
|
|
1026
|
-
});
|
|
1027
|
-
continue;
|
|
739
|
+
}
|
|
740
|
+
if (groups.length === 0) {
|
|
741
|
+
if (ambiguousGroup) {
|
|
742
|
+
throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
|
|
1028
743
|
}
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
1029
746
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
)
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
if (group.payload.length < group.deleteIndices.length + oneSided.count) {
|
|
1042
|
-
throw new Error(
|
|
1043
|
-
ambiguousBoundaryEchoMessage(group.startLine, group.endLine, oneSided.side, oneSided.count),
|
|
1044
|
-
);
|
|
1045
|
-
}
|
|
1046
|
-
const trimmed =
|
|
1047
|
-
oneSided.side === "leading"
|
|
1048
|
-
? inserts.slice(oneSided.count)
|
|
1049
|
-
: inserts.slice(0, inserts.length - oneSided.count);
|
|
1050
|
-
slots.push({
|
|
1051
|
-
kind: "edits",
|
|
1052
|
-
edits: [...trimmed, ...deletes],
|
|
1053
|
-
warning: describeOneSidedEchoRepair(group, oneSided.side, oneSided.count),
|
|
747
|
+
let combos: BoundaryCombo[] = [{ variants: [], touched: 0, kept: 0, dropped: 0 }];
|
|
748
|
+
for (const { variants } of groups) {
|
|
749
|
+
const next: BoundaryCombo[] = [];
|
|
750
|
+
for (const combo of combos) {
|
|
751
|
+
next.push({ ...combo, variants: [...combo.variants, null] });
|
|
752
|
+
for (const variant of variants) {
|
|
753
|
+
next.push({
|
|
754
|
+
variants: [...combo.variants, variant],
|
|
755
|
+
touched: combo.touched + 1,
|
|
756
|
+
kept: combo.kept + variant.kept,
|
|
757
|
+
dropped: combo.dropped + variant.dropped,
|
|
1054
758
|
});
|
|
1055
|
-
continue;
|
|
1056
759
|
}
|
|
1057
|
-
slots.push({ kind: "edits", edits: [...inserts, ...deletes] });
|
|
1058
|
-
continue;
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
|
|
1062
|
-
if (dupSuffix > 0) {
|
|
1063
|
-
slots.push({
|
|
1064
|
-
kind: "edits",
|
|
1065
|
-
edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes],
|
|
1066
|
-
warning: describeBoundaryRepair(
|
|
1067
|
-
group,
|
|
1068
|
-
`dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
|
|
1069
|
-
),
|
|
1070
|
-
});
|
|
1071
|
-
continue;
|
|
1072
|
-
}
|
|
1073
|
-
const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
|
|
1074
|
-
if (dupPrefix > 0) {
|
|
1075
|
-
slots.push({
|
|
1076
|
-
kind: "edits",
|
|
1077
|
-
edits: [...inserts.slice(dupPrefix), ...deletes],
|
|
1078
|
-
warning: describeBoundaryRepair(
|
|
1079
|
-
group,
|
|
1080
|
-
`dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
|
|
1081
|
-
),
|
|
1082
|
-
});
|
|
1083
|
-
continue;
|
|
1084
760
|
}
|
|
1085
|
-
|
|
761
|
+
next.sort(compareBoundaryCombo);
|
|
762
|
+
combos = next.slice(0, MAX_BOUNDARY_COMBOS);
|
|
1086
763
|
}
|
|
1087
764
|
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if (inserted) inserted.push(edit.text);
|
|
1105
|
-
else insertedByLine.set(1, [edit.text]);
|
|
1106
|
-
const before = insertedLineMaps.before.get(1);
|
|
1107
|
-
if (before) before.push(edit.text);
|
|
1108
|
-
else insertedLineMaps.before.set(1, [edit.text]);
|
|
765
|
+
const authored = materializeEdits(
|
|
766
|
+
fileLines,
|
|
767
|
+
edits.map((edit, index) => cloneAppliedEdit(edit, index)),
|
|
768
|
+
).text;
|
|
769
|
+
const candidates = combos.filter(combo => combo.touched > 0).sort(compareBoundaryCombo);
|
|
770
|
+
|
|
771
|
+
let bestText: string | undefined;
|
|
772
|
+
let bestCombo: BoundaryCombo | undefined;
|
|
773
|
+
for (const combo of candidates) {
|
|
774
|
+
if (bestCombo !== undefined && compareBoundaryCombo(combo, bestCombo) > 0) break;
|
|
775
|
+
const candidate = spliceBoundaryCombo(edits, groups, combo);
|
|
776
|
+
const text = materializeEdits(fileLines, candidate).text;
|
|
777
|
+
if (text === authored || !parsesCleanly(path, text)) continue;
|
|
778
|
+
if (bestCombo === undefined) {
|
|
779
|
+
bestCombo = combo;
|
|
780
|
+
bestText = text;
|
|
1109
781
|
continue;
|
|
1110
782
|
}
|
|
1111
|
-
if (
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
783
|
+
if (text !== bestText) {
|
|
784
|
+
if (ambiguousGroup) {
|
|
785
|
+
throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
|
|
786
|
+
}
|
|
787
|
+
return undefined;
|
|
1116
788
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
else bySide.set(edit.cursor.anchor.line, [edit.text]);
|
|
789
|
+
}
|
|
790
|
+
if (bestCombo === undefined) {
|
|
791
|
+
if (ambiguousGroup) {
|
|
792
|
+
throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
|
|
1122
793
|
}
|
|
794
|
+
return undefined;
|
|
1123
795
|
}
|
|
1124
|
-
let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
1125
|
-
for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
|
|
1126
796
|
|
|
1127
|
-
const out: AppliedEdit[] = [];
|
|
1128
797
|
const warnings: string[] = [];
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
);
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
|
-
// Sparing a closer re-inserts it *after* the payload, which claims
|
|
1160
|
-
// the payload lives inside the block the closer terminates. That
|
|
1161
|
-
// claim needs evidence: the payload carries the closer's unmatched
|
|
1162
|
-
// opener itself, or its indentation sits deeper than the closer.
|
|
1163
|
-
// Without either, "before or after the closer" is a coin flip —
|
|
1164
|
-
// reject rather than guess (e.g. a statement swapped onto a lone
|
|
1165
|
-
// `}` at the closer's own depth belongs after the block).
|
|
1166
|
-
const keptIndent = leadingIndent(fileLines[droppedClosers.startLine - 1] ?? "");
|
|
1167
|
-
const payloadIndent = bodyTargetIndent(slot.group.payload);
|
|
1168
|
-
const payloadOpens = balanceCovers(
|
|
1169
|
-
computeDelimiterBalance(slot.group.payload),
|
|
1170
|
-
balanceNegate(droppedClosers.balance),
|
|
1171
|
-
);
|
|
1172
|
-
if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) {
|
|
1173
|
-
throw new CloserSpareAmbiguityError(
|
|
1174
|
-
ambiguousCloserSpareMessage(
|
|
1175
|
-
slot.group.startLine,
|
|
1176
|
-
slot.group.endLine,
|
|
1177
|
-
droppedClosers.startLine,
|
|
1178
|
-
droppedClosers.count,
|
|
1179
|
-
),
|
|
1180
|
-
);
|
|
1181
|
-
}
|
|
1182
|
-
warnings.push(
|
|
1183
|
-
describeBoundaryRepair(
|
|
1184
|
-
slot.group,
|
|
1185
|
-
`kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`,
|
|
1186
|
-
),
|
|
1187
|
-
);
|
|
1188
|
-
out.push(
|
|
1189
|
-
...slot.inserts,
|
|
1190
|
-
...slot.deletes.filter(
|
|
1191
|
-
edit =>
|
|
1192
|
-
edit.kind !== "delete" ||
|
|
1193
|
-
edit.anchor.line < droppedClosers.startLine ||
|
|
1194
|
-
edit.anchor.line >= droppedClosers.startLine + droppedClosers.count,
|
|
1195
|
-
),
|
|
1196
|
-
);
|
|
1197
|
-
for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) {
|
|
1198
|
-
deletedLines.delete(line);
|
|
1199
|
-
}
|
|
1200
|
-
remainingDelta = balanceSum(remainingDelta, droppedClosers.balance);
|
|
798
|
+
groups.forEach((entry, index) => {
|
|
799
|
+
const variant = bestCombo.variants[index];
|
|
800
|
+
if (variant) warnings.push(boundaryVariantRepairWarning(entry.group.startLine, variant.kept, variant.dropped));
|
|
801
|
+
});
|
|
802
|
+
return { edits: spliceBoundaryCombo(edits, groups, bestCombo), warnings };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Replace each group's authored edits with its combo variant (or the authored
|
|
806
|
+
* edits where the combo leaves a group untouched). Groups are keyed by their
|
|
807
|
+
* first insert index — `findReplacementGroup` builds fresh objects per scan,
|
|
808
|
+
* so object identity cannot be the key. */
|
|
809
|
+
function spliceBoundaryCombo(
|
|
810
|
+
edits: readonly AppliedEdit[],
|
|
811
|
+
groups: readonly { group: ReplacementGroup; variants: GroupVariant[] }[],
|
|
812
|
+
combo: BoundaryCombo,
|
|
813
|
+
): AppliedEdit[] {
|
|
814
|
+
const chosen = new Map<number, GroupVariant>();
|
|
815
|
+
groups.forEach((entry, idx) => {
|
|
816
|
+
const variant = combo.variants[idx];
|
|
817
|
+
if (variant) chosen.set(entry.group.insertIndices[0], variant);
|
|
818
|
+
});
|
|
819
|
+
const out: AppliedEdit[] = [];
|
|
820
|
+
let i = 0;
|
|
821
|
+
while (i < edits.length) {
|
|
822
|
+
const group = findReplacementGroup(edits, i);
|
|
823
|
+
if (!group) {
|
|
824
|
+
out.push(cloneAppliedEdit(edits[i], i));
|
|
825
|
+
i++;
|
|
1201
826
|
continue;
|
|
1202
827
|
}
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
deletedLines,
|
|
1210
|
-
insertedByLine,
|
|
1211
|
-
);
|
|
1212
|
-
if (droppedPrefix) {
|
|
1213
|
-
suspicious = true;
|
|
1214
|
-
sparesProposed = true;
|
|
1215
|
-
if (!applySpares) {
|
|
1216
|
-
out.push(...slot.inserts, ...slot.deletes);
|
|
1217
|
-
continue;
|
|
1218
|
-
}
|
|
1219
|
-
// Sparing a leading closer re-inserts it *before* the payload,
|
|
1220
|
-
// which claims the payload lives outside (after) the block the
|
|
1221
|
-
// closer terminates. The payload's indentation makes that call:
|
|
1222
|
-
// at-or-above the closer's depth is sibling position; a deeper or
|
|
1223
|
-
// incomparable claim would put the payload inside the block the
|
|
1224
|
-
// range just closed — reject rather than guess.
|
|
1225
|
-
const closerIndent = leadingIndent(fileLines[slot.group.startLine - 1] ?? "");
|
|
1226
|
-
const payloadIndent = bodyTargetIndent(slot.group.payload);
|
|
1227
|
-
if (payloadIndent !== undefined) {
|
|
1228
|
-
if (!closerIndent.startsWith(payloadIndent)) {
|
|
1229
|
-
throw new CloserSpareAmbiguityError(
|
|
1230
|
-
ambiguousLeadingCloserSpareMessage(slot.group.startLine, slot.group.endLine, droppedPrefix.count),
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
const spareEnd = slot.group.startLine + droppedPrefix.count;
|
|
1234
|
-
warnings.push(
|
|
1235
|
-
describeBoundaryRepair(
|
|
1236
|
-
slot.group,
|
|
1237
|
-
`kept ${droppedPrefix.count} leading structural closing line(s) the range deleted without restating; the payload lands after them`,
|
|
1238
|
-
),
|
|
1239
|
-
);
|
|
1240
|
-
out.push(
|
|
1241
|
-
...slot.inserts.map(edit =>
|
|
1242
|
-
edit.kind === "insert"
|
|
1243
|
-
? { ...edit, cursor: { kind: "before_anchor" as const, anchor: { line: spareEnd } } }
|
|
1244
|
-
: edit,
|
|
1245
|
-
),
|
|
1246
|
-
...slot.deletes.filter(edit => edit.kind !== "delete" || edit.anchor.line >= spareEnd),
|
|
1247
|
-
);
|
|
1248
|
-
for (let line = slot.group.startLine; line < spareEnd; line++) deletedLines.delete(line);
|
|
1249
|
-
remainingDelta = balanceSum(remainingDelta, droppedPrefix.balance);
|
|
1250
|
-
continue;
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
const orphanedOpeners = countOrphanedOpeners(slot.group, slot.delta, remainingDelta, fileLines);
|
|
1254
|
-
if (orphanedOpeners > 0) {
|
|
1255
|
-
suspicious = true;
|
|
1256
|
-
advisories.push(midBlockRangeWarning(slot.group.startLine, slot.group.endLine, orphanedOpeners));
|
|
828
|
+
const variant = chosen.get(group.insertIndices[0]);
|
|
829
|
+
if (variant) {
|
|
830
|
+
out.push(...variant.edits);
|
|
831
|
+
} else {
|
|
832
|
+
for (const idx of group.insertIndices) out.push(cloneAppliedEdit(edits[idx], idx));
|
|
833
|
+
for (const idx of group.deleteIndices) out.push(cloneAppliedEdit(edits[idx], idx));
|
|
1257
834
|
}
|
|
1258
|
-
|
|
835
|
+
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
1259
836
|
}
|
|
1260
|
-
return
|
|
837
|
+
return out;
|
|
1261
838
|
}
|
|
1262
839
|
|
|
1263
840
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1449,12 +1026,12 @@ function repairAfterInsertLandings(
|
|
|
1449
1026
|
const retarget = (group: AfterInsertGroup, line: number): void => {
|
|
1450
1027
|
out ??= [...edits];
|
|
1451
1028
|
for (const idx of group.members) {
|
|
1452
|
-
const edit = out
|
|
1029
|
+
const edit = insertEditAt(out, idx);
|
|
1453
1030
|
out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
|
|
1454
1031
|
}
|
|
1455
1032
|
};
|
|
1456
1033
|
for (const group of groups.values()) {
|
|
1457
|
-
const target = bodyTargetIndent(group.members.map(idx => (edits
|
|
1034
|
+
const target = bodyTargetIndent(group.members.map(idx => insertEditAt(edits, idx).text));
|
|
1458
1035
|
if (target === undefined) continue;
|
|
1459
1036
|
const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
|
|
1460
1037
|
if (outward !== undefined) {
|
|
@@ -1482,11 +1059,10 @@ export interface ApplyEditsOptions {
|
|
|
1482
1059
|
/** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */
|
|
1483
1060
|
onEmptyPaste?: "throw" | "drop";
|
|
1484
1061
|
/**
|
|
1485
|
-
* Target
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
* no veto and the delimiter heuristics decide alone.
|
|
1062
|
+
* Target path used to infer a language for the tree-sitter syntax probe.
|
|
1063
|
+
* Required for syntax-essential boundary retention and post-apply syntax
|
|
1064
|
+
* advisories. Without it, only exact-text boundary normalization and its
|
|
1065
|
+
* evidence-complete rejections run.
|
|
1490
1066
|
*/
|
|
1491
1067
|
path?: string;
|
|
1492
1068
|
}
|
|
@@ -1590,13 +1166,11 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
|
|
|
1590
1166
|
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
1591
1167
|
* Throws if an anchor is out of bounds.
|
|
1592
1168
|
*
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
* authored result does not parse — or the language is unknown to the parser, so
|
|
1599
|
-
* balance arithmetic is the sole evidence — do the closer-spare repairs run.
|
|
1169
|
+
* Mis-set replacement boundaries are repaired by {@link repairBoundaryVariants}
|
|
1170
|
+
* when `options.path` lets tree-sitter judge the result. A parsing authored
|
|
1171
|
+
* result is never second-guessed. For a broken result, only syntax-essential
|
|
1172
|
+
* edge retention with matching indentation and exact outside-row echo removal
|
|
1173
|
+
* are considered; every selected candidate must parse.
|
|
1600
1174
|
*/
|
|
1601
1175
|
export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
|
|
1602
1176
|
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
@@ -1614,10 +1188,12 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1614
1188
|
// Block edits are deferred until `resolveBlockEdits` expands them into
|
|
1615
1189
|
// concrete inserts + deletes. Reaching the applier with one still present
|
|
1616
1190
|
// is an internal wiring bug, not authored-input error.
|
|
1191
|
+
const appliedEdits: AppliedEdit[] = [];
|
|
1617
1192
|
for (const edit of concrete) {
|
|
1618
1193
|
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
|
1194
|
+
if (edit.kind === "cut" || edit.kind === "paste") throw new Error(UNRESOLVED_CLIPBOARD_INTERNAL);
|
|
1195
|
+
appliedEdits.push(edit);
|
|
1619
1196
|
}
|
|
1620
|
-
const appliedEdits = concrete as readonly AppliedEdit[];
|
|
1621
1197
|
|
|
1622
1198
|
const targetEdits = dropTrailingPhantomDeletes(
|
|
1623
1199
|
appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
|
|
@@ -1625,50 +1201,49 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1625
1201
|
);
|
|
1626
1202
|
validateLineBounds(targetEdits, fileLines);
|
|
1627
1203
|
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
const authored = repairReplacementBoundaries(targetEdits, fileLines, false);
|
|
1204
|
+
const normalized = normalizeTextualBoundaryEchoes(targetEdits, fileLines);
|
|
1205
|
+
const leading = [...clipboardWarnings, ...indentationWarnings, ...normalized.warnings];
|
|
1206
|
+
const authoredResult = materializeEdits(fileLines, normalized.edits);
|
|
1207
|
+
const baselineParses = parsesCleanly(options.path, text);
|
|
1208
|
+
const authoredParses = parsesCleanly(options.path, authoredResult.text);
|
|
1634
1209
|
const finish = (result: Materialized, warnings: string[]): ApplyResult => {
|
|
1635
1210
|
const merged = [...warnings, ...result.warnings];
|
|
1211
|
+
// Post-apply syntax advisory: the result stopped parsing while the
|
|
1212
|
+
// pre-edit text parsed, so this patch demonstrably introduced the
|
|
1213
|
+
// error. Catches misplacements no boundary variant can explain.
|
|
1214
|
+
if (!parsesCleanly(options.path, result.text) && baselineParses) {
|
|
1215
|
+
merged.push(editBrokeParseWarning(result.firstChangedLine));
|
|
1216
|
+
}
|
|
1636
1217
|
return {
|
|
1637
1218
|
text: result.text,
|
|
1638
1219
|
firstChangedLine: result.firstChangedLine,
|
|
1639
1220
|
...(merged.length > 0 ? { warnings: merged } : {}),
|
|
1640
1221
|
};
|
|
1641
1222
|
};
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const sparedResult = materializeEdits(fileLines, spared.edits);
|
|
1659
|
-
if (parsesCleanly(options.path, sparedResult.text)) {
|
|
1660
|
-
return finish(sparedResult, [...leading, ...spared.warnings, ...spared.advisories]);
|
|
1661
|
-
}
|
|
1662
|
-
} catch (error) {
|
|
1663
|
-
// Only the closer-spare verdict is the parser's business, and only on
|
|
1664
|
-
// a file it can vouch for. Every other rejection — notably the
|
|
1665
|
-
// evidence-complete one-sided boundary echo, which is proven by exact
|
|
1666
|
-
// line equality and would otherwise delete range lines the body never
|
|
1667
|
-
// restates — propagates regardless of what the parser knows.
|
|
1668
|
-
if (baselineParses || !(error instanceof CloserSpareAmbiguityError)) throw error;
|
|
1223
|
+
const ambiguity = normalized.ambiguities[0];
|
|
1224
|
+
// Exact-text normalization is evidence-complete. If it leaves a parsing
|
|
1225
|
+
// result, no speculative keep/drop variant may second-guess it.
|
|
1226
|
+
if (authoredParses) {
|
|
1227
|
+
if (ambiguity) {
|
|
1228
|
+
throw new Error(
|
|
1229
|
+
ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count),
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
return finish(authoredResult, leading);
|
|
1233
|
+
}
|
|
1234
|
+
const repaired = repairBoundaryVariants(normalized.edits, fileLines, options.path, baselineParses);
|
|
1235
|
+
if (repaired) {
|
|
1236
|
+
const repairedResult = materializeEdits(fileLines, repaired.edits);
|
|
1237
|
+
if (parsesCleanly(options.path, repairedResult.text)) {
|
|
1238
|
+
return finish(repairedResult, [...leading, ...repaired.warnings]);
|
|
1669
1239
|
}
|
|
1670
1240
|
}
|
|
1241
|
+
if (ambiguity) {
|
|
1242
|
+
throw new Error(
|
|
1243
|
+
ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count),
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1671
1246
|
// Nothing proven: leave the authored edit exactly as written. Report the
|
|
1672
|
-
// damage
|
|
1673
|
-
return finish(authoredResult,
|
|
1247
|
+
// damage — the baseline parsed, so this edit demonstrably caused it.
|
|
1248
|
+
return finish(authoredResult, leading);
|
|
1674
1249
|
}
|