@oh-my-pi/hashline 17.2.15 → 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 +6 -0
- package/dist/types/apply.d.ts +13 -15
- package/dist/types/messages.d.ts +20 -33
- package/dist/types/syntax.d.ts +8 -7
- package/package.json +3 -3
- package/src/apply.ts +536 -1003
- package/src/messages.ts +39 -58
- package/src/prompt.md +4 -2
- package/src/syntax.ts +33 -7
package/src/apply.ts
CHANGED
|
@@ -3,24 +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
|
+
boundaryVariantRepairWarning,
|
|
18
19
|
editBrokeParseWarning,
|
|
19
|
-
midBlockRangeWarning,
|
|
20
20
|
REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
|
|
21
|
+
textualBoundaryEchoWarning,
|
|
21
22
|
UNRESOLVED_BLOCK_INTERNAL,
|
|
23
|
+
UNRESOLVED_CLIPBOARD_INTERNAL,
|
|
22
24
|
} from "./messages";
|
|
23
|
-
import { parsesCleanly } from "./syntax";
|
|
25
|
+
import { enclosingBoundaries, parsesCleanly } from "./syntax";
|
|
24
26
|
import { cloneCursor } from "./tokenizer";
|
|
25
27
|
import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
|
|
26
28
|
|
|
@@ -30,6 +32,14 @@ type InsertEdit = Extract<Edit, { kind: "insert" }>;
|
|
|
30
32
|
type DeleteEdit = Extract<Edit, { kind: "delete" }>;
|
|
31
33
|
type AppliedEdit = InsertEdit | DeleteEdit;
|
|
32
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
|
+
|
|
33
43
|
interface IndexedEdit {
|
|
34
44
|
edit: AppliedEdit;
|
|
35
45
|
idx: number;
|
|
@@ -124,269 +134,25 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
124
134
|
}
|
|
125
135
|
return byLine;
|
|
126
136
|
}
|
|
127
|
-
/**
|
|
128
|
-
* A closer-spare repair could not tell which side of a spared delimiter the
|
|
129
|
-
* payload belongs on. Distinct from the evidence-complete textual rejections
|
|
130
|
-
* (a one-sided boundary echo) so {@link applyEdits} can withhold *only* this
|
|
131
|
-
* delimiter-semantics verdict on a file the parser cannot vouch for, while
|
|
132
|
-
* every other rejection propagates unconditionally.
|
|
133
|
-
*/
|
|
134
|
-
class CloserSpareAmbiguityError extends Error {
|
|
135
|
-
constructor(message: string) {
|
|
136
|
-
super(message);
|
|
137
|
-
this.name = "CloserSpareAmbiguityError";
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
137
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
142
138
|
// Replacement-boundary repair
|
|
143
139
|
//
|
|
144
|
-
// Models routinely miscount
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
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.
|
|
148
146
|
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// 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.
|
|
155
152
|
|
|
156
153
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
157
154
|
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
158
155
|
|
|
159
|
-
/** A JSX/XML closing boundary that carries structure but no bracket tokens. */
|
|
160
|
-
const JSX_CLOSER_RE = /^\s*(?:<\/>|<\/[A-Za-z][\w.:-]*>|\/>)\s*[;,]?\s*$/;
|
|
161
|
-
const JSX_NAMED_CLOSER_RE = /^\s*<\/([A-Za-z][\w.:-]*)>\s*[;,]?\s*$/;
|
|
162
|
-
const JSX_FRAGMENT_CLOSER_RE = /^\s*<\/>\s*[;,]?\s*$/;
|
|
163
|
-
|
|
164
|
-
function isStructuralCloserLine(text: string): boolean {
|
|
165
|
-
return STRUCTURAL_CLOSER_RE.test(text) || JSX_CLOSER_RE.test(text);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function jsxCloserName(text: string): string | undefined {
|
|
169
|
-
if (JSX_FRAGMENT_CLOSER_RE.test(text)) return "";
|
|
170
|
-
const match = JSX_NAMED_CLOSER_RE.exec(text);
|
|
171
|
-
return match?.[1];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
interface JsxPayloadTag {
|
|
175
|
-
readonly name: string;
|
|
176
|
-
readonly closing: boolean;
|
|
177
|
-
readonly selfClosing: boolean;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function isJsxTagStart(text: string, index: number): boolean {
|
|
181
|
-
const next = text[index + 1];
|
|
182
|
-
return next === ">" || next === "/" || (next >= "A" && next <= "Z") || (next >= "a" && next <= "z");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function findJsxTagEnd(text: string, start: number): number {
|
|
186
|
-
let quote: string | undefined;
|
|
187
|
-
let braces = 0;
|
|
188
|
-
for (let i = start + 1; i < text.length; i++) {
|
|
189
|
-
const ch = text[i];
|
|
190
|
-
if (quote) {
|
|
191
|
-
if (ch === "\\" && i + 1 < text.length) {
|
|
192
|
-
i++;
|
|
193
|
-
} else if (ch === quote) {
|
|
194
|
-
quote = undefined;
|
|
195
|
-
}
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
199
|
-
quote = ch;
|
|
200
|
-
} else if (ch === "{") {
|
|
201
|
-
braces++;
|
|
202
|
-
} else if (ch === "}" && braces > 0) {
|
|
203
|
-
braces--;
|
|
204
|
-
} else if (ch === ">" && braces === 0) {
|
|
205
|
-
return i;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return -1;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function parseJsxPayloadTag(raw: string): JsxPayloadTag | undefined {
|
|
212
|
-
if (raw === "<>") return { name: "", closing: false, selfClosing: false };
|
|
213
|
-
if (raw === "</>") return { name: "", closing: true, selfClosing: false };
|
|
214
|
-
const closing = raw.startsWith("</");
|
|
215
|
-
const nameStart = closing ? 2 : 1;
|
|
216
|
-
let nameEnd = nameStart;
|
|
217
|
-
while (nameEnd < raw.length && /[\w.:-]/.test(raw[nameEnd])) nameEnd++;
|
|
218
|
-
if (nameEnd === nameStart) return undefined;
|
|
219
|
-
return {
|
|
220
|
-
name: raw.slice(nameStart, nameEnd),
|
|
221
|
-
closing,
|
|
222
|
-
selfClosing: !closing && /\/>\s*$/.test(raw),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function readJsxPayloadTags(text: string): JsxPayloadTag[] {
|
|
227
|
-
const tags: JsxPayloadTag[] = [];
|
|
228
|
-
for (let start = text.indexOf("<"); start >= 0; start = text.indexOf("<", start + 1)) {
|
|
229
|
-
if (!isJsxTagStart(text, start)) continue;
|
|
230
|
-
const end = findJsxTagEnd(text, start);
|
|
231
|
-
if (end < 0) break;
|
|
232
|
-
const tag = parseJsxPayloadTag(text.slice(start, end + 1));
|
|
233
|
-
if (tag) tags.push(tag);
|
|
234
|
-
start = end;
|
|
235
|
-
}
|
|
236
|
-
return tags;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function payloadHasJsxOpenerForEcho(payloadPrefix: readonly string[], echoLines: readonly string[]): boolean {
|
|
240
|
-
const openTags: string[] = [];
|
|
241
|
-
for (const tag of readJsxPayloadTags(payloadPrefix.join("\n"))) {
|
|
242
|
-
if (tag.closing) {
|
|
243
|
-
if (openTags[openTags.length - 1] === tag.name) openTags.pop();
|
|
244
|
-
} else if (!tag.selfClosing) {
|
|
245
|
-
openTags.push(tag.name);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
for (const line of echoLines) {
|
|
249
|
-
const name = jsxCloserName(line);
|
|
250
|
-
if (name !== undefined && openTags.includes(name)) return true;
|
|
251
|
-
}
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
interface DelimiterBalance {
|
|
256
|
-
paren: number;
|
|
257
|
-
bracket: number;
|
|
258
|
-
brace: number;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Single-quote lexing mode for {@link computeDelimiterBalance}. `"literal"`:
|
|
263
|
-
* `'…'` is a same-line string (JS/Python/C-like default, the original
|
|
264
|
-
* behavior). `"rust"`: `'` opens a literal only when it lexes as a Rust char
|
|
265
|
-
* literal (`'a'`, `'\n'`, `'\u{7FFF}'`); any other `'` is a lifetime or
|
|
266
|
-
* apostrophe and stays an ordinary character — pairing arbitrary apostrophes
|
|
267
|
-
* would swallow real delimiters between two lifetimes (`<'a>(x: &'a str)`
|
|
268
|
-
* loses the `(`), and quote-state-to-EOL hides the opener on signature lines
|
|
269
|
-
* (`&'static str {` — the `extension()` incident).
|
|
270
|
-
*
|
|
271
|
-
* Module-scoped rather than threaded: the scan helpers are a dozen pure free
|
|
272
|
-
* functions all rooted in the synchronous `applyEdits` call, which sets the
|
|
273
|
-
* mode from its target path on every entry.
|
|
274
|
-
*/
|
|
275
|
-
let singleQuoteMode: "literal" | "rust" = "literal";
|
|
276
|
-
|
|
277
|
-
/** Set {@link singleQuoteMode} from the target file's extension. */
|
|
278
|
-
function setDelimiterScanLanguage(path: string | undefined): void {
|
|
279
|
-
singleQuoteMode = path?.endsWith(".rs") ? "rust" : "literal";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Rust char literal at one position: `'a'`, `'\n'`, `'\x41'`, `'\u{7FFF}'`. */
|
|
283
|
-
const RUST_CHAR_LITERAL_RE = /^'(?:\\u\{[0-9a-fA-F_]{1,6}\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'])'/;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
|
|
287
|
-
* comments (`//`), block comments, and string/template literals. Block-comment
|
|
288
|
-
* and backtick-template state carry across lines; `"` / `'` reset at EOL since
|
|
289
|
-
* they cannot span lines. Single-quote handling follows the target language
|
|
290
|
-
* (see {@link singleQuoteMode}). Deliberately language-light otherwise:
|
|
291
|
-
* constructs it cannot classify (e.g. regex literals) are counted naively,
|
|
292
|
-
* which can only suppress a repair (the safe direction), never force one.
|
|
293
|
-
*/
|
|
294
|
-
function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
|
|
295
|
-
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
296
|
-
let inBlockComment = false;
|
|
297
|
-
let quote = "";
|
|
298
|
-
for (const line of lines) {
|
|
299
|
-
for (let i = 0; i < line.length; i++) {
|
|
300
|
-
const ch = line[i];
|
|
301
|
-
if (inBlockComment) {
|
|
302
|
-
if (ch === "*" && line[i + 1] === "/") {
|
|
303
|
-
inBlockComment = false;
|
|
304
|
-
i++;
|
|
305
|
-
}
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (quote) {
|
|
309
|
-
if (ch === "\\") i++;
|
|
310
|
-
else if (ch === quote) quote = "";
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (ch === "'" && singleQuoteMode === "rust") {
|
|
314
|
-
// A real char literal is skipped whole; a lifetime (`'static`,
|
|
315
|
-
// `<'a>`) or apostrophe is an ordinary character.
|
|
316
|
-
const literal = RUST_CHAR_LITERAL_RE.exec(line.slice(i));
|
|
317
|
-
if (literal) i += literal[0].length - 1;
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
321
|
-
quote = ch;
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
if (ch === "/" && line[i + 1] === "/") break;
|
|
325
|
-
if (ch === "/" && line[i + 1] === "*") {
|
|
326
|
-
inBlockComment = true;
|
|
327
|
-
i++;
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
switch (ch) {
|
|
331
|
-
case "(":
|
|
332
|
-
balance.paren++;
|
|
333
|
-
break;
|
|
334
|
-
case ")":
|
|
335
|
-
balance.paren--;
|
|
336
|
-
break;
|
|
337
|
-
case "[":
|
|
338
|
-
balance.bracket++;
|
|
339
|
-
break;
|
|
340
|
-
case "]":
|
|
341
|
-
balance.bracket--;
|
|
342
|
-
break;
|
|
343
|
-
case "{":
|
|
344
|
-
balance.brace++;
|
|
345
|
-
break;
|
|
346
|
-
case "}":
|
|
347
|
-
balance.brace--;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
// `"` / `'` cannot span lines; only backtick templates and block comments do.
|
|
352
|
-
if (quote === '"' || quote === "'") quote = "";
|
|
353
|
-
}
|
|
354
|
-
return balance;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
358
|
-
return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function balanceNegate(a: DelimiterBalance): DelimiterBalance {
|
|
362
|
-
return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
|
|
366
|
-
return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function balanceIsZero(a: DelimiterBalance): boolean {
|
|
370
|
-
return a.paren === 0 && a.bracket === 0 && a.brace === 0;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
374
|
-
return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function balanceComponentCovers(candidate: number, target: number): boolean {
|
|
378
|
-
if (target === 0) return true;
|
|
379
|
-
return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean {
|
|
383
|
-
return (
|
|
384
|
-
balanceComponentCovers(candidate.paren, target.paren) &&
|
|
385
|
-
balanceComponentCovers(candidate.bracket, target.bracket) &&
|
|
386
|
-
balanceComponentCovers(candidate.brace, target.brace)
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
156
|
interface ReplacementGroup {
|
|
391
157
|
/** Positions in the edit array of the payload inserts, in payload order. */
|
|
392
158
|
insertIndices: number[];
|
|
@@ -500,797 +266,575 @@ function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly
|
|
|
500
266
|
return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : [];
|
|
501
267
|
}
|
|
502
268
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
* the imbalance, so intentional duplicates of ordinary statements stay intact,
|
|
508
|
-
* while duplicated structural lines (closers like `});`, openers like `foo(`)
|
|
509
|
-
* are dropped when they exactly explain the imbalance.
|
|
510
|
-
*/
|
|
511
|
-
function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
512
|
-
if (balanceIsZero(delta)) return 0;
|
|
513
|
-
const { payload, endLine } = group;
|
|
514
|
-
const maxK = Math.min(payload.length, fileLines.length - endLine);
|
|
515
|
-
for (let k = maxK; k >= 1; k--) {
|
|
516
|
-
let matches = true;
|
|
517
|
-
for (let t = 0; t < k; t++) {
|
|
518
|
-
if (payload[payload.length - k + t] !== fileLines[endLine + t]) {
|
|
519
|
-
matches = false;
|
|
520
|
-
break;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
if (!matches) continue;
|
|
524
|
-
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;
|
|
525
273
|
}
|
|
526
|
-
return
|
|
274
|
+
return false;
|
|
527
275
|
}
|
|
528
276
|
|
|
529
|
-
|
|
530
|
-
* Largest `j` such that the payload's first `j` lines exactly equal the `j`
|
|
531
|
-
* surviving file lines just above the range AND dropping them zeroes `delta`.
|
|
532
|
-
* Requires a non-zero `delta`; see {@link findDuplicateSuffix}.
|
|
533
|
-
*/
|
|
534
|
-
function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
535
|
-
if (balanceIsZero(delta)) return 0;
|
|
277
|
+
function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
536
278
|
const { payload, startLine } = group;
|
|
537
|
-
const
|
|
538
|
-
for (let
|
|
279
|
+
const max = Math.min(payload.length, startLine - 1);
|
|
280
|
+
for (let count = max; count >= 1; count--) {
|
|
539
281
|
let matches = true;
|
|
540
|
-
|
|
541
|
-
|
|
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]) {
|
|
542
286
|
matches = false;
|
|
543
287
|
break;
|
|
544
288
|
}
|
|
289
|
+
hasContent ||= hasNonWhitespace(line);
|
|
545
290
|
}
|
|
546
|
-
if (
|
|
547
|
-
if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
|
|
291
|
+
if (matches && hasContent) return count;
|
|
548
292
|
}
|
|
549
293
|
return 0;
|
|
550
294
|
}
|
|
551
|
-
interface DroppedSuffixClosers {
|
|
552
|
-
readonly startLine: number;
|
|
553
|
-
readonly count: number;
|
|
554
|
-
readonly balance: DelimiterBalance;
|
|
555
|
-
}
|
|
556
295
|
|
|
557
|
-
function
|
|
558
|
-
const
|
|
559
|
-
|
|
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--) {
|
|
560
300
|
let matches = true;
|
|
301
|
+
let hasContent = false;
|
|
561
302
|
for (let offset = 0; offset < count; offset++) {
|
|
562
|
-
|
|
303
|
+
const line = payload[payload.length - count + offset];
|
|
304
|
+
if (line !== fileLines[endLine + offset]) {
|
|
563
305
|
matches = false;
|
|
564
306
|
break;
|
|
565
307
|
}
|
|
308
|
+
hasContent ||= hasNonWhitespace(line);
|
|
566
309
|
}
|
|
567
|
-
if (matches) return count;
|
|
310
|
+
if (matches && hasContent) return count;
|
|
568
311
|
}
|
|
569
312
|
return 0;
|
|
570
313
|
}
|
|
314
|
+
interface TextualBoundaryAmbiguity {
|
|
315
|
+
readonly startLine: number;
|
|
316
|
+
readonly endLine: number;
|
|
317
|
+
readonly side: "leading" | "trailing";
|
|
318
|
+
readonly count: number;
|
|
319
|
+
}
|
|
571
320
|
|
|
572
|
-
|
|
573
|
-
|
|
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[],
|
|
574
339
|
fileLines: readonly string[],
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
return true;
|
|
587
|
-
};
|
|
588
|
-
if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0;
|
|
589
|
-
for (let line = group.endLine + 1; line <= fileLines.length; line++) {
|
|
590
|
-
if (!appendCloserLines(insertedLineMaps.before.get(line))) break;
|
|
591
|
-
if (!deletedLines.has(line)) {
|
|
592
|
-
const text = fileLines[line - 1] ?? "";
|
|
593
|
-
if (!STRUCTURAL_CLOSER_RE.test(text)) break;
|
|
594
|
-
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;
|
|
595
351
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
+
});
|
|
605
385
|
}
|
|
606
386
|
}
|
|
607
|
-
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;
|
|
608
395
|
}
|
|
609
|
-
return
|
|
396
|
+
return { edits: out, warnings, ambiguities };
|
|
610
397
|
}
|
|
611
398
|
|
|
612
|
-
interface
|
|
613
|
-
readonly
|
|
614
|
-
readonly
|
|
399
|
+
interface KeepPlan {
|
|
400
|
+
readonly beforeLine?: number;
|
|
401
|
+
readonly afterLine?: number;
|
|
402
|
+
readonly kept: number;
|
|
615
403
|
}
|
|
616
404
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
): DelimiterBalance {
|
|
624
|
-
const prefix: string[] = [];
|
|
625
|
-
for (let line = 1; line < group.startLine; line++) {
|
|
626
|
-
const inserted = insertedByLine.get(line);
|
|
627
|
-
if (inserted) prefix.push(...inserted);
|
|
628
|
-
if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? "");
|
|
629
|
-
}
|
|
630
|
-
const insertedAtStart = insertedLineMaps.before.get(group.startLine);
|
|
631
|
-
if (insertedAtStart) prefix.push(...insertedAtStart);
|
|
632
|
-
prefix.push(...group.payload);
|
|
633
|
-
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;
|
|
634
411
|
}
|
|
635
412
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
suffixBalance: DelimiterBalance,
|
|
640
|
-
coveredBelowBalance: DelimiterBalance,
|
|
641
|
-
deletedLines: ReadonlySet<number>,
|
|
642
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
643
|
-
insertedLineMaps: InsertedLineMaps,
|
|
644
|
-
): boolean {
|
|
645
|
-
const neededOpeners = balanceNegate(suffixBalance);
|
|
646
|
-
const prefixBalance = computeProjectedPrefixBalance(
|
|
647
|
-
group,
|
|
648
|
-
fileLines,
|
|
649
|
-
deletedLines,
|
|
650
|
-
insertedByLine,
|
|
651
|
-
insertedLineMaps,
|
|
652
|
-
);
|
|
653
|
-
const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance);
|
|
654
|
-
return balanceCovers(uncoveredPrefixBalance, neededOpeners);
|
|
413
|
+
interface GroupVariants {
|
|
414
|
+
readonly variants: GroupVariant[];
|
|
415
|
+
readonly ambiguous: boolean;
|
|
655
416
|
}
|
|
656
417
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
deletedLines: ReadonlySet<number>,
|
|
671
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
672
|
-
insertedLineMaps: InsertedLineMaps,
|
|
673
|
-
): DroppedSuffixClosers | undefined {
|
|
674
|
-
let suffixLength = 0;
|
|
675
|
-
while (
|
|
676
|
-
suffixLength < group.deleteIndices.length &&
|
|
677
|
-
STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "")
|
|
678
|
-
) {
|
|
679
|
-
suffixLength++;
|
|
680
|
-
}
|
|
681
|
-
if (suffixLength === 0) return undefined;
|
|
682
|
-
|
|
683
|
-
const suffixStartLine = group.endLine - suffixLength + 1;
|
|
684
|
-
const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine);
|
|
685
|
-
const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines);
|
|
686
|
-
const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines);
|
|
687
|
-
const keepStart = restatedHead;
|
|
688
|
-
const keepEnd = suffixLength - coveredTail;
|
|
689
|
-
if (keepStart >= keepEnd) return undefined;
|
|
690
|
-
|
|
691
|
-
const keptLines = suffixLines.slice(keepStart, keepEnd);
|
|
692
|
-
const keptBalance = computeDelimiterBalance(keptLines);
|
|
693
|
-
const neededOpeners = balanceNegate(keptBalance);
|
|
694
|
-
const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd));
|
|
695
|
-
if (!balanceCovers(delta, neededOpeners)) return undefined;
|
|
696
|
-
if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
|
|
697
|
-
if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
|
|
698
|
-
if (
|
|
699
|
-
!prefixCanCoverSuffixClosers(
|
|
700
|
-
group,
|
|
701
|
-
fileLines,
|
|
702
|
-
keptBalance,
|
|
703
|
-
coveredBelowBalance,
|
|
704
|
-
deletedLines,
|
|
705
|
-
insertedByLine,
|
|
706
|
-
insertedLineMaps,
|
|
707
|
-
)
|
|
708
|
-
) {
|
|
709
|
-
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
|
+
}
|
|
710
431
|
}
|
|
711
|
-
return
|
|
432
|
+
return column;
|
|
712
433
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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;
|
|
716
441
|
}
|
|
717
442
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
* range account for their opener, and dangling opener(s) actually survive
|
|
725
|
-
* above the range in the projected file.
|
|
726
|
-
*/
|
|
727
|
-
function findDroppedPrefixClosers(
|
|
728
|
-
group: ReplacementGroup,
|
|
729
|
-
fileLines: readonly string[],
|
|
730
|
-
delta: DelimiterBalance,
|
|
731
|
-
remainingDelta: DelimiterBalance,
|
|
732
|
-
deletedPrefixBalance: DelimiterBalance,
|
|
733
|
-
deletedLines: ReadonlySet<number>,
|
|
734
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
735
|
-
): DroppedPrefixClosers | undefined {
|
|
736
|
-
let prefixLength = 0;
|
|
737
|
-
while (
|
|
738
|
-
prefixLength < group.deleteIndices.length &&
|
|
739
|
-
STRUCTURAL_CLOSER_RE.test(fileLines[group.startLine + prefixLength - 1] ?? "")
|
|
740
|
-
) {
|
|
741
|
-
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;
|
|
742
449
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (group.payload.length === 0 || isStructuralCloserLine(group.payload[0])) return undefined;
|
|
747
|
-
const prefixLines = fileLines.slice(group.startLine - 1, group.startLine - 1 + prefixLength);
|
|
748
|
-
const balance = computeDelimiterBalance(prefixLines);
|
|
749
|
-
if (balanceIsZero(balance)) return undefined;
|
|
750
|
-
const neededOpeners = balanceNegate(balance);
|
|
751
|
-
if (!balanceCovers(delta, neededOpeners)) return undefined;
|
|
752
|
-
if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
|
|
753
|
-
if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
|
|
754
|
-
// The spared closers need dangling opener(s) above the range in the
|
|
755
|
-
// projected file; the payload cannot supply them — it lands below the
|
|
756
|
-
// closers either way.
|
|
757
|
-
const above: string[] = [];
|
|
758
|
-
for (let line = 1; line < group.startLine; line++) {
|
|
759
|
-
const inserted = insertedByLine.get(line);
|
|
760
|
-
if (inserted) above.push(...inserted);
|
|
761
|
-
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;
|
|
762
453
|
}
|
|
763
|
-
|
|
764
|
-
return { count: prefixLength, balance };
|
|
454
|
+
return undefined;
|
|
765
455
|
}
|
|
766
456
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
* than proof: {@link applyEdits} surfaces it only once the tree-sitter probe
|
|
773
|
-
* confirms the authored edit broke the file, which is what separates a real
|
|
774
|
-
* mid-block range from a `}` living in prose or a regex literal. Zero when the
|
|
775
|
-
* payload is itself net-closing (deliberate rebalancing of a broken file) or
|
|
776
|
-
* when another hunk removes the surplus (whole-patch residual clean).
|
|
777
|
-
*/
|
|
778
|
-
function countOrphanedOpeners(
|
|
779
|
-
group: ReplacementGroup,
|
|
780
|
-
delta: DelimiterBalance,
|
|
781
|
-
remainingDelta: DelimiterBalance,
|
|
782
|
-
fileLines: readonly string[],
|
|
783
|
-
): number {
|
|
784
|
-
const deletedBalance = computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine));
|
|
785
|
-
const payloadBalance = computeDelimiterBalance(group.payload);
|
|
786
|
-
let orphaned = 0;
|
|
787
|
-
for (const key of ["paren", "bracket", "brace"] as const) {
|
|
788
|
-
if (payloadBalance[key] < 0) return 0;
|
|
789
|
-
if (delta[key] >= 0 || deletedBalance[key] <= 0 || remainingDelta[key] >= 0) continue;
|
|
790
|
-
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);
|
|
791
462
|
}
|
|
792
|
-
return
|
|
793
|
-
}
|
|
794
|
-
interface BoundaryEcho {
|
|
795
|
-
leading: number;
|
|
796
|
-
trailing: number;
|
|
463
|
+
return inserts;
|
|
797
464
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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);
|
|
802
471
|
}
|
|
803
|
-
return
|
|
472
|
+
return deletes;
|
|
804
473
|
}
|
|
805
474
|
|
|
806
|
-
function
|
|
807
|
-
|
|
808
|
-
const max = Math.min(payload.length, startLine - 1);
|
|
809
|
-
for (let count = max; count >= 1; count--) {
|
|
810
|
-
let matches = true;
|
|
811
|
-
let hasContent = false;
|
|
812
|
-
for (let offset = 0; offset < count; offset++) {
|
|
813
|
-
const line = payload[offset];
|
|
814
|
-
if (line !== fileLines[startLine - 1 - count + offset]) {
|
|
815
|
-
matches = false;
|
|
816
|
-
break;
|
|
817
|
-
}
|
|
818
|
-
hasContent ||= hasNonWhitespace(line);
|
|
819
|
-
}
|
|
820
|
-
if (matches && hasContent) return count;
|
|
821
|
-
}
|
|
822
|
-
return 0;
|
|
475
|
+
function isSourceLineDeleted(edits: readonly AppliedEdit[], line: number): boolean {
|
|
476
|
+
return edits.some(edit => edit.kind === "delete" && edit.anchor.line === line);
|
|
823
477
|
}
|
|
824
478
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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++;
|
|
840
499
|
}
|
|
841
|
-
return
|
|
500
|
+
return line;
|
|
842
501
|
}
|
|
843
502
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
// either be delimiter-neutral on their own or exactly cancel the payload/range
|
|
855
|
-
// balance delta. In brace-heavy code where bare closer lines repeat, an
|
|
856
|
-
// "echo" that shifts delimiter balance is structural content the payload
|
|
857
|
-
// placed intentionally — stripping it would corrupt the result.
|
|
858
|
-
const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
|
|
859
|
-
const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
|
|
860
|
-
const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
|
|
861
|
-
if (!balanceIsZero(droppedBalance)) {
|
|
862
|
-
const delta = balanceDelta(
|
|
863
|
-
computeDelimiterBalance(group.payload),
|
|
864
|
-
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
865
|
-
);
|
|
866
|
-
if (!balanceEqual(droppedBalance, delta)) return undefined;
|
|
867
|
-
}
|
|
868
|
-
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);
|
|
869
513
|
}
|
|
870
514
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
`Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
|
|
876
|
-
);
|
|
515
|
+
interface EdgeEvidence {
|
|
516
|
+
readonly first: boolean;
|
|
517
|
+
readonly last: boolean;
|
|
518
|
+
readonly leadingStructure: boolean;
|
|
877
519
|
}
|
|
878
520
|
|
|
879
|
-
function
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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 };
|
|
884
538
|
}
|
|
885
539
|
|
|
886
540
|
/**
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
* short of the keeper I retyped" mistake (e.g. att: payload ends with
|
|
891
|
-
* `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
|
|
892
|
-
* are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
|
|
893
|
-
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
|
894
|
-
*
|
|
895
|
-
* Scoped broadly for multi-line ranges (a construct rewrite) because retouched
|
|
896
|
-
* neutral keepers are usually boundary mistakes there. Single-line expansions
|
|
897
|
-
* are riskier — ordinary duplicated statements may be intentional — so they are
|
|
898
|
-
* only repaired when the duplicated edge is a structural closer line that
|
|
899
|
-
* carries no delimiter-balance signal itself, such as a JSX `</section>` close.
|
|
900
|
-
* The dropped lines must keep the already-balanced result balanced, and must
|
|
901
|
-
* not consume the whole payload.
|
|
902
|
-
*
|
|
903
|
-
* A detected echo is only *repairable* when the payload is long enough to be
|
|
904
|
-
* the widened range's full content (`payload ≥ range + echo`). Shorter
|
|
905
|
-
* payloads are ambiguous — the echo may instead mean the range itself was
|
|
906
|
-
* shifted by the echo, which keeps the far boundary line(s) the repair would
|
|
907
|
-
* 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.
|
|
908
544
|
*/
|
|
909
|
-
function
|
|
545
|
+
function buildKeepPlans(
|
|
910
546
|
group: ReplacementGroup,
|
|
547
|
+
trailingLine: number,
|
|
548
|
+
payload: readonly string[],
|
|
911
549
|
fileLines: readonly string[],
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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 };
|
|
926
576
|
}
|
|
927
|
-
return { side, count };
|
|
928
|
-
}
|
|
929
577
|
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
`The range was one line short of the content you retyped — issue the payload as the final content for the ` +
|
|
936
|
-
`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,
|
|
937
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 };
|
|
938
616
|
}
|
|
939
617
|
|
|
940
618
|
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
|
|
945
|
-
type RepairSlot =
|
|
946
|
-
| { kind: "edits"; edits: AppliedEdit[]; warning?: string }
|
|
947
|
-
| {
|
|
948
|
-
kind: "candidate";
|
|
949
|
-
group: ReplacementGroup;
|
|
950
|
-
inserts: AppliedEdit[];
|
|
951
|
-
deletes: AppliedEdit[];
|
|
952
|
-
delta: DelimiterBalance;
|
|
953
|
-
};
|
|
954
|
-
|
|
955
|
-
/**
|
|
956
|
-
* Delimiter balance of the lines immediately above a group's range that are
|
|
957
|
-
* themselves deleted by other hunks, netted against any payload inserted at
|
|
958
|
-
* those lines. When this covers the group's own delta the matching opener was
|
|
959
|
-
* deleted (or replaced by an opener of the same shape) just above — a deliberate
|
|
960
|
-
* wrapper removal — so the range's deleted closer must stay deleted, not be
|
|
961
|
-
* "kept". Scanned over its own contiguous lines so quote/comment state never
|
|
962
|
-
* bleeds in from elsewhere in the patch.
|
|
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.
|
|
963
623
|
*/
|
|
964
|
-
function
|
|
624
|
+
function buildGroupVariants(
|
|
965
625
|
group: ReplacementGroup,
|
|
966
|
-
|
|
967
|
-
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
626
|
+
edits: readonly AppliedEdit[],
|
|
968
627
|
fileLines: readonly string[],
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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 }));
|
|
976
693
|
}
|
|
977
|
-
return
|
|
694
|
+
return [...retainedInserts, ...retainedDeletes];
|
|
978
695
|
}
|
|
979
696
|
|
|
980
|
-
/**
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
else deleted.push(fileLines[edit.anchor.line - 1] ?? "");
|
|
994
|
-
}
|
|
995
|
-
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;
|
|
996
710
|
}
|
|
997
711
|
|
|
998
712
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
* against the whole-patch delimiter residual, so a closer is only kept when
|
|
1004
|
-
* the patch as a whole is missing it — never when another hunk already
|
|
1005
|
-
* removed the matching opener.
|
|
1006
|
-
*
|
|
1007
|
-
* Textual repairs (boundary echoes, payload lines duplicated from just outside
|
|
1008
|
-
* the range) are evidence-complete on their own and always applied. The
|
|
1009
|
-
* closer-spare repairs are not: they claim a lone `}` is syntax. They run only
|
|
1010
|
-
* when `applySpares` is set, which {@link applyEdits} does only after the
|
|
1011
|
-
* tree-sitter probe shows the authored edits broke a file that previously
|
|
1012
|
-
* parsed. With `applySpares` false the same detections are reported through
|
|
1013
|
-
* `suspicious` / `advisories` and nothing is rewritten.
|
|
1014
|
-
*
|
|
1015
|
-
* When the spares do run, they fire only if exactly one reading explains the
|
|
1016
|
-
* mistake; ambiguous evidence — a one-sided echo whose payload is too short
|
|
1017
|
-
* for the widened range, a spared trailing closer the payload neither opens
|
|
1018
|
-
* nor indents into, or a spared leading closer whose payload claims the block
|
|
1019
|
-
* interior — throws instead of guessing, so the author re-issues the edit
|
|
1020
|
-
* 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.
|
|
1021
717
|
*/
|
|
1022
|
-
function
|
|
718
|
+
function repairBoundaryVariants(
|
|
1023
719
|
edits: readonly AppliedEdit[],
|
|
1024
720
|
fileLines: readonly string[],
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
sparesProposed: boolean;
|
|
1033
|
-
/** Diagnostics to surface only if the result is kept unrepaired. */
|
|
1034
|
-
advisories: string[];
|
|
1035
|
-
} {
|
|
1036
|
-
// Pass 1: apply every repair whose correctness is local to one group
|
|
1037
|
-
// (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
|
|
1038
|
-
// it must weigh a group's imbalance against the whole patch, which is only
|
|
1039
|
-
// known once the local repairs above have settled.
|
|
1040
|
-
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;
|
|
1041
728
|
let i = 0;
|
|
1042
729
|
while (i < edits.length) {
|
|
1043
730
|
const group = findReplacementGroup(edits, i);
|
|
1044
|
-
if (
|
|
1045
|
-
|
|
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 {
|
|
1046
737
|
i++;
|
|
1047
|
-
continue;
|
|
1048
738
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const boundaryEcho = findBoundaryEcho(group, fileLines);
|
|
1054
|
-
if (boundaryEcho) {
|
|
1055
|
-
slots.push({
|
|
1056
|
-
kind: "edits",
|
|
1057
|
-
edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes],
|
|
1058
|
-
warning: describeBoundaryEchoRepair(group, boundaryEcho),
|
|
1059
|
-
});
|
|
1060
|
-
continue;
|
|
739
|
+
}
|
|
740
|
+
if (groups.length === 0) {
|
|
741
|
+
if (ambiguousGroup) {
|
|
742
|
+
throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
|
|
1061
743
|
}
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
1062
746
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
)
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
if (group.payload.length < group.deleteIndices.length + oneSided.count) {
|
|
1075
|
-
throw new Error(
|
|
1076
|
-
ambiguousBoundaryEchoMessage(group.startLine, group.endLine, oneSided.side, oneSided.count),
|
|
1077
|
-
);
|
|
1078
|
-
}
|
|
1079
|
-
const trimmed =
|
|
1080
|
-
oneSided.side === "leading"
|
|
1081
|
-
? inserts.slice(oneSided.count)
|
|
1082
|
-
: inserts.slice(0, inserts.length - oneSided.count);
|
|
1083
|
-
slots.push({
|
|
1084
|
-
kind: "edits",
|
|
1085
|
-
edits: [...trimmed, ...deletes],
|
|
1086
|
-
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,
|
|
1087
758
|
});
|
|
1088
|
-
continue;
|
|
1089
759
|
}
|
|
1090
|
-
slots.push({ kind: "edits", edits: [...inserts, ...deletes] });
|
|
1091
|
-
continue;
|
|
1092
760
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
if (dupSuffix > 0) {
|
|
1096
|
-
slots.push({
|
|
1097
|
-
kind: "edits",
|
|
1098
|
-
edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes],
|
|
1099
|
-
warning: describeBoundaryRepair(
|
|
1100
|
-
group,
|
|
1101
|
-
`dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
|
|
1102
|
-
),
|
|
1103
|
-
});
|
|
1104
|
-
continue;
|
|
1105
|
-
}
|
|
1106
|
-
const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
|
|
1107
|
-
if (dupPrefix > 0) {
|
|
1108
|
-
slots.push({
|
|
1109
|
-
kind: "edits",
|
|
1110
|
-
edits: [...inserts.slice(dupPrefix), ...deletes],
|
|
1111
|
-
warning: describeBoundaryRepair(
|
|
1112
|
-
group,
|
|
1113
|
-
`dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
|
|
1114
|
-
),
|
|
1115
|
-
});
|
|
1116
|
-
continue;
|
|
1117
|
-
}
|
|
1118
|
-
slots.push({ kind: "candidate", group, inserts, deletes, delta });
|
|
761
|
+
next.sort(compareBoundaryCombo);
|
|
762
|
+
combos = next.slice(0, MAX_BOUNDARY_COMBOS);
|
|
1119
763
|
}
|
|
1120
764
|
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
if (inserted) inserted.push(edit.text);
|
|
1138
|
-
else insertedByLine.set(1, [edit.text]);
|
|
1139
|
-
const before = insertedLineMaps.before.get(1);
|
|
1140
|
-
if (before) before.push(edit.text);
|
|
1141
|
-
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;
|
|
1142
781
|
continue;
|
|
1143
782
|
}
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
783
|
+
if (text !== bestText) {
|
|
784
|
+
if (ambiguousGroup) {
|
|
785
|
+
throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
|
|
786
|
+
}
|
|
787
|
+
return undefined;
|
|
1149
788
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
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));
|
|
1155
793
|
}
|
|
794
|
+
return undefined;
|
|
1156
795
|
}
|
|
1157
|
-
let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
1158
|
-
for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
|
|
1159
796
|
|
|
1160
|
-
const out: AppliedEdit[] = [];
|
|
1161
797
|
const warnings: string[] = [];
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
);
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
continue;
|
|
1191
|
-
}
|
|
1192
|
-
// Sparing a closer re-inserts it *after* the payload, which claims
|
|
1193
|
-
// the payload lives inside the block the closer terminates. That
|
|
1194
|
-
// claim needs evidence: the payload carries the closer's unmatched
|
|
1195
|
-
// opener itself, or its indentation sits deeper than the closer.
|
|
1196
|
-
// Without either, "before or after the closer" is a coin flip —
|
|
1197
|
-
// reject rather than guess (e.g. a statement swapped onto a lone
|
|
1198
|
-
// `}` at the closer's own depth belongs after the block).
|
|
1199
|
-
const keptIndent = leadingIndent(fileLines[droppedClosers.startLine - 1] ?? "");
|
|
1200
|
-
const payloadIndent = bodyTargetIndent(slot.group.payload);
|
|
1201
|
-
const payloadOpens = balanceCovers(
|
|
1202
|
-
computeDelimiterBalance(slot.group.payload),
|
|
1203
|
-
balanceNegate(droppedClosers.balance),
|
|
1204
|
-
);
|
|
1205
|
-
if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) {
|
|
1206
|
-
throw new CloserSpareAmbiguityError(
|
|
1207
|
-
ambiguousCloserSpareMessage(
|
|
1208
|
-
slot.group.startLine,
|
|
1209
|
-
slot.group.endLine,
|
|
1210
|
-
droppedClosers.startLine,
|
|
1211
|
-
droppedClosers.count,
|
|
1212
|
-
),
|
|
1213
|
-
);
|
|
1214
|
-
}
|
|
1215
|
-
warnings.push(
|
|
1216
|
-
describeBoundaryRepair(
|
|
1217
|
-
slot.group,
|
|
1218
|
-
`kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`,
|
|
1219
|
-
),
|
|
1220
|
-
);
|
|
1221
|
-
out.push(
|
|
1222
|
-
...slot.inserts,
|
|
1223
|
-
...slot.deletes.filter(
|
|
1224
|
-
edit =>
|
|
1225
|
-
edit.kind !== "delete" ||
|
|
1226
|
-
edit.anchor.line < droppedClosers.startLine ||
|
|
1227
|
-
edit.anchor.line >= droppedClosers.startLine + droppedClosers.count,
|
|
1228
|
-
),
|
|
1229
|
-
);
|
|
1230
|
-
for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) {
|
|
1231
|
-
deletedLines.delete(line);
|
|
1232
|
-
}
|
|
1233
|
-
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++;
|
|
1234
826
|
continue;
|
|
1235
827
|
}
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
deletedLines,
|
|
1243
|
-
insertedByLine,
|
|
1244
|
-
);
|
|
1245
|
-
if (droppedPrefix) {
|
|
1246
|
-
suspicious = true;
|
|
1247
|
-
sparesProposed = true;
|
|
1248
|
-
if (!applySpares) {
|
|
1249
|
-
out.push(...slot.inserts, ...slot.deletes);
|
|
1250
|
-
continue;
|
|
1251
|
-
}
|
|
1252
|
-
// Sparing a leading closer re-inserts it *before* the payload,
|
|
1253
|
-
// which claims the payload lives outside (after) the block the
|
|
1254
|
-
// closer terminates. The payload's indentation makes that call:
|
|
1255
|
-
// at-or-above the closer's depth is sibling position; a deeper or
|
|
1256
|
-
// incomparable claim would put the payload inside the block the
|
|
1257
|
-
// range just closed — reject rather than guess.
|
|
1258
|
-
const closerIndent = leadingIndent(fileLines[slot.group.startLine - 1] ?? "");
|
|
1259
|
-
const payloadIndent = bodyTargetIndent(slot.group.payload);
|
|
1260
|
-
if (payloadIndent !== undefined) {
|
|
1261
|
-
if (!closerIndent.startsWith(payloadIndent)) {
|
|
1262
|
-
throw new CloserSpareAmbiguityError(
|
|
1263
|
-
ambiguousLeadingCloserSpareMessage(slot.group.startLine, slot.group.endLine, droppedPrefix.count),
|
|
1264
|
-
);
|
|
1265
|
-
}
|
|
1266
|
-
const spareEnd = slot.group.startLine + droppedPrefix.count;
|
|
1267
|
-
warnings.push(
|
|
1268
|
-
describeBoundaryRepair(
|
|
1269
|
-
slot.group,
|
|
1270
|
-
`kept ${droppedPrefix.count} leading structural closing line(s) the range deleted without restating; the payload lands after them`,
|
|
1271
|
-
),
|
|
1272
|
-
);
|
|
1273
|
-
out.push(
|
|
1274
|
-
...slot.inserts.map(edit =>
|
|
1275
|
-
edit.kind === "insert"
|
|
1276
|
-
? { ...edit, cursor: { kind: "before_anchor" as const, anchor: { line: spareEnd } } }
|
|
1277
|
-
: edit,
|
|
1278
|
-
),
|
|
1279
|
-
...slot.deletes.filter(edit => edit.kind !== "delete" || edit.anchor.line >= spareEnd),
|
|
1280
|
-
);
|
|
1281
|
-
for (let line = slot.group.startLine; line < spareEnd; line++) deletedLines.delete(line);
|
|
1282
|
-
remainingDelta = balanceSum(remainingDelta, droppedPrefix.balance);
|
|
1283
|
-
continue;
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
const orphanedOpeners = countOrphanedOpeners(slot.group, slot.delta, remainingDelta, fileLines);
|
|
1287
|
-
if (orphanedOpeners > 0) {
|
|
1288
|
-
suspicious = true;
|
|
1289
|
-
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));
|
|
1290
834
|
}
|
|
1291
|
-
|
|
835
|
+
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
1292
836
|
}
|
|
1293
|
-
return
|
|
837
|
+
return out;
|
|
1294
838
|
}
|
|
1295
839
|
|
|
1296
840
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1482,12 +1026,12 @@ function repairAfterInsertLandings(
|
|
|
1482
1026
|
const retarget = (group: AfterInsertGroup, line: number): void => {
|
|
1483
1027
|
out ??= [...edits];
|
|
1484
1028
|
for (const idx of group.members) {
|
|
1485
|
-
const edit = out
|
|
1029
|
+
const edit = insertEditAt(out, idx);
|
|
1486
1030
|
out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
|
|
1487
1031
|
}
|
|
1488
1032
|
};
|
|
1489
1033
|
for (const group of groups.values()) {
|
|
1490
|
-
const target = bodyTargetIndent(group.members.map(idx => (edits
|
|
1034
|
+
const target = bodyTargetIndent(group.members.map(idx => insertEditAt(edits, idx).text));
|
|
1491
1035
|
if (target === undefined) continue;
|
|
1492
1036
|
const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
|
|
1493
1037
|
if (outward !== undefined) {
|
|
@@ -1515,11 +1059,10 @@ export interface ApplyEditsOptions {
|
|
|
1515
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. */
|
|
1516
1060
|
onEmptyPaste?: "throw" | "drop";
|
|
1517
1061
|
/**
|
|
1518
|
-
* Target
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
* 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.
|
|
1523
1066
|
*/
|
|
1524
1067
|
path?: string;
|
|
1525
1068
|
}
|
|
@@ -1623,17 +1166,14 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
|
|
|
1623
1166
|
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
1624
1167
|
* Throws if an anchor is out of bounds.
|
|
1625
1168
|
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
* authored result does not parse — or the language is unknown to the parser, so
|
|
1632
|
-
* 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.
|
|
1633
1174
|
*/
|
|
1634
1175
|
export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
|
|
1635
1176
|
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
1636
|
-
setDelimiterScanLanguage(options.path);
|
|
1637
1177
|
|
|
1638
1178
|
const fileLines = text.split("\n");
|
|
1639
1179
|
|
|
@@ -1648,10 +1188,12 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1648
1188
|
// Block edits are deferred until `resolveBlockEdits` expands them into
|
|
1649
1189
|
// concrete inserts + deletes. Reaching the applier with one still present
|
|
1650
1190
|
// is an internal wiring bug, not authored-input error.
|
|
1191
|
+
const appliedEdits: AppliedEdit[] = [];
|
|
1651
1192
|
for (const edit of concrete) {
|
|
1652
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);
|
|
1653
1196
|
}
|
|
1654
|
-
const appliedEdits = concrete as readonly AppliedEdit[];
|
|
1655
1197
|
|
|
1656
1198
|
const targetEdits = dropTrailingPhantomDeletes(
|
|
1657
1199
|
appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
|
|
@@ -1659,20 +1201,17 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1659
1201
|
);
|
|
1660
1202
|
validateLineBounds(targetEdits, fileLines);
|
|
1661
1203
|
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
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);
|
|
1668
1209
|
const finish = (result: Materialized, warnings: string[]): ApplyResult => {
|
|
1669
1210
|
const merged = [...warnings, ...result.warnings];
|
|
1670
1211
|
// Post-apply syntax advisory: the result stopped parsing while the
|
|
1671
1212
|
// pre-edit text parsed, so this patch demonstrably introduced the
|
|
1672
|
-
// error. Catches
|
|
1673
|
-
|
|
1674
|
-
// probes are content-cached; a pathless call short-circuits to false.
|
|
1675
|
-
if (!parsesCleanly(options.path, result.text) && parsesCleanly(options.path, text)) {
|
|
1213
|
+
// error. Catches misplacements no boundary variant can explain.
|
|
1214
|
+
if (!parsesCleanly(options.path, result.text) && baselineParses) {
|
|
1676
1215
|
merged.push(editBrokeParseWarning(result.firstChangedLine));
|
|
1677
1216
|
}
|
|
1678
1217
|
return {
|
|
@@ -1681,36 +1220,30 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1681
1220
|
...(merged.length > 0 ? { warnings: merged } : {}),
|
|
1682
1221
|
};
|
|
1683
1222
|
};
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
const sparedResult = materializeEdits(fileLines, spared.edits);
|
|
1701
|
-
if (parsesCleanly(options.path, sparedResult.text)) {
|
|
1702
|
-
return finish(sparedResult, [...leading, ...spared.warnings, ...spared.advisories]);
|
|
1703
|
-
}
|
|
1704
|
-
} catch (error) {
|
|
1705
|
-
// Only the closer-spare verdict is the parser's business, and only on
|
|
1706
|
-
// a file it can vouch for. Every other rejection — notably the
|
|
1707
|
-
// evidence-complete one-sided boundary echo, which is proven by exact
|
|
1708
|
-
// line equality and would otherwise delete range lines the body never
|
|
1709
|
-
// restates — propagates regardless of what the parser knows.
|
|
1710
|
-
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]);
|
|
1711
1239
|
}
|
|
1712
1240
|
}
|
|
1241
|
+
if (ambiguity) {
|
|
1242
|
+
throw new Error(
|
|
1243
|
+
ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count),
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1713
1246
|
// Nothing proven: leave the authored edit exactly as written. Report the
|
|
1714
|
-
// damage
|
|
1715
|
-
return finish(authoredResult,
|
|
1247
|
+
// damage — the baseline parsed, so this edit demonstrably caused it.
|
|
1248
|
+
return finish(authoredResult, leading);
|
|
1716
1249
|
}
|