@oh-my-pi/hashline 17.2.11 → 17.2.13

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 CHANGED
@@ -2,6 +2,23 @@
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
+
5
22
  ## [17.2.11] - 2026-08-07
6
23
 
7
24
  ### Changed
@@ -20,11 +20,27 @@ export interface ApplyEditsOptions {
20
20
  clipboard?: Clipboard;
21
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;
@@ -14,5 +14,6 @@ export * from "./prefixes.js";
14
14
  export * from "./recovery.js";
15
15
  export * from "./snapshots.js";
16
16
  export * from "./stream.js";
17
+ export * from "./syntax.js";
17
18
  export * from "./tokenizer.js";
18
19
  export * from "./types.js";
@@ -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; the paste applied as empty (span targets are still removed). */
145
+ /** Named paste read a register that holds nothing; a gap paste applies as empty. */
113
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
  /**
@@ -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.11",
4
+ "version": "17.2.13",
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.11",
37
- "@oh-my-pi/pi-utils": "17.2.11"
36
+ "@oh-my-pi/pi-natives": "17.2.13",
37
+ "@oh-my-pi/pi-utils": "17.2.13"
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
+ }
664
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
+ }
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 repair is deferred to pass 2 and
874
- * weighed against the whole-patch delimiter residual, so a closer the range
875
- * deleted is only kept when the patch as a whole is missing it — never when
876
- * another hunk already removed the matching opener. Returns the repaired edits
877
- * plus one warning per repaired group.
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.
973
+ *
974
+ * Textual repairs (boundary echoes, payload lines duplicated from just outside
975
+ * the range) are evidence-complete on their own and always applied. The
976
+ * closer-spare repairs are not: they claim a lone `}` is syntax. They run only
977
+ * when `applySpares` is set, which {@link applyEdits} does only after the
978
+ * tree-sitter probe shows the authored edits broke a file that previously
979
+ * parsed. With `applySpares` false the same detections are reported through
980
+ * `suspicious` / `advisories` and nothing is rewritten.
878
981
  *
879
- * Repairs fire only when exactly one reading explains the mistake. When the
880
- * evidence is ambiguous — a one-sided echo whose payload is too short for the
881
- * widened range, or a spared closer the payload neither opens nor indents
882
- * into the function throws instead of guessing, so the author re-issues the
883
- * edit rather than shipping silently corrupted content.
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 Error(
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
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1303,35 +1481,31 @@ export interface ApplyEditsOptions {
1303
1481
  clipboard?: Clipboard;
1304
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
- * Apply a parsed list of edits to a text body. Pure function — no I/O.
1310
- *
1311
- * Returns the post-edit text and the first changed line number (1-indexed).
1312
- * Throws if an anchor is out of bounds.
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
- export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
1315
- if (edits.length === 0) return { text, firstChangedLine: undefined };
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 clipboardWarnings: string[] = [];
1322
- const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
1323
- ...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
1324
- onWarning: message => clipboardWarnings.push(message),
1325
- });
1326
-
1327
- // Block edits are deferred until `resolveBlockEdits` expands them into
1328
- // concrete inserts + deletes. Reaching the applier with one still present
1329
- // is an internal wiring bug, not authored-input error.
1330
- for (const edit of concrete) {
1331
- if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
1332
- }
1333
- const appliedEdits = concrete as readonly AppliedEdit[];
1334
-
1506
+ function materializeEdits(originalLines: readonly string[], edits: readonly AppliedEdit[]): Materialized {
1507
+ const { edits: landed, warnings } = repairAfterInsertLandings(edits, originalLines);
1508
+ const fileLines = [...originalLines];
1335
1509
  const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
1336
1510
 
1337
1511
  let firstChangedLine: number | undefined;
@@ -1339,16 +1513,6 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1339
1513
  if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
1340
1514
  };
1341
1515
 
1342
- const targetEdits = dropTrailingPhantomDeletes(
1343
- appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
1344
- fileLines,
1345
- );
1346
- validateLineBounds(targetEdits, fileLines);
1347
- const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
1348
- const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
1349
- const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
1350
- const warnings = [...clipboardWarnings, ...indentationWarnings, ...boundaryWarnings, ...landingWarnings];
1351
-
1352
1516
  // Partition edits into bof, eof, and anchor-targeted buckets.
1353
1517
  const bofLines: string[] = [];
1354
1518
  const eofLines: string[] = [];
@@ -1417,9 +1581,94 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1417
1581
  const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
1418
1582
  if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
1419
1583
 
1420
- return {
1421
- text: fileLines.join("\n"),
1422
- firstChangedLine,
1423
- ...(warnings.length > 0 ? { warnings } : {}),
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
+ };
1424
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);
1425
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 { ambiguousAnonymousPasteMessage, EMPTY_PASTE, emptyRegisterPasteWarning } from "./messages";
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
 
@@ -40,11 +45,14 @@ export interface ResolveClipboardEditsOptions {
40
45
  }
41
46
 
42
47
  /**
43
- * Read lines from a register. A missing named register warns and reads as
44
- * empty; anonymous misuse throws unless `onEmptyPaste === "drop"`.
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"`.
45
52
  */
46
53
  function readRegister(
47
54
  register: string | undefined,
55
+ target: "gap" | "span",
48
56
  clipboard: Clipboard,
49
57
  lineNum: number,
50
58
  onEmptyPaste: "throw" | "drop",
@@ -55,6 +63,9 @@ function readRegister(
55
63
  if (lines !== undefined) return lines;
56
64
  if (onEmptyPaste === "drop") return null;
57
65
  const known = clipboard.named ? [...clipboard.named.keys()] : [];
66
+ if (target === "span") {
67
+ throw new Error(`line ${lineNum}: ${emptyRegisterSpanPasteMessage(register, known)}`);
68
+ }
58
69
  onWarning?.(`line ${lineNum}: ${emptyRegisterPasteWarning(register, known)}`);
59
70
  return [];
60
71
  }
@@ -117,7 +128,14 @@ export function resolveClipboardEdits(
117
128
  continue;
118
129
  }
119
130
  if (edit.kind === "paste") {
120
- const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste, options.onWarning);
131
+ const lines = readRegister(
132
+ edit.register,
133
+ edit.at.kind,
134
+ clipboard,
135
+ edit.lineNum,
136
+ onEmptyPaste,
137
+ options.onWarning,
138
+ );
121
139
  if (lines === null) continue;
122
140
 
123
141
  if (edit.at.kind === "gap") {
@@ -209,7 +227,7 @@ export function validateClipboardSequence(edits: readonly Edit[], clipboard: Cli
209
227
  fork.pendingAnonCuts.push(describeCutEdit(edit));
210
228
  }
211
229
  } else if (edit.kind === "paste") {
212
- readRegister(edit.register, fork, edit.lineNum, "throw");
230
+ readRegister(edit.register, edit.at.kind, fork, edit.lineNum, "throw");
213
231
  }
214
232
  }
215
233
  }
package/src/index.ts CHANGED
@@ -14,5 +14,6 @@ export * from "./prefixes";
14
14
  export * from "./recovery";
15
15
  export * from "./snapshots";
16
16
  export * from "./stream";
17
+ export * from "./syntax";
17
18
  export * from "./tokenizer";
18
19
  export * from "./types";
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, { clipboard: clipboard ?? {}, onEmptyPaste: "drop" });
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 { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP } from "./format";
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; the paste applied as empty (span targets are still removed). */
405
+ /** Named paste read a register that holds nothing; a gap paste applies as empty. */
335
406
  export function emptyRegisterPasteWarning(name: string, known: readonly string[]): string {
336
- 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 (a span target is still removed).`;
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. */
@@ -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(
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/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, clipboard === undefined ? {} : { clipboard });
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;