pi-hashline-edit-pro 0.5.1 → 0.6.0
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 +15 -15
- package/index.ts +9 -3
- package/package.json +2 -2
- package/prompts/read-guidelines.md +2 -3
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +7 -21
- package/prompts/replace-guidelines.md +3 -0
- package/prompts/replace-snippet.md +1 -0
- package/prompts/replace.md +69 -0
- package/src/hashline/apply.ts +59 -223
- package/src/hashline/resolve.ts +75 -192
- package/src/read.ts +5 -6
- package/src/{edit-normalize.ts → replace-normalize.ts} +1 -1
- package/src/{edit-render.ts → replace-render.ts} +16 -16
- package/src/{edit-response.ts → replace-response.ts} +8 -8
- package/src/{edit.ts → replace.ts} +36 -49
- package/prompts/edit-guidelines.md +0 -3
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -59
- /package/src/{edit-diff.ts → replace-diff.ts} +0 -0
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-
|
|
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
|
|
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
|
-
### `
|
|
60
|
+
### `replace` -- hash-anchored modifications
|
|
61
61
|
|
|
62
|
-
|
|
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
|
-
{ "
|
|
68
|
+
{ "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
|
|
69
69
|
]
|
|
70
70
|
}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
|
|
|
74
|
-
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
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
|
|
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
|
|
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. `
|
|
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
|
|
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` / `
|
|
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 {
|
|
5
|
+
import { registerReplaceTool } from "./src/replace";
|
|
6
6
|
import { registerReadTool } from "./src/read";
|
|
7
|
-
import { normalizeToLF } from "./src/
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Strict hashline read/
|
|
3
|
+
"version": "0.6.0",
|
|
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
|
|
2
|
-
-
|
|
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.
|
package/prompts/read-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file
|
|
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`.
|
|
1
|
+
Read a text file. Each line is returned as `HASH│content`.
|
|
2
2
|
|
|
3
|
-
HASH
|
|
4
|
-
- 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
|
|
5
|
-
- The
|
|
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
|
|
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
|
|
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,69 @@
|
|
|
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
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
Auto-read after write:
|
|
64
|
+
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block.
|
|
65
|
+
- Use those anchors directly for `replace` calls without a separate `read`.
|
|
66
|
+
|
|
67
|
+
Error recovery:
|
|
68
|
+
- `[E_STALE_ANCHOR]` — file changed since last read. The error includes fresh `>>> HASH│content` lines; copy the HASH and retry.
|
|
69
|
+
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
package/src/hashline/apply.ts
CHANGED
|
@@ -42,14 +42,12 @@ export function buildLineIndex(content: string): LineIndex {
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
type ResolvedEditSpan = {
|
|
45
|
-
kind: "replace"
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
}
|
|
@@ -401,7 +238,6 @@ export function applyHashlineEdits(
|
|
|
401
238
|
|
|
402
239
|
// Normalize lines: [""] to lines: [] for deletion.
|
|
403
240
|
edits = edits.map((edit) =>
|
|
404
|
-
edit.op === "replace" &&
|
|
405
241
|
edit.lines.length === 1 &&
|
|
406
242
|
edit.lines[0] === ""
|
|
407
243
|
? { ...edit, lines: [] }
|