@d3ara1n/pi-hashline-edit 0.1.1 → 0.1.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/README.md +15 -13
- package/package.json +1 -1
- package/src/core/apply.test.ts +80 -92
- package/src/core/apply.ts +54 -46
- package/src/core/hash.test.ts +16 -11
- package/src/core/hash.ts +30 -57
- package/src/core/index.ts +1 -5
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +11 -30
- package/src/index.ts +7 -10
- package/src/pi/edit-tool.ts +124 -106
- package/src/pi/execute.test.ts +193 -47
- package/src/pi/pi.test.ts +1 -20
- package/src/pi/read-tool.ts +12 -15
- package/src/pi/state.ts +4 -58
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -41
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -166
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -84
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@ Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead
|
|
|
6
6
|
|
|
7
7
|
## Design
|
|
8
8
|
|
|
9
|
-
- **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
9
|
+
- **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
|
|
10
|
+
- **Line + content hash**: each line's hash mixes its 1-based line number into its content, so every line gets a unique hash by construction — no in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
|
|
11
|
+
- **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the CURRENT line content and compared — no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair, no drift relocation.
|
|
12
|
+
- **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` (`LINE#HASH│`) for the lines it produced (and the line that shifted into a deletion gap), so the next edit to the same file can cite them directly.
|
|
13
|
+
- **No legacy compatibility**: overrides the built-in `edit`/`read`. `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use.
|
|
13
14
|
|
|
14
15
|
## Protocol
|
|
15
16
|
|
|
@@ -22,18 +23,19 @@ src/foo.ts · 6 lines
|
|
|
22
23
|
3#mP0│export function foo(x: number) {
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
`edit` `
|
|
26
|
+
`edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
|
|
26
27
|
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
```jsonc
|
|
29
|
+
{
|
|
30
|
+
"path": "src/foo.ts",
|
|
31
|
+
"edits": [
|
|
32
|
+
{ "op": "replace", "anchor": { "line": 4, "hash": "kLp" }, "body": [" return x + 1"] },
|
|
33
|
+
{ "op": "insert_after", "anchor": { "line": 6, "hash": "b2H" }, "body": ["", "export const bar = foo"] }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
Ops: `replace
|
|
38
|
+
Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
|
|
37
39
|
|
|
38
40
|
## Installation
|
|
39
41
|
|
package/package.json
CHANGED
package/src/core/apply.test.ts
CHANGED
|
@@ -1,185 +1,173 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import { computeLineHash } from "./hash.ts";
|
|
4
|
+
import { splitLines } from "./lines.ts";
|
|
4
5
|
import { applyEdits } from "./apply.ts";
|
|
5
|
-
import type {
|
|
6
|
+
import type { Anchor } from "./types.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/** Live anchor: hash the current content at the given line (1-based). */
|
|
9
|
+
function at(text: string, line: number): Anchor {
|
|
10
|
+
return { line, hash: computeLineHash(line, splitLines(text)[line - 1]) };
|
|
11
|
+
}
|
|
9
12
|
|
|
10
13
|
// --- happy paths ---
|
|
11
14
|
|
|
12
15
|
test("replace single line", () => {
|
|
13
16
|
const text = "a\nb\nc\n";
|
|
14
|
-
const
|
|
15
|
-
const r = applyEdits(text, [{ op: "replace", start: ln(s, 2), body: ["B"] }], s);
|
|
17
|
+
const r = applyEdits(text, [{ op: "replace", start: at(text, 2), body: ["B"] }]);
|
|
16
18
|
assert.equal(r.ok, true);
|
|
17
19
|
if (r.ok) assert.equal(r.text, "a\nB\nc\n");
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
test("replace range", () => {
|
|
21
23
|
const text = "a\nb\nc\nd\ne\n";
|
|
22
|
-
const
|
|
23
|
-
const r = applyEdits(
|
|
24
|
-
text,
|
|
25
|
-
[{ op: "replace", start: ln(s, 2), end: ln(s, 4), body: ["X", "Y"] }],
|
|
26
|
-
s,
|
|
27
|
-
);
|
|
24
|
+
const r = applyEdits(text, [{ op: "replace", start: at(text, 2), end: at(text, 4), body: ["X", "Y"] }]);
|
|
28
25
|
assert.equal(r.ok, true);
|
|
29
26
|
if (r.ok) assert.equal(r.text, "a\nX\nY\ne\n");
|
|
30
27
|
});
|
|
31
28
|
|
|
32
29
|
test("delete single line / range", () => {
|
|
33
30
|
const text = "a\nb\nc\nd\n";
|
|
34
|
-
const
|
|
35
|
-
const r = applyEdits(text, [{ op: "delete", start: ln(s, 2), end: ln(s, 3) }], s);
|
|
31
|
+
const r = applyEdits(text, [{ op: "delete", start: at(text, 2), end: at(text, 3) }]);
|
|
36
32
|
assert.equal(r.ok, true);
|
|
37
33
|
if (r.ok) assert.equal(r.text, "a\nd\n");
|
|
38
34
|
});
|
|
39
35
|
|
|
40
36
|
test("insert_after", () => {
|
|
41
37
|
const text = "a\nb\n";
|
|
42
|
-
const
|
|
43
|
-
const r = applyEdits(text, [{ op: "insert_after", anchor: ln(s, 1), body: ["x"] }], s);
|
|
38
|
+
const r = applyEdits(text, [{ op: "insert_after", anchor: at(text, 1), body: ["x"] }]);
|
|
44
39
|
assert.equal(r.ok, true);
|
|
45
40
|
if (r.ok) assert.equal(r.text, "a\nx\nb\n");
|
|
46
41
|
});
|
|
47
42
|
|
|
48
43
|
test("insert_before", () => {
|
|
49
44
|
const text = "a\nb\n";
|
|
50
|
-
const
|
|
51
|
-
const r = applyEdits(text, [{ op: "insert_before", anchor: ln(s, 2), body: ["x"] }], s);
|
|
45
|
+
const r = applyEdits(text, [{ op: "insert_before", anchor: at(text, 2), body: ["x"] }]);
|
|
52
46
|
assert.equal(r.ok, true);
|
|
53
47
|
if (r.ok) assert.equal(r.text, "a\nx\nb\n");
|
|
54
48
|
});
|
|
55
49
|
|
|
56
50
|
test("append / prepend", () => {
|
|
57
51
|
const text = "a\nb\n";
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{ op: "prepend", body: ["head"] },
|
|
63
|
-
{ op: "append", body: ["tail"] },
|
|
64
|
-
],
|
|
65
|
-
s,
|
|
66
|
-
);
|
|
52
|
+
const r = applyEdits(text, [
|
|
53
|
+
{ op: "prepend", body: ["head"] },
|
|
54
|
+
{ op: "append", body: ["tail"] },
|
|
55
|
+
]);
|
|
67
56
|
assert.equal(r.ok, true);
|
|
68
57
|
if (r.ok) assert.equal(r.text, "head\na\nb\ntail\n");
|
|
69
58
|
});
|
|
70
59
|
|
|
71
60
|
test("multiple out-of-order ops → applied at the right positions", () => {
|
|
72
61
|
const text = "a\nb\nc\n";
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
text,
|
|
76
|
-
|
|
77
|
-
{ op: "insert_after", anchor: ln(s, 3), body: ["z"] },
|
|
78
|
-
{ op: "replace", start: ln(s, 1), body: ["A"] },
|
|
79
|
-
],
|
|
80
|
-
s,
|
|
81
|
-
);
|
|
62
|
+
const r = applyEdits(text, [
|
|
63
|
+
{ op: "insert_after", anchor: at(text, 3), body: ["z"] },
|
|
64
|
+
{ op: "replace", start: at(text, 1), body: ["A"] },
|
|
65
|
+
]);
|
|
82
66
|
assert.equal(r.ok, true);
|
|
83
67
|
if (r.ok) assert.equal(r.text, "A\nb\nc\nz\n");
|
|
84
68
|
});
|
|
85
69
|
|
|
86
|
-
test("
|
|
87
|
-
const text = "a\nb\n";
|
|
88
|
-
const
|
|
89
|
-
|
|
70
|
+
test("touchedLines covers exactly the lines this edit produced (new-file indices)", () => {
|
|
71
|
+
const text = "a\nb\nc\n";
|
|
72
|
+
const r = applyEdits(text, [
|
|
73
|
+
{ op: "insert_after", anchor: at(text, 3), body: ["z"] },
|
|
74
|
+
{ op: "replace", start: at(text, 1), body: ["A"] },
|
|
75
|
+
]);
|
|
90
76
|
assert.equal(r.ok, true);
|
|
91
77
|
if (r.ok) {
|
|
92
|
-
|
|
93
|
-
assert.
|
|
94
|
-
assert.equal(r.newSnapshot.lineHashes.length, 2);
|
|
78
|
+
// new file: [A, b, c, z]; this edit produced line index 0 (A) and 3 (z)
|
|
79
|
+
assert.deepEqual([...r.touchedLines], [0, 3]);
|
|
95
80
|
}
|
|
96
81
|
});
|
|
97
82
|
|
|
98
|
-
test("
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
assert.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
83
|
+
test("touchedLines for append", () => {
|
|
84
|
+
const text = "a\n";
|
|
85
|
+
const r = applyEdits(text, [{ op: "append", body: ["b", "c"] }]);
|
|
86
|
+
assert.equal(r.ok, true);
|
|
87
|
+
if (r.ok) assert.deepEqual([...r.touchedLines], [1, 2]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("touchedLines for delete re-anchors the line that shifted into the gap", () => {
|
|
91
|
+
const text = "a\nb\nc\nd\n";
|
|
92
|
+
const r = applyEdits(text, [{ op: "delete", start: at(text, 2) }]);
|
|
93
|
+
assert.equal(r.ok, true);
|
|
94
|
+
if (r.ok) {
|
|
95
|
+
// new file: [a, c, d]; deleting line 2 (b) shifts c into index 1
|
|
96
|
+
assert.deepEqual([...r.touchedLines], [1]);
|
|
109
97
|
}
|
|
110
98
|
});
|
|
111
99
|
|
|
112
100
|
// --- error paths ---
|
|
113
101
|
|
|
114
|
-
test("
|
|
115
|
-
const s = snap("a\nb\n");
|
|
116
|
-
const r = applyEdits("a\nCHANGED\n", [{ op: "replace", start: ln(s, 1), body: ["x"] }], s);
|
|
117
|
-
assert.equal(r.ok, false);
|
|
118
|
-
if (!r.ok) assert.equal(r.error.kind, "stale");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("anchor hash mismatch rejected (guards against misremembering)", () => {
|
|
102
|
+
test("anchor hash mismatch rejected (line changed)", () => {
|
|
122
103
|
const text = "a\nb\n";
|
|
123
|
-
const
|
|
124
|
-
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }], s);
|
|
104
|
+
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
|
|
125
105
|
assert.equal(r.ok, false);
|
|
126
106
|
if (!r.ok) assert.equal(r.error.kind, "anchor");
|
|
127
107
|
});
|
|
128
108
|
|
|
129
109
|
test("line out of range rejected", () => {
|
|
130
110
|
const text = "a\n";
|
|
131
|
-
const
|
|
132
|
-
|
|
111
|
+
const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
|
|
112
|
+
assert.equal(r.ok, false);
|
|
113
|
+
if (!r.ok) assert.equal(r.error.kind, "anchor");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("anchor mismatch when the cited line's content differs (live verification)", () => {
|
|
117
|
+
// hash for line 2's content, but cited as line 1 → must fail because line 1's live content differs
|
|
118
|
+
const text = "a\nb\n";
|
|
119
|
+
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: computeLineHash(2, "b") }, body: ["x"] }]);
|
|
133
120
|
assert.equal(r.ok, false);
|
|
134
121
|
if (!r.ok) assert.equal(r.error.kind, "anchor");
|
|
135
122
|
});
|
|
136
123
|
|
|
137
124
|
test("reverse-order range rejected", () => {
|
|
138
125
|
const text = "a\nb\nc\n";
|
|
139
|
-
const
|
|
140
|
-
const r = applyEdits(
|
|
141
|
-
text,
|
|
142
|
-
[{ op: "replace", start: ln(s, 3), end: ln(s, 1), body: ["x"] }],
|
|
143
|
-
s,
|
|
144
|
-
);
|
|
126
|
+
const r = applyEdits(text, [{ op: "replace", start: at(text, 3), end: at(text, 1), body: ["x"] }]);
|
|
145
127
|
assert.equal(r.ok, false);
|
|
146
128
|
if (!r.ok) assert.equal(r.error.kind, "range");
|
|
147
129
|
});
|
|
148
130
|
|
|
149
131
|
test("overlapping edits rejected", () => {
|
|
150
132
|
const text = "a\nb\nc\nd\n";
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
text,
|
|
154
|
-
|
|
155
|
-
{ op: "replace", start: ln(s, 2), end: ln(s, 3), body: ["x"] },
|
|
156
|
-
{ op: "replace", start: ln(s, 3), body: ["y"] },
|
|
157
|
-
],
|
|
158
|
-
s,
|
|
159
|
-
);
|
|
133
|
+
const r = applyEdits(text, [
|
|
134
|
+
{ op: "replace", start: at(text, 2), end: at(text, 3), body: ["x"] },
|
|
135
|
+
{ op: "replace", start: at(text, 3), body: ["y"] },
|
|
136
|
+
]);
|
|
160
137
|
assert.equal(r.ok, false);
|
|
161
138
|
if (!r.ok) assert.equal(r.error.kind, "range");
|
|
162
139
|
});
|
|
163
140
|
|
|
164
141
|
test("conflict at the same insertion point rejected", () => {
|
|
165
142
|
const text = "a\nb\n";
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
text,
|
|
169
|
-
|
|
170
|
-
{ op: "insert_after", anchor: ln(s, 1), body: ["x"] },
|
|
171
|
-
{ op: "insert_after", anchor: ln(s, 1), body: ["y"] },
|
|
172
|
-
],
|
|
173
|
-
s,
|
|
174
|
-
);
|
|
143
|
+
const r = applyEdits(text, [
|
|
144
|
+
{ op: "insert_after", anchor: at(text, 1), body: ["x"] },
|
|
145
|
+
{ op: "insert_after", anchor: at(text, 1), body: ["y"] },
|
|
146
|
+
]);
|
|
175
147
|
assert.equal(r.ok, false);
|
|
176
148
|
if (!r.ok) assert.equal(r.error.kind, "range");
|
|
177
149
|
});
|
|
178
150
|
|
|
179
151
|
test("noop (byte-identical body) rejected", () => {
|
|
180
152
|
const text = "a\nb\n";
|
|
181
|
-
const
|
|
182
|
-
const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["a"] }], s);
|
|
153
|
+
const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
|
|
183
154
|
assert.equal(r.ok, false);
|
|
184
155
|
if (!r.ok) assert.equal(r.error.kind, "noop");
|
|
185
156
|
});
|
|
157
|
+
|
|
158
|
+
test("unrelated change elsewhere does NOT block the edit (no global stale check)", () => {
|
|
159
|
+
// read-time text and current text differ at line 3, but we only touch line 1 — must succeed
|
|
160
|
+
const readText = "a\nb\nc\n";
|
|
161
|
+
const currentText = "a\nb\nCHANGED\n";
|
|
162
|
+
const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 1), body: ["A"] }]);
|
|
163
|
+
assert.equal(r.ok, true);
|
|
164
|
+
if (r.ok) assert.equal(r.text, "A\nb\nCHANGED\n");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("CRLF line endings preserved", () => {
|
|
168
|
+
const text = "a\r\nb\r\n";
|
|
169
|
+
const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["A"] }]);
|
|
170
|
+
assert.equal(r.ok, true);
|
|
171
|
+
if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
|
|
172
|
+
});
|
|
173
|
+
|
package/src/core/apply.ts
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure-function applicator: applies edits to
|
|
2
|
+
* Pure-function applicator: applies edits to a file's current text.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Verification is live and surgical: each anchor's hash is recomputed from the
|
|
5
|
+
* CURRENT line content at the cited line number and compared to the cited hash.
|
|
6
|
+
* No snapshot, no global stale check — a line that changed (or was
|
|
7
|
+
* misremembered) fails its own anchor; unchanged lines elsewhere never block
|
|
8
|
+
* the edit.
|
|
9
|
+
*
|
|
10
|
+
* Other strict semantics:
|
|
10
11
|
* - Operation ranges must not overlap (including the same insertion point).
|
|
11
|
-
* - body byte-identical to the
|
|
12
|
-
*
|
|
12
|
+
* - body byte-identical to the whole-file result → `noop` error (guides the
|
|
13
|
+
* model to investigate rather than blindly retry).
|
|
13
14
|
*
|
|
14
15
|
* @module pi-hashline-edit/core
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import type { ApplyResult, Edit,
|
|
18
|
+
import { computeLineHash } from "./hash.ts";
|
|
19
|
+
import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
|
|
20
|
+
import type { Anchor, ApplyResult, Edit, PatchError } from "./types.ts";
|
|
20
21
|
|
|
21
22
|
/** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
|
|
22
23
|
interface SpanOp {
|
|
@@ -25,33 +26,33 @@ interface SpanOp {
|
|
|
25
26
|
newLines: string[];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
/** Verify
|
|
29
|
-
function checkAnchor(
|
|
30
|
-
if (line < 1 || line >
|
|
29
|
+
/** Verify an anchor against the current file: line in range and hash matches the live content. */
|
|
30
|
+
function checkAnchor(lines: readonly string[], line: number, hash: string, hashLen: number): PatchError | null {
|
|
31
|
+
if (line < 1 || line > lines.length) {
|
|
31
32
|
return {
|
|
32
33
|
kind: "anchor",
|
|
33
|
-
message: `line ${line} does not exist (file has ${
|
|
34
|
+
message: `line ${line} does not exist (file has ${lines.length} lines)`,
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
|
-
if (
|
|
37
|
+
if (computeLineHash(line, lines[line - 1], hashLen) !== hash) {
|
|
37
38
|
return {
|
|
38
39
|
kind: "anchor",
|
|
39
|
-
message: `
|
|
40
|
+
message: `line ${line} changed or hash is wrong: re-read to get the current #HASH`,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Translate an Edit into a SpanOp,
|
|
46
|
-
function translateEdit(edit: Edit,
|
|
46
|
+
/** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
|
|
47
|
+
function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): { op: SpanOp } | { error: PatchError } {
|
|
47
48
|
switch (edit.op) {
|
|
48
49
|
case "replace":
|
|
49
50
|
case "delete": {
|
|
50
|
-
const startErr = checkAnchor(
|
|
51
|
+
const startErr = checkAnchor(lines, edit.start.line, edit.start.hash, hashLen);
|
|
51
52
|
if (startErr) return { error: startErr };
|
|
52
53
|
let endLine = edit.start.line;
|
|
53
54
|
if (edit.end) {
|
|
54
|
-
const endErr = checkAnchor(
|
|
55
|
+
const endErr = checkAnchor(lines, edit.end.line, edit.end.hash, hashLen);
|
|
55
56
|
if (endErr) return { error: endErr };
|
|
56
57
|
endLine = edit.end.line;
|
|
57
58
|
}
|
|
@@ -67,17 +68,17 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
|
|
|
67
68
|
};
|
|
68
69
|
}
|
|
69
70
|
case "insert_after": {
|
|
70
|
-
const err = checkAnchor(
|
|
71
|
+
const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
|
|
71
72
|
if (err) return { error: err };
|
|
72
73
|
return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
|
|
73
74
|
}
|
|
74
75
|
case "insert_before": {
|
|
75
|
-
const err = checkAnchor(
|
|
76
|
+
const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
|
|
76
77
|
if (err) return { error: err };
|
|
77
78
|
return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
|
|
78
79
|
}
|
|
79
80
|
case "append": {
|
|
80
|
-
return { op: { lo:
|
|
81
|
+
return { op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
|
|
81
82
|
}
|
|
82
83
|
case "prepend": {
|
|
83
84
|
return { op: { lo: 0, hi: 0, newLines: edit.body } };
|
|
@@ -91,26 +92,21 @@ function maxAffected(op: SpanOp): number {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
|
-
* Apply edits to the
|
|
95
|
+
* Apply edits to `text`. Anchors are verified against the current content; on
|
|
96
|
+
* success `touchedLines` gives the 0-based indices of the new-file lines this
|
|
97
|
+
* edit produced.
|
|
95
98
|
*
|
|
96
|
-
* @param text
|
|
97
|
-
* @param edits
|
|
98
|
-
* @param
|
|
99
|
-
* @returns apply result; on failure returns a structured error
|
|
99
|
+
* @param text current full file text
|
|
100
|
+
* @param edits parsed edit operations
|
|
101
|
+
* @param hashLen hash length used to verify anchors (default 4)
|
|
100
102
|
*/
|
|
101
|
-
export function applyEdits(text: string, edits: Edit[],
|
|
102
|
-
if (text !== snapshot.text) {
|
|
103
|
-
return {
|
|
104
|
-
ok: false,
|
|
105
|
-
error: { kind: "stale", message: "file changed since last read; re-read before editing" },
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
103
|
+
export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResult {
|
|
109
104
|
const lines = splitLines(text);
|
|
105
|
+
const ending = detectLineEnding(text);
|
|
110
106
|
|
|
111
107
|
const ops: SpanOp[] = [];
|
|
112
108
|
for (const edit of edits) {
|
|
113
|
-
const t = translateEdit(edit,
|
|
109
|
+
const t = translateEdit(edit, lines, hashLen);
|
|
114
110
|
if ("error" in t) return { ok: false, error: t.error };
|
|
115
111
|
ops.push(t.op);
|
|
116
112
|
}
|
|
@@ -129,25 +125,37 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
|
|
|
129
125
|
}
|
|
130
126
|
}
|
|
131
127
|
|
|
132
|
-
// Apply back-to-front (lo descending)
|
|
128
|
+
// Apply back-to-front (lo descending) so original lo/hi stay valid
|
|
133
129
|
let result = [...lines];
|
|
134
130
|
for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
|
|
135
131
|
result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
|
|
136
132
|
}
|
|
137
133
|
|
|
138
|
-
const newText = joinLines(result,
|
|
134
|
+
const newText = joinLines(result, ending);
|
|
139
135
|
if (newText === text) {
|
|
140
136
|
return {
|
|
141
137
|
ok: false,
|
|
142
138
|
error: {
|
|
143
139
|
kind: "noop",
|
|
144
|
-
message:
|
|
145
|
-
"edit parsed and applied cleanly but produced no change; body is byte-identical to the target — the bug is elsewhere, re-read first",
|
|
140
|
+
message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
|
|
146
141
|
},
|
|
147
142
|
};
|
|
148
143
|
}
|
|
149
144
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
// touchedLines: new-file indices worth re-anchoring — each produced line,
|
|
146
|
+
// and for a pure delete the line that shifted into the gap (so the model
|
|
147
|
+
// gets a fresh anchor for the shifted region).
|
|
148
|
+
const touched: number[] = [];
|
|
149
|
+
let delta = 0;
|
|
150
|
+
for (const op of sorted) {
|
|
151
|
+
const newLo = op.lo + delta;
|
|
152
|
+
if (op.newLines.length > 0) {
|
|
153
|
+
for (let i = 0; i < op.newLines.length; i++) touched.push(newLo + i);
|
|
154
|
+
} else if (newLo < result.length) {
|
|
155
|
+
touched.push(newLo);
|
|
156
|
+
}
|
|
157
|
+
delta += op.newLines.length - (op.hi - op.lo);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { ok: true, text: newText, changed: true, touchedLines: touched };
|
|
153
161
|
}
|
package/src/core/hash.test.ts
CHANGED
|
@@ -5,26 +5,29 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
|
|
|
5
5
|
const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
|
|
6
6
|
|
|
7
7
|
test("computeLineHash is stable and base32", () => {
|
|
8
|
-
const a = computeLineHash(
|
|
9
|
-
const b = computeLineHash(
|
|
8
|
+
const a = computeLineHash(3, "code", 4);
|
|
9
|
+
const b = computeLineHash(3, "code", 4);
|
|
10
10
|
assert.equal(a, b);
|
|
11
11
|
assert.equal(a.length, 4);
|
|
12
12
|
for (const ch of a) assert.ok(ALLOWED.has(ch), `bad char ${ch}`);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test("
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
assert.notEqual(h1, h2);
|
|
15
|
+
test("different line number → different hash (even for identical content)", () => {
|
|
16
|
+
assert.notEqual(computeLineHash(2, ""), computeLineHash(5, ""));
|
|
17
|
+
assert.notEqual(computeLineHash(1, "}"), computeLineHash(2, "}"));
|
|
19
18
|
});
|
|
20
19
|
|
|
21
|
-
test("
|
|
22
|
-
assert.
|
|
20
|
+
test("different content → different hash", () => {
|
|
21
|
+
assert.notEqual(computeLineHash(1, "a"), computeLineHash(1, "b"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("same (line, content) → same hash", () => {
|
|
25
|
+
assert.equal(computeLineHash(7, "x"), computeLineHash(7, "x"));
|
|
23
26
|
});
|
|
24
27
|
|
|
25
28
|
test("base32 alphabet (without I/L/O/U) in bulk", () => {
|
|
26
29
|
for (let i = 0; i < 2000; i++) {
|
|
27
|
-
const h = computeLineHash(
|
|
30
|
+
const h = computeLineHash(i + 1, `line ${i}`, 4);
|
|
28
31
|
for (const ch of h) assert.ok(ALLOWED.has(ch), `bad char ${ch} in ${h}`);
|
|
29
32
|
}
|
|
30
33
|
});
|
|
@@ -37,10 +40,12 @@ test("hashFileLines empty file", () => {
|
|
|
37
40
|
assert.deepEqual(hashFileLines([]), []);
|
|
38
41
|
});
|
|
39
42
|
|
|
40
|
-
test("hashFileLines
|
|
43
|
+
test("hashFileLines: identical content lines get distinct hashes (no collision, no length bloat)", () => {
|
|
44
|
+
// runs of identical lines — the case neighbor-aware hashing explodes on
|
|
41
45
|
const lines = ["", "", "", "", "", "}", "}", "}", "return", "return", ",", ","];
|
|
42
46
|
const hashes = hashFileLines(lines);
|
|
43
|
-
assert.equal(new Set(hashes).size, hashes.length, "
|
|
47
|
+
assert.equal(new Set(hashes).size, hashes.length, "duplicate hashes");
|
|
48
|
+
for (const h of hashes) assert.equal(h.length, 4, `hash ${h} is not 4 chars`);
|
|
44
49
|
});
|
|
45
50
|
|
|
46
51
|
test("hashFileLines respects the length parameter", () => {
|