@oh-my-pi/hashline 15.10.10 → 15.10.11
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/README.md +1 -0
- package/dist/types/block.d.ts +3 -3
- package/dist/types/messages.d.ts +14 -5
- package/dist/types/snapshots.d.ts +6 -0
- package/dist/types/tokenizer.d.ts +3 -0
- package/dist/types/types.d.ts +17 -14
- package/package.json +1 -1
- package/src/apply.ts +174 -3
- package/src/block.ts +22 -9
- package/src/diff-preview.ts +13 -1
- package/src/grammar.lark +3 -1
- package/src/messages.ts +35 -7
- package/src/parser.ts +61 -6
- package/src/patcher.ts +18 -1
- package/src/prompt.md +13 -10
- package/src/snapshots.ts +17 -1
- package/src/tokenizer.ts +11 -0
- package/src/types.ts +17 -14
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ Inside a section:
|
|
|
51
51
|
- `replace block A:` — replace the syntactic block beginning on line A.
|
|
52
52
|
- `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
|
|
53
53
|
- `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
|
|
54
|
+
- `insert after block A:` — insert following body rows after the resolved block's last line.
|
|
54
55
|
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
|
55
56
|
|
|
56
57
|
## Abstractions
|
package/dist/types/block.d.ts
CHANGED
|
@@ -16,11 +16,11 @@ export interface ResolveBlockEditsOptions {
|
|
|
16
16
|
*/
|
|
17
17
|
onResolved?: (resolution: BlockResolution) => void;
|
|
18
18
|
}
|
|
19
|
-
/** True when at least one edit is an unresolved
|
|
19
|
+
/** True when at least one edit is an unresolved deferred block edit. */
|
|
20
20
|
export declare function hasBlockEdit(edits: readonly Edit[]): boolean;
|
|
21
21
|
/**
|
|
22
|
-
* Resolve every
|
|
23
|
-
*
|
|
22
|
+
* Resolve every deferred block edit in `edits` against `text` (parsed as the
|
|
23
|
+
* language inferred from `path`). Non-block edits pass through untouched.
|
|
24
24
|
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
25
25
|
* input unchanged when there is nothing to resolve.
|
|
26
26
|
*
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -29,19 +29,19 @@ export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT`
|
|
|
29
29
|
/** Error text emitted when a `replace block N:` hunk has no body. */
|
|
30
30
|
export declare const EMPTY_BLOCK = "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
|
|
31
31
|
/**
|
|
32
|
-
* Error text emitted when a
|
|
32
|
+
* Error text emitted when a block-anchored op cannot be resolved to a
|
|
33
33
|
* syntactic block (unrecognized language, blank/out-of-range line, no node
|
|
34
34
|
* begins on line N such as a lone closing delimiter, or the resolved block has
|
|
35
35
|
* a syntax error). Names the offending line and steers back to an explicit
|
|
36
|
-
*
|
|
36
|
+
* concrete-line form.
|
|
37
37
|
*/
|
|
38
|
-
export declare function blockUnresolvedMessage(line: number): string;
|
|
38
|
+
export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete" | "insert_after"): string;
|
|
39
39
|
/**
|
|
40
|
-
* Error text emitted when a
|
|
40
|
+
* Error text emitted when a block-anchored edit reaches a code path that
|
|
41
41
|
* has no {@link BlockResolver} wired in. Indicates a host-configuration bug
|
|
42
42
|
* rather than authored-input error.
|
|
43
43
|
*/
|
|
44
|
-
export declare const BLOCK_RESOLVER_UNAVAILABLE = "`replace block N:`
|
|
44
|
+
export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block-anchored ops (`replace block N:`, `delete block N`, `insert after block N:`) are not available here (no tree-sitter block resolver is configured). Use a concrete line range instead.";
|
|
45
45
|
/**
|
|
46
46
|
* Internal invariant error: `applyEdits` received an unresolved `replace block
|
|
47
47
|
* N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
|
|
@@ -54,6 +54,15 @@ export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body ro
|
|
|
54
54
|
export declare const DELETE_BLOCK_TAKES_NO_BODY = "`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
|
|
55
55
|
/** Error text emitted when an insert hunk has no body. */
|
|
56
56
|
export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
57
|
+
/**
|
|
58
|
+
* Warning emitted when an `insert after` edit's body rows are indented
|
|
59
|
+
* shallower than the anchor line and the landing point was slid forward past
|
|
60
|
+
* the structural closer lines that follow. The body's indentation names the
|
|
61
|
+
* depth the author wants the new lines to sit at; anchoring inside a deeper
|
|
62
|
+
* construct is the common "insert after the block, anchored on the last line
|
|
63
|
+
* I read" mistake.
|
|
64
|
+
*/
|
|
65
|
+
export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
|
|
57
66
|
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
58
67
|
export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
59
68
|
/** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
|
|
@@ -34,6 +34,12 @@ export interface InMemorySnapshotStoreOptions {
|
|
|
34
34
|
maxPaths?: number;
|
|
35
35
|
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
|
36
36
|
maxVersionsPerPath?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Global ceiling on retained snapshot text summed across every path's
|
|
39
|
+
* version history, measured in UTF-16 code units (default 64 MiB).
|
|
40
|
+
* Least-recently-used path histories are evicted to stay under it.
|
|
41
|
+
*/
|
|
42
|
+
maxTotalBytes?: number;
|
|
37
43
|
}
|
|
38
44
|
/**
|
|
39
45
|
* In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path history is a
|
package/dist/types/types.d.ts
CHANGED
|
@@ -41,18 +41,21 @@ export type Edit = {
|
|
|
41
41
|
oldAssertion?: string;
|
|
42
42
|
} | {
|
|
43
43
|
/**
|
|
44
|
-
* Deferred block edit (`replace block N:` / `delete block N`
|
|
45
|
-
* line span is unknown at parse
|
|
46
|
-
* {@link resolveBlockEdits} once file text +
|
|
47
|
-
* available, then expanded into concrete edits:
|
|
48
|
-
* (from `replace block`) becomes
|
|
49
|
-
* that `replace start..end:`
|
|
50
|
-
*
|
|
44
|
+
* Deferred block edit (`replace block N:` / `delete block N` /
|
|
45
|
+
* `insert after block N:`). The exact line span is unknown at parse
|
|
46
|
+
* time — it is computed by {@link resolveBlockEdits} once file text +
|
|
47
|
+
* path (→ language) are available, then expanded into concrete edits:
|
|
48
|
+
* a non-empty `payloads` without `mode` (from `replace block`) becomes
|
|
49
|
+
* the same `replacement` inserts + deletes that `replace start..end:`
|
|
50
|
+
* produces; an empty `payloads` (from `delete block`) becomes a pure
|
|
51
|
+
* range deletion; `mode: "insert_after"` becomes plain `after_anchor`
|
|
52
|
+
* inserts at the block's last line. `applyEdits` never sees this
|
|
51
53
|
* variant.
|
|
52
54
|
*/
|
|
53
55
|
kind: "block";
|
|
54
56
|
anchor: Anchor;
|
|
55
57
|
payloads: string[];
|
|
58
|
+
mode?: "insert_after";
|
|
56
59
|
lineNum: number;
|
|
57
60
|
index: number;
|
|
58
61
|
};
|
|
@@ -120,11 +123,11 @@ export interface BlockSpan {
|
|
|
120
123
|
end: number;
|
|
121
124
|
}
|
|
122
125
|
/**
|
|
123
|
-
* One `replace block N:` / `delete block N`
|
|
124
|
-
* line span. Surfaced on {@link ApplyResult} so the
|
|
125
|
-
* "block N → lines start..end" and let the model catch a wrong
|
|
126
|
-
* decorator or doc-comment that sits in a separate node
|
|
127
|
-
* block.
|
|
126
|
+
* One `replace block N:` / `delete block N` / `insert after block N:` anchor
|
|
127
|
+
* resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
|
|
128
|
+
* host can echo "block N → lines start..end" and let the model catch a wrong
|
|
129
|
+
* opener — e.g. a decorator or doc-comment that sits in a separate node
|
|
130
|
+
* outside the resolved block.
|
|
128
131
|
*/
|
|
129
132
|
export interface BlockResolution {
|
|
130
133
|
/** The 1-indexed line the block op was anchored on (the `N`). */
|
|
@@ -133,8 +136,8 @@ export interface BlockResolution {
|
|
|
133
136
|
start: number;
|
|
134
137
|
/** Last line of the resolved span (1-indexed, inclusive). */
|
|
135
138
|
end: number;
|
|
136
|
-
/**
|
|
137
|
-
|
|
139
|
+
/** Which block op produced this resolution. */
|
|
140
|
+
op: "replace" | "delete" | "insert_after";
|
|
138
141
|
}
|
|
139
142
|
/** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
|
|
140
143
|
export interface BlockResolverRequest {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.11",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
package/src/apply.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* which absorbs common model mistakes where a payload restates unchanged range
|
|
8
8
|
* boundaries or duplicates/drops structural closers.
|
|
9
9
|
*/
|
|
10
|
-
import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
10
|
+
import { afterInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
11
11
|
import { cloneCursor } from "./tokenizer";
|
|
12
12
|
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
|
|
13
13
|
|
|
@@ -40,11 +40,21 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] {
|
|
|
40
40
|
* checked once per section via the header hash before this function runs.
|
|
41
41
|
*/
|
|
42
42
|
function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
|
|
43
|
+
// `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
|
|
44
|
+
// It is addressable for inserts (append-past-end), but deleting it would
|
|
45
|
+
// silently strip the file's final newline — an off-by-one that must error.
|
|
46
|
+
const phantomLine = fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
|
|
43
47
|
for (const edit of edits) {
|
|
44
48
|
for (const anchor of getEditAnchors(edit)) {
|
|
45
49
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
46
50
|
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
47
51
|
}
|
|
52
|
+
if (edit.kind === "delete" && anchor.line === phantomLine) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Line ${anchor.line} is the trailing blank sentinel of a newline-terminated file and has no content to delete. ` +
|
|
55
|
+
`End the range at line ${anchor.line - 1}, or use \`insert tail:\` to append.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
}
|
|
@@ -383,6 +393,21 @@ function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[])
|
|
|
383
393
|
// repair would strip explicit replacement content with no signal that the
|
|
384
394
|
// payload was a mistake rather than an intentional duplication.
|
|
385
395
|
if (leadingMax + trailingMax >= group.payload.length) return undefined;
|
|
396
|
+
// Balance-neutrality guard (see header comment): the dropped echo lines must
|
|
397
|
+
// either be delimiter-neutral on their own or exactly cancel the payload/range
|
|
398
|
+
// balance delta. In brace-heavy code where bare closer lines repeat, an
|
|
399
|
+
// "echo" that shifts delimiter balance is structural content the payload
|
|
400
|
+
// placed intentionally — stripping it would corrupt the result.
|
|
401
|
+
const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
|
|
402
|
+
const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
|
|
403
|
+
const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
|
|
404
|
+
if (!balanceIsZero(droppedBalance)) {
|
|
405
|
+
const delta = balanceDelta(
|
|
406
|
+
computeDelimiterBalance(group.payload),
|
|
407
|
+
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
408
|
+
);
|
|
409
|
+
if (!balanceEqual(droppedBalance, delta)) return undefined;
|
|
410
|
+
}
|
|
386
411
|
return { leading: leadingMax, trailing: trailingMax };
|
|
387
412
|
}
|
|
388
413
|
|
|
@@ -481,6 +506,150 @@ function repairReplacementBoundaries(
|
|
|
481
506
|
return { edits: out, warnings };
|
|
482
507
|
}
|
|
483
508
|
|
|
509
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
510
|
+
// After-insert landing correction
|
|
511
|
+
//
|
|
512
|
+
// The body rows of an `insert after N:` hunk carry an implicit depth claim:
|
|
513
|
+
// their leading indentation says how deep the author expects the new lines
|
|
514
|
+
// to sit. When that depth is shallower than line N itself, the hunk is
|
|
515
|
+
// inserting a sibling of some enclosing construct while anchored inside it —
|
|
516
|
+
// the common shape is anchoring on the last statement of a block and writing
|
|
517
|
+
// the body at the parent's depth. Sliding the landing point forward across
|
|
518
|
+
// the structural closer lines that follow (and nothing else — content lines
|
|
519
|
+
// are never crossed) places the body at the depth its indentation names.
|
|
520
|
+
//
|
|
521
|
+
// The shift is deliberately conservative: it fires only when the body and
|
|
522
|
+
// anchor indentation are comparable (one is a prefix of the other), crosses
|
|
523
|
+
// only pure closing-delimiter lines indented at or deeper than the body,
|
|
524
|
+
// stops as soon as depth returns to the body's level, and is abandoned when
|
|
525
|
+
// any other edit in the patch targets a crossed line. Every shift is
|
|
526
|
+
// reported as a warning so the author can re-issue with deeper indentation
|
|
527
|
+
// when the original landing was intended.
|
|
528
|
+
|
|
529
|
+
/** Leading run of tabs and spaces. */
|
|
530
|
+
function leadingIndent(line: string): string {
|
|
531
|
+
let end = 0;
|
|
532
|
+
while (end < line.length) {
|
|
533
|
+
const code = line.charCodeAt(end);
|
|
534
|
+
if (code !== 9 && code !== 32) break;
|
|
535
|
+
end++;
|
|
536
|
+
}
|
|
537
|
+
return line.slice(0, end);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** `deeper` strictly extends `shallower` (same indent style, more depth). */
|
|
541
|
+
function isIndentDeeper(deeper: string, shallower: string): boolean {
|
|
542
|
+
return deeper.length > shallower.length && deeper.startsWith(shallower);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
interface AfterInsertGroup {
|
|
546
|
+
/** Anchor line shared by every insert row of the hunk. */
|
|
547
|
+
anchor: number;
|
|
548
|
+
/** Indices into the edit list, in patch order. */
|
|
549
|
+
members: number[];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Depth of an after-insert hunk's body: the shallowest indentation across its
|
|
554
|
+
* non-blank rows. Returns `undefined` when no depth claim can be made — an
|
|
555
|
+
* all-blank or all-closer body, or rows whose indentation styles are not
|
|
556
|
+
* mutually comparable (tabs vs spaces).
|
|
557
|
+
*/
|
|
558
|
+
function bodyTargetIndent(rows: readonly string[]): string | undefined {
|
|
559
|
+
const nonBlank = rows.filter(hasNonWhitespace);
|
|
560
|
+
if (nonBlank.length === 0) return undefined;
|
|
561
|
+
// A body of pure closers re-balances delimiters; it claims no depth.
|
|
562
|
+
if (nonBlank.every(row => STRUCTURAL_CLOSER_RE.test(row))) return undefined;
|
|
563
|
+
let target = leadingIndent(nonBlank[0] ?? "");
|
|
564
|
+
for (const row of nonBlank) {
|
|
565
|
+
const indent = leadingIndent(row);
|
|
566
|
+
if (indent.startsWith(target)) continue;
|
|
567
|
+
if (target.startsWith(indent)) target = indent;
|
|
568
|
+
else return undefined;
|
|
569
|
+
}
|
|
570
|
+
return target;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Resolve where an after-insert hunk anchored on `group.anchor` should land
|
|
575
|
+
* given its body depth `target`: the last structural closer line in the run
|
|
576
|
+
* directly below the anchor whose indentation still covers `target`. Returns
|
|
577
|
+
* `undefined` when the landing stays put.
|
|
578
|
+
*/
|
|
579
|
+
function resolveShiftedLanding(
|
|
580
|
+
group: AfterInsertGroup,
|
|
581
|
+
target: string,
|
|
582
|
+
fileLines: readonly string[],
|
|
583
|
+
targetedLines: ReadonlySet<number>,
|
|
584
|
+
): { line: number; crossed: number } | undefined {
|
|
585
|
+
const anchorText = fileLines[group.anchor - 1];
|
|
586
|
+
if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
|
|
587
|
+
if (!isIndentDeeper(leadingIndent(anchorText), target)) return undefined;
|
|
588
|
+
|
|
589
|
+
let landing = group.anchor;
|
|
590
|
+
let crossed = 0;
|
|
591
|
+
for (let line = group.anchor + 1; line <= fileLines.length; line++) {
|
|
592
|
+
const text = fileLines[line - 1] ?? "";
|
|
593
|
+
if (!hasNonWhitespace(text)) continue; // look past blanks, never land on them
|
|
594
|
+
if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content is never crossed
|
|
595
|
+
const indent = leadingIndent(text);
|
|
596
|
+
if (!indent.startsWith(target)) break; // shallower than the body — crossing would over-escape
|
|
597
|
+
if (targetedLines.has(line)) return undefined; // another hunk owns this closer
|
|
598
|
+
landing = line;
|
|
599
|
+
crossed++;
|
|
600
|
+
if (indent.length === target.length) break; // depth returned to the body's level
|
|
601
|
+
}
|
|
602
|
+
return landing === group.anchor ? undefined : { line: landing, crossed };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Slide mis-anchored `insert after N:` hunks past the structural closer lines
|
|
607
|
+
* that directly follow their anchor when the body's indentation says the new
|
|
608
|
+
* lines belong at a shallower depth. Returns the corrected edit list plus one
|
|
609
|
+
* warning per shifted hunk.
|
|
610
|
+
*/
|
|
611
|
+
function repairAfterInsertLandings(
|
|
612
|
+
edits: readonly AppliedEdit[],
|
|
613
|
+
fileLines: readonly string[],
|
|
614
|
+
): { edits: readonly AppliedEdit[]; warnings: string[] } {
|
|
615
|
+
// Group plain (non-replacement) after-anchor inserts per authored hunk:
|
|
616
|
+
// rows of one hunk share the anchor line and the patch header line.
|
|
617
|
+
const groups = new Map<string, AfterInsertGroup>();
|
|
618
|
+
edits.forEach((edit, idx) => {
|
|
619
|
+
if (edit.kind !== "insert" || edit.mode === "replacement") return;
|
|
620
|
+
if (edit.cursor.kind !== "after_anchor") return;
|
|
621
|
+
const key = `${edit.cursor.anchor.line}:${edit.lineNum}`;
|
|
622
|
+
const group = groups.get(key);
|
|
623
|
+
if (group === undefined) groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx] });
|
|
624
|
+
else group.members.push(idx);
|
|
625
|
+
});
|
|
626
|
+
if (groups.size === 0) return { edits, warnings: [] };
|
|
627
|
+
|
|
628
|
+
// Lines explicitly targeted by any edit; a shift never crosses them.
|
|
629
|
+
const targetedLines = new Set<number>();
|
|
630
|
+
for (const edit of edits) {
|
|
631
|
+
if (edit.kind === "delete") targetedLines.add(edit.anchor.line);
|
|
632
|
+
else if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor")
|
|
633
|
+
targetedLines.add(edit.cursor.anchor.line);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
let out: AppliedEdit[] | undefined;
|
|
637
|
+
const warnings: string[] = [];
|
|
638
|
+
for (const group of groups.values()) {
|
|
639
|
+
const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
|
|
640
|
+
if (target === undefined) continue;
|
|
641
|
+
const landing = resolveShiftedLanding(group, target, fileLines, targetedLines);
|
|
642
|
+
if (landing === undefined) continue;
|
|
643
|
+
out ??= [...edits];
|
|
644
|
+
for (const idx of group.members) {
|
|
645
|
+
const edit = out[idx] as InsertEdit;
|
|
646
|
+
out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line: landing.line } } };
|
|
647
|
+
}
|
|
648
|
+
warnings.push(afterInsertLandingShiftWarning(group.anchor, landing.line, landing.crossed));
|
|
649
|
+
}
|
|
650
|
+
return { edits: out ?? edits, warnings };
|
|
651
|
+
}
|
|
652
|
+
|
|
484
653
|
/**
|
|
485
654
|
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
486
655
|
*
|
|
@@ -508,13 +677,15 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
|
|
508
677
|
|
|
509
678
|
const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
|
|
510
679
|
validateLineBounds(targetEdits, fileLines);
|
|
511
|
-
const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
680
|
+
const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
681
|
+
const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
|
|
682
|
+
const warnings = [...boundaryWarnings, ...landingWarnings];
|
|
512
683
|
|
|
513
684
|
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
514
685
|
const bofLines: string[] = [];
|
|
515
686
|
const eofLines: string[] = [];
|
|
516
687
|
const anchorEdits: IndexedEdit[] = [];
|
|
517
|
-
|
|
688
|
+
landed.forEach((edit, idx) => {
|
|
518
689
|
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
519
690
|
bofLines.push(edit.text);
|
|
520
691
|
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
package/src/block.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Expand deferred `replace block N:`
|
|
2
|
+
* Expand deferred block edits (`replace block N:` / `delete block N` /
|
|
3
|
+
* `insert after block N:`) into concrete inserts + deletes.
|
|
3
4
|
*
|
|
4
5
|
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
5
6
|
* unknown until file text + path (→ language) are available. This transform
|
|
6
7
|
* runs at every apply/preview boundary that has text: it calls the injected
|
|
7
8
|
* {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
|
|
8
|
-
* the exact same
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* the exact same edits the concrete form produces in the parser: `replace
|
|
10
|
+
* start..end:` inserts + deletes for a replace, a pure range delete for a
|
|
11
|
+
* delete, and plain `after_anchor` inserts at `end` for an insert-after. After
|
|
12
|
+
* it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
|
|
13
|
+
* ever see resolved edits.
|
|
11
14
|
*/
|
|
12
15
|
import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
|
|
13
16
|
import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
|
|
@@ -30,14 +33,14 @@ export interface ResolveBlockEditsOptions {
|
|
|
30
33
|
onResolved?: (resolution: BlockResolution) => void;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
/** True when at least one edit is an unresolved
|
|
36
|
+
/** True when at least one edit is an unresolved deferred block edit. */
|
|
34
37
|
export function hasBlockEdit(edits: readonly Edit[]): boolean {
|
|
35
38
|
return edits.some(edit => edit.kind === "block");
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
* Resolve every
|
|
40
|
-
*
|
|
42
|
+
* Resolve every deferred block edit in `edits` against `text` (parsed as the
|
|
43
|
+
* language inferred from `path`). Non-block edits pass through untouched.
|
|
41
44
|
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
42
45
|
* input unchanged when there is nothing to resolve.
|
|
43
46
|
*
|
|
@@ -61,19 +64,29 @@ export function resolveBlockEdits(
|
|
|
61
64
|
resolved.push(edit);
|
|
62
65
|
continue;
|
|
63
66
|
}
|
|
67
|
+
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
|
|
64
68
|
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
65
69
|
if (span === null) {
|
|
66
70
|
if (onUnresolved === "drop") continue;
|
|
67
71
|
throw new Error(
|
|
68
|
-
`line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
|
|
72
|
+
`line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line, op) : BLOCK_RESOLVER_UNAVAILABLE}`,
|
|
69
73
|
);
|
|
70
74
|
}
|
|
71
75
|
options.onResolved?.({
|
|
72
76
|
anchorLine: edit.anchor.line,
|
|
73
77
|
start: span.start,
|
|
74
78
|
end: span.end,
|
|
75
|
-
|
|
79
|
+
op,
|
|
76
80
|
});
|
|
81
|
+
if (op === "insert_after") {
|
|
82
|
+
// Mirror the parser's `insert after N:` lowering: one `after_anchor`
|
|
83
|
+
// insert per payload row, anchored on the block's last line.
|
|
84
|
+
for (const payload of edit.payloads) {
|
|
85
|
+
const cursor: Cursor = { kind: "after_anchor", anchor: { line: span.end } };
|
|
86
|
+
resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
77
90
|
// Mirror the parser's `replace start..end:` expansion exactly: one
|
|
78
91
|
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
79
92
|
// then one delete per line across `[span.start, span.end]`. An empty
|
package/src/diff-preview.ts
CHANGED
|
@@ -15,11 +15,22 @@ import type { CompactDiffOptions, CompactDiffPreview } from "./types";
|
|
|
15
15
|
const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2;
|
|
16
16
|
|
|
17
17
|
const PREVIEW_ELISION_MARKER = "…";
|
|
18
|
+
/** Blank row separating non-contiguous regions of a numbered diff. */
|
|
19
|
+
const PREVIEW_GAP_ROW = "";
|
|
18
20
|
const RAW_ELISION_MARKERS = new Set(["...", PREVIEW_ELISION_MARKER, `+${PREVIEW_ELISION_MARKER}`]);
|
|
19
21
|
|
|
22
|
+
function isPreviewSeparator(line: string | undefined): boolean {
|
|
23
|
+
return line === PREVIEW_ELISION_MARKER || line === PREVIEW_GAP_ROW;
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
function appendPreviewLine(output: string[], line: string): void {
|
|
21
27
|
const normalized = RAW_ELISION_MARKERS.has(line) ? PREVIEW_ELISION_MARKER : line;
|
|
22
|
-
|
|
28
|
+
// Separators (elision markers, blank gap rows) never stack: omitted
|
|
29
|
+
// removed lines between two separators would otherwise leave them
|
|
30
|
+
// adjacent. A leading separator is dropped outright.
|
|
31
|
+
if (isPreviewSeparator(normalized) && (output.length === 0 || isPreviewSeparator(output[output.length - 1]))) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
23
34
|
output.push(normalized);
|
|
24
35
|
}
|
|
25
36
|
|
|
@@ -107,6 +118,7 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
flushAddedRun();
|
|
121
|
+
while (formatted.length > 0 && isPreviewSeparator(formatted[formatted.length - 1])) formatted.pop();
|
|
110
122
|
|
|
111
123
|
return { preview: formatted.join("\n"), addedLines, removedLines };
|
|
112
124
|
}
|
package/src/grammar.lark
CHANGED
|
@@ -7,15 +7,17 @@ file_header: "[" filename "#" file_hash "]" LF
|
|
|
7
7
|
file_hash: /[0-9A-F]{4}/
|
|
8
8
|
filename: /[^#\r\n]+/
|
|
9
9
|
|
|
10
|
-
hunk: replace_hunk | replace_block_hunk | insert_hunk | delete_hunk | delete_block_hunk
|
|
10
|
+
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk
|
|
11
11
|
replace_hunk: replace_anchor LF emit_op*
|
|
12
12
|
replace_block_hunk: replace_block_anchor LF emit_op+
|
|
13
13
|
insert_hunk: insert_anchor LF emit_op+
|
|
14
|
+
insert_block_hunk: insert_block_anchor LF emit_op+
|
|
14
15
|
delete_hunk: "delete " header_range LF
|
|
15
16
|
delete_block_hunk: "delete block " LID LF
|
|
16
17
|
replace_anchor: "replace " header_range ":"
|
|
17
18
|
replace_block_anchor: "replace block " LID ":"
|
|
18
19
|
insert_anchor: "insert " insert_pos ":"
|
|
20
|
+
insert_block_anchor: "insert after block " LID ":"
|
|
19
21
|
insert_pos: "before " LID | "after " LID | "head" | "tail"
|
|
20
22
|
emit_op: "+" /(.*)/ LF
|
|
21
23
|
|
package/src/messages.ts
CHANGED
|
@@ -47,27 +47,39 @@ export const EMPTY_BLOCK =
|
|
|
47
47
|
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Error text emitted when a
|
|
50
|
+
* Error text emitted when a block-anchored op cannot be resolved to a
|
|
51
51
|
* syntactic block (unrecognized language, blank/out-of-range line, no node
|
|
52
52
|
* begins on line N such as a lone closing delimiter, or the resolved block has
|
|
53
53
|
* a syntax error). Names the offending line and steers back to an explicit
|
|
54
|
-
*
|
|
54
|
+
* concrete-line form.
|
|
55
55
|
*/
|
|
56
|
-
export function blockUnresolvedMessage(line: number): string {
|
|
56
|
+
export function blockUnresolvedMessage(line: number, op: "replace" | "delete" | "insert_after" = "replace"): string {
|
|
57
|
+
const phrase =
|
|
58
|
+
op === "delete"
|
|
59
|
+
? `delete block ${line}`
|
|
60
|
+
: op === "insert_after"
|
|
61
|
+
? `insert after block ${line}:`
|
|
62
|
+
: `replace block ${line}:`;
|
|
63
|
+
const fallback =
|
|
64
|
+
op === "delete"
|
|
65
|
+
? `\`delete ${line}..M\``
|
|
66
|
+
: op === "insert_after"
|
|
67
|
+
? `\`insert after M:\` with the block's explicit last line`
|
|
68
|
+
: `\`replace ${line}..M:\` with the block's explicit end line`;
|
|
57
69
|
return (
|
|
58
|
-
|
|
70
|
+
`\`${phrase}\` could not resolve a syntactic block beginning on line ${line}. ` +
|
|
59
71
|
`The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. ` +
|
|
60
|
-
`Use
|
|
72
|
+
`Use ${fallback} instead.`
|
|
61
73
|
);
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
/**
|
|
65
|
-
* Error text emitted when a
|
|
77
|
+
* Error text emitted when a block-anchored edit reaches a code path that
|
|
66
78
|
* has no {@link BlockResolver} wired in. Indicates a host-configuration bug
|
|
67
79
|
* rather than authored-input error.
|
|
68
80
|
*/
|
|
69
81
|
export const BLOCK_RESOLVER_UNAVAILABLE =
|
|
70
|
-
"`replace block N:`
|
|
82
|
+
"Block-anchored ops (`replace block N:`, `delete block N`, `insert after block N:`) are not available here (no tree-sitter block resolver is configured). Use a concrete line range instead.";
|
|
71
83
|
|
|
72
84
|
/**
|
|
73
85
|
* Internal invariant error: `applyEdits` received an unresolved `replace block
|
|
@@ -87,6 +99,22 @@ export const DELETE_BLOCK_TAKES_NO_BODY =
|
|
|
87
99
|
/** Error text emitted when an insert hunk has no body. */
|
|
88
100
|
export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
89
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Warning emitted when an `insert after` edit's body rows are indented
|
|
104
|
+
* shallower than the anchor line and the landing point was slid forward past
|
|
105
|
+
* the structural closer lines that follow. The body's indentation names the
|
|
106
|
+
* depth the author wants the new lines to sit at; anchoring inside a deeper
|
|
107
|
+
* construct is the common "insert after the block, anchored on the last line
|
|
108
|
+
* I read" mistake.
|
|
109
|
+
*/
|
|
110
|
+
export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
|
|
111
|
+
return (
|
|
112
|
+
`insert after ${anchorLine}: the body is indented shallower than line ${anchorLine}, so the landing was moved past ` +
|
|
113
|
+
`${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. ` +
|
|
114
|
+
`If you meant the deeper position inside the block, re-issue with the body indented to match.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
91
119
|
export const RECOVERY_EXTERNAL_WARNING =
|
|
92
120
|
"Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
package/src/parser.ts
CHANGED
|
@@ -32,6 +32,13 @@ function isSkippableCommentLine(line: string): boolean {
|
|
|
32
32
|
return line.trimStart().startsWith("#");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Stripped remainder of a bare `N: <value>` row that is a lone quoted or
|
|
37
|
+
* numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
|
|
38
|
+
* dict/YAML body rather than read-output paste.
|
|
39
|
+
*/
|
|
40
|
+
const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
|
|
41
|
+
|
|
35
42
|
function detectApplyPatchContamination(text: string, _hasPending: boolean): string | null {
|
|
36
43
|
const trimmed = text.trimStart();
|
|
37
44
|
if (trimmed.length === 0) return null;
|
|
@@ -88,6 +95,12 @@ interface Pending {
|
|
|
88
95
|
target: BlockTarget;
|
|
89
96
|
lineNum: number;
|
|
90
97
|
payloads: PayloadRow[];
|
|
98
|
+
/**
|
|
99
|
+
* Blank rows seen after the body started. Interior blanks are committed to
|
|
100
|
+
* the payload when the next non-blank row arrives; trailing blanks before
|
|
101
|
+
* the next header/op are layout separators and are discarded on flush.
|
|
102
|
+
*/
|
|
103
|
+
deferredBlanks: PayloadRow[];
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
export class Executor {
|
|
@@ -127,6 +140,7 @@ export class Executor {
|
|
|
127
140
|
return;
|
|
128
141
|
case "blank":
|
|
129
142
|
this.#consumePendingSkippableComments();
|
|
143
|
+
this.#handleBlank("", token.lineNum);
|
|
130
144
|
return;
|
|
131
145
|
case "payload-literal":
|
|
132
146
|
this.#consumePendingSkippableComments();
|
|
@@ -146,7 +160,7 @@ export class Executor {
|
|
|
146
160
|
validateRangeOrder(token.target.range, token.lineNum);
|
|
147
161
|
}
|
|
148
162
|
this.#flushPending();
|
|
149
|
-
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [] };
|
|
163
|
+
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
|
|
150
164
|
return;
|
|
151
165
|
}
|
|
152
166
|
}
|
|
@@ -208,6 +222,7 @@ export class Executor {
|
|
|
208
222
|
}
|
|
209
223
|
if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
|
210
224
|
if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
|
225
|
+
this.#commitDeferredBlanks(pending);
|
|
211
226
|
pending.payloads.push({ kind: "literal", text, lineNum });
|
|
212
227
|
}
|
|
213
228
|
|
|
@@ -215,12 +230,16 @@ export class Executor {
|
|
|
215
230
|
const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
|
|
216
231
|
if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
|
|
217
232
|
if (this.#pending) {
|
|
218
|
-
if (text.trim().length === 0)
|
|
233
|
+
if (text.trim().length === 0) {
|
|
234
|
+
this.#handleBlank(text, lineNum);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
219
237
|
if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
|
220
238
|
if (this.#pending.target.kind === "delete_block")
|
|
221
239
|
throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
|
222
240
|
if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
|
|
223
241
|
if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
|
242
|
+
this.#commitDeferredBlanks(this.#pending);
|
|
224
243
|
// Defer read-output line-number stripping to #flushPending: a bare
|
|
225
244
|
// "N:text" row is only a copy-paste artifact from snapshot output
|
|
226
245
|
// when *every* bare row in the hunk carries that prefix. Stripping a
|
|
@@ -238,6 +257,28 @@ export class Executor {
|
|
|
238
257
|
);
|
|
239
258
|
}
|
|
240
259
|
|
|
260
|
+
/**
|
|
261
|
+
* A blank row inside a hunk body is ambiguous: interior blanks are body
|
|
262
|
+
* content (a bare-pasted body legitimately contains empty lines), while
|
|
263
|
+
* blanks before the body starts or trailing into the next op are layout.
|
|
264
|
+
* Defer them; {@link #commitDeferredBlanks} folds them in only when a later
|
|
265
|
+
* non-blank row proves they were interior.
|
|
266
|
+
*/
|
|
267
|
+
#handleBlank(text: string, lineNum: number): void {
|
|
268
|
+
const pending = this.#pending;
|
|
269
|
+
if (!pending) return;
|
|
270
|
+
if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
|
|
271
|
+
if (pending.payloads.length === 0) return;
|
|
272
|
+
pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#commitDeferredBlanks(pending: Pending): void {
|
|
276
|
+
if (pending.deferredBlanks.length === 0) return;
|
|
277
|
+
if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
|
278
|
+
pending.payloads.push(...pending.deferredBlanks);
|
|
279
|
+
pending.deferredBlanks = [];
|
|
280
|
+
}
|
|
281
|
+
|
|
241
282
|
/**
|
|
242
283
|
* Strip a single read-output line-number prefix (`N:`) from every bare body
|
|
243
284
|
* row, but only when *all* bare rows carry one. A uniform set of prefixes is
|
|
@@ -247,14 +288,22 @@ export class Executor {
|
|
|
247
288
|
*/
|
|
248
289
|
#stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
|
|
249
290
|
let sawBare = false;
|
|
291
|
+
let allLiteralValues = true;
|
|
250
292
|
for (const row of payloads) {
|
|
251
|
-
if (!row.bare) continue;
|
|
293
|
+
if (!row.bare || row.text.trim().length === 0) continue;
|
|
252
294
|
sawBare = true;
|
|
253
|
-
|
|
295
|
+
const stripped = stripOneLeadingHashlinePrefix(row.text);
|
|
296
|
+
if (stripped === row.text) return;
|
|
297
|
+
allLiteralValues &&= BARE_LITERAL_VALUE_RE.test(stripped);
|
|
254
298
|
}
|
|
255
299
|
if (!sawBare) return;
|
|
300
|
+
// A body where every stripped remainder is a lone quoted/numeric literal
|
|
301
|
+
// (optionally comma-terminated) is the shape of a numeric-keyed dict or
|
|
302
|
+
// YAML mapping (`1: "one",`), not read-output paste; stripping the "N:"
|
|
303
|
+
// keys would mangle every line. Leave such bodies untouched.
|
|
304
|
+
if (allLiteralValues) return;
|
|
256
305
|
for (const row of payloads) {
|
|
257
|
-
if (row.bare) row.text = stripOneLeadingHashlinePrefix(row.text);
|
|
306
|
+
if (row.bare && row.text.trim().length > 0) row.text = stripOneLeadingHashlinePrefix(row.text);
|
|
258
307
|
}
|
|
259
308
|
}
|
|
260
309
|
|
|
@@ -273,11 +322,12 @@ export class Executor {
|
|
|
273
322
|
this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
|
|
274
323
|
}
|
|
275
324
|
|
|
276
|
-
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number): void {
|
|
325
|
+
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
|
|
277
326
|
this.#edits.push({
|
|
278
327
|
kind: "block",
|
|
279
328
|
anchor: { ...anchor },
|
|
280
329
|
payloads: payloads.map(payload => payload.text),
|
|
330
|
+
...(mode === undefined ? {} : { mode }),
|
|
281
331
|
lineNum,
|
|
282
332
|
index: this.#editIndex++,
|
|
283
333
|
});
|
|
@@ -307,6 +357,11 @@ export class Executor {
|
|
|
307
357
|
this.#pushBlock(target.anchor, payloads, lineNum);
|
|
308
358
|
return;
|
|
309
359
|
}
|
|
360
|
+
if (target.kind === "insert_after_block") {
|
|
361
|
+
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
362
|
+
this.#pushBlock(target.anchor, payloads, lineNum, "insert_after");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
310
365
|
if (payloads.length === 0) {
|
|
311
366
|
if (target.kind === "replace") {
|
|
312
367
|
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
package/src/patcher.ts
CHANGED
|
@@ -199,7 +199,24 @@ export class Patcher {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
const results: PatchSectionResult[] = [];
|
|
202
|
-
for (
|
|
202
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
203
|
+
try {
|
|
204
|
+
results.push(await this.commit(prepared[index]));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// A mid-batch write failure leaves earlier sections on disk with no
|
|
207
|
+
// rollback; report exactly which sections landed so the caller can
|
|
208
|
+
// re-issue only the missing ones instead of double-applying.
|
|
209
|
+
const written = prepared.slice(0, index).map(entry => entry.section.path);
|
|
210
|
+
const notWritten = prepared.slice(index + 1).map(entry => entry.section.path);
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Failed to write ${prepared[index].section.path}: ${message}` +
|
|
214
|
+
(written.length > 0 ? ` Sections already written: ${written.join(", ")}.` : "") +
|
|
215
|
+
(notWritten.length > 0 ? ` Sections not written: ${notWritten.join(", ")}.` : ""),
|
|
216
|
+
{ cause: error },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
203
220
|
return { sections: results };
|
|
204
221
|
}
|
|
205
222
|
|
package/src/prompt.md
CHANGED
|
@@ -5,14 +5,15 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro
|
|
|
5
5
|
</headers>
|
|
6
6
|
|
|
7
7
|
<ops>
|
|
8
|
-
replace N..M
|
|
9
|
-
replace block N
|
|
10
|
-
delete N..M
|
|
11
|
-
delete block N
|
|
12
|
-
insert before N
|
|
13
|
-
insert after N
|
|
14
|
-
insert
|
|
15
|
-
insert
|
|
8
|
+
`replace N..M:` — replace original lines N..M with the body rows below. CAUTION, IT IS INCLUSIVE! MAKE SURE YOU INTEND TO DELETE BOTH ENDS!
|
|
9
|
+
`replace block N:` — replace the whole syntactic block that BEGINS on line N — header line through closing line — resolved with tree-sitter, so you never count the end. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line; a leading decorator/attribute/doc-comment is a separate node and is NOT swept in (see rules).
|
|
10
|
+
`delete N..M` — delete original lines N..M. No body.
|
|
11
|
+
`delete block N` — delete the whole syntactic block that BEGINS on line N.
|
|
12
|
+
`insert before N:` — insert the body rows immediately before line N.
|
|
13
|
+
`insert after N:` — insert the body rows immediately after line N.
|
|
14
|
+
`insert after block N:` — insert the body rows after the END of the syntactic block that BEGINS on line N (resolved like `replace block`).
|
|
15
|
+
`insert head:` — insert the body rows at the very start of the file.
|
|
16
|
+
`insert tail:` — insert the body rows at the very end of the file.
|
|
16
17
|
Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
|
|
17
18
|
</ops>
|
|
18
19
|
|
|
@@ -27,12 +28,14 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
27
28
|
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
|
28
29
|
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
|
|
29
30
|
- A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
|
|
31
|
+
- Body indentation is a depth claim: indent body rows for the depth they should live at — an `insert after` body indented shallower than its anchor lands past the closing lines below it (the result warns and names the landing line).
|
|
30
32
|
- A valid `#TAG` is NOT permission to patch the whole file — it certifies the snapshot, not your knowledge of it. Authority to touch a line comes from having literally seen that line as a `LINE:TEXT` row in a `read`/`search`, not from holding the tag. Every line in a hunk's range, and the lines bounding it, must be lines you actually saw.
|
|
31
33
|
- An elided or partial read is NOT a read of the gap. A `…` (or any collapsed/truncated region) between two excerpts means those lines are UNSEEN — treat them exactly like lines you never opened. Never place a hunk on, or span a range across, an elided region; `read` that range explicitly first. Reconstructing it from memory of "what the code probably looks like" is how ranges drift off-by-N and shred neighboring blocks.
|
|
32
34
|
- On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
|
|
33
35
|
- One hunk per range; the body is the final content, never an old/new pair.
|
|
34
|
-
- Keep every range as tight as the change: a range
|
|
35
|
-
- `replace block N` vs `replace N..M`: use `replace block N` to rewrite a WHOLE construct (function / `if` / loop / class body) — tree-sitter resolves its closing line, so a long body can't be mis-counted and a stale end can't clip it mid-block
|
|
36
|
+
- Keep every range as tight as the change: a range covers ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside — change one line with `replace N..N`, not the whole block around it. Tightness means excluding unchanged lines, not being short: a range where every line genuinely changes is correctly long. Tight ranges bound the blast radius of a stale number: a stale one-line range corrupts one line; a stale wide range shreds every line it spans. This applies to hand-counted `replace N..M` ranges; `replace block N` is exempt — tree-sitter fixes the end.
|
|
37
|
+
- `replace block N` vs `replace N..M`: use `replace block N` to rewrite a WHOLE construct (function / `if` / loop / class body) — tree-sitter resolves its closing line, so a long body can't be mis-counted and a stale end can't clip it mid-block. The edit result echoes the span it matched (`replace block N → resolved lines A-B`); glance at it to confirm you got what you meant. Use `replace N..M` to change specific lines inside a construct.
|
|
38
|
+
- The resolved span of `replace block N` is EXACTLY the node beginning on line N. A leading decorator, attribute, or doc-comment is a separate node and is NOT included; to take a decorated definition together with its decorator, point N at the FIRST decorator line (Python parses `@dec` + `def` as one block). A leading line-comment that parses as its own node (e.g. Rust `///`) is not captured by any single opener — use `replace N..M` spanning the comment and the construct.
|
|
36
39
|
- To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
|
|
37
40
|
- Pure additions use `insert`, never a widened `replace`. If the change only adds lines, `insert before/after` the spot and keep every existing line out of all ranges. Do NOT `replace` a span of keepers and retype them around the new line "to preserve" them — those retyped keepers are exactly what gets silently dropped when one is forgotten. A keeper that never enters your body cannot be lost. `replace` is only for lines whose own text changes.
|
|
38
41
|
- NEVER use this tool to format code — reordering imports, re-indenting, aligning columns, or any mechanical restyling. That is the project formatter's job; run it instead of hand-editing layout here.
|
package/src/snapshots.ts
CHANGED
|
@@ -62,12 +62,20 @@ export abstract class SnapshotStore {
|
|
|
62
62
|
|
|
63
63
|
const DEFAULT_MAX_PATHS = 30;
|
|
64
64
|
const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
|
65
|
+
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
|
66
|
+
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
65
67
|
|
|
66
68
|
export interface InMemorySnapshotStoreOptions {
|
|
67
69
|
/** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
|
|
68
70
|
maxPaths?: number;
|
|
69
71
|
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
|
70
72
|
maxVersionsPerPath?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Global ceiling on retained snapshot text summed across every path's
|
|
75
|
+
* version history, measured in UTF-16 code units (default 64 MiB).
|
|
76
|
+
* Least-recently-used path histories are evicted to stay under it.
|
|
77
|
+
*/
|
|
78
|
+
maxTotalBytes?: number;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/**
|
|
@@ -85,7 +93,15 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
85
93
|
|
|
86
94
|
constructor(options: InMemorySnapshotStoreOptions = {}) {
|
|
87
95
|
super();
|
|
88
|
-
this.#versions = new LRUCache<string, Snapshot[]>({
|
|
96
|
+
this.#versions = new LRUCache<string, Snapshot[]>({
|
|
97
|
+
max: options.maxPaths ?? DEFAULT_MAX_PATHS,
|
|
98
|
+
maxSize: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
|
|
99
|
+
sizeCalculation: history => {
|
|
100
|
+
let total = 1;
|
|
101
|
+
for (const version of history) total += version.text.length;
|
|
102
|
+
return total;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
89
105
|
this.#maxVersionsPerPath = options.maxVersionsPerPath ?? DEFAULT_MAX_VERSIONS_PER_PATH;
|
|
90
106
|
}
|
|
91
107
|
|
package/src/tokenizer.ts
CHANGED
|
@@ -204,6 +204,7 @@ export type BlockTarget =
|
|
|
204
204
|
| { kind: "delete_block"; anchor: Anchor }
|
|
205
205
|
| { kind: "insert_before"; anchor: Anchor }
|
|
206
206
|
| { kind: "insert_after"; anchor: Anchor }
|
|
207
|
+
| { kind: "insert_after_block"; anchor: Anchor }
|
|
207
208
|
| { kind: "bof" }
|
|
208
209
|
| { kind: "eof" };
|
|
209
210
|
|
|
@@ -238,6 +239,16 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
|
|
|
238
239
|
}
|
|
239
240
|
const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
|
|
240
241
|
if (afterEnd !== null) {
|
|
242
|
+
// `insert after block N:` — resolve N to a tree-sitter block range at
|
|
243
|
+
// apply time and insert after its last line. Try the `block` sub-keyword
|
|
244
|
+
// before falling back to a literal `insert after N:` anchor.
|
|
245
|
+
const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD);
|
|
246
|
+
if (blockEnd !== null) {
|
|
247
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
|
|
248
|
+
if (anchor === null) return null;
|
|
249
|
+
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
|
250
|
+
return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex };
|
|
251
|
+
}
|
|
241
252
|
const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
|
|
242
253
|
if (anchor === null) return null;
|
|
243
254
|
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
package/src/types.ts
CHANGED
|
@@ -35,18 +35,21 @@ export type Edit =
|
|
|
35
35
|
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
36
36
|
| {
|
|
37
37
|
/**
|
|
38
|
-
* Deferred block edit (`replace block N:` / `delete block N`
|
|
39
|
-
* line span is unknown at parse
|
|
40
|
-
* {@link resolveBlockEdits} once file text +
|
|
41
|
-
* available, then expanded into concrete edits:
|
|
42
|
-
* (from `replace block`) becomes
|
|
43
|
-
* that `replace start..end:`
|
|
44
|
-
*
|
|
38
|
+
* Deferred block edit (`replace block N:` / `delete block N` /
|
|
39
|
+
* `insert after block N:`). The exact line span is unknown at parse
|
|
40
|
+
* time — it is computed by {@link resolveBlockEdits} once file text +
|
|
41
|
+
* path (→ language) are available, then expanded into concrete edits:
|
|
42
|
+
* a non-empty `payloads` without `mode` (from `replace block`) becomes
|
|
43
|
+
* the same `replacement` inserts + deletes that `replace start..end:`
|
|
44
|
+
* produces; an empty `payloads` (from `delete block`) becomes a pure
|
|
45
|
+
* range deletion; `mode: "insert_after"` becomes plain `after_anchor`
|
|
46
|
+
* inserts at the block's last line. `applyEdits` never sees this
|
|
45
47
|
* variant.
|
|
46
48
|
*/
|
|
47
49
|
kind: "block";
|
|
48
50
|
anchor: Anchor;
|
|
49
51
|
payloads: string[];
|
|
52
|
+
mode?: "insert_after";
|
|
50
53
|
lineNum: number;
|
|
51
54
|
index: number;
|
|
52
55
|
};
|
|
@@ -122,11 +125,11 @@ export interface BlockSpan {
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
/**
|
|
125
|
-
* One `replace block N:` / `delete block N`
|
|
126
|
-
* line span. Surfaced on {@link ApplyResult} so the
|
|
127
|
-
* "block N → lines start..end" and let the model catch a wrong
|
|
128
|
-
* decorator or doc-comment that sits in a separate node
|
|
129
|
-
* block.
|
|
128
|
+
* One `replace block N:` / `delete block N` / `insert after block N:` anchor
|
|
129
|
+
* resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
|
|
130
|
+
* host can echo "block N → lines start..end" and let the model catch a wrong
|
|
131
|
+
* opener — e.g. a decorator or doc-comment that sits in a separate node
|
|
132
|
+
* outside the resolved block.
|
|
130
133
|
*/
|
|
131
134
|
export interface BlockResolution {
|
|
132
135
|
/** The 1-indexed line the block op was anchored on (the `N`). */
|
|
@@ -135,8 +138,8 @@ export interface BlockResolution {
|
|
|
135
138
|
start: number;
|
|
136
139
|
/** Last line of the resolved span (1-indexed, inclusive). */
|
|
137
140
|
end: number;
|
|
138
|
-
/**
|
|
139
|
-
|
|
141
|
+
/** Which block op produced this resolution. */
|
|
142
|
+
op: "replace" | "delete" | "insert_after";
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
|