@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.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 +71 -15
- package/package.json +1 -1
- package/src/core/apply.test.ts +177 -98
- package/src/core/apply.ts +161 -66
- package/src/core/hash.test.ts +16 -11
- package/src/core/hash.ts +26 -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 +52 -36
- package/src/index.ts +9 -10
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +184 -118
- package/src/pi/execute.test.ts +193 -47
- package/src/pi/grep-tool.ts +335 -0
- package/src/pi/pi.test.ts +6 -18
- package/src/pi/read-tool.ts +27 -17
- package/src/pi/state.ts +5 -59
- 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/src/core/apply.ts
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
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
|
+
* Shifted-anchor recovery: when a cited anchor no longer matches, we rescan
|
|
11
|
+
* ±radius lines for the original content, holding the ORIGINAL line number fixed
|
|
12
|
+
* and re-hashing each candidate's content. On a unique hit the new anchor (with
|
|
13
|
+
* its freshly computed hash) is returned so the caller can retry without a
|
|
14
|
+
* re-read; on several hits they are reported as ambiguous; on none the live
|
|
15
|
+
* content at the cited line is returned to steer a re-read.
|
|
16
|
+
*
|
|
17
|
+
* Batch semantics: all ops are verified against the same current snapshot. If
|
|
18
|
+
* ANY anchor fails, EVERY failure (with recovery) is collected and returned
|
|
19
|
+
* together — nothing is written. This keeps the rescue report and the on-disk
|
|
20
|
+
* file in sync: a partial write would shift lines and invalidate the very
|
|
21
|
+
* recovery info we just returned. Range issues among the surviving ops are
|
|
22
|
+
* deferred until anchors are corrected.
|
|
23
|
+
*
|
|
24
|
+
* Other strict semantics:
|
|
10
25
|
* - Operation ranges must not overlap (including the same insertion point).
|
|
11
|
-
* - body byte-identical to the
|
|
12
|
-
*
|
|
26
|
+
* - body byte-identical to the whole-file result → `noop` error (guides the
|
|
27
|
+
* model to investigate rather than blindly retry).
|
|
13
28
|
*
|
|
14
29
|
* @module pi-hashline-edit/core
|
|
15
30
|
*/
|
|
16
31
|
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import type {
|
|
32
|
+
import { computeLineHash } from "./hash.ts";
|
|
33
|
+
import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
|
|
34
|
+
import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
|
|
20
35
|
|
|
21
36
|
/** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
|
|
22
37
|
interface SpanOp {
|
|
@@ -25,40 +40,94 @@ interface SpanOp {
|
|
|
25
40
|
newLines: string[];
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
/** Default ±line radius for shifted-anchor recovery. */
|
|
44
|
+
const DEFAULT_SHIFT_RADIUS = 15;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verify an anchor against the live content; on mismatch, attempt shifted
|
|
48
|
+
* recovery. Returns null when the anchor matches, otherwise an
|
|
49
|
+
* {@link AnchorFailure} carrying the recovery outcome and the cited line's
|
|
50
|
+
* current snapshot.
|
|
51
|
+
*
|
|
52
|
+
* Recovery holds the ORIGINAL line number fixed and re-hashes each candidate's
|
|
53
|
+
* content: `computeLineHash(citedLine, candidateContent) === citedHash` holds
|
|
54
|
+
* iff the candidate IS the original content (modulo negligible hash collision).
|
|
55
|
+
* A returned candidate's anchor uses the candidate's real line number with a
|
|
56
|
+
* hash computed for that line, so it verifies on retry.
|
|
57
|
+
*/
|
|
58
|
+
function verifyAnchor(
|
|
59
|
+
lines: readonly string[],
|
|
60
|
+
cited: Anchor,
|
|
61
|
+
which: "anchor" | "end",
|
|
62
|
+
opIndex: number,
|
|
63
|
+
op: Edit["op"],
|
|
64
|
+
hashLen: number,
|
|
65
|
+
radius: number,
|
|
66
|
+
): AnchorFailure | null {
|
|
67
|
+
const { line, hash } = cited;
|
|
68
|
+
if (line >= 1 && line <= lines.length && computeLineHash(line, lines[line - 1], hashLen) === hash) {
|
|
69
|
+
return null;
|
|
35
70
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
71
|
+
|
|
72
|
+
// Shifted recovery: scan ±radius (excluding the already-failed cited line).
|
|
73
|
+
const candidates: { line: number; hash: string }[] = [];
|
|
74
|
+
const lo = Math.max(1, line - radius);
|
|
75
|
+
const hi = Math.min(lines.length, line + radius);
|
|
76
|
+
for (let c = lo; c <= hi; c++) {
|
|
77
|
+
if (c === line) continue;
|
|
78
|
+
if (computeLineHash(line, lines[c - 1], hashLen) === hash) {
|
|
79
|
+
candidates.push({ line: c, hash: computeLineHash(c, lines[c - 1], hashLen) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let recovery: AnchorRecovery;
|
|
84
|
+
if (candidates.length === 1) {
|
|
85
|
+
recovery = { kind: "found", newLine: candidates[0].line, newHash: candidates[0].hash };
|
|
86
|
+
} else if (candidates.length > 1) {
|
|
87
|
+
recovery = { kind: "ambiguous", candidates };
|
|
88
|
+
} else {
|
|
89
|
+
recovery = { kind: "none" };
|
|
41
90
|
}
|
|
42
|
-
|
|
91
|
+
|
|
92
|
+
const current =
|
|
93
|
+
line >= 1 && line <= lines.length
|
|
94
|
+
? { hash: computeLineHash(line, lines[line - 1], hashLen), content: lines[line - 1] }
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
return { opIndex, which, op, cited, recovery, current };
|
|
43
98
|
}
|
|
44
99
|
|
|
45
|
-
|
|
46
|
-
|
|
100
|
+
type TranslateResult =
|
|
101
|
+
| { readonly ok: true; readonly op: SpanOp }
|
|
102
|
+
| { readonly ok: false; readonly anchorFailures: AnchorFailure[] }
|
|
103
|
+
| { readonly ok: false; readonly rangeError: string };
|
|
104
|
+
|
|
105
|
+
/** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
|
|
106
|
+
function translateEdit(
|
|
107
|
+
edit: Edit,
|
|
108
|
+
opIndex: number,
|
|
109
|
+
lines: readonly string[],
|
|
110
|
+
hashLen: number,
|
|
111
|
+
radius: number,
|
|
112
|
+
): TranslateResult {
|
|
47
113
|
switch (edit.op) {
|
|
48
114
|
case "replace":
|
|
49
115
|
case "delete": {
|
|
50
|
-
const
|
|
51
|
-
|
|
116
|
+
const failures: AnchorFailure[] = [];
|
|
117
|
+
const startF = verifyAnchor(lines, edit.start, "anchor", opIndex, edit.op, hashLen, radius);
|
|
118
|
+
if (startF) failures.push(startF);
|
|
52
119
|
let endLine = edit.start.line;
|
|
53
120
|
if (edit.end) {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
121
|
+
const endF = verifyAnchor(lines, edit.end, "end", opIndex, edit.op, hashLen, radius);
|
|
122
|
+
if (endF) failures.push(endF);
|
|
56
123
|
endLine = edit.end.line;
|
|
57
124
|
}
|
|
125
|
+
if (failures.length > 0) return { ok: false, anchorFailures: failures };
|
|
58
126
|
if (endLine < edit.start.line) {
|
|
59
|
-
return {
|
|
127
|
+
return { ok: false, rangeError: `range ${edit.start.line}..${endLine} ends before it starts` };
|
|
60
128
|
}
|
|
61
129
|
return {
|
|
130
|
+
ok: true,
|
|
62
131
|
op: {
|
|
63
132
|
lo: edit.start.line - 1,
|
|
64
133
|
hi: endLine,
|
|
@@ -67,20 +136,20 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
|
|
|
67
136
|
};
|
|
68
137
|
}
|
|
69
138
|
case "insert_after": {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
|
|
139
|
+
const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
|
|
140
|
+
if (f) return { ok: false, anchorFailures: [f] };
|
|
141
|
+
return { ok: true, op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
|
|
73
142
|
}
|
|
74
143
|
case "insert_before": {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
|
|
144
|
+
const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
|
|
145
|
+
if (f) return { ok: false, anchorFailures: [f] };
|
|
146
|
+
return { ok: true, op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
|
|
78
147
|
}
|
|
79
148
|
case "append": {
|
|
80
|
-
return { op: { lo:
|
|
149
|
+
return { ok: true, op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
|
|
81
150
|
}
|
|
82
151
|
case "prepend": {
|
|
83
|
-
return { op: { lo: 0, hi: 0, newLines: edit.body } };
|
|
152
|
+
return { ok: true, op: { lo: 0, hi: 0, newLines: edit.body } };
|
|
84
153
|
}
|
|
85
154
|
}
|
|
86
155
|
}
|
|
@@ -91,28 +160,42 @@ function maxAffected(op: SpanOp): number {
|
|
|
91
160
|
}
|
|
92
161
|
|
|
93
162
|
/**
|
|
94
|
-
* Apply edits to the
|
|
163
|
+
* Apply edits to `text`. Anchors are verified against the current content; on
|
|
164
|
+
* success `touchedLines` gives the 0-based indices of the new-file lines this
|
|
165
|
+
* edit produced. On any anchor mismatch, all failures (with shifted recovery)
|
|
166
|
+
* are collected and returned together — nothing is written.
|
|
95
167
|
*
|
|
96
|
-
* @param text
|
|
97
|
-
* @param edits
|
|
98
|
-
* @param
|
|
99
|
-
* @
|
|
168
|
+
* @param text current full file text
|
|
169
|
+
* @param edits parsed edit operations
|
|
170
|
+
* @param hashLen hash length used to verify anchors (default 4)
|
|
171
|
+
* @param shiftRadius ±line radius for shifted-anchor recovery (default 15; 0 disables rescue)
|
|
100
172
|
*/
|
|
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
|
-
|
|
173
|
+
export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius = DEFAULT_SHIFT_RADIUS): ApplyResult {
|
|
109
174
|
const lines = splitLines(text);
|
|
175
|
+
const ending = detectLineEnding(text);
|
|
110
176
|
|
|
111
177
|
const ops: SpanOp[] = [];
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
178
|
+
const anchorFailures: AnchorFailure[] = [];
|
|
179
|
+
let rangeError: string | null = null;
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < edits.length; i++) {
|
|
182
|
+
const t = translateEdit(edits[i], i, lines, hashLen, shiftRadius);
|
|
183
|
+
if (t.ok) {
|
|
184
|
+
ops.push(t.op);
|
|
185
|
+
} else if ("anchorFailures" in t) {
|
|
186
|
+
anchorFailures.push(...t.anchorFailures);
|
|
187
|
+
} else if (rangeError === null) {
|
|
188
|
+
rangeError = t.rangeError;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Anchor failures take priority: the model must fix anchors first; range
|
|
193
|
+
// issues among surviving ops are premature until anchors are corrected.
|
|
194
|
+
if (anchorFailures.length > 0) {
|
|
195
|
+
return { ok: false, failure: { kind: "anchor", failures: anchorFailures } };
|
|
196
|
+
}
|
|
197
|
+
if (rangeError !== null) {
|
|
198
|
+
return { ok: false, failure: { kind: "range", message: rangeError } };
|
|
116
199
|
}
|
|
117
200
|
|
|
118
201
|
// Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
|
|
@@ -121,7 +204,7 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
|
|
|
121
204
|
if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
|
|
122
205
|
return {
|
|
123
206
|
ok: false,
|
|
124
|
-
|
|
207
|
+
failure: {
|
|
125
208
|
kind: "range",
|
|
126
209
|
message: `overlapping edits near line ${sorted[k].lo + 1}; issue one edit per range`,
|
|
127
210
|
},
|
|
@@ -129,25 +212,37 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
|
|
|
129
212
|
}
|
|
130
213
|
}
|
|
131
214
|
|
|
132
|
-
// Apply back-to-front (lo descending)
|
|
215
|
+
// Apply back-to-front (lo descending) so original lo/hi stay valid
|
|
133
216
|
let result = [...lines];
|
|
134
217
|
for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
|
|
135
218
|
result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
|
|
136
219
|
}
|
|
137
220
|
|
|
138
|
-
const newText = joinLines(result,
|
|
221
|
+
const newText = joinLines(result, ending);
|
|
139
222
|
if (newText === text) {
|
|
140
223
|
return {
|
|
141
224
|
ok: false,
|
|
142
|
-
|
|
225
|
+
failure: {
|
|
143
226
|
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",
|
|
227
|
+
message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
|
|
146
228
|
},
|
|
147
229
|
};
|
|
148
230
|
}
|
|
149
231
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
232
|
+
// touchedLines: new-file indices worth re-anchoring — each produced line,
|
|
233
|
+
// and for a pure delete the line that shifted into the gap (so the model
|
|
234
|
+
// gets a fresh anchor for the shifted region).
|
|
235
|
+
const touched: number[] = [];
|
|
236
|
+
let delta = 0;
|
|
237
|
+
for (const op of sorted) {
|
|
238
|
+
const newLo = op.lo + delta;
|
|
239
|
+
if (op.newLines.length > 0) {
|
|
240
|
+
for (let i = 0; i < op.newLines.length; i++) touched.push(newLo + i);
|
|
241
|
+
} else if (newLo < result.length) {
|
|
242
|
+
touched.push(newLo);
|
|
243
|
+
}
|
|
244
|
+
delta += op.newLines.length - (op.hi - op.lo);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { ok: true, text: newText, changed: true, touchedLines: touched };
|
|
153
248
|
}
|
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", () => {
|
package/src/core/hash.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-line
|
|
2
|
+
* Per-line content hash.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The hash mixes the 1-based line number into the line content, so every line
|
|
5
|
+
* gets a unique hash by construction — line numbers are unique, therefore no
|
|
6
|
+
* in-file collision is possible, and no length extension / fallback is ever
|
|
7
|
+
* needed.
|
|
8
|
+
*
|
|
9
|
+
* Why line + content (not content alone, not content + neighbors):
|
|
10
|
+
*
|
|
11
|
+
* - The line number is the address; the hash is a checksum that the line at
|
|
12
|
+
* that address is still what was read. Mixing the number in makes the hash a
|
|
13
|
+
* pure fingerprint of (position, content): it changes only when the line's
|
|
14
|
+
* own content changes, never when a neighbor changes. (A neighbor-aware hash
|
|
15
|
+
* would change an unchanged line's hash when an adjacent line is edited — a
|
|
16
|
+
* spurious dependency with no benefit under this design's position-fixed
|
|
17
|
+
* apply.)
|
|
18
|
+
* - Content alone would leave identical lines (blank lines, `}`) sharing a
|
|
19
|
+
* hash; mixing the line number disambiguates them for free.
|
|
9
20
|
*
|
|
10
21
|
* @module pi-hashline-edit/core
|
|
11
22
|
*/
|
|
@@ -38,62 +49,20 @@ function toBase32(n: number, len: number): string {
|
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
|
-
* Compute the
|
|
52
|
+
* Compute the hash of a single line from its 1-based line number and content.
|
|
42
53
|
*
|
|
43
|
-
* @param
|
|
44
|
-
* @param
|
|
45
|
-
* @param
|
|
46
|
-
* @param len hash length (default 4, 20 bits ≈ 1M values)
|
|
54
|
+
* @param line 1-based line number
|
|
55
|
+
* @param content the line's content (no line terminator)
|
|
56
|
+
* @param len hash length (default 4, 20 bits ≈ 1M values)
|
|
47
57
|
*/
|
|
48
|
-
export function computeLineHash(
|
|
49
|
-
|
|
50
|
-
return toBase32(h, len);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Compute raw per-line hashes (no collision handling). */
|
|
54
|
-
function rawHashes(lines: readonly string[], len: number): string[] {
|
|
55
|
-
return lines.map((line, i) =>
|
|
56
|
-
computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Find hash values that appear more than once. */
|
|
61
|
-
function duplicatedHashes(hashes: readonly string[]): Set<string> {
|
|
62
|
-
const counts = new Map<string, number>();
|
|
63
|
-
for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
|
|
64
|
-
return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Fallback: force uniqueness with an index suffix (theoretically unreachable under context-aware hashing). */
|
|
68
|
-
function forceUnique(hashes: string[]): string[] {
|
|
69
|
-
const seen = new Map<string, number>();
|
|
70
|
-
return hashes.map((h) => {
|
|
71
|
-
const c = seen.get(h) ?? 0;
|
|
72
|
-
seen.set(h, c + 1);
|
|
73
|
-
return c === 0 ? h : `${h}${c}`;
|
|
74
|
-
});
|
|
58
|
+
export function computeLineHash(line: number, content: string, len = 4): string {
|
|
59
|
+
return toBase32(fnv1a32(`${line}\n${content}`), len);
|
|
75
60
|
}
|
|
76
61
|
|
|
77
62
|
/**
|
|
78
|
-
* Compute per-line hashes for
|
|
79
|
-
*
|
|
80
|
-
* (per-file zero-collision guarantee).
|
|
81
|
-
*
|
|
82
|
-
* Design rationale: context-aware hashing already drives the collision
|
|
83
|
-
* probability near zero; this extension is a defensive fallback guaranteeing
|
|
84
|
-
* apply never mislocates due to hash ambiguity.
|
|
63
|
+
* Compute per-line hashes for a file. Unique by construction — the 1-based line
|
|
64
|
+
* number is part of each hash, so two identical content lines always differ.
|
|
85
65
|
*/
|
|
86
66
|
export function hashFileLines(lines: readonly string[], len = 4): string[] {
|
|
87
|
-
|
|
88
|
-
let hashes = rawHashes(lines, len);
|
|
89
|
-
for (let curLen = len; curLen <= len + 4; curLen++) {
|
|
90
|
-
const dups = duplicatedHashes(hashes);
|
|
91
|
-
if (dups.size === 0) return hashes;
|
|
92
|
-
hashes = hashes.map((h, i) =>
|
|
93
|
-
dups.has(h)
|
|
94
|
-
? computeLineHash(lines[i - 1] ?? "", lines[i] ?? "", lines[i + 1] ?? "", curLen + 1)
|
|
95
|
-
: h,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
return forceUnique(hashes);
|
|
67
|
+
return lines.map((content, i) => computeLineHash(i + 1, content, len));
|
|
99
68
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -9,9 +9,5 @@
|
|
|
9
9
|
|
|
10
10
|
export * from "./types.ts";
|
|
11
11
|
export { computeLineHash, hashFileLines } from "./hash.ts";
|
|
12
|
-
export { splitLines, joinLines,
|
|
13
|
-
export type { AnchorVerifyResult } from "./snapshot.ts";
|
|
14
|
-
export { parsePatch } from "./parse.ts";
|
|
15
|
-
export type { ParseResult } from "./parse.ts";
|
|
12
|
+
export { splitLines, joinLines, detectLineEnding } from "./lines.ts";
|
|
16
13
|
export { applyEdits } from "./apply.ts";
|
|
17
|
-
export { buildDiff } from "./diff.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { splitLines, joinLines, detectLineEnding } from "./lines.ts";
|
|
4
|
+
|
|
5
|
+
test("splitLines edge cases", () => {
|
|
6
|
+
assert.deepEqual(splitLines(""), []);
|
|
7
|
+
assert.deepEqual(splitLines("a"), ["a"]);
|
|
8
|
+
assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
|
|
9
|
+
assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
|
|
10
|
+
assert.deepEqual(splitLines("a\n\n"), ["a", ""]);
|
|
11
|
+
assert.deepEqual(splitLines("\n"), [""]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("splitLines strips CRLF \\r", () => {
|
|
15
|
+
assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
|
|
16
|
+
assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("joinLines restores line endings", () => {
|
|
20
|
+
assert.equal(joinLines(["a", "b"]), "a\nb\n");
|
|
21
|
+
assert.equal(joinLines([]), "");
|
|
22
|
+
assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
|
|
23
|
+
assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("detectLineEnding", () => {
|
|
27
|
+
assert.equal(detectLineEnding("a\nb\n"), "lf");
|
|
28
|
+
assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
|
|
29
|
+
assert.equal(detectLineEnding("a\nb\r\nc\n"), "crlf");
|
|
30
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line text helpers: split/join with CRLF normalization and line-ending
|
|
3
|
+
* detection.
|
|
4
|
+
*
|
|
5
|
+
* CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
|
|
6
|
+
* clean lines, matching the `\r`-free content the model copies from the
|
|
7
|
+
* display); detectLineEnding records the original ending so joinLines can
|
|
8
|
+
* restore it — guaranteeing a CRLF file keeps its endings after edit.
|
|
9
|
+
*
|
|
10
|
+
* @module pi-hashline-edit/core
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LineEnding } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split text into lines, stripping the trailing `\r` of each line (CRLF
|
|
17
|
+
* normalization, so hashes are based on clean lines).
|
|
18
|
+
*
|
|
19
|
+
* Convention: a trailing newline is treated as the terminator of the last line,
|
|
20
|
+
* not as producing an extra empty trailing line.
|
|
21
|
+
* - `"a\nb\n"` → `["a", "b"]`
|
|
22
|
+
* - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
|
|
23
|
+
* - `"a\n\n"` → `["a", ""]`
|
|
24
|
+
* - `""` → `[]`
|
|
25
|
+
*/
|
|
26
|
+
export function splitLines(text: string): string[] {
|
|
27
|
+
if (text === "") return [];
|
|
28
|
+
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
29
|
+
return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
|
|
33
|
+
export function detectLineEnding(text: string): LineEnding {
|
|
34
|
+
return text.includes("\r\n") ? "crlf" : "lf";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
|
|
38
|
+
export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
|
|
39
|
+
if (lines.length === 0) return "";
|
|
40
|
+
const sep = ending === "crlf" ? "\r\n" : "\n";
|
|
41
|
+
return lines.join(sep) + sep;
|
|
42
|
+
}
|