@oh-my-pi/hashline 17.2.1 → 17.2.2

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/src/patcher.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  import * as path from "node:path";
26
26
  import { applyEdits } from "./apply";
27
27
  import { hasBlockEdit, resolveBlockEdits } from "./block";
28
- import { commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard";
28
+ import { commitClipboard, forkClipboard, startClipboardBatch, validateClipboardSequence } from "./clipboard";
29
29
  import { computeFileHash, formatHashlineHeader } from "./format";
30
30
  import type { Filesystem, WriteResult } from "./fs";
31
31
  import { isNotFound } from "./fs";
@@ -161,11 +161,11 @@ export class PreparedSection {
161
161
 
162
162
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
163
163
  return edits.some(edit => {
164
- if (edit.kind === "delete") return true;
165
- // A `replace_block N:` edit anchors to concrete content on line N.
166
- if (edit.kind === "block") return true;
167
- // A `CUT` range reads concrete content.
168
- if (edit.kind === "cut") return true;
164
+ if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true;
165
+ if (edit.kind === "paste") {
166
+ if (edit.at.kind === "span") return true;
167
+ return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor";
168
+ }
169
169
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
170
170
  });
171
171
  }
@@ -247,7 +247,7 @@ export class Patcher {
247
247
  // work on a fork and publish it per landed section, so a failed batch
248
248
  // never poisons the persistent register and a mid-batch failure still
249
249
  // preserves content already cut from disk.
250
- const clipboard = forkClipboard(this.clipboard);
250
+ const clipboard = startClipboardBatch(this.clipboard);
251
251
 
252
252
  // Single-section fast path.
253
253
  if (patch.sections.length === 1) {
@@ -305,7 +305,7 @@ export class Patcher {
305
305
  */
306
306
  async preflight(patch: Patch): Promise<void> {
307
307
  // Dry run: fork the register and never publish it back.
308
- const clipboard = forkClipboard(this.clipboard);
308
+ const clipboard = startClipboardBatch(this.clipboard);
309
309
  const prepared: PreparedSection[] = [];
310
310
  for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard));
311
311
  assertUniqueCanonicalPaths(prepared);
package/src/prefixes.ts CHANGED
@@ -20,7 +20,15 @@ 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 = /^[+](?![+])/;
23
- const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
23
+ const READ_TRUNCATION_NOTICE_RE =
24
+ /^\s*\[(?:(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+|(?:…|\.\.\.)?\d+\s*ln elided;\s*re-read needed ranges with .+)\]\s*$/;
25
+ const READ_RANGE_ELISION_RE = /^\s*[1-9]\d*\s*-\s*[1-9]\d*:.*(?:…|\.\.\.).*$/;
26
+ const READ_SINGLE_ELISION_RE = /^\s*(?:…|\.\.\.)\s*$/;
27
+
28
+ /** Whether a row is display-only metadata emitted by `read`, never source. */
29
+ export function isReadMetadataLine(line: string): boolean {
30
+ return READ_TRUNCATION_NOTICE_RE.test(line) || READ_RANGE_ELISION_RE.test(line) || READ_SINGLE_ELISION_RE.test(line);
31
+ }
24
32
 
25
33
  function stripLeadingHashlinePrefixes(line: string): string {
26
34
  let result = line;
@@ -63,7 +71,7 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
63
71
 
64
72
  for (const line of lines) {
65
73
  if (line.length === 0) continue;
66
- if (READ_TRUNCATION_NOTICE_RE.test(line)) {
74
+ if (isReadMetadataLine(line)) {
67
75
  stats.truncationNoticeCount++;
68
76
  continue;
69
77
  }
@@ -103,7 +111,7 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
103
111
  if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
104
112
 
105
113
  return lines
106
- .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !(stripHash && HL_HEADER_RE.test(line)))
114
+ .filter(line => !isReadMetadataLine(line) && !(stripHash && HL_HEADER_RE.test(line)))
107
115
  .map(line => {
108
116
  if (stripHash) return stripLeadingHashlinePrefixes(line);
109
117
  if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
@@ -124,7 +132,7 @@ export function stripHashlinePrefixes(lines: string[]): string[] {
124
132
  const contentLineCount = stats.nonEmpty - stats.headerCount;
125
133
  if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines;
126
134
  return lines
127
- .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !HL_HEADER_RE.test(line))
135
+ .filter(line => !isReadMetadataLine(line) && !HL_HEADER_RE.test(line))
128
136
  .map(line => stripLeadingHashlinePrefixes(line));
129
137
  }
130
138
 
package/src/prompt.md CHANGED
@@ -1,19 +1,18 @@
1
- Line-anchored patch language: name original lines to replace, cut, or insert at, then list new content. A header ending in `:` takes `+` body rows; `CUT`, `PASTE`, `REM`, `MV` take none.
1
+ Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste, then list new content. A header ending in `:` takes `+` body rows; colonless `PUT` (paste), `CUT`, `REM`, `MV` take none.
2
2
 
3
3
  <headers>
4
4
  Every file section starts `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search` — REQUIRED on every section. Create new files with `write`; hashline only edits existing files.
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `SWAP N.=M:` — replace original lines N.=M (INCLUSIVE).
9
- `SWAP.BLK N:` — replace the whole syntactic block BEGINNING on line N; its closing line is resolved for you.
10
- `CUT N.=M` / `CUT.BLK N`delete lines N.=M / the block beginning at N, and capture them for `PASTE`.
11
- `INS.PRE N:` / `INS.POST N:` insert immediately before / after line N.
12
- `INS.BLK.POST N:` insert after the END of the block beginning at N, outside it at sibling depth. Append inside a block `INS.POST`.
13
- `INS.HEAD:` / `INS.TAIL:`insert at the very start / end of the file.
14
- `PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N` — insert the clipboard at the position (the clipboard IS the body).
8
+ `PUT N.=M:` — replace original lines N through M (INCLUSIVE) with body rows.
9
+ `PUT N*:` — replace the syntactic block BEGINNING on line N; its closing line is resolved for you.
10
+ `PUT <N:` / `PUT >N:`insert body rows before / after line N (`PUT <1:` = file head, `PUT >$:` = file tail).
11
+ `PUT >N*:` insert body rows after the END of the block beginning at N (at sibling depth). Append inside a block → `PUT >M:`.
12
+ `PUT <N` / `PUT >N` / `PUT N.=M @name` / `PUT N* @name` — paste a captured register at a gap, over a range, or over a resolved block (no `:` header, no body rows). Unlabeled gap `PUT` pastes the anonymous register; span/block paste requires `@name`.
13
+ `CUT N.=M` / `CUT N*` delete lines N through M / block N and capture them (anonymous, or `@name` when given).
15
14
  `REM` — delete the whole section file. `MV DEST` — move/rename to `DEST` (quote paths with spaces); edits above `MV` land on the source first, final content written at `DEST`.
16
- Single line: `SWAP N.=N:` / `CUT N`. Range = ORIGINAL lines touched; body length irrelevant (1 line → 10 is still `SWAP N.=N:`).
15
+ Single line: `PUT N.=N:` / `CUT N.=N`. Range = ORIGINAL lines touched (`N.=M`, inclusive); body length irrelevant.
17
16
  </ops>
18
17
 
19
18
  <body-rows>
@@ -27,12 +26,12 @@ Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept
27
26
  - Elided regions are UNSEEN (`…`/`..` markers, collapsed `N-M:` summary rows) — NEVER place or span a hunk inside one; `read` the range first.
28
27
  - NEVER start or end a range mid-expression or mid-block.
29
28
  - Ranges cover ONLY changed lines — never widen over keepers. Non-adjacent changes = separate hunks.
30
- - Whole construct → `SWAP.BLK N`; lines inside one → `SWAP N.=M`.
31
- - `SWAP.BLK` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes — point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `SWAP N.=M`).
32
- - Block ops anchor the OPENING line of a MULTI-LINE construct — never the closer, last line, or a bare inner statement; one statement → plain op (`SWAP N.=N:` / `CUT N` / `INS.POST N:`). Saw the closer? `INS.POST M:`.
33
- - Markdown: a heading IS a block opener — block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `INS.BLK.POST` after a section: end the body with a blank line to keep the next heading separated.
34
- - Pure additions → `INS.PRE`/`INS.POST`/`INS.HEAD`/`INS.TAIL`, never a widened `SWAP`.
35
- - Move code with `CUT`+`PASTE`, never retype. Clipboard: top-to-bottom across the whole patch (cross-file moves), persists across edit calls, latest `CUT` wins, and `PASTE` repeats freely. Pasted indentation is verbatim; re-indent via `SWAP`.
29
+ - Whole construct → `PUT N*:`; lines inside one → `PUT N.=M:`.
30
+ - `PUT N*:` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes — point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `PUT N.=M:`).
31
+ - Block ops anchor the OPENING line of a MULTI-LINE construct — never the closer, last line, or a bare inner statement; one statement → plain op (`PUT N.=N:` / `CUT N.=N` / `PUT >N:`). Saw the closer? `PUT >M:`.
32
+ - Markdown: a heading IS a block opener — block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `PUT >N*:` after a section: end the body with a blank line to keep the next heading separated.
33
+ - Pure additions → `PUT <N:` / `PUT >N:`, never a widened `PUT N.=M:`.
34
+ - Move code with `CUT`+`PUT`: `CUT 5.=9 @fn` captures into `@fn`; `PUT >40 @fn` pastes it. Unlabeled `CUT` + `PUT >40` works for a single call-local move. Named registers persist across edit calls.
36
35
  - NEVER format/restyle code with this tool; run the project formatter.
37
36
  </rules>
38
37
 
@@ -49,7 +48,7 @@ Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept
49
48
  Edit, then move:
50
49
  ```
51
50
  [greet.py#A1B2]
52
- SWAP 1.=3:
51
+ PUT 1.=3:
53
52
  +def greet(name):
54
53
  + print(f"Hi, {name}")
55
54
  MV lib/greet.py
@@ -58,23 +57,23 @@ MV lib/greet.py
58
57
  Markdown bullets — the file receives `- task`:
59
58
  ```
60
59
  [PLAN.md#A1B2]
61
- INS.POST 2:
60
+ PUT >2:
62
61
  +- task
63
62
  + - nested task
64
63
  ```
65
64
 
66
- Move `greet` to a sibling file — clipboard flows across sections:
65
+ Move `greet` to a sibling file using a named register — flows across sections:
67
66
  ```
68
67
  [greet.py#A1B2]
69
- CUT.BLK 1
68
+ CUT 1* @fn
70
69
  [other.py#3C4D]
71
- PASTE.HEAD
70
+ PUT <1 @fn
72
71
  ```
73
72
 
74
- `SWAP.BLK 1:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays:
73
+ `PUT 1*:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays:
75
74
  ```
76
75
  [greet.py#A1B2]
77
- SWAP.BLK 1:
76
+ PUT 1*:
78
77
  +def greet(name):
79
78
  + print(f"Hello, {name}")
80
79
  ```
@@ -82,7 +81,7 @@ SWAP.BLK 1:
82
81
  Decorator/doc-comment = SEPARATE block — point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`:
83
82
  ```
84
83
  [svc.py#C3D4]
85
- SWAP.BLK 1:
84
+ PUT 1*:
86
85
  +@cache
87
86
  +def load(key):
88
87
  + return store[key]
@@ -90,45 +89,45 @@ SWAP.BLK 1:
90
89
  </example>
91
90
 
92
91
  <anti-patterns>
93
- # WRONG — empty `SWAP` to delete. RIGHT: CUT 4
94
- SWAP 4.=4:
92
+ # WRONG — empty `PUT` to delete. RIGHT: `CUT 4.=4`
93
+ PUT 4.=4:
95
94
 
96
- # WRONG — range sized to the post-edit content. RIGHT: SWAP 1.=1: (body length irrelevant)
97
- SWAP 1.=2:
95
+ # WRONG — range sized to the post-edit content. RIGHT: `PUT 1.=1:` (body length irrelevant)
96
+ PUT 1.=2:
98
97
  +def greet(name):
99
98
 
100
99
  # WRONG — `-` rows / bare context lines do not exist; the range deletes, the body is only new content.
101
- SWAP 3.=3:
100
+ PUT 3.=3:
102
101
  msg = "Hello, " + name
103
102
  - print(msg)
104
103
  + return msg
105
104
  # RIGHT
106
- SWAP 3.=3:
105
+ PUT 3.=3:
107
106
  + return msg
108
107
 
109
- # WRONG — pure insertion as a widened `SWAP`: retyped keepers get dropped (here line 4).
110
- SWAP 2.=4:
108
+ # WRONG — pure insertion as a widened `PUT`: retyped keepers get dropped (here line 4).
109
+ PUT 2.=4:
111
110
  + msg = "Hello, " + name
112
111
  + extra = compute(name)
113
112
  + print(msg)
114
113
  # RIGHT — touch nothing you keep.
115
- INS.POST 2:
114
+ PUT >2:
116
115
  + extra = compute(name)
117
116
 
118
- # WRONG — `INS.BLK.POST` anchored on the closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
119
- INS.BLK.POST 3:
117
+ # WRONG — `PUT >N*:` anchored on the closing delimiter / last visible line. RIGHT: plain `PUT >M:`
118
+ PUT >3*:
120
119
  +after()
121
120
  # RIGHT
122
- INS.POST 3:
121
+ PUT >3:
123
122
  +after()
124
123
 
125
- # WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20`.
126
- PASTE.POST 20:
124
+ # WRONG — body rows under register PUT; register pastes take no body. RIGHT: bodyless `PUT >20 @fn`.
125
+ PUT >20 @fn:
127
126
  +function f() {}
128
127
  </anti-patterns>
129
128
 
130
129
  <critical>
131
130
  1. RE-GROUND AFTER EVERY EDIT — applied edits renumber the file and change the `#TAG`; take next numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
132
- 2. RANGES ARE TIGHT — cover only lines that change. Whole construct → `SWAP.BLK N`.
131
+ 2. RANGES ARE TIGHT — cover only lines that change. Whole construct → `PUT N*:`.
133
132
  3. BODY = FINAL CONTENT — every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
134
133
  </critical>
package/src/recovery.ts CHANGED
@@ -49,6 +49,15 @@ function getEditAnchors(edit: Edit): Anchor[] {
49
49
  for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line });
50
50
  return anchors;
51
51
  }
52
+ if (edit.kind === "paste") {
53
+ if (edit.at.kind === "span") {
54
+ const anchors: Anchor[] = [];
55
+ for (let line = edit.at.range.start.line; line <= edit.at.range.end.line; line++) anchors.push({ line });
56
+ return anchors;
57
+ }
58
+ const cursor = edit.at.cursor;
59
+ return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : [];
60
+ }
52
61
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
53
62
  }
54
63
 
@@ -236,23 +245,55 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
236
245
  remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } });
237
246
  continue;
238
247
  }
239
-
240
- let blockStart = edit.blockStart;
241
- if (blockStart !== undefined) {
242
- const mappedBlockStart = mapLine(blockStart);
243
- if (mappedBlockStart === null) return null;
244
- blockStart = mappedBlockStart;
245
- }
246
-
247
- const cursor = edit.cursor;
248
- if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") {
249
- remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart });
248
+ if (edit.kind === "paste") {
249
+ let blockStart = edit.blockStart;
250
+ if (blockStart !== undefined) {
251
+ const mappedBlockStart = mapLine(blockStart);
252
+ if (mappedBlockStart === null) return null;
253
+ blockStart = mappedBlockStart;
254
+ }
255
+ if (edit.at.kind === "span") {
256
+ const start = mapLine(edit.at.range.start.line);
257
+ if (start === null) return null;
258
+ let end = start;
259
+ for (let line = edit.at.range.start.line + 1; line <= edit.at.range.end.line; line++) {
260
+ const mapped = mapLine(line);
261
+ if (mapped === null) return null;
262
+ end = mapped;
263
+ }
264
+ remapped.push({
265
+ ...edit,
266
+ at: { kind: "span", range: { start: { line: start }, end: { line: end } } },
267
+ blockStart,
268
+ });
269
+ continue;
270
+ }
271
+ const cursor = edit.at.cursor;
272
+ if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") {
273
+ remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart });
274
+ continue;
275
+ }
276
+ const anchor = mapAnchor(cursor.anchor);
277
+ if (anchor === null) return null;
278
+ remapped.push({ ...edit, at: { kind: "gap", cursor: { kind: cursor.kind, anchor } }, blockStart });
250
279
  continue;
251
280
  }
252
-
253
- const anchor = mapAnchor(cursor.anchor);
254
- if (anchor === null) return null;
255
- remapped.push({ ...edit, cursor: { kind: cursor.kind, anchor }, blockStart });
281
+ if (edit.kind === "insert") {
282
+ let blockStart = edit.blockStart;
283
+ if (blockStart !== undefined) {
284
+ const mappedBlockStart = mapLine(blockStart);
285
+ if (mappedBlockStart === null) return null;
286
+ blockStart = mappedBlockStart;
287
+ }
288
+ const cursor = edit.cursor;
289
+ if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") {
290
+ remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart });
291
+ continue;
292
+ }
293
+ const anchor = mapAnchor(cursor.anchor);
294
+ if (anchor === null) return null;
295
+ remapped.push({ ...edit, cursor: { kind: cursor.kind, anchor }, blockStart });
296
+ }
256
297
  }
257
298
 
258
299
  if (offsets.length === 0) return null;