@oh-my-pi/hashline 17.2.10 → 17.2.12
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 +27 -0
- package/dist/types/apply.d.ts +17 -1
- package/dist/types/clipboard.d.ts +6 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/messages.d.ts +41 -2
- package/dist/types/prefixes.d.ts +9 -9
- package/dist/types/syntax.d.ts +24 -0
- package/dist/types/tokenizer.d.ts +6 -0
- package/package.json +3 -3
- package/src/apply.ts +302 -51
- package/src/clipboard.ts +32 -8
- package/src/index.ts +1 -0
- package/src/input.ts +6 -2
- package/src/messages.ts +88 -4
- package/src/parser.ts +23 -7
- package/src/patcher.ts +2 -2
- package/src/prefixes.ts +10 -10
- package/src/recovery.ts +6 -2
- package/src/syntax.ts +49 -0
- package/src/tokenizer.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.12] - 2026-08-08
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- `PUT N.=M @name` over a *span* now throws when `@name` was never captured, instead of warning and deleting the range. Pasting a never-captured register over a span wrote nothing back, so a mistyped or hallucinated register name silently destroyed content. Gap pastes (`PUT >N @name`) keep the warned no-op behaviour from 17.2.11.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `applyEdits` now takes a `path` and uses the native tree-sitter parser to decide every boundary repair that depends on delimiter *semantics*. The authored edits are materialized first: if that result parses, it is returned untouched, so a `}` inside a regex literal, a string, or Markdown prose is never mistaken for a block closer. A closer-spare repair lands only when the repaired result is *shown* to parse — never on delimiter arithmetic alone — so an unrecognized language or an unprovable candidate leaves the edit exactly as authored. Wired through the patcher, recovery, section apply, and the edit tool's preview.
|
|
14
|
+
- Auto-repair for replacement ranges that start one line early on a structural closer (the `}` of the construct above): the closer is spared and the payload lands after it, gated on the same parse proof.
|
|
15
|
+
- Warning for balanced payloads over ranges that end mid-block (deleting opener(s) whose closer(s) survive below), pointing at the block-op remedy (`PUT N*:`). Raised only when the baseline parsed and the authored result does not, so it cannot fire on prose or an unknown language.
|
|
16
|
+
- Warning when a `+` body row is itself a valid hunk header (`+CUT 5.=9`). Such a row is literal content by definition and is inserted into the file as text; naming it at the moment it happens turns a silent source-file corruption into an actionable diagnostic.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Rejected patches whose pasted `N:TEXT` read-output rows repeat a source line number. Each such row is recovered as a single-line `PUT N.=N:`, so a body written as consecutive lines under one number collapsed through the same-range coalescer, keeping only the last row and silently dropping the rest — in one incident replacing a block opener with `}` and deleting the following statement. The error now names the repeated line and teaches the explicit `PUT` form.
|
|
21
|
+
|
|
22
|
+
## [17.2.11] - 2026-08-07
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Pasting an empty named register (`PUT … @name` with no matching capture) now surfaces a warning listing available registers and removes the span target instead of throwing an error.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Fixed an issue where pipe-numbered `read`/`search` rows copied into top-level and bare-body patch payloads were not properly recovered (#7905).
|
|
31
|
+
|
|
5
32
|
## [17.2.10] - 2026-08-06
|
|
6
33
|
|
|
7
34
|
### Changed
|
package/dist/types/apply.d.ts
CHANGED
|
@@ -18,13 +18,29 @@ export interface ApplyEditsOptions {
|
|
|
18
18
|
* across files; omitted, the call gets a private register.
|
|
19
19
|
*/
|
|
20
20
|
clipboard?: Clipboard;
|
|
21
|
-
/** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
|
21
|
+
/** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */
|
|
22
22
|
onEmptyPaste?: "throw" | "drop";
|
|
23
|
+
/**
|
|
24
|
+
* Target file path, used only to infer a language for the tree-sitter
|
|
25
|
+
* syntax probe (see {@link parsesCleanly}). Supplying it lets the applier
|
|
26
|
+
* confirm that the edit as authored still parses, in which case no
|
|
27
|
+
* delimiter-shape repair or advisory may touch it. Omitted, the probe casts
|
|
28
|
+
* no veto and the delimiter heuristics decide alone.
|
|
29
|
+
*/
|
|
30
|
+
path?: string;
|
|
23
31
|
}
|
|
24
32
|
/**
|
|
25
33
|
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
26
34
|
*
|
|
27
35
|
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
28
36
|
* Throws if an anchor is out of bounds.
|
|
37
|
+
*
|
|
38
|
+
* Repairs that hinge on delimiter *semantics* (a range that swallowed the `}`
|
|
39
|
+
* closing the construct above or below it) are subject to a parser veto when
|
|
40
|
+
* `options.path` is supplied: the authored edits are materialized first, and if
|
|
41
|
+
* that result parses it is returned untouched. A `}` in prose, a string, or a
|
|
42
|
+
* regex literal is therefore never mistaken for a block closer. Only when the
|
|
43
|
+
* authored result does not parse — or the language is unknown to the parser, so
|
|
44
|
+
* balance arithmetic is the sole evidence — do the closer-spare repairs run.
|
|
29
45
|
*/
|
|
30
46
|
export declare function applyEdits(text: string, edits: readonly Edit[], options?: ApplyEditsOptions): ApplyResult;
|
|
@@ -3,8 +3,10 @@ import type { Clipboard, Edit } from "./types.js";
|
|
|
3
3
|
export declare function hasClipboardEdit(edits: readonly Edit[]): boolean;
|
|
4
4
|
/** Optional knobs for {@link resolveClipboardEdits}. */
|
|
5
5
|
export interface ResolveClipboardEditsOptions {
|
|
6
|
-
/** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
|
6
|
+
/** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). Named registers never throw — an empty named paste warns and pastes nothing. */
|
|
7
7
|
onEmptyPaste?: "throw" | "drop";
|
|
8
|
+
/** Receives non-fatal diagnostics (e.g. an empty named-register paste). */
|
|
9
|
+
onWarning?: (message: string) => void;
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Resolve clipboard edits against the original file lines in authored order.
|
|
@@ -18,7 +20,8 @@ export declare function forkClipboard(source?: Clipboard): Clipboard;
|
|
|
18
20
|
/** Publish a clipboard fork back to its source register (only named registers persist across batches). */
|
|
19
21
|
export declare function commitClipboard(fork: Clipboard, target: Clipboard): void;
|
|
20
22
|
/**
|
|
21
|
-
* Validate
|
|
22
|
-
* mutating the register or reading file content.
|
|
23
|
+
* Validate anonymous clipboard sequencing (empty or ambiguous unlabeled paste)
|
|
24
|
+
* without mutating the register or reading file content. Empty named-register
|
|
25
|
+
* pastes are non-fatal — they surface as apply-time warnings instead.
|
|
23
26
|
*/
|
|
24
27
|
export declare function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void;
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/messages.d.ts
CHANGED
|
@@ -29,6 +29,20 @@ export declare const REPLACEMENT_INDENT_AUTO_SHIFT_WARNING = "Auto-indented a re
|
|
|
29
29
|
export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
|
|
30
30
|
/** Top-level read-output rows recovered as single-line replacements. */
|
|
31
31
|
export declare const SNAPSHOT_ROWS_AUTO_PUT_WARNING = "Recovered top-level `N:TEXT` snapshot row(s) as single-line `PUT N.=N:` replacements. Use explicit `PUT` headers for reliable edits.";
|
|
32
|
+
/**
|
|
33
|
+
* Two or more top-level `N:TEXT` read-output rows named the same source line.
|
|
34
|
+
* Each recovered row lowers to a single-line `PUT N.=N:`, so the coalescer would
|
|
35
|
+
* keep only the last and silently drop the others — reject and teach the format
|
|
36
|
+
* instead.
|
|
37
|
+
*/
|
|
38
|
+
export declare function repeatedSnapshotRowMessage(line: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* A `+` body row whose text is itself a valid hunk header — the op was written
|
|
41
|
+
* with the payload prefix, so it is inserted into the file as literal text
|
|
42
|
+
* instead of executing. Warned rather than rejected: a literal `CUT …` line is
|
|
43
|
+
* legitimate content in documentation and test fixtures.
|
|
44
|
+
*/
|
|
45
|
+
export declare function literalOpRowWarning(line: number, text: string): string;
|
|
32
46
|
/** Bare range header recovered as an implicit replacement hunk. */
|
|
33
47
|
export declare const BARE_RANGE_AUTO_PUT_WARNING = "Recovered a bare `N.=M:` header as `PUT N.=M:`. Prefix replacement ranges with `PUT`.";
|
|
34
48
|
/** Copied read-output elision rows were ignored rather than written as source. */
|
|
@@ -88,6 +102,25 @@ export declare function ambiguousBoundaryEchoMessage(startLine: number, endLine:
|
|
|
88
102
|
* sibling), so the edit is rejected instead of repaired.
|
|
89
103
|
*/
|
|
90
104
|
export declare function ambiguousCloserSpareMessage(startLine: number, endLine: number, closerLine: number, count: number): string;
|
|
105
|
+
/**
|
|
106
|
+
* A replacement range starts by deleting structural closer(s) the payload
|
|
107
|
+
* never restates — the "range started one line early, on the `}` that ends
|
|
108
|
+
* the construct above" mistake — but the payload's indentation claims a depth
|
|
109
|
+
* inside the block those closers terminate, so whether the new content
|
|
110
|
+
* belongs before or after the spared closer is ambiguous. Rejected instead of
|
|
111
|
+
* repaired; the at-or-above-depth reading is auto-repaired by sparing the
|
|
112
|
+
* closer ahead of the payload.
|
|
113
|
+
*/
|
|
114
|
+
export declare function ambiguousLeadingCloserSpareMessage(startLine: number, endLine: number, count: number): string;
|
|
115
|
+
/**
|
|
116
|
+
* A replacement range deletes more opening delimiter(s) than the payload
|
|
117
|
+
* reopens while the matching closer(s) survive below the range — the
|
|
118
|
+
* "payload is a complete construct but the range ends mid-block" mistake.
|
|
119
|
+
* Surfaced as a warning, never a rejection: the applier is language-agnostic
|
|
120
|
+
* and opener/closer text shape cannot prove a syntactic block (the braces may
|
|
121
|
+
* be literal prose), so the edit applies as authored and the author decides.
|
|
122
|
+
*/
|
|
123
|
+
export declare function midBlockRangeWarning(startLine: number, endLine: number, orphaned: number): string;
|
|
91
124
|
/**
|
|
92
125
|
* Internal invariant: `applyEdits` received an unresolved block edit;
|
|
93
126
|
* `resolveBlockEdits` must run first.
|
|
@@ -109,8 +142,14 @@ export declare const COLONLESS_PUT_TAKES_NO_BODY = "`PUT` without `:` is clipboa
|
|
|
109
142
|
export declare const COLONLESS_SPAN_PUT = "Colonless `PUT` is clipboard-backed, and span targets need a named register (`PUT 5.=9 @name`); the anonymous register pastes only at gaps (`PUT >40`). To write literal content, add `:` and `+TEXT` body rows.";
|
|
110
143
|
/** Anonymous paste ran with an empty anonymous register. */
|
|
111
144
|
export declare const EMPTY_PASTE = "Nothing to paste: no unlabeled `CUT` precedes this `PUT` in this call, and the anonymous register never carries across calls. Put `CUT N.=M` / `CUT N*` above it, or use named registers (`CUT \u2026 @name` \u2192 `PUT \u2026 @name`) for cross-call moves.";
|
|
112
|
-
/** Named paste read a register that holds nothing. */
|
|
113
|
-
export declare function
|
|
145
|
+
/** Named paste read a register that holds nothing; a gap paste applies as empty. */
|
|
146
|
+
export declare function emptyRegisterPasteWarning(name: string, known: readonly string[]): string;
|
|
147
|
+
/**
|
|
148
|
+
* Named paste over a *span* read a register that holds nothing. Pasting empty
|
|
149
|
+
* would delete the span, which the author never asked for — almost always a
|
|
150
|
+
* mistyped or never-captured register name — so the edit is rejected instead.
|
|
151
|
+
*/
|
|
152
|
+
export declare function emptyRegisterSpanPasteMessage(name: string, known: readonly string[]): string;
|
|
114
153
|
/** Unlabeled paste with two or more unlabeled cuts pending. */
|
|
115
154
|
export declare function ambiguousAnonymousPasteMessage(pending: readonly string[]): string;
|
|
116
155
|
/**
|
package/dist/types/prefixes.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* When a
|
|
3
|
-
*
|
|
4
|
-
* diff-style echoes, a leading `+`. These helpers detect
|
|
5
|
-
* the raw text. Two strip modes are exposed:
|
|
2
|
+
* When a payload is authored against `read`/`search` output, each line is
|
|
3
|
+
* prefixed with either a line number (`123:` in hashline mode or `123|`
|
|
4
|
+
* otherwise) or, for diff-style echoes, a leading `+`. These helpers detect
|
|
5
|
+
* that and recover the raw text. Two strip modes are exposed:
|
|
6
6
|
*
|
|
7
7
|
* - {@link stripNewLinePrefixes} — opportunistic: strips when the input
|
|
8
8
|
* clearly carries hashline or diff prefixes, leaves it alone otherwise.
|
|
@@ -17,15 +17,15 @@
|
|
|
17
17
|
export declare function isReadMetadataLine(line: string): boolean;
|
|
18
18
|
/**
|
|
19
19
|
* Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
|
|
20
|
-
* most one leading
|
|
21
|
-
* loop. Use this when the input carries at most one snapshot prefix
|
|
22
|
-
* bare body row paste from `read` output) — recursive stripping would
|
|
23
|
-
* content whose own text starts with
|
|
20
|
+
* most one leading line-number prefix (`N:`, `N|`, `>>>N:`, `+N:` etc.) and
|
|
21
|
+
* does NOT loop. Use this when the input carries at most one snapshot prefix
|
|
22
|
+
* (e.g. a bare body row paste from `read` output) — recursive stripping would
|
|
23
|
+
* corrupt content whose own text starts with a line-number prefix.
|
|
24
24
|
*/
|
|
25
25
|
export declare function stripOneLeadingHashlinePrefix(line: string): string;
|
|
26
26
|
/**
|
|
27
27
|
* Strip whichever prefix scheme the lines appear to be carrying:
|
|
28
|
-
* -
|
|
28
|
+
* - line-number prefixes (`123:` or `123|`) when every content line has one
|
|
29
29
|
* - leading `+` (diff style) when at least half the lines have one
|
|
30
30
|
* - mixed `+<n>:` form when present
|
|
31
31
|
*
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax probe for candidate edit results, via the native tree-sitter parser.
|
|
3
|
+
*
|
|
4
|
+
* Delimiter-balance arithmetic cannot tell a block closer from a `}` inside a
|
|
5
|
+
* regex literal, a string, or Markdown prose. A parser can, so it holds veto
|
|
6
|
+
* power over every repair whose justification is "this line closes a syntactic
|
|
7
|
+
* block": when the edit the author actually wrote still parses, no such repair
|
|
8
|
+
* may rewrite it. The probe never *forces* a repair — an unrecognized language
|
|
9
|
+
* or an already-broken file simply yields no veto, leaving the delimiter
|
|
10
|
+
* heuristics as the only available evidence.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* `true` when `text` parses without a syntax error under the language inferred
|
|
14
|
+
* from `path`. `false` covers "does not parse" and "cannot tell" alike — no
|
|
15
|
+
* path, an unrecognized language, or a native failure — because both mean the
|
|
16
|
+
* probe has nothing to prove with. Callers must therefore never treat `false`
|
|
17
|
+
* as evidence *about the edit*: it only withholds permission to rewrite.
|
|
18
|
+
*
|
|
19
|
+
* Uses `enclosingBlockBoundaries` over a whole-file window: no node can cross
|
|
20
|
+
* that window, so the boundary walk is trivial and the tree-sitter parse is the
|
|
21
|
+
* only real cost. It returns `null` for an unrecognized language and for a
|
|
22
|
+
* source that fails to parse, which this predicate deliberately conflates.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parsesCleanly(path: string | undefined, text: string): boolean;
|
|
@@ -43,6 +43,12 @@ export type BlockTarget = {
|
|
|
43
43
|
kind: "move";
|
|
44
44
|
dest: string;
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Whether `text` would parse as a hunk header on its own (`PUT …`, `CUT …`,
|
|
48
|
+
* `REM`, `MV …`). Used to catch an op row mistakenly written as a `+` body row,
|
|
49
|
+
* which the applier would otherwise insert into the file as literal text.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isHunkHeaderText(text: string): boolean;
|
|
46
52
|
interface TokenBase {
|
|
47
53
|
lineNum: number;
|
|
48
54
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.12",
|
|
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",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"fmt": "biome format --write ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "17.2.
|
|
37
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
36
|
+
"@oh-my-pi/pi-natives": "17.2.12",
|
|
37
|
+
"@oh-my-pi/pi-utils": "17.2.12"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.14"
|
package/src/apply.ts
CHANGED
|
@@ -13,10 +13,13 @@ import {
|
|
|
13
13
|
afterInsertLandingShiftWarning,
|
|
14
14
|
ambiguousBoundaryEchoMessage,
|
|
15
15
|
ambiguousCloserSpareMessage,
|
|
16
|
+
ambiguousLeadingCloserSpareMessage,
|
|
16
17
|
blockInsertLandingShiftWarning,
|
|
18
|
+
midBlockRangeWarning,
|
|
17
19
|
REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
|
|
18
20
|
UNRESOLVED_BLOCK_INTERNAL,
|
|
19
21
|
} from "./messages";
|
|
22
|
+
import { parsesCleanly } from "./syntax";
|
|
20
23
|
import { cloneCursor } from "./tokenizer";
|
|
21
24
|
import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
|
|
22
25
|
|
|
@@ -120,6 +123,19 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
120
123
|
}
|
|
121
124
|
return byLine;
|
|
122
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* A closer-spare repair could not tell which side of a spared delimiter the
|
|
128
|
+
* payload belongs on. Distinct from the evidence-complete textual rejections
|
|
129
|
+
* (a one-sided boundary echo) so {@link applyEdits} can withhold *only* this
|
|
130
|
+
* delimiter-semantics verdict on a file the parser cannot vouch for, while
|
|
131
|
+
* every other rejection propagates unconditionally.
|
|
132
|
+
*/
|
|
133
|
+
class CloserSpareAmbiguityError extends Error {
|
|
134
|
+
constructor(message: string) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "CloserSpareAmbiguityError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
123
139
|
|
|
124
140
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
125
141
|
// Replacement-boundary repair
|
|
@@ -661,12 +677,91 @@ function findDroppedSuffixClosers(
|
|
|
661
677
|
}
|
|
662
678
|
return { startLine: suffixStartLine + keepStart, count: keepEnd - keepStart, balance: keptBalance };
|
|
663
679
|
}
|
|
680
|
+
interface DroppedPrefixClosers {
|
|
681
|
+
readonly count: number;
|
|
682
|
+
readonly balance: DelimiterBalance;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Leading run of the range's deleted structural-closer line(s) that the
|
|
687
|
+
* payload never restates — the mirror of {@link findDroppedSuffixClosers} for
|
|
688
|
+
* the "range started one line early, on the `}` that ends the construct
|
|
689
|
+
* above" mistake. Fires only when the group's own delta and the whole-patch
|
|
690
|
+
* residual are both missing exactly those closers, no deleted lines above the
|
|
691
|
+
* range account for their opener, and dangling opener(s) actually survive
|
|
692
|
+
* above the range in the projected file.
|
|
693
|
+
*/
|
|
694
|
+
function findDroppedPrefixClosers(
|
|
695
|
+
group: ReplacementGroup,
|
|
696
|
+
fileLines: readonly string[],
|
|
697
|
+
delta: DelimiterBalance,
|
|
698
|
+
remainingDelta: DelimiterBalance,
|
|
699
|
+
deletedPrefixBalance: DelimiterBalance,
|
|
700
|
+
deletedLines: ReadonlySet<number>,
|
|
701
|
+
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
702
|
+
): DroppedPrefixClosers | undefined {
|
|
703
|
+
let prefixLength = 0;
|
|
704
|
+
while (
|
|
705
|
+
prefixLength < group.deleteIndices.length &&
|
|
706
|
+
STRUCTURAL_CLOSER_RE.test(fileLines[group.startLine + prefixLength - 1] ?? "")
|
|
707
|
+
) {
|
|
708
|
+
prefixLength++;
|
|
709
|
+
}
|
|
710
|
+
if (prefixLength === 0 || prefixLength >= group.deleteIndices.length) return undefined;
|
|
711
|
+
// A payload that opens with a closer restates the boundary itself; that is
|
|
712
|
+
// an echo/duplicate mistake with a different reading — leave it alone.
|
|
713
|
+
if (group.payload.length === 0 || isStructuralCloserLine(group.payload[0])) return undefined;
|
|
714
|
+
const prefixLines = fileLines.slice(group.startLine - 1, group.startLine - 1 + prefixLength);
|
|
715
|
+
const balance = computeDelimiterBalance(prefixLines);
|
|
716
|
+
if (balanceIsZero(balance)) return undefined;
|
|
717
|
+
const neededOpeners = balanceNegate(balance);
|
|
718
|
+
if (!balanceCovers(delta, neededOpeners)) return undefined;
|
|
719
|
+
if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
|
|
720
|
+
if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
|
|
721
|
+
// The spared closers need dangling opener(s) above the range in the
|
|
722
|
+
// projected file; the payload cannot supply them — it lands below the
|
|
723
|
+
// closers either way.
|
|
724
|
+
const above: string[] = [];
|
|
725
|
+
for (let line = 1; line < group.startLine; line++) {
|
|
726
|
+
const inserted = insertedByLine.get(line);
|
|
727
|
+
if (inserted) above.push(...inserted);
|
|
728
|
+
if (!deletedLines.has(line)) above.push(fileLines[line - 1] ?? "");
|
|
729
|
+
}
|
|
730
|
+
if (!balanceCovers(computeDelimiterBalance(above), neededOpeners)) return undefined;
|
|
731
|
+
return { count: prefixLength, balance };
|
|
732
|
+
}
|
|
664
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Total opening delimiters the range deletes without the payload reopening
|
|
736
|
+
* them while their matching closer(s) survive below — the "payload is a
|
|
737
|
+
* complete construct but the range ends mid-block" mistake, which orphans the
|
|
738
|
+
* surviving closers. A balance-only signal, so it is advisory input rather
|
|
739
|
+
* than proof: {@link applyEdits} surfaces it only once the tree-sitter probe
|
|
740
|
+
* confirms the authored edit broke the file, which is what separates a real
|
|
741
|
+
* mid-block range from a `}` living in prose or a regex literal. Zero when the
|
|
742
|
+
* payload is itself net-closing (deliberate rebalancing of a broken file) or
|
|
743
|
+
* when another hunk removes the surplus (whole-patch residual clean).
|
|
744
|
+
*/
|
|
745
|
+
function countOrphanedOpeners(
|
|
746
|
+
group: ReplacementGroup,
|
|
747
|
+
delta: DelimiterBalance,
|
|
748
|
+
remainingDelta: DelimiterBalance,
|
|
749
|
+
fileLines: readonly string[],
|
|
750
|
+
): number {
|
|
751
|
+
const deletedBalance = computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine));
|
|
752
|
+
const payloadBalance = computeDelimiterBalance(group.payload);
|
|
753
|
+
let orphaned = 0;
|
|
754
|
+
for (const key of ["paren", "bracket", "brace"] as const) {
|
|
755
|
+
if (payloadBalance[key] < 0) return 0;
|
|
756
|
+
if (delta[key] >= 0 || deletedBalance[key] <= 0 || remainingDelta[key] >= 0) continue;
|
|
757
|
+
orphaned += Math.min(-delta[key], deletedBalance[key], -remainingDelta[key]);
|
|
758
|
+
}
|
|
759
|
+
return orphaned;
|
|
760
|
+
}
|
|
665
761
|
interface BoundaryEcho {
|
|
666
762
|
leading: number;
|
|
667
763
|
trailing: number;
|
|
668
764
|
}
|
|
669
|
-
|
|
670
765
|
function hasNonWhitespace(text: string): boolean {
|
|
671
766
|
for (let i = 0; i < text.length; i++) {
|
|
672
767
|
const code = text.charCodeAt(i);
|
|
@@ -870,24 +965,40 @@ function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): Delimit
|
|
|
870
965
|
/**
|
|
871
966
|
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
872
967
|
* unchanged surrounding lines or wrongly drop/keep structural closers. Local
|
|
873
|
-
* repairs run in pass 1; the missing-closer
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
968
|
+
* repairs run in pass 1; the missing-closer repairs (a closer the range
|
|
969
|
+
* deleted at its trailing or leading edge) are deferred to pass 2 and weighed
|
|
970
|
+
* against the whole-patch delimiter residual, so a closer is only kept when
|
|
971
|
+
* the patch as a whole is missing it — never when another hunk already
|
|
972
|
+
* removed the matching opener.
|
|
878
973
|
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
974
|
+
* Textual repairs (boundary echoes, payload lines duplicated from just outside
|
|
975
|
+
* the range) are evidence-complete on their own and always applied. The
|
|
976
|
+
* closer-spare repairs are not: they claim a lone `}` is syntax. They run only
|
|
977
|
+
* when `applySpares` is set, which {@link applyEdits} does only after the
|
|
978
|
+
* tree-sitter probe shows the authored edits broke a file that previously
|
|
979
|
+
* parsed. With `applySpares` false the same detections are reported through
|
|
980
|
+
* `suspicious` / `advisories` and nothing is rewritten.
|
|
981
|
+
*
|
|
982
|
+
* When the spares do run, they fire only if exactly one reading explains the
|
|
983
|
+
* mistake; ambiguous evidence — a one-sided echo whose payload is too short
|
|
984
|
+
* for the widened range, a spared trailing closer the payload neither opens
|
|
985
|
+
* nor indents into, or a spared leading closer whose payload claims the block
|
|
986
|
+
* interior — throws instead of guessing, so the author re-issues the edit
|
|
987
|
+
* rather than shipping silently corrupted content.
|
|
884
988
|
*/
|
|
885
989
|
function repairReplacementBoundaries(
|
|
886
990
|
edits: readonly AppliedEdit[],
|
|
887
991
|
fileLines: readonly string[],
|
|
992
|
+
applySpares: boolean,
|
|
888
993
|
): {
|
|
889
994
|
edits: AppliedEdit[];
|
|
890
995
|
warnings: string[];
|
|
996
|
+
/** A delimiter-semantics anomaly was detected: worth a parse to confirm. */
|
|
997
|
+
suspicious: boolean;
|
|
998
|
+
/** A swallowed block closer was detected and a spare repair is available. */
|
|
999
|
+
sparesProposed: boolean;
|
|
1000
|
+
/** Diagnostics to surface only if the result is kept unrepaired. */
|
|
1001
|
+
advisories: string[];
|
|
891
1002
|
} {
|
|
892
1003
|
// Pass 1: apply every repair whose correctness is local to one group
|
|
893
1004
|
// (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
|
|
@@ -1015,6 +1126,9 @@ function repairReplacementBoundaries(
|
|
|
1015
1126
|
|
|
1016
1127
|
const out: AppliedEdit[] = [];
|
|
1017
1128
|
const warnings: string[] = [];
|
|
1129
|
+
const advisories: string[] = [];
|
|
1130
|
+
let suspicious = false;
|
|
1131
|
+
let sparesProposed = false;
|
|
1018
1132
|
for (const slot of slots) {
|
|
1019
1133
|
if (slot.kind !== "candidate") {
|
|
1020
1134
|
if (slot.warning !== undefined) warnings.push(slot.warning);
|
|
@@ -1033,6 +1147,15 @@ function repairReplacementBoundaries(
|
|
|
1033
1147
|
insertedLineMaps,
|
|
1034
1148
|
);
|
|
1035
1149
|
if (droppedClosers) {
|
|
1150
|
+
suspicious = true;
|
|
1151
|
+
sparesProposed = true;
|
|
1152
|
+
if (!applySpares) {
|
|
1153
|
+
// A lone `}` is only syntax if the parser says so. Keep the
|
|
1154
|
+
// authored edit; `sparesProposed` tells the caller a repair is
|
|
1155
|
+
// available should the probe decline to vouch for it.
|
|
1156
|
+
out.push(...slot.inserts, ...slot.deletes);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1036
1159
|
// Sparing a closer re-inserts it *after* the payload, which claims
|
|
1037
1160
|
// the payload lives inside the block the closer terminates. That
|
|
1038
1161
|
// claim needs evidence: the payload carries the closer's unmatched
|
|
@@ -1047,7 +1170,7 @@ function repairReplacementBoundaries(
|
|
|
1047
1170
|
balanceNegate(droppedClosers.balance),
|
|
1048
1171
|
);
|
|
1049
1172
|
if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) {
|
|
1050
|
-
throw new
|
|
1173
|
+
throw new CloserSpareAmbiguityError(
|
|
1051
1174
|
ambiguousCloserSpareMessage(
|
|
1052
1175
|
slot.group.startLine,
|
|
1053
1176
|
slot.group.endLine,
|
|
@@ -1077,9 +1200,64 @@ function repairReplacementBoundaries(
|
|
|
1077
1200
|
remainingDelta = balanceSum(remainingDelta, droppedClosers.balance);
|
|
1078
1201
|
continue;
|
|
1079
1202
|
}
|
|
1203
|
+
const droppedPrefix = findDroppedPrefixClosers(
|
|
1204
|
+
slot.group,
|
|
1205
|
+
fileLines,
|
|
1206
|
+
slot.delta,
|
|
1207
|
+
remainingDelta,
|
|
1208
|
+
deletedPrefixBalance,
|
|
1209
|
+
deletedLines,
|
|
1210
|
+
insertedByLine,
|
|
1211
|
+
);
|
|
1212
|
+
if (droppedPrefix) {
|
|
1213
|
+
suspicious = true;
|
|
1214
|
+
sparesProposed = true;
|
|
1215
|
+
if (!applySpares) {
|
|
1216
|
+
out.push(...slot.inserts, ...slot.deletes);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
// Sparing a leading closer re-inserts it *before* the payload,
|
|
1220
|
+
// which claims the payload lives outside (after) the block the
|
|
1221
|
+
// closer terminates. The payload's indentation makes that call:
|
|
1222
|
+
// at-or-above the closer's depth is sibling position; a deeper or
|
|
1223
|
+
// incomparable claim would put the payload inside the block the
|
|
1224
|
+
// range just closed — reject rather than guess.
|
|
1225
|
+
const closerIndent = leadingIndent(fileLines[slot.group.startLine - 1] ?? "");
|
|
1226
|
+
const payloadIndent = bodyTargetIndent(slot.group.payload);
|
|
1227
|
+
if (payloadIndent !== undefined) {
|
|
1228
|
+
if (!closerIndent.startsWith(payloadIndent)) {
|
|
1229
|
+
throw new CloserSpareAmbiguityError(
|
|
1230
|
+
ambiguousLeadingCloserSpareMessage(slot.group.startLine, slot.group.endLine, droppedPrefix.count),
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
const spareEnd = slot.group.startLine + droppedPrefix.count;
|
|
1234
|
+
warnings.push(
|
|
1235
|
+
describeBoundaryRepair(
|
|
1236
|
+
slot.group,
|
|
1237
|
+
`kept ${droppedPrefix.count} leading structural closing line(s) the range deleted without restating; the payload lands after them`,
|
|
1238
|
+
),
|
|
1239
|
+
);
|
|
1240
|
+
out.push(
|
|
1241
|
+
...slot.inserts.map(edit =>
|
|
1242
|
+
edit.kind === "insert"
|
|
1243
|
+
? { ...edit, cursor: { kind: "before_anchor" as const, anchor: { line: spareEnd } } }
|
|
1244
|
+
: edit,
|
|
1245
|
+
),
|
|
1246
|
+
...slot.deletes.filter(edit => edit.kind !== "delete" || edit.anchor.line >= spareEnd),
|
|
1247
|
+
);
|
|
1248
|
+
for (let line = slot.group.startLine; line < spareEnd; line++) deletedLines.delete(line);
|
|
1249
|
+
remainingDelta = balanceSum(remainingDelta, droppedPrefix.balance);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const orphanedOpeners = countOrphanedOpeners(slot.group, slot.delta, remainingDelta, fileLines);
|
|
1254
|
+
if (orphanedOpeners > 0) {
|
|
1255
|
+
suspicious = true;
|
|
1256
|
+
advisories.push(midBlockRangeWarning(slot.group.startLine, slot.group.endLine, orphanedOpeners));
|
|
1257
|
+
}
|
|
1080
1258
|
out.push(...slot.inserts, ...slot.deletes);
|
|
1081
1259
|
}
|
|
1082
|
-
return { edits: out, warnings };
|
|
1260
|
+
return { edits: out, warnings, suspicious, sparesProposed, advisories };
|
|
1083
1261
|
}
|
|
1084
1262
|
|
|
1085
1263
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1301,35 +1479,33 @@ export interface ApplyEditsOptions {
|
|
|
1301
1479
|
* across files; omitted, the call gets a private register.
|
|
1302
1480
|
*/
|
|
1303
1481
|
clipboard?: Clipboard;
|
|
1304
|
-
/** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
|
1482
|
+
/** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */
|
|
1305
1483
|
onEmptyPaste?: "throw" | "drop";
|
|
1484
|
+
/**
|
|
1485
|
+
* Target file path, used only to infer a language for the tree-sitter
|
|
1486
|
+
* syntax probe (see {@link parsesCleanly}). Supplying it lets the applier
|
|
1487
|
+
* confirm that the edit as authored still parses, in which case no
|
|
1488
|
+
* delimiter-shape repair or advisory may touch it. Omitted, the probe casts
|
|
1489
|
+
* no veto and the delimiter heuristics decide alone.
|
|
1490
|
+
*/
|
|
1491
|
+
path?: string;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
interface Materialized {
|
|
1495
|
+
text: string;
|
|
1496
|
+
firstChangedLine: number | undefined;
|
|
1497
|
+
warnings: string[];
|
|
1306
1498
|
}
|
|
1307
1499
|
|
|
1308
1500
|
/**
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1501
|
+
* Splice one candidate edit list into `originalLines` and return the resulting
|
|
1502
|
+
* text. Pure and repeatable: the caller materializes the authored edits, probes
|
|
1503
|
+
* that result, and only materializes a repaired candidate if the probe casts no
|
|
1504
|
+
* veto.
|
|
1313
1505
|
*/
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const fileLines = text.split("\n");
|
|
1318
|
-
|
|
1319
|
-
// Clipboard pre-pass: capture `cut` ranges from the original lines and
|
|
1320
|
-
// expand `paste` edits into plain inserts in authored order.
|
|
1321
|
-
const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
|
|
1322
|
-
...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
|
|
1323
|
-
});
|
|
1324
|
-
|
|
1325
|
-
// Block edits are deferred until `resolveBlockEdits` expands them into
|
|
1326
|
-
// concrete inserts + deletes. Reaching the applier with one still present
|
|
1327
|
-
// is an internal wiring bug, not authored-input error.
|
|
1328
|
-
for (const edit of concrete) {
|
|
1329
|
-
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
|
1330
|
-
}
|
|
1331
|
-
const appliedEdits = concrete as readonly AppliedEdit[];
|
|
1332
|
-
|
|
1506
|
+
function materializeEdits(originalLines: readonly string[], edits: readonly AppliedEdit[]): Materialized {
|
|
1507
|
+
const { edits: landed, warnings } = repairAfterInsertLandings(edits, originalLines);
|
|
1508
|
+
const fileLines = [...originalLines];
|
|
1333
1509
|
const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
|
|
1334
1510
|
|
|
1335
1511
|
let firstChangedLine: number | undefined;
|
|
@@ -1337,16 +1513,6 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1337
1513
|
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
1338
1514
|
};
|
|
1339
1515
|
|
|
1340
|
-
const targetEdits = dropTrailingPhantomDeletes(
|
|
1341
|
-
appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
|
|
1342
|
-
fileLines,
|
|
1343
|
-
);
|
|
1344
|
-
validateLineBounds(targetEdits, fileLines);
|
|
1345
|
-
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
|
|
1346
|
-
const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
1347
|
-
const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
|
|
1348
|
-
const warnings = [...indentationWarnings, ...boundaryWarnings, ...landingWarnings];
|
|
1349
|
-
|
|
1350
1516
|
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
1351
1517
|
const bofLines: string[] = [];
|
|
1352
1518
|
const eofLines: string[] = [];
|
|
@@ -1415,9 +1581,94 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1415
1581
|
const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
|
|
1416
1582
|
if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
|
|
1417
1583
|
|
|
1418
|
-
return {
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1584
|
+
return { text: fileLines.join("\n"), firstChangedLine, warnings };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
1589
|
+
*
|
|
1590
|
+
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
1591
|
+
* Throws if an anchor is out of bounds.
|
|
1592
|
+
*
|
|
1593
|
+
* Repairs that hinge on delimiter *semantics* (a range that swallowed the `}`
|
|
1594
|
+
* closing the construct above or below it) are subject to a parser veto when
|
|
1595
|
+
* `options.path` is supplied: the authored edits are materialized first, and if
|
|
1596
|
+
* that result parses it is returned untouched. A `}` in prose, a string, or a
|
|
1597
|
+
* regex literal is therefore never mistaken for a block closer. Only when the
|
|
1598
|
+
* authored result does not parse — or the language is unknown to the parser, so
|
|
1599
|
+
* balance arithmetic is the sole evidence — do the closer-spare repairs run.
|
|
1600
|
+
*/
|
|
1601
|
+
export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
|
|
1602
|
+
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
1603
|
+
|
|
1604
|
+
const fileLines = text.split("\n");
|
|
1605
|
+
|
|
1606
|
+
// Clipboard pre-pass: capture `cut` ranges from the original lines and
|
|
1607
|
+
// expand `paste` edits into plain inserts in authored order.
|
|
1608
|
+
const clipboardWarnings: string[] = [];
|
|
1609
|
+
const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
|
|
1610
|
+
...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
|
|
1611
|
+
onWarning: message => clipboardWarnings.push(message),
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// Block edits are deferred until `resolveBlockEdits` expands them into
|
|
1615
|
+
// concrete inserts + deletes. Reaching the applier with one still present
|
|
1616
|
+
// is an internal wiring bug, not authored-input error.
|
|
1617
|
+
for (const edit of concrete) {
|
|
1618
|
+
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
|
1619
|
+
}
|
|
1620
|
+
const appliedEdits = concrete as readonly AppliedEdit[];
|
|
1621
|
+
|
|
1622
|
+
const targetEdits = dropTrailingPhantomDeletes(
|
|
1623
|
+
appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
|
|
1624
|
+
fileLines,
|
|
1625
|
+
);
|
|
1626
|
+
validateLineBounds(targetEdits, fileLines);
|
|
1627
|
+
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
|
|
1628
|
+
const leading = [...clipboardWarnings, ...indentationWarnings];
|
|
1629
|
+
|
|
1630
|
+
// Pass 1: the authored edits, with every delimiter-semantics repair held
|
|
1631
|
+
// back. Textual repairs (boundary echoes, duplicated payload lines) are
|
|
1632
|
+
// evidence-complete on their own and already applied here.
|
|
1633
|
+
const authored = repairReplacementBoundaries(targetEdits, fileLines, false);
|
|
1634
|
+
const finish = (result: Materialized, warnings: string[]): ApplyResult => {
|
|
1635
|
+
const merged = [...warnings, ...result.warnings];
|
|
1636
|
+
return {
|
|
1637
|
+
text: result.text,
|
|
1638
|
+
firstChangedLine: result.firstChangedLine,
|
|
1639
|
+
...(merged.length > 0 ? { warnings: merged } : {}),
|
|
1640
|
+
};
|
|
1422
1641
|
};
|
|
1642
|
+
const authoredWarnings = [...leading, ...authored.warnings];
|
|
1643
|
+
if (!authored.suspicious) return finish(materializeEdits(fileLines, authored.edits), authoredWarnings);
|
|
1644
|
+
const authoredResult = materializeEdits(fileLines, authored.edits);
|
|
1645
|
+
// The authored edit keeps the file parsing, so no delimiter heuristic may
|
|
1646
|
+
// second-guess its boundaries. This is what keeps a `}` in prose, in a
|
|
1647
|
+
// string, or in a regex literal from ever being mistaken for a block closer.
|
|
1648
|
+
if (parsesCleanly(options.path, authoredResult.text)) return finish(authoredResult, authoredWarnings);
|
|
1649
|
+
|
|
1650
|
+
// The authored result does not parse — or the parser does not know this
|
|
1651
|
+
// language, in which case nothing below can be proven and nothing is
|
|
1652
|
+
// rewritten. A repair lands only when it is *shown* to restore a parsing
|
|
1653
|
+
// file, never on delimiter arithmetic alone.
|
|
1654
|
+
const baselineParses = parsesCleanly(options.path, text);
|
|
1655
|
+
if (authored.sparesProposed) {
|
|
1656
|
+
try {
|
|
1657
|
+
const spared = repairReplacementBoundaries(targetEdits, fileLines, true);
|
|
1658
|
+
const sparedResult = materializeEdits(fileLines, spared.edits);
|
|
1659
|
+
if (parsesCleanly(options.path, sparedResult.text)) {
|
|
1660
|
+
return finish(sparedResult, [...leading, ...spared.warnings, ...spared.advisories]);
|
|
1661
|
+
}
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
// Only the closer-spare verdict is the parser's business, and only on
|
|
1664
|
+
// a file it can vouch for. Every other rejection — notably the
|
|
1665
|
+
// evidence-complete one-sided boundary echo, which is proven by exact
|
|
1666
|
+
// line equality and would otherwise delete range lines the body never
|
|
1667
|
+
// restates — propagates regardless of what the parser knows.
|
|
1668
|
+
if (baselineParses || !(error instanceof CloserSpareAmbiguityError)) throw error;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
// Nothing proven: leave the authored edit exactly as written. Report the
|
|
1672
|
+
// damage only when the baseline parsed, so this edit demonstrably caused it.
|
|
1673
|
+
return finish(authoredResult, baselineParses ? [...authoredWarnings, ...authored.advisories] : authoredWarnings);
|
|
1423
1674
|
}
|
package/src/clipboard.ts
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* (`lines`) is batch-local and resets between calls.
|
|
9
9
|
*/
|
|
10
10
|
import { HL_CUT_KEYWORD, HL_PUT_KEYWORD, HL_RANGE_SEP } from "./format";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
ambiguousAnonymousPasteMessage,
|
|
13
|
+
EMPTY_PASTE,
|
|
14
|
+
emptyRegisterPasteWarning,
|
|
15
|
+
emptyRegisterSpanPasteMessage,
|
|
16
|
+
} from "./messages";
|
|
12
17
|
import { cloneCursor } from "./tokenizer";
|
|
13
18
|
import type { Clipboard, Edit } from "./types";
|
|
14
19
|
|
|
@@ -33,25 +38,36 @@ export function hasClipboardEdit(edits: readonly Edit[]): boolean {
|
|
|
33
38
|
|
|
34
39
|
/** Optional knobs for {@link resolveClipboardEdits}. */
|
|
35
40
|
export interface ResolveClipboardEditsOptions {
|
|
36
|
-
/** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
|
41
|
+
/** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). Named registers never throw — an empty named paste warns and pastes nothing. */
|
|
37
42
|
onEmptyPaste?: "throw" | "drop";
|
|
43
|
+
/** Receives non-fatal diagnostics (e.g. an empty named-register paste). */
|
|
44
|
+
onWarning?: (message: string) => void;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/**
|
|
41
|
-
* Read lines from a register.
|
|
48
|
+
* Read lines from a register. A missing named register reads as empty for a gap
|
|
49
|
+
* paste (a harmless no-op, warned) but throws for a `span` target, where pasting
|
|
50
|
+
* empty would delete the range; anonymous misuse throws unless
|
|
51
|
+
* `onEmptyPaste === "drop"`.
|
|
42
52
|
*/
|
|
43
53
|
function readRegister(
|
|
44
54
|
register: string | undefined,
|
|
55
|
+
target: "gap" | "span",
|
|
45
56
|
clipboard: Clipboard,
|
|
46
57
|
lineNum: number,
|
|
47
58
|
onEmptyPaste: "throw" | "drop",
|
|
59
|
+
onWarning?: (message: string) => void,
|
|
48
60
|
): readonly string[] | null {
|
|
49
61
|
if (register !== undefined) {
|
|
50
62
|
const lines = clipboard.named?.get(register);
|
|
51
63
|
if (lines !== undefined) return lines;
|
|
52
64
|
if (onEmptyPaste === "drop") return null;
|
|
53
65
|
const known = clipboard.named ? [...clipboard.named.keys()] : [];
|
|
54
|
-
|
|
66
|
+
if (target === "span") {
|
|
67
|
+
throw new Error(`line ${lineNum}: ${emptyRegisterSpanPasteMessage(register, known)}`);
|
|
68
|
+
}
|
|
69
|
+
onWarning?.(`line ${lineNum}: ${emptyRegisterPasteWarning(register, known)}`);
|
|
70
|
+
return [];
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
const pending = clipboard.pendingAnonCuts ?? [];
|
|
@@ -112,7 +128,14 @@ export function resolveClipboardEdits(
|
|
|
112
128
|
continue;
|
|
113
129
|
}
|
|
114
130
|
if (edit.kind === "paste") {
|
|
115
|
-
const lines = readRegister(
|
|
131
|
+
const lines = readRegister(
|
|
132
|
+
edit.register,
|
|
133
|
+
edit.at.kind,
|
|
134
|
+
clipboard,
|
|
135
|
+
edit.lineNum,
|
|
136
|
+
onEmptyPaste,
|
|
137
|
+
options.onWarning,
|
|
138
|
+
);
|
|
116
139
|
if (lines === null) continue;
|
|
117
140
|
|
|
118
141
|
if (edit.at.kind === "gap") {
|
|
@@ -187,8 +210,9 @@ export function commitClipboard(fork: Clipboard, target: Clipboard): void {
|
|
|
187
210
|
}
|
|
188
211
|
|
|
189
212
|
/**
|
|
190
|
-
* Validate
|
|
191
|
-
* mutating the register or reading file content.
|
|
213
|
+
* Validate anonymous clipboard sequencing (empty or ambiguous unlabeled paste)
|
|
214
|
+
* without mutating the register or reading file content. Empty named-register
|
|
215
|
+
* pastes are non-fatal — they surface as apply-time warnings instead.
|
|
192
216
|
*/
|
|
193
217
|
export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
|
|
194
218
|
const fork = forkClipboard(clipboard);
|
|
@@ -203,7 +227,7 @@ export function validateClipboardSequence(edits: readonly Edit[], clipboard: Cli
|
|
|
203
227
|
fork.pendingAnonCuts.push(describeCutEdit(edit));
|
|
204
228
|
}
|
|
205
229
|
} else if (edit.kind === "paste") {
|
|
206
|
-
readRegister(edit.register, fork, edit.lineNum, "throw");
|
|
230
|
+
readRegister(edit.register, edit.at.kind, fork, edit.lineNum, "throw");
|
|
207
231
|
}
|
|
208
232
|
}
|
|
209
233
|
}
|
package/src/index.ts
CHANGED
package/src/input.ts
CHANGED
|
@@ -361,7 +361,7 @@ export class PatchSection {
|
|
|
361
361
|
onUnresolved: "throw",
|
|
362
362
|
onWarning: warning => resolveWarnings.push(warning),
|
|
363
363
|
});
|
|
364
|
-
const result = applyEdits(text, resolved, { clipboard: clipboard ?? {} });
|
|
364
|
+
const result = applyEdits(text, resolved, { clipboard: clipboard ?? {}, path: this.path });
|
|
365
365
|
// Preserve parse warnings so consumers don't need to call `parse()`
|
|
366
366
|
// separately.
|
|
367
367
|
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
|
|
@@ -388,7 +388,11 @@ export class PatchSection {
|
|
|
388
388
|
onUnresolved: "drop",
|
|
389
389
|
onWarning: warning => resolveWarnings.push(warning),
|
|
390
390
|
});
|
|
391
|
-
const result = applyEdits(text, resolved, {
|
|
391
|
+
const result = applyEdits(text, resolved, {
|
|
392
|
+
clipboard: clipboard ?? {},
|
|
393
|
+
onEmptyPaste: "drop",
|
|
394
|
+
path: this.path,
|
|
395
|
+
});
|
|
392
396
|
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
|
|
393
397
|
return merged.length > 0
|
|
394
398
|
? { ...result, warnings: merged }
|
package/src/messages.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/** Centralized error/warning text for the hashline parser, applier, and patcher. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatNumberedLine,
|
|
5
|
+
HL_FILE_HASH_SEP,
|
|
6
|
+
HL_FILE_PREFIX,
|
|
7
|
+
HL_FILE_SUFFIX,
|
|
8
|
+
HL_PAYLOAD_REPLACE,
|
|
9
|
+
HL_RANGE_SEP,
|
|
10
|
+
} from "./format";
|
|
4
11
|
import type { BlockSpan } from "./types";
|
|
5
12
|
|
|
6
13
|
/** Lines of context shown either side of a hash mismatch. */
|
|
@@ -118,6 +125,34 @@ export const BARE_BODY_AUTO_PIPED_WARNING =
|
|
|
118
125
|
|
|
119
126
|
/** Top-level read-output rows recovered as single-line replacements. */
|
|
120
127
|
export const SNAPSHOT_ROWS_AUTO_PUT_WARNING = `Recovered top-level \`N:TEXT\` snapshot row(s) as single-line \`PUT N${HL_RANGE_SEP}N:\` replacements. Use explicit \`PUT\` headers for reliable edits.`;
|
|
128
|
+
/**
|
|
129
|
+
* Two or more top-level `N:TEXT` read-output rows named the same source line.
|
|
130
|
+
* Each recovered row lowers to a single-line `PUT N.=N:`, so the coalescer would
|
|
131
|
+
* keep only the last and silently drop the others — reject and teach the format
|
|
132
|
+
* instead.
|
|
133
|
+
*/
|
|
134
|
+
export function repeatedSnapshotRowMessage(line: number): string {
|
|
135
|
+
return (
|
|
136
|
+
`two or more pasted \`${line}:TEXT\` read-output rows name line ${line}. ` +
|
|
137
|
+
`Such rows are recovered as single-line \`PUT ${line}${HL_RANGE_SEP}${line}:\` replacements, so repeating a ` +
|
|
138
|
+
`number would keep only the last row and drop the rest. Write the hunk explicitly: one ` +
|
|
139
|
+
`\`PUT ${line}${HL_RANGE_SEP}M:\` header covering exactly the lines that change, followed by \`+TEXT\` body ` +
|
|
140
|
+
`rows holding their complete final content.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A `+` body row whose text is itself a valid hunk header — the op was written
|
|
145
|
+
* with the payload prefix, so it is inserted into the file as literal text
|
|
146
|
+
* instead of executing. Warned rather than rejected: a literal `CUT …` line is
|
|
147
|
+
* legitimate content in documentation and test fixtures.
|
|
148
|
+
*/
|
|
149
|
+
export function literalOpRowWarning(line: number, text: string): string {
|
|
150
|
+
return (
|
|
151
|
+
`line ${line}: body row \`${HL_PAYLOAD_REPLACE}${text}\` is itself a valid hunk header, so it was inserted ` +
|
|
152
|
+
`into the file as literal text rather than executed. Ops are never \`${HL_PAYLOAD_REPLACE}\`-prefixed — drop ` +
|
|
153
|
+
`the \`${HL_PAYLOAD_REPLACE}\` to run it, and re-issue if this line landed in the file by mistake.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
121
156
|
/** Bare range header recovered as an implicit replacement hunk. */
|
|
122
157
|
export const BARE_RANGE_AUTO_PUT_WARNING = `Recovered a bare \`N${HL_RANGE_SEP}M:\` header as \`PUT N${HL_RANGE_SEP}M:\`. Prefix replacement ranges with \`PUT\`.`;
|
|
123
158
|
|
|
@@ -294,6 +329,42 @@ export function ambiguousCloserSpareMessage(
|
|
|
294
329
|
`or use \`PUT <${closerLine}:\` / \`PUT >${closerLine}:\` instead.`
|
|
295
330
|
);
|
|
296
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* A replacement range starts by deleting structural closer(s) the payload
|
|
334
|
+
* never restates — the "range started one line early, on the `}` that ends
|
|
335
|
+
* the construct above" mistake — but the payload's indentation claims a depth
|
|
336
|
+
* inside the block those closers terminate, so whether the new content
|
|
337
|
+
* belongs before or after the spared closer is ambiguous. Rejected instead of
|
|
338
|
+
* repaired; the at-or-above-depth reading is auto-repaired by sparing the
|
|
339
|
+
* closer ahead of the payload.
|
|
340
|
+
*/
|
|
341
|
+
export function ambiguousLeadingCloserSpareMessage(startLine: number, endLine: number, count: number): string {
|
|
342
|
+
const closers = count === 1 ? `line ${startLine}` : `lines ${startLine}-${startLine + count - 1}`;
|
|
343
|
+
return (
|
|
344
|
+
`\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range starts by deleting the closing-delimiter ` +
|
|
345
|
+
`${closers} but the body never restates it, and the body's indentation claims a depth inside the block that ` +
|
|
346
|
+
`closer terminates — whether the new content belongs before or after the closer is ambiguous. ` +
|
|
347
|
+
`Start the range on the first line that actually changes, or restate the closer in the body at the intended position.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A replacement range deletes more opening delimiter(s) than the payload
|
|
353
|
+
* reopens while the matching closer(s) survive below the range — the
|
|
354
|
+
* "payload is a complete construct but the range ends mid-block" mistake.
|
|
355
|
+
* Surfaced as a warning, never a rejection: the applier is language-agnostic
|
|
356
|
+
* and opener/closer text shape cannot prove a syntactic block (the braces may
|
|
357
|
+
* be literal prose), so the edit applies as authored and the author decides.
|
|
358
|
+
*/
|
|
359
|
+
export function midBlockRangeWarning(startLine: number, endLine: number, orphaned: number): string {
|
|
360
|
+
return (
|
|
361
|
+
`\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` deleted ${orphaned} opening delimiter(s) the body never ` +
|
|
362
|
+
`reopens. If this file is brace-structured code, the matching closing line(s) below the range are now ` +
|
|
363
|
+
`orphaned — the range likely ended mid-block. If the body was the construct's complete new content, ` +
|
|
364
|
+
`re-issue with a block op on the construct's opening line (\`PUT N*:\`) so the closing line resolves ` +
|
|
365
|
+
`automatically; if the delimiters are literal text, ignore this warning.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
297
368
|
|
|
298
369
|
/**
|
|
299
370
|
* Internal invariant: `applyEdits` received an unresolved block edit;
|
|
@@ -331,9 +402,22 @@ export const COLONLESS_SPAN_PUT = `Colonless \`PUT\` is clipboard-backed, and sp
|
|
|
331
402
|
/** Anonymous paste ran with an empty anonymous register. */
|
|
332
403
|
export const EMPTY_PASTE = `Nothing to paste: no unlabeled \`CUT\` precedes this \`PUT\` in this call, and the anonymous register never carries across calls. Put \`CUT N${HL_RANGE_SEP}M\` / \`CUT N*\` above it, or use named registers (\`CUT … @name\` → \`PUT … @name\`) for cross-call moves.`;
|
|
333
404
|
|
|
334
|
-
/** Named paste read a register that holds nothing. */
|
|
335
|
-
export function
|
|
336
|
-
const base = `\`@${name}\`
|
|
405
|
+
/** Named paste read a register that holds nothing; a gap paste applies as empty. */
|
|
406
|
+
export function emptyRegisterPasteWarning(name: string, known: readonly string[]): string {
|
|
407
|
+
const base = `\`@${name}\` was empty — no \`CUT … @${name}\` precedes this op in this call and no persisted register has that name — so nothing was pasted.`;
|
|
408
|
+
return known.length === 0 ? base : `${base} Available registers: ${known.map(k => `\`@${k}\``).join(", ")}.`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Named paste over a *span* read a register that holds nothing. Pasting empty
|
|
413
|
+
* would delete the span, which the author never asked for — almost always a
|
|
414
|
+
* mistyped or never-captured register name — so the edit is rejected instead.
|
|
415
|
+
*/
|
|
416
|
+
export function emptyRegisterSpanPasteMessage(name: string, known: readonly string[]): string {
|
|
417
|
+
const base =
|
|
418
|
+
`\`@${name}\` is empty — no \`CUT … @${name}\` precedes this op in this call and no persisted register ` +
|
|
419
|
+
`has that name — so pasting it over a range would delete those lines and write nothing back. ` +
|
|
420
|
+
`Capture the register first (\`CUT … @${name}\`), or use \`CUT\` if deleting the range is what you meant.`;
|
|
337
421
|
return known.length === 0 ? base : `${base} Available registers: ${known.map(k => `\`@${k}\``).join(", ")}.`;
|
|
338
422
|
}
|
|
339
423
|
|
package/src/parser.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
EMPTY_INSERT,
|
|
18
18
|
EMPTY_PUT_AUTO_CUT_WARNING,
|
|
19
19
|
invalidAbsoluteRangeMessage,
|
|
20
|
+
literalOpRowWarning,
|
|
20
21
|
MINUS_BULLET_AUTO_PIPED_WARNING,
|
|
21
22
|
MINUS_ROW_REJECTED,
|
|
22
23
|
MOVE_TAKES_NO_BODY,
|
|
@@ -24,10 +25,11 @@ import {
|
|
|
24
25
|
REGISTER_PUT_TAKES_NO_BODY,
|
|
25
26
|
REM_TAKES_NO_BODY,
|
|
26
27
|
REPLACE_PAIR_COALESCED_WARNING,
|
|
28
|
+
repeatedSnapshotRowMessage,
|
|
27
29
|
SNAPSHOT_ROWS_AUTO_PUT_WARNING,
|
|
28
30
|
} from "./messages";
|
|
29
31
|
import { isReadMetadataLine, stripOneLeadingHashlinePrefix } from "./prefixes";
|
|
30
|
-
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
|
32
|
+
import { type BlockTarget, cloneCursor, isHunkHeaderText, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
|
31
33
|
import type { Anchor, BlockSpan, Cursor, Edit, FileOp, PasteTarget } from "./types";
|
|
32
34
|
|
|
33
35
|
/** Bounds parser amplification before the target file's line count is available. */
|
|
@@ -112,7 +114,7 @@ function bodylessTargetMessage(target: BlockTarget, hadColon: boolean): string |
|
|
|
112
114
|
*/
|
|
113
115
|
const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
|
|
114
116
|
|
|
115
|
-
const TOP_LEVEL_SNAPSHOT_ROW_RE = /^\s*([1-9]\d*)
|
|
117
|
+
const TOP_LEVEL_SNAPSHOT_ROW_RE = /^\s*([1-9]\d*)[:|](.*)$/;
|
|
116
118
|
|
|
117
119
|
function parseTopLevelSnapshotRow(text: string): { line: number; text: string } | null {
|
|
118
120
|
const match = TOP_LEVEL_SNAPSHOT_ROW_RE.exec(text);
|
|
@@ -210,6 +212,8 @@ export class Executor {
|
|
|
210
212
|
#fileOp: FileOp | undefined;
|
|
211
213
|
#terminated = false;
|
|
212
214
|
#skippableComments: PendingComment[] = [];
|
|
215
|
+
/** Source lines already recovered from top-level `N:TEXT` rows in this section. */
|
|
216
|
+
#recoveredSnapshotLines = new Set<number>();
|
|
213
217
|
|
|
214
218
|
#discardPendingSkippableComments(): void {
|
|
215
219
|
this.#skippableComments = [];
|
|
@@ -458,6 +462,10 @@ export class Executor {
|
|
|
458
462
|
const noBodyOnLiteral = bodylessTargetMessage(pending.target, pending.hadColon);
|
|
459
463
|
if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`);
|
|
460
464
|
this.#commitDeferredBlanks(pending);
|
|
465
|
+
// An op written with the payload prefix is inserted as literal text. That
|
|
466
|
+
// is the correct reading of `+TEXT`, but it silently plants a `CUT …` line
|
|
467
|
+
// in the file, so name it at the moment it happens.
|
|
468
|
+
if (isHunkHeaderText(text)) this.#warnings.push(literalOpRowWarning(lineNum, text));
|
|
461
469
|
pending.payloads.push({ kind: "literal", text, lineNum });
|
|
462
470
|
}
|
|
463
471
|
|
|
@@ -513,6 +521,14 @@ export class Executor {
|
|
|
513
521
|
}
|
|
514
522
|
const snapshotRow = parseTopLevelSnapshotRow(text);
|
|
515
523
|
if (snapshotRow !== null) {
|
|
524
|
+
// Each recovered row becomes a single-line replacement, so a repeated
|
|
525
|
+
// line number is never a set of replacements — it is a body written as
|
|
526
|
+
// consecutive lines under one number. Collapsing it would silently keep
|
|
527
|
+
// only the last row and drop the rest.
|
|
528
|
+
if (this.#recoveredSnapshotLines.has(snapshotRow.line)) {
|
|
529
|
+
throw new Error(`line ${lineNum}: ${repeatedSnapshotRowMessage(snapshotRow.line)}`);
|
|
530
|
+
}
|
|
531
|
+
this.#recoveredSnapshotLines.add(snapshotRow.line);
|
|
516
532
|
const range = { start: { line: snapshotRow.line }, end: { line: snapshotRow.line } };
|
|
517
533
|
validateRange(range, lineNum, "replace");
|
|
518
534
|
this.#pushInsert(
|
|
@@ -595,11 +611,11 @@ export class Executor {
|
|
|
595
611
|
}
|
|
596
612
|
|
|
597
613
|
/**
|
|
598
|
-
* Strip a single read-output line-number prefix (`N:`) from every
|
|
599
|
-
* row, but only when *all* bare rows carry one. A uniform set of
|
|
600
|
-
* the signature of content pasted straight from `read`/`search`
|
|
601
|
-
* mixed set means the
|
|
602
|
-
* authored with an explicit `+` are not bare and are never touched.
|
|
614
|
+
* Strip a single read-output line-number prefix (`N:` or `N|`) from every
|
|
615
|
+
* bare body row, but only when *all* bare rows carry one. A uniform set of
|
|
616
|
+
* prefixes is the signature of content pasted straight from `read`/`search`
|
|
617
|
+
* output; a mixed set means the prefix is genuine payload content and must
|
|
618
|
+
* stay. Rows authored with an explicit `+` are not bare and are never touched.
|
|
603
619
|
*/
|
|
604
620
|
#stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
|
|
605
621
|
let sawBare = false;
|
package/src/patcher.ts
CHANGED
|
@@ -732,7 +732,7 @@ export class Patcher {
|
|
|
732
732
|
if (expected !== undefined && this.#enforceSeenLines) {
|
|
733
733
|
this.#assertSeenLines(section, expected, matchedSnapshot);
|
|
734
734
|
}
|
|
735
|
-
const result = applyEdits(normalized, resolved, { clipboard });
|
|
735
|
+
const result = applyEdits(normalized, resolved, { clipboard, path: canonicalPath });
|
|
736
736
|
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
|
737
737
|
}
|
|
738
738
|
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
|
@@ -740,7 +740,7 @@ export class Patcher {
|
|
|
740
740
|
// content and warn instead of hard-failing — unlike an anchored
|
|
741
741
|
// mismatch, which cannot be safely relocated and must reject.
|
|
742
742
|
if (!hasAnchorScopedEdit(resolved)) {
|
|
743
|
-
const result = applyEdits(normalized, resolved, { clipboard });
|
|
743
|
+
const result = applyEdits(normalized, resolved, { clipboard, path: canonicalPath });
|
|
744
744
|
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
|
|
745
745
|
}
|
|
746
746
|
// File drifted: map every anchor from the tagged snapshot to unchanged
|
package/src/prefixes.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* When a
|
|
3
|
-
*
|
|
4
|
-
* diff-style echoes, a leading `+`. These helpers detect
|
|
5
|
-
* the raw text. Two strip modes are exposed:
|
|
2
|
+
* When a payload is authored against `read`/`search` output, each line is
|
|
3
|
+
* prefixed with either a line number (`123:` in hashline mode or `123|`
|
|
4
|
+
* otherwise) or, for diff-style echoes, a leading `+`. These helpers detect
|
|
5
|
+
* that and recover the raw text. Two strip modes are exposed:
|
|
6
6
|
*
|
|
7
7
|
* - {@link stripNewLinePrefixes} — opportunistic: strips when the input
|
|
8
8
|
* clearly carries hashline or diff prefixes, leaves it alone otherwise.
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { HL_FILE_HASH_LENGTH } from "./format";
|
|
18
18
|
|
|
19
|
-
const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d
|
|
19
|
+
const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+[:|]/;
|
|
20
20
|
const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
|
|
21
21
|
const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
|
|
22
22
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
@@ -41,10 +41,10 @@ function stripLeadingHashlinePrefixes(line: string): string {
|
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
|
|
44
|
-
* most one leading
|
|
45
|
-
* loop. Use this when the input carries at most one snapshot prefix
|
|
46
|
-
* bare body row paste from `read` output) — recursive stripping would
|
|
47
|
-
* content whose own text starts with
|
|
44
|
+
* most one leading line-number prefix (`N:`, `N|`, `>>>N:`, `+N:` etc.) and
|
|
45
|
+
* does NOT loop. Use this when the input carries at most one snapshot prefix
|
|
46
|
+
* (e.g. a bare body row paste from `read` output) — recursive stripping would
|
|
47
|
+
* corrupt content whose own text starts with a line-number prefix.
|
|
48
48
|
*/
|
|
49
49
|
export function stripOneLeadingHashlinePrefix(line: string): string {
|
|
50
50
|
return line.replace(HL_PREFIX_RE, "");
|
|
@@ -90,7 +90,7 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* Strip whichever prefix scheme the lines appear to be carrying:
|
|
93
|
-
* -
|
|
93
|
+
* - line-number prefixes (`123:` or `123|`) when every content line has one
|
|
94
94
|
* - leading `+` (diff style) when at least half the lines have one
|
|
95
95
|
* - mixed `+<n>:` form when present
|
|
96
96
|
*
|
package/src/recovery.ts
CHANGED
|
@@ -308,12 +308,16 @@ function replayRemappedAnchorsOnCurrent(
|
|
|
308
308
|
edits: readonly Edit[],
|
|
309
309
|
recoveryWarning: string,
|
|
310
310
|
clipboard: Clipboard | undefined,
|
|
311
|
+
path: string,
|
|
311
312
|
): RecoveryResult | null {
|
|
312
313
|
const remapped = remapEditsToCurrent(previousText, currentText, edits);
|
|
313
314
|
if (remapped === null) return null;
|
|
314
315
|
let applied: ApplyResult;
|
|
315
316
|
try {
|
|
316
|
-
applied = applyEdits(currentText, remapped.edits,
|
|
317
|
+
applied = applyEdits(currentText, remapped.edits, {
|
|
318
|
+
...(clipboard === undefined ? {} : { clipboard }),
|
|
319
|
+
path,
|
|
320
|
+
});
|
|
317
321
|
} catch {
|
|
318
322
|
return null;
|
|
319
323
|
}
|
|
@@ -348,6 +352,6 @@ export class Recovery {
|
|
|
348
352
|
if (!snapshot) return null;
|
|
349
353
|
const recoveryWarning =
|
|
350
354
|
this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
|
|
351
|
-
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning, clipboard);
|
|
355
|
+
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning, clipboard, path);
|
|
352
356
|
}
|
|
353
357
|
}
|
package/src/syntax.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax probe for candidate edit results, via the native tree-sitter parser.
|
|
3
|
+
*
|
|
4
|
+
* Delimiter-balance arithmetic cannot tell a block closer from a `}` inside a
|
|
5
|
+
* regex literal, a string, or Markdown prose. A parser can, so it holds veto
|
|
6
|
+
* power over every repair whose justification is "this line closes a syntactic
|
|
7
|
+
* block": when the edit the author actually wrote still parses, no such repair
|
|
8
|
+
* may rewrite it. The probe never *forces* a repair — an unrecognized language
|
|
9
|
+
* or an already-broken file simply yields no veto, leaving the delimiter
|
|
10
|
+
* heuristics as the only available evidence.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives";
|
|
14
|
+
|
|
15
|
+
/** Parse-result cache keyed by content hash + path; FIFO-bounded. */
|
|
16
|
+
const parseCache = new Map<string, boolean>();
|
|
17
|
+
const PARSE_CACHE_MAX = 256;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `true` when `text` parses without a syntax error under the language inferred
|
|
21
|
+
* from `path`. `false` covers "does not parse" and "cannot tell" alike — no
|
|
22
|
+
* path, an unrecognized language, or a native failure — because both mean the
|
|
23
|
+
* probe has nothing to prove with. Callers must therefore never treat `false`
|
|
24
|
+
* as evidence *about the edit*: it only withholds permission to rewrite.
|
|
25
|
+
*
|
|
26
|
+
* Uses `enclosingBlockBoundaries` over a whole-file window: no node can cross
|
|
27
|
+
* that window, so the boundary walk is trivial and the tree-sitter parse is the
|
|
28
|
+
* only real cost. It returns `null` for an unrecognized language and for a
|
|
29
|
+
* source that fails to parse, which this predicate deliberately conflates.
|
|
30
|
+
*/
|
|
31
|
+
export function parsesCleanly(path: string | undefined, text: string): boolean {
|
|
32
|
+
if (path === undefined) return false;
|
|
33
|
+
const key = `${Bun.hash(text).toString(36)}:${text.length}:${path}`;
|
|
34
|
+
const cached = parseCache.get(key);
|
|
35
|
+
if (cached !== undefined) return cached;
|
|
36
|
+
const lineCount = text.length === 0 ? 1 : text.split("\n").length;
|
|
37
|
+
let ok: boolean;
|
|
38
|
+
try {
|
|
39
|
+
ok = enclosingBlockBoundaries({ code: text, path, ranges: [{ startLine: 1, endLine: lineCount }] }) !== null;
|
|
40
|
+
} catch {
|
|
41
|
+
ok = false;
|
|
42
|
+
}
|
|
43
|
+
if (parseCache.size >= PARSE_CACHE_MAX) {
|
|
44
|
+
const oldest = parseCache.keys().next().value;
|
|
45
|
+
if (oldest !== undefined) parseCache.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
parseCache.set(key, ok);
|
|
48
|
+
return ok;
|
|
49
|
+
}
|
package/src/tokenizer.ts
CHANGED
|
@@ -428,6 +428,21 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
|
|
|
428
428
|
if (scan.nextIndex !== end) return null;
|
|
429
429
|
return { target: scan.target, hadColon: scan.hadColon };
|
|
430
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Whether `text` would parse as a hunk header on its own (`PUT …`, `CUT …`,
|
|
433
|
+
* `REM`, `MV …`). Used to catch an op row mistakenly written as a `+` body row,
|
|
434
|
+
* which the applier would otherwise insert into the file as literal text.
|
|
435
|
+
*/
|
|
436
|
+
export function isHunkHeaderText(text: string): boolean {
|
|
437
|
+
const end = trimEndIndex(text);
|
|
438
|
+
const lead = skipWhitespace(text, 0, end);
|
|
439
|
+
const isHunkLead =
|
|
440
|
+
text.startsWith(HL_PUT_KEYWORD, lead) ||
|
|
441
|
+
text.startsWith(HL_CUT_KEYWORD, lead) ||
|
|
442
|
+
text.startsWith(HL_REM_KEYWORD, lead) ||
|
|
443
|
+
text.startsWith(HL_MOVE_KEYWORD, lead);
|
|
444
|
+
return isHunkLead && tryParseHunkHeader(text) !== null;
|
|
445
|
+
}
|
|
431
446
|
|
|
432
447
|
function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
|
|
433
448
|
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|