pi-hashline-edit-pro 0.16.9 → 0.16.11
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/index.ts +4 -2
- package/package.json +1 -1
- package/src/constants.ts +5 -0
- package/src/fs-write.ts +20 -8
- package/src/hash-store.ts +20 -5
- package/src/hashline/hash.ts +3 -0
- package/src/hashline/parse.ts +2 -4
- package/src/hashline/resolve.ts +70 -71
- package/src/replace-normalize.ts +6 -9
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { regReplaceFlat } from "./src/replace-flat";
|
|
|
7
7
|
import { regReplaceUndo } from "./src/replace-undo";
|
|
8
8
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
9
9
|
import { toLF, stripBOM } from "./src/replace-diff";
|
|
10
|
+
import { resolveTarget } from "./src/fs-write";
|
|
10
11
|
import { visLines } from "./src/utils";
|
|
11
12
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
12
13
|
import {
|
|
@@ -88,13 +89,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
88
89
|
|
|
89
90
|
try {
|
|
90
91
|
const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
|
|
91
|
-
const
|
|
92
|
+
const resolvedPath = await resolveTarget(absolutePath);
|
|
93
|
+
const content = await readFile(resolvedPath, "utf-8");
|
|
92
94
|
const { text: rawContent } = stripBOM(content);
|
|
93
95
|
const normalized = toLF(rawContent);
|
|
94
96
|
|
|
95
97
|
if (visLines(normalized).length === 0) return;
|
|
96
98
|
|
|
97
|
-
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, undefined,
|
|
99
|
+
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, undefined, resolvedPath);
|
|
98
100
|
|
|
99
101
|
return {
|
|
100
102
|
content: [
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -3,3 +3,8 @@ export const SNIFF_BYTES = 8192;
|
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
|
|
5
5
|
export const MAX_HASH_LINES = 1_000_000;
|
|
6
|
+
export const MAX_HASH_RETRIES = 262_144;
|
|
7
|
+
|
|
8
|
+
export const CONTENT_LINES_NOT_STRING_MSG =
|
|
9
|
+
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
10
|
+
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
|
package/src/fs-write.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readlink,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
copyFile,
|
|
11
12
|
} from "fs/promises";
|
|
12
13
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
13
14
|
import { errCode } from "./validation";
|
|
@@ -108,6 +109,17 @@ export async function writeAtomic(
|
|
|
108
109
|
await tempHandle.close();
|
|
109
110
|
await rename(tempPath, targetPath);
|
|
110
111
|
} catch (error: unknown) {
|
|
112
|
+
if (errCode(error) === "EXDEV") {
|
|
113
|
+
try {
|
|
114
|
+
await tempHandle.close();
|
|
115
|
+
await copyFile(tempPath, targetPath);
|
|
116
|
+
await rm(tempPath, { force: true });
|
|
117
|
+
return;
|
|
118
|
+
} catch {
|
|
119
|
+
try { await rm(tempPath, { force: true }); } catch {}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
111
123
|
try { await rm(tempPath, { force: true }); } catch {}
|
|
112
124
|
throw error;
|
|
113
125
|
}
|
package/src/hash-store.ts
CHANGED
|
@@ -3,7 +3,16 @@ import { stat } from "fs/promises";
|
|
|
3
3
|
import { hashStorePath, hashStoreDir } from "./paths";
|
|
4
4
|
import { errCode } from "./validation";
|
|
5
5
|
import { writeAtomic } from "./fs-write";
|
|
6
|
-
|
|
6
|
+
function isValidSnapshot(value: unknown): value is FileSnapshot {
|
|
7
|
+
if (typeof value !== "object" || value === null) return false;
|
|
8
|
+
const v = value as Record<string, unknown>;
|
|
9
|
+
if (typeof v.content !== "string") return false;
|
|
10
|
+
if (!Array.isArray(v.hashes)) return false;
|
|
11
|
+
for (const h of v.hashes) {
|
|
12
|
+
if (typeof h !== "string") return false;
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
7
16
|
export interface FileSnapshot {
|
|
8
17
|
content: string;
|
|
9
18
|
hashes: string[];
|
|
@@ -18,10 +27,16 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
18
27
|
try {
|
|
19
28
|
const content = await readFile(hashStorePath(), "utf-8");
|
|
20
29
|
const parsed = JSON.parse(content) as Partial<HashStore>;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
const raw = parsed.snapshots;
|
|
31
|
+
const snapshots: Record<string, FileSnapshot> = {};
|
|
32
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
33
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
34
|
+
if (isValidSnapshot(value)) {
|
|
35
|
+
snapshots[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { version: 1, snapshots };
|
|
25
40
|
} catch (error: unknown) {
|
|
26
41
|
if (errCode(error) !== "ENOENT") {
|
|
27
42
|
console.error("Hash store corrupted, creating fresh store:", error);
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import xxhash from "xxhash-wasm";
|
|
2
2
|
import * as Diff from "diff";
|
|
3
3
|
import { loadHashStore, saveHashStore } from "../hash-store";
|
|
4
|
+
import { MAX_HASH_RETRIES } from "../constants";
|
|
4
5
|
|
|
5
6
|
export const HASH_LEN = 3;
|
|
6
7
|
export const ANCHOR_LEN = HASH_LEN;
|
|
@@ -79,6 +80,7 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
79
80
|
let retry = 0;
|
|
80
81
|
while (assigned.has(hash)) {
|
|
81
82
|
retry++;
|
|
83
|
+
if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
|
|
82
84
|
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
83
85
|
}
|
|
84
86
|
assigned.add(hash);
|
|
@@ -170,6 +172,7 @@ function mapStableHashes(
|
|
|
170
172
|
let hash = h2s(xxh32(c));
|
|
171
173
|
while (used.has(hash)) {
|
|
172
174
|
retry++;
|
|
175
|
+
if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
|
|
173
176
|
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
174
177
|
}
|
|
175
178
|
used.add(hash);
|
package/src/hashline/parse.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
HL_PREFIX_PLUS_RE,
|
|
6
6
|
DIFF_MINUS_RE,
|
|
7
7
|
} from "./hash";
|
|
8
|
+
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
8
9
|
|
|
9
10
|
export type Anchor = { hash: string };
|
|
10
11
|
|
|
@@ -58,10 +59,7 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
58
59
|
export function parseText(edit: string[] | string | null): string[] {
|
|
59
60
|
if (edit === null) return [];
|
|
60
61
|
if (typeof edit === "string") {
|
|
61
|
-
throw new Error(
|
|
62
|
-
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
63
|
-
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
|
|
64
|
-
);
|
|
62
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
65
63
|
}
|
|
66
64
|
assertNoPrefixes(edit);
|
|
67
65
|
return edit;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { abortIf } from "../runtime";
|
|
|
2
2
|
import { rejectUnknownFields } from "../utils";
|
|
3
3
|
import { HL_BARE_PREFIX_RE } from "./hash";
|
|
4
4
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
5
|
+
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
5
6
|
|
|
6
7
|
export type RAnchor = {
|
|
7
8
|
line: number;
|
|
@@ -25,7 +26,6 @@ export interface BDupWarn {
|
|
|
25
26
|
kind: "trailing" | "leading";
|
|
26
27
|
survivingLineContent: string;
|
|
27
28
|
survivingLineIndex: number;
|
|
28
|
-
occurrence: number;
|
|
29
29
|
replacementLineContent: string;
|
|
30
30
|
editIndex: number;
|
|
31
31
|
}
|
|
@@ -48,16 +48,12 @@ export type HTEdit = {
|
|
|
48
48
|
hash_range_inclusive: [string, string];
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function resAnchorFromMap(
|
|
52
52
|
ref: Anchor,
|
|
53
|
-
|
|
54
|
-
fileHashes: string[],
|
|
53
|
+
hashIndex: Map<string, number[]>,
|
|
55
54
|
): RAnchor | HMismatch {
|
|
56
|
-
const hashMatches
|
|
57
|
-
|
|
58
|
-
if (fileHashes[i] === ref.hash) hashMatches.push(i + 1);
|
|
59
|
-
}
|
|
60
|
-
if (hashMatches.length === 0) {
|
|
55
|
+
const hashMatches = hashIndex.get(ref.hash);
|
|
56
|
+
if (!hashMatches || hashMatches.length === 0) {
|
|
61
57
|
return { ref, kind: "not_found" };
|
|
62
58
|
}
|
|
63
59
|
if (hashMatches.length === 1) {
|
|
@@ -157,10 +153,7 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
|
|
|
157
153
|
if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
|
|
158
154
|
const val = edit.content_lines;
|
|
159
155
|
if (typeof val === "string") {
|
|
160
|
-
throw new Error(
|
|
161
|
-
`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
162
|
-
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
|
|
163
|
-
);
|
|
156
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
164
157
|
}
|
|
165
158
|
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
|
|
166
159
|
}
|
|
@@ -238,24 +231,22 @@ export function descEdit(edit: RHEdit): string {
|
|
|
238
231
|
}
|
|
239
232
|
|
|
240
233
|
function checkBoundaryDup(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
editIndex: number,
|
|
234
|
+
adjacentLine: string | undefined,
|
|
235
|
+
replacementEdge: string | undefined,
|
|
236
|
+
kind: "trailing" | "leading",
|
|
237
|
+
survivingLineIndex: number,
|
|
238
|
+
editIndex: number,
|
|
247
239
|
): BDupWarn | null {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
240
|
+
if (
|
|
241
|
+
adjacentLine === undefined ||
|
|
242
|
+
replacementEdge === undefined ||
|
|
243
|
+
replacementEdge.length === 0 ||
|
|
244
|
+
replacementEdge !== adjacentLine
|
|
245
|
+
) return null;
|
|
254
246
|
return {
|
|
255
247
|
kind,
|
|
256
248
|
survivingLineContent: adjacentLine,
|
|
257
249
|
survivingLineIndex,
|
|
258
|
-
occurrence: fileLines.slice(0, survivingLineIndex).filter((l) => l === adjacentLine).length,
|
|
259
250
|
replacementLineContent: replacementEdge,
|
|
260
251
|
editIndex,
|
|
261
252
|
};
|
|
@@ -263,54 +254,62 @@ function checkBoundaryDup(
|
|
|
263
254
|
|
|
264
255
|
|
|
265
256
|
export function valEdits(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
257
|
+
edits: HEdit[],
|
|
258
|
+
fileLines: string[],
|
|
259
|
+
fileHashes: string[],
|
|
260
|
+
warnings: string[],
|
|
261
|
+
signal: AbortSignal | undefined,
|
|
271
262
|
): { resolved: RHEdit[]; mismatches: HMismatch[]; boundaryWarnings: BDupWarn[] } {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const tryResolve = (ref: Anchor): RAnchor | undefined => {
|
|
278
|
-
const result = resAnchor(ref, fileLines, fileHashes);
|
|
279
|
-
if ("kind" in result) {
|
|
280
|
-
mismatches.push(result);
|
|
281
|
-
return undefined;
|
|
282
|
-
}
|
|
283
|
-
return result;
|
|
284
|
-
};
|
|
263
|
+
assertAligned(fileLines, fileHashes, "valEdits");
|
|
264
|
+
const resolved: RHEdit[] = [];
|
|
265
|
+
const mismatches: HMismatch[] = [];
|
|
266
|
+
const boundaryWarnings: BDupWarn[] = [];
|
|
285
267
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
268
|
+
const hashIndex = new Map<string, number[]>();
|
|
269
|
+
for (let i = 0; i < fileHashes.length; i++) {
|
|
270
|
+
const h = fileHashes[i]!;
|
|
271
|
+
const list = hashIndex.get(h) ?? [];
|
|
272
|
+
list.push(i + 1);
|
|
273
|
+
hashIndex.set(h, list);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const tryResolve = (ref: Anchor): RAnchor | undefined => {
|
|
277
|
+
const result = resAnchorFromMap(ref, hashIndex);
|
|
278
|
+
if ("kind" in result) {
|
|
279
|
+
mismatches.push(result);
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const edit of edits) {
|
|
286
|
+
abortIf(signal);
|
|
287
|
+
const startResolved = tryResolve(edit.hash_range_inclusive[0]);
|
|
288
|
+
const endResolved = tryResolve(edit.hash_range_inclusive[1]);
|
|
289
|
+
if (!startResolved || !endResolved) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (startResolved.line > endResolved.line) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_inclusive[0].hash} and ${edit.hash_range_inclusive[1].hash}).`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const endLine = endResolved.line;
|
|
298
|
+
const nextLine = fileLines[endLine];
|
|
299
|
+
const replacementLastLine = edit.content_lines.at(-1);
|
|
300
|
+
const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, resolved.length);
|
|
301
|
+
if (trailing) boundaryWarnings.push(trailing);
|
|
302
|
+
const prevLine = fileLines[startResolved.line - 2];
|
|
303
|
+
const replacementFirstLine = edit.content_lines[0];
|
|
304
|
+
const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, resolved.length);
|
|
305
|
+
if (leading) boundaryWarnings.push(leading);
|
|
306
|
+
resolved.push({
|
|
307
|
+
content_lines: edit.content_lines,
|
|
308
|
+
hash_range_inclusive: [startResolved, endResolved],
|
|
309
|
+
});
|
|
310
|
+
}
|
|
312
311
|
|
|
313
|
-
|
|
312
|
+
return { resolved, mismatches, boundaryWarnings };
|
|
314
313
|
}
|
|
315
314
|
|
|
316
315
|
export { warnUnicodeEsc };
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { isRec, has } from "./utils";
|
|
2
|
-
|
|
2
|
+
import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
|
|
3
3
|
function assertContentLinesNotString(
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
value: unknown,
|
|
5
|
+
label: string,
|
|
6
6
|
): void {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
|
|
11
|
-
);
|
|
12
|
-
}
|
|
7
|
+
if (typeof value === "string") {
|
|
8
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
9
|
+
}
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
export function normalizeFilePath(record: Record<string, unknown>): void {
|