pi-hashline-edit-pro 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-editing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 4-character content hashes over a 64-character URL-safe base64 alphabet give 24 bits of entropy per anchor, so collisions are effectively zero in any realistic file.
3
+ A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 4-character content hashes over a 64-character URL-safe base64 alphabet give 24 bits of entropy per anchor, so collisions are effectively zero in any realistic file.
4
4
 
5
5
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design in two ways: a 4-character hash length and an occurrence-aware discriminator that makes identical content at different positions hash to different values.
6
6
 
@@ -55,42 +55,42 @@ Optional parameters:
55
55
  - `offset` -- start reading from this line number (1-indexed).
56
56
  - `limit` -- maximum number of lines to return.
57
57
 
58
- Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
58
+ Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files return an advisory suggesting using replace to insert content.
59
59
 
60
- ### `edit` -- hash-anchored modifications
60
+ ### `replace` -- hash-anchored modifications
61
61
 
62
- Edits use the `HASH│content` anchors from `read` output to target lines precisely:
62
+ Replaces using the `HASH│content` anchors from `read` output to target lines precisely:
63
63
 
64
64
  ```json
65
65
  {
66
66
  "path": "src/main.ts",
67
67
  "edits": [
68
- { "op": "replace", "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
68
+ { "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
69
69
  ]
70
70
  }
71
71
  ```
72
72
 
73
- | Op | Purpose | Fields |
74
- |---|---|---|
75
- | `replace` | Replace the inclusive range `start`..`end`. To replace a single line, set `start` = `end`. | `start` required, `end` required, `lines` |
76
- | `append` | Insert lines after `pos`. Omit `pos` to append at EOF. | `pos` optional, `lines` |
77
- | `prepend` | Insert lines before `pos`. Omit `pos` to prepend at BOF. | `pos` optional, `lines` |
73
+ | Field | Purpose |
74
+ |---|---|
75
+ | `start` | Range-start anchor (required). |
76
+ | `end` | Range-end anchor (required). Single-line replace: set `start` = `end`. |
77
+ | `lines` | Replacement content (one string per line). Use `[]` to delete. |
78
78
 
79
79
  - **Request structure validation.** The request envelope (path, edits, returnMode, returnRanges) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_OP]`.
80
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect and `op: "replace_text"` are rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{op:"replace", start:"<HASH>", end:"<HASH>", lines:[...]}` (or `append`/`prepend` with `pos`).
80
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
81
81
 
82
82
  All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
83
83
 
84
84
  ### Chained edits
85
85
 
86
- After a successful edit, the result text contains an `--- Anchors ---` block with fresh `HASH│content` references for the changed region. These can be used directly in the next `edit` call on the same file without a full re-read, provided the next edit targets the same or nearby lines. For distant changes, use `read` first.
86
+ After a successful replace, the result text contains an `--- Anchors ---` block with fresh `HASH│content` references for the changed region. These can be used directly in the next `replace` call on the same file without a full re-read, provided the next replace targets the same or nearby lines. For distant changes, use `read` first.
87
87
 
88
88
  ### Auto-read after write
89
89
 
90
90
  After a successful `write`, the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the newly written file without requiring a separate `read` call. The workflow becomes:
91
91
 
92
92
  1. `write` a file, result includes hashline anchors
93
- 2. `edit` using those anchors directly
93
+ 2. `replace` using those anchors directly
94
94
 
95
95
  For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use `read` with `offset` to see more.
96
96
 
@@ -103,7 +103,7 @@ The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is ex
103
103
  - **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes fresh `>>> HASH│content` lines for the affected region. The model copies the HASH portion and retries.
104
104
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
105
105
  - **Strict patch content.** If `lines` contains `+HASH│` display prefixes (or `-N ` diff rows), the edit is rejected with `[E_INVALID_PATCH]`. Bare `HASH│` content (the first 5 chars of a `lines` entry looking like 4 base64 chars + `│`) is also rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
106
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect and `op: "replace_text"` are rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{op:"replace", start:"<HASH>", end:"<HASH>", lines:[...]}` (or `append`/`prepend` with `pos`).
106
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
107
107
  - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
108
108
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
109
109
 
@@ -118,7 +118,7 @@ Hashes are occurrence-aware: a discriminator prefix is mixed into the xxHash inp
118
118
  - `}` on line 5 and `}` on line 17 hash differently (1st vs 2nd occurrence of `}`).
119
119
  - `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (1st vs 2nd occurrence).
120
120
 
121
- The runtime always precomputes the full per-line hash array for a file via `computeLineHashes(content)`, then looks up by line number during validation and during `read` / `edit` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
121
+ The runtime always precomputes the full per-line hash array for a file via `computeLineHashes(content)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
122
122
 
123
123
  `HASH_LENGTH` and `HASH_ALPHABET` are constants at the top of `src/hashline/hash.ts`; bump the length to 5 if you ever need even more entropy.
124
124
 
package/index.ts CHANGED
@@ -2,14 +2,20 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "fs/promises";
3
3
  import { join, isAbsolute } from "path";
4
4
  import { computeLineHashes, formatHashlineRegion } from "./src/hashline";
5
- import { registerEditTool } from "./src/edit";
5
+ import { registerReplaceTool } from "./src/replace";
6
6
  import { registerReadTool } from "./src/read";
7
- import { normalizeToLF } from "./src/edit-diff";
7
+ import { normalizeToLF } from "./src/replace-diff";
8
8
  import { getVisibleLines } from "./src/utils";
9
9
 
10
10
  export default function (pi: ExtensionAPI): void {
11
11
  registerReadTool(pi);
12
- registerEditTool(pi);
12
+ registerReplaceTool(pi);
13
+
14
+ // Disable the built-in `edit` tool so the model uses `replace` instead.
15
+ pi.on("session_start", async (_event, ctx) => {
16
+ const active = pi.getActiveTools();
17
+ pi.setActiveTools(active.filter((t) => t !== "edit"));
18
+ });
13
19
 
14
20
  // Auto-read after write: append hashline read output to write results
15
21
  // so the model immediately has anchors for subsequent edits.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.5.1",
4
- "description": "Strict hashline read/edit tool override for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
3
+ "version": "0.6.1",
4
+ "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
5
5
  "main": "index.ts",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1,3 +1,2 @@
1
- - Use read before edit when you do not have current HASH anchors for the file.
2
- - Copy exactly the HASH (the 4 characters before the `│`); never include the `│` or line content in `pos`/`end`.
3
- - A HASH may contain `-` as a normal alphabet character.
1
+ - Use `read` before `replace` when you do not have current HASH anchors for the file.
2
+ - A HASH may contain `-` as a normal alphabet character.
@@ -1 +1 @@
1
- Read a text file with HASH│content anchors for edit (copy the HASH into `start`/`end`/`pos`)
1
+ Read a text file and return HASH│content lines
package/prompts/read.md CHANGED
@@ -1,31 +1,17 @@
1
- Read a text file. Each line is returned as `HASH│content`. The HASH is 4 base64 characters; the content after the `│` separator is the line verbatim. Pass the HASH (e.g. `aB3x`) into `edit`'s `start`/`end` (for `replace`) or `pos` (for `append`/`prepend`) — never include the line content.
1
+ Read a text file. Each line is returned as `HASH│content`.
2
2
 
3
- HASH shape:
4
- - 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
5
- - The line number is not part of the wire format. Anchor by HASH, never by reading a line number off the rendered output.
6
-
7
- HASH → edit:
8
- - Copy the full 4-character HASH. Use that HASH as `start` or `end` (for `replace`) or `pos` (for `append`/`prepend`) in the next `edit` call.
9
- - Do not include the `│`, the line content, or surrounding whitespace. The wire format for `start`/`end`/`pos` is the HASH only.
3
+ HASH format:
4
+ - The HASH is 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
5
+ - The content after the `│` separator is the line verbatim.
6
+ - The line number is not part of the output. Use the HASH to reference lines.
10
7
 
11
8
  Pagination:
12
9
  - Large files return a truncated preview with a `nextOffset` line. Call `read` again with `offset=nextOffset` to continue.
13
- - For nearby follow-up edits, prefer the `--- Anchors ---` block from a previous `edit` call — fresh HASHes, cheaper than re-reading.
14
- - Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
15
-
16
- Error recovery:
17
- - `[E_STALE_ANCHOR]` — the file changed since your last read. The error includes fresh `>>> HASH│content` lines; copy the HASH portion (the 4 chars before `│`) and retry.
18
- - `[E_BAD_REF]` — malformed HASH. Re-read and try again with a valid HASH anchor (e.g. `aB3x`).
19
10
 
20
11
  File kinds:
21
12
  - Text files are returned as `HASH│content` lines.
22
- - Images (JPEG, PNG, GIF, WebP) are returned as visual attachments; the HASH-line protocol does not apply.
13
+ - Images (JPEG, PNG, GIF, WebP) are returned as visual attachments.
23
14
  - Binary files and directories are rejected with a descriptive error.
24
15
 
25
16
  Non-UTF-8 bytes:
26
- - Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens; editing such a file rewrites it as UTF-8. Recover the original encoding with `iconv` afterwards if it must survive.
27
-
28
- Auto-read after write:
29
- - After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with `HASH│content` for the written file.
30
- - Use those anchors directly for `edit` calls without a separate `read`.
31
- - The auto-read output follows the same format and rules as `read` output.
17
+ - Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens.
@@ -0,0 +1,3 @@
1
+ - Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
2
+ - After a successful `replace`, the returned `--- Anchors ---` block has fresh hashes for nearby follow-up replaces.
3
+ - On `[E_STALE_ANCHOR]`, retry with the `>>>` lines quoted in the error instead of re-reading the whole file.
@@ -0,0 +1 @@
1
+ Replace lines in a text file via HASH anchors from read, batching all edits to a file in one call
@@ -0,0 +1,70 @@
1
+ Replace lines in a text file using HASH anchors from `read`.
2
+
3
+ Put all operations on one file in a single `replace` call. Stack every region into the `edits` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.
4
+
5
+ How to use:
6
+
7
+ 1. Call `read` to get HASH anchors:
8
+ ```
9
+ read({ path: "src/main.ts" })
10
+ // Returns:
11
+ // MQXV│const x = 1;
12
+ // ZPMQ│const y = 2;
13
+ // VRWS│const z = 3;
14
+ ```
15
+
16
+ 2. Copy the 4-character HASH (before `│`) into `start`/`end`:
17
+ ```json
18
+ { "path": "src/main.ts", "edits": [
19
+ { "start": "MQXV", "end": "MQXV", "lines": ["const x = 99;"] }
20
+ ] }
21
+ ```
22
+
23
+ Examples:
24
+
25
+ 1. Single line replace:
26
+ ```json
27
+ { "path": "src/main.ts", "edits": [
28
+ { "start": "MQXV", "end": "MQXV", "lines": ["const x = 1;"] }
29
+ ] }
30
+ ```
31
+
32
+ 2. Range replace (3 lines → 3 new lines):
33
+ ```json
34
+ { "path": "src/main.ts", "edits": [
35
+ { "start": "ZPMQ", "end": "VRWS", "lines": [
36
+ "function greet(name) {",
37
+ " return `Hello, ${name}`;",
38
+ "}"
39
+ }
40
+ ] }
41
+ ```
42
+
43
+ 3. Multiple regions in one call (delete two non-adjacent ranges):
44
+ ```json
45
+ { "path": "src/server.ts", "edits": [
46
+ { "start": "aB3x", "end": "xY7q", "lines": [] },
47
+ { "start": "MQXV", "end": "ZPMQ", "lines": [] }
48
+ ] }
49
+ ```
50
+
51
+ Rules:
52
+ - `start` and `end` are required. A single-line replace is `start=X, end=X`.
53
+ - To delete a range, use `lines: []`.
54
+ - `start`, `end` are HASH anchors only (e.g. `aB3x`). Do not include `│` or line content.
55
+ - `lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix, no `+`/`-` diff markers.
56
+ - Don't add `""` for spacing unless you actually want a new blank line.
57
+ - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
58
+ - All edits in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
59
+ - If `lines` matches current content, the replace is classified as `noop` (file unchanged).
60
+ - The `start`/`end` range is inclusive — both anchors and every line between them are replaced. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
61
+
62
+ On success, the response contains an `--- Anchors ---` block with fresh HASH anchors for the changed region. Use those for nearby follow-up replaces instead of re-reading.
63
+
64
+ Auto-read after write:
65
+ - After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block.
66
+ - Use those anchors directly for `replace` calls without a separate `read`.
67
+
68
+ Error recovery:
69
+ - `[E_STALE_ANCHOR]` — file changed since last read. The error includes fresh `>>> HASH│content` lines; copy the HASH and retry.
70
+ - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
@@ -42,14 +42,12 @@ export function buildLineIndex(content: string): LineIndex {
42
42
 
43
43
 
44
44
  type ResolvedEditSpan = {
45
- kind: "replace" | "insert";
45
+ kind: "replace";
46
46
  index: number;
47
47
  label: string;
48
48
  start: number;
49
49
  end: number;
50
50
  replacement: string;
51
- boundary?: number;
52
- insertMode?: "append-empty-origin" | "prepend-empty-origin";
53
51
  };
54
52
 
55
53
  function assertDoesNotEmptyFile(originalContent: string, result: string): void {
@@ -71,27 +69,6 @@ function throwEditConflict(
71
69
  );
72
70
  }
73
71
 
74
- function computeInsertionBoundary(
75
- edit: Extract<ResolvedHashlineEdit, { op: "append" | "prepend" }>,
76
- lineIndex: LineIndex,
77
- ): number {
78
- switch (edit.op) {
79
- case "append": {
80
- const fileLineCount = lineIndex.fileLines.length;
81
- const eofBoundary =
82
- lineIndex.hasTerminalNewline && fileLineCount > 0
83
- ? fileLineCount - 1
84
- : fileLineCount;
85
- return edit.pos
86
- ? lineIndex.hasTerminalNewline && edit.pos.line === fileLineCount
87
- ? eofBoundary
88
- : edit.pos.line
89
- : eofBoundary;
90
- }
91
- case "prepend":
92
- return edit.pos ? edit.pos.line - 1 : 0;
93
- }
94
- }
95
72
 
96
73
  function resolveEditToSpan(
97
74
  edit: ResolvedHashlineEdit,
@@ -102,155 +79,64 @@ function resolveEditToSpan(
102
79
  ): ResolvedEditSpan | null {
103
80
  const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
104
81
 
105
- switch (edit.op) {
106
- case "replace": {
107
- const startLine = edit.start.line;
108
- const endLine = edit.end.line;
109
- const originalLines = fileLines.slice(startLine - 1, endLine);
110
- if (
111
- originalLines.length === edit.lines.length &&
112
- originalLines.every(
113
- (line, lineIndex) => line === edit.lines[lineIndex],
114
- )
115
- ) {
116
- noopEdits.push({
117
- editIndex: index,
118
- loc: edit.start.hash,
119
- currentContent: originalLines.join("\n"),
120
- });
121
- return null;
122
- }
123
-
124
- if (edit.lines.length > 0) {
125
- return {
126
- kind: "replace",
127
- index,
128
- label: describeEdit(edit),
129
- start: lineStarts[startLine - 1]!,
130
- end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
131
- replacement: edit.lines.join("\n"),
132
- };
133
- }
134
-
135
- if (startLine === 1 && endLine === fileLines.length) {
136
- return {
137
- kind: "replace",
138
- index,
139
- label: describeEdit(edit),
140
- start: 0,
141
- end: content.length,
142
- replacement: "",
143
- };
144
- }
145
-
146
- if (endLine < fileLines.length) {
147
- return {
148
- kind: "replace",
149
- index,
150
- label: describeEdit(edit),
151
- start: lineStarts[startLine - 1]!,
152
- end: lineStarts[endLine]!,
153
- replacement: "",
154
- };
155
- }
156
-
157
- return {
158
- kind: "replace",
159
- index,
160
- label: describeEdit(edit),
161
- start: Math.max(0, lineStarts[startLine - 1]! - 1),
162
- end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
163
- replacement: "",
164
- };
165
- }
166
- case "append": {
167
- if (edit.lines.length === 0) {
168
- noopEdits.push({
169
- editIndex: index,
170
- loc: edit.pos ? edit.pos.hash : "EOF",
171
- currentContent: edit.pos
172
- ? (fileLines[edit.pos.line - 1] ?? "")
173
- : "",
174
- });
175
- return null;
176
- }
82
+ const startLine = edit.start.line;
83
+ const endLine = edit.end.line;
84
+ const originalLines = fileLines.slice(startLine - 1, endLine);
85
+ if (
86
+ originalLines.length === edit.lines.length &&
87
+ originalLines.every(
88
+ (line, lineIndex) => line === edit.lines[lineIndex],
89
+ )
90
+ ) {
91
+ noopEdits.push({
92
+ editIndex: index,
93
+ loc: edit.start.hash,
94
+ currentContent: originalLines.join("\n"),
95
+ });
96
+ return null;
97
+ }
177
98
 
178
- const insertedText = edit.lines.join("\n");
179
- if (content.length === 0) {
180
- return {
181
- kind: "insert",
182
- index,
183
- label: describeEdit(edit),
184
- start: 0,
185
- end: 0,
186
- replacement: insertedText,
187
- boundary: computeInsertionBoundary(edit, lineIndex),
188
- insertMode: "append-empty-origin",
189
- };
190
- }
99
+ if (edit.lines.length > 0) {
100
+ return {
101
+ kind: "replace",
102
+ index,
103
+ label: describeEdit(edit),
104
+ start: lineStarts[startLine - 1]!,
105
+ end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
106
+ replacement: edit.lines.join("\n"),
107
+ };
108
+ }
191
109
 
192
- if (!edit.pos) {
193
- return {
194
- kind: "insert",
195
- index,
196
- label: describeEdit(edit),
197
- start: content.length,
198
- end: content.length,
199
- replacement: hasTerminalNewline
200
- ? `${insertedText}\n`
201
- : `\n${insertedText}`,
202
- boundary: computeInsertionBoundary(edit, lineIndex),
203
- };
204
- }
110
+ if (startLine === 1 && endLine === fileLines.length) {
111
+ return {
112
+ kind: "replace",
113
+ index,
114
+ label: describeEdit(edit),
115
+ start: 0,
116
+ end: content.length,
117
+ replacement: "",
118
+ };
119
+ }
205
120
 
206
- const isSentinelAppend =
207
- hasTerminalNewline && edit.pos.line === fileLines.length;
208
- return {
209
- kind: "insert",
210
- index,
211
- label: describeEdit(edit),
212
- start: isSentinelAppend
213
- ? content.length
214
- : lineStarts[edit.pos.line - 1]! +
215
- fileLines[edit.pos.line - 1]!.length,
216
- end: isSentinelAppend
217
- ? content.length
218
- : lineStarts[edit.pos.line - 1]! +
219
- fileLines[edit.pos.line - 1]!.length,
220
- replacement: isSentinelAppend
221
- ? `${insertedText}\n`
222
- : `\n${insertedText}`,
223
- boundary: computeInsertionBoundary(edit, lineIndex),
224
- };
225
- }
226
- case "prepend": {
227
- if (edit.lines.length === 0) {
228
- noopEdits.push({
229
- editIndex: index,
230
- loc: edit.pos ? edit.pos.hash : "BOF",
231
- currentContent: edit.pos
232
- ? (fileLines[edit.pos.line - 1] ?? "")
233
- : "",
234
- });
235
- return null;
236
- }
237
- const insertedText = edit.lines.join("\n");
238
- const start = edit.pos ? lineStarts[edit.pos.line - 1]! : 0;
239
- return {
240
- kind: "insert",
241
- index,
242
- label: describeEdit(edit),
243
- start,
244
- end: start,
245
- replacement:
246
- content.length === 0 ? insertedText : `${insertedText}\n`,
247
- boundary: computeInsertionBoundary(edit, lineIndex),
248
- ...(content.length === 0
249
- ? { insertMode: "prepend-empty-origin" as const }
250
- : {}),
251
- };
252
- }
121
+ if (endLine < fileLines.length) {
122
+ return {
123
+ kind: "replace",
124
+ index,
125
+ label: describeEdit(edit),
126
+ start: lineStarts[startLine - 1]!,
127
+ end: lineStarts[endLine]!,
128
+ replacement: "",
129
+ };
253
130
  }
131
+
132
+ return {
133
+ kind: "replace",
134
+ index,
135
+ label: describeEdit(edit),
136
+ start: Math.max(0, lineStarts[startLine - 1]! - 1),
137
+ end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
138
+ replacement: "",
139
+ };
254
140
  }
255
141
 
256
142
  function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
@@ -263,38 +149,11 @@ function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
263
149
  ) {
264
150
  const right = spans[rightIndex]!;
265
151
 
266
- if (left.kind === "insert" && right.kind === "insert") {
267
- if (left.boundary === right.boundary) {
268
- throwEditConflict(
269
- left,
270
- right,
271
- "target the same insertion boundary",
272
- );
273
- }
274
- continue;
275
- }
276
-
277
- if (left.kind === "replace" && right.kind === "replace") {
278
- if (left.start < right.end && right.start < left.end) {
279
- throwEditConflict(
280
- left,
281
- right,
282
- "overlap on the same original line range",
283
- );
284
- }
285
- continue;
286
- }
287
-
288
- const replaceSpan = left.kind === "replace" ? left : right;
289
- const insertSpan = left.kind === "insert" ? left : right;
290
- if (
291
- insertSpan.start >= replaceSpan.start &&
292
- insertSpan.start < replaceSpan.end
293
- ) {
152
+ if (left.start < right.end && right.start < left.end) {
294
153
  throwEditConflict(
295
154
  left,
296
155
  right,
297
- "cannot be applied together because one inserts inside a replaced original range",
156
+ "overlap on the same original line range",
298
157
  );
299
158
  }
300
159
  }
@@ -324,9 +183,7 @@ function resolveEditSpans(
324
183
  }
325
184
 
326
185
  const spanKey =
327
- span.kind === "insert"
328
- ? `insert:${span.boundary}:${span.replacement}`
329
- : `replace:${span.start}:${span.end}:${span.replacement}`;
186
+ `replace:${span.start}:${span.end}:${span.replacement}`;
330
187
  if (seenSpanKeys.has(spanKey)) {
331
188
  continue;
332
189
  }
@@ -335,20 +192,10 @@ function resolveEditSpans(
335
192
  }
336
193
 
337
194
  assertNoConflictingSpans(resolvedSpans);
338
-
339
195
  return [...resolvedSpans].sort((left, right) => {
340
196
  if (right.end !== left.end) {
341
197
  return right.end - left.end;
342
198
  }
343
- if (left.kind !== right.kind) {
344
- return left.kind === "replace" ? -1 : 1;
345
- }
346
- if (left.kind === "insert" && right.kind === "insert") {
347
- return (
348
- (right.boundary ?? -1) - (left.boundary ?? -1) ||
349
- left.index - right.index
350
- );
351
- }
352
199
  return left.index - right.index;
353
200
  });
354
201
  }
@@ -361,18 +208,8 @@ function assembleEditResult(
361
208
  let result = content;
362
209
  for (const span of spans) {
363
210
  throwIfAborted(signal);
364
- const replacement =
365
- span.insertMode === "append-empty-origin"
366
- ? result.length === 0
367
- ? span.replacement
368
- : `\n${span.replacement}`
369
- : span.insertMode === "prepend-empty-origin"
370
- ? result.length === 0
371
- ? span.replacement
372
- : `${span.replacement}\n`
373
- : span.replacement;
374
211
  result =
375
- result.slice(0, span.start) + replacement + result.slice(span.end);
212
+ result.slice(0, span.start) + span.replacement + result.slice(span.end);
376
213
  }
377
214
  return result;
378
215
  }
@@ -383,7 +220,6 @@ export function applyHashlineEdits(
383
220
  edits: import("./resolve").HashlineEdit[],
384
221
  signal?: AbortSignal,
385
222
  precomputedHashes?: string[],
386
- filePath?: string,
387
223
  ): {
388
224
  content: string;
389
225
  firstChangedLine: number | undefined;
@@ -401,7 +237,6 @@ export function applyHashlineEdits(
401
237
 
402
238
  // Normalize lines: [""] to lines: [] for deletion.
403
239
  edits = edits.map((edit) =>
404
- edit.op === "replace" &&
405
240
  edit.lines.length === 1 &&
406
241
  edit.lines[0] === ""
407
242
  ? { ...edit, lines: [] }
@@ -426,7 +261,7 @@ export function applyHashlineEdits(
426
261
  );
427
262
  }
428
263
 
429
- const barePrefixWarnings = assertNoBareHashPrefixLines(edits, lineIndex.fileLines, fileHashes, filePath);
264
+ const barePrefixWarnings = assertNoBareHashPrefixLines(edits, lineIndex.fileLines, fileHashes);
430
265
  warnings.push(...barePrefixWarnings);
431
266
  maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
432
267