@oh-my-pi/hashline 17.2.0 → 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/CHANGELOG.md +20 -0
- package/README.md +7 -7
- package/dist/types/clipboard.d.ts +6 -4
- package/dist/types/format.d.ts +21 -27
- package/dist/types/messages.d.ts +48 -27
- package/dist/types/parser.d.ts +2 -1
- package/dist/types/prefixes.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +12 -8
- package/dist/types/types.d.ts +53 -25
- package/package.json +2 -2
- package/src/apply.ts +72 -1
- package/src/block.ts +27 -5
- package/src/clipboard.ts +141 -42
- package/src/format.ts +35 -34
- package/src/grammar.lark +12 -11
- package/src/input.ts +14 -10
- package/src/messages.ts +129 -65
- package/src/parser.ts +307 -103
- package/src/patcher.ts +8 -8
- package/src/prefixes.ts +12 -4
- package/src/prompt.md +37 -38
- package/src/recovery.ts +56 -15
- package/src/tokenizer.ts +160 -191
- package/src/types.ts +48 -25
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 => !
|
|
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 => !
|
|
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
|
|
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
|
-
`
|
|
9
|
-
`
|
|
10
|
-
`
|
|
11
|
-
`
|
|
12
|
-
`
|
|
13
|
-
`
|
|
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: `
|
|
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 → `
|
|
31
|
-
- `
|
|
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 (`
|
|
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). `
|
|
34
|
-
- Pure additions → `
|
|
35
|
-
- Move code with `CUT`+`
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
PUT >2:
|
|
62
61
|
+- task
|
|
63
62
|
+ - nested task
|
|
64
63
|
```
|
|
65
64
|
|
|
66
|
-
Move `greet` to a sibling file —
|
|
65
|
+
Move `greet` to a sibling file using a named register — flows across sections:
|
|
67
66
|
```
|
|
68
67
|
[greet.py#A1B2]
|
|
69
|
-
CUT
|
|
68
|
+
CUT 1* @fn
|
|
70
69
|
[other.py#3C4D]
|
|
71
|
-
|
|
70
|
+
PUT <1 @fn
|
|
72
71
|
```
|
|
73
72
|
|
|
74
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
94
|
-
|
|
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:
|
|
97
|
-
|
|
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
|
-
|
|
100
|
+
PUT 3.=3:
|
|
102
101
|
msg = "Hello, " + name
|
|
103
102
|
- print(msg)
|
|
104
103
|
+ return msg
|
|
105
104
|
# RIGHT
|
|
106
|
-
|
|
105
|
+
PUT 3.=3:
|
|
107
106
|
+ return msg
|
|
108
107
|
|
|
109
|
-
# WRONG — pure insertion as a widened `
|
|
110
|
-
|
|
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
|
-
|
|
114
|
+
PUT >2:
|
|
116
115
|
+ extra = compute(name)
|
|
117
116
|
|
|
118
|
-
# WRONG — `
|
|
119
|
-
|
|
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
|
-
|
|
121
|
+
PUT >3:
|
|
123
122
|
+after()
|
|
124
123
|
|
|
125
|
-
# WRONG — body rows under
|
|
126
|
-
|
|
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 → `
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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;
|