pi-hashline-edit-pro 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +21 -21
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/replace.md +18 -18
- package/src/constants.ts +6 -6
- package/src/file-kind.ts +24 -24
- package/src/file-reader.ts +48 -0
- package/src/fs-write.ts +14 -13
- package/src/hashline/apply.ts +75 -83
- package/src/hashline/hash.ts +37 -51
- package/src/hashline/index.ts +26 -30
- package/src/hashline/parse.ts +18 -22
- package/src/hashline/resolve.ts +73 -88
- package/src/path-utils.ts +3 -3
- package/src/prompts.ts +3 -3
- package/src/read.ts +34 -37
- package/src/replace-diff.ts +13 -121
- package/src/replace-normalize.ts +4 -4
- package/src/replace-render.ts +45 -47
- package/src/replace-response.ts +34 -44
- package/src/replace.ts +109 -134
- package/src/runtime.ts +1 -1
- package/src/snapshot.ts +6 -6
- package/src/utils.ts +20 -6
- package/src/validation.ts +9 -9
package/index.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
3
|
import { join, isAbsolute } from "path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import { lineHashes, initHasher, fmtRegion } from "./src/hashline";
|
|
5
|
+
import { regReplace } from "./src/replace";
|
|
6
|
+
import { regRead } from "./src/read";
|
|
7
|
+
import { toLF } from "./src/replace-diff";
|
|
8
|
+
import { visLines } from "./src/utils";
|
|
9
|
+
import { AUTO_READ_MAX } from "./src/constants";
|
|
10
10
|
|
|
11
11
|
export default function (pi: ExtensionAPI): void {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
regRead(pi);
|
|
13
|
+
regReplace(pi);
|
|
14
14
|
|
|
15
15
|
pi.on("session_start", async (_event, ctx) => {
|
|
16
16
|
const active = pi.getActiveTools();
|
|
17
17
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
18
|
-
await
|
|
18
|
+
await initHasher();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
22
|
-
let
|
|
22
|
+
let autoRead = autoReadValue === "1" || autoReadValue === "true";
|
|
23
23
|
|
|
24
24
|
pi.registerCommand("toggle-auto-read", {
|
|
25
25
|
description: "Toggle automatic hashline anchors after write operations",
|
|
26
26
|
handler: async (_args, ctx) => {
|
|
27
|
-
|
|
28
|
-
const state =
|
|
27
|
+
autoRead = !autoRead;
|
|
28
|
+
const state = autoRead ? "enabled" : "disabled";
|
|
29
29
|
ctx.ui.notify(`Auto-read after write: ${state}`, "info");
|
|
30
30
|
},
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
pi.on("tool_result", async (event, ctx) => {
|
|
34
|
-
if (!
|
|
34
|
+
if (!autoRead) return;
|
|
35
35
|
if (event.toolName !== "write" || event.isError) return;
|
|
36
36
|
|
|
37
37
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
@@ -41,20 +41,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
41
41
|
const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
|
|
42
42
|
const content = await readFile(absolutePath, "utf-8");
|
|
43
43
|
|
|
44
|
-
const normalized =
|
|
45
|
-
const visibleLines =
|
|
44
|
+
const normalized = toLF(content);
|
|
45
|
+
const visibleLines = visLines(normalized);
|
|
46
46
|
|
|
47
47
|
if (visibleLines.length === 0) return;
|
|
48
48
|
|
|
49
|
-
const truncated = visibleLines.length >
|
|
50
|
-
const displayLines = truncated ? visibleLines.slice(0,
|
|
49
|
+
const truncated = visibleLines.length > AUTO_READ_MAX;
|
|
50
|
+
const displayLines = truncated ? visibleLines.slice(0, AUTO_READ_MAX) : visibleLines;
|
|
51
51
|
|
|
52
|
-
const hashes =
|
|
52
|
+
const hashes = lineHashes(normalized);
|
|
53
53
|
const selectedHashes = hashes.slice(0, displayLines.length);
|
|
54
|
-
const hashlineOutput =
|
|
54
|
+
const hashlineOutput = fmtRegion(selectedHashes, displayLines);
|
|
55
55
|
|
|
56
56
|
const paginationHint = truncated
|
|
57
|
-
? `\n\n[Showing lines 1-${
|
|
57
|
+
? `\n\n[Showing lines 1-${AUTO_READ_MAX} of ${visibleLines.length}. Use offset=${AUTO_READ_MAX + 1} to continue.]`
|
|
58
58
|
: "";
|
|
59
59
|
|
|
60
60
|
if (hashlineOutput) {
|
|
@@ -75,4 +75,4 @@ export default function (pi: ExtensionAPI): void {
|
|
|
75
75
|
ctx.ui.notify("Hashline Edit mode active", "info");
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
-
}
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
|
|
2
2
|
- After a successful `replace`, the response text is empty (warnings only). Call `read` to get fresh anchors for follow-up edits.
|
|
3
|
-
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH from each line into `
|
|
3
|
+
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH from each line into `hash_range_incl`, and retry.
|
package/prompts/replace.md
CHANGED
|
@@ -13,10 +13,10 @@ read({ path: "src/main.ts" })
|
|
|
13
13
|
// VRW│const z = 3;
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
2. Copy the 3-character HASH (before `│`) into `
|
|
16
|
+
2. Copy the 3-character HASH (before `│`) into `hash_range_incl`:
|
|
17
17
|
```json
|
|
18
18
|
{ "path": "src/main.ts", "edits": [
|
|
19
|
-
{ "
|
|
19
|
+
{ "hash_range_incl": ["MQX", "MQX"], "new_lines": ["const x = 99;"] }
|
|
20
20
|
] }
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -25,14 +25,14 @@ Examples:
|
|
|
25
25
|
1. Single line replace:
|
|
26
26
|
```json
|
|
27
27
|
{ "path": "src/main.ts", "edits": [
|
|
28
|
-
{ "
|
|
28
|
+
{ "hash_range_incl": ["MQX", "MQX"], "new_lines": ["const x = 1;"] }
|
|
29
29
|
] }
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
2. Range replace (3 lines → 3 new lines):
|
|
33
33
|
```json
|
|
34
34
|
{ "path": "src/main.ts", "edits": [
|
|
35
|
-
{ "
|
|
35
|
+
{ "hash_range_incl": ["ZPM", "VRW"], "new_lines": [
|
|
36
36
|
"function greet(name) {",
|
|
37
37
|
" return `Hello, ${name}`;",
|
|
38
38
|
"}"
|
|
@@ -43,8 +43,8 @@ Examples:
|
|
|
43
43
|
3. Multiple regions in one call (delete two non-adjacent ranges):
|
|
44
44
|
```json
|
|
45
45
|
{ "path": "src/server.ts", "edits": [
|
|
46
|
-
{ "
|
|
47
|
-
{ "
|
|
46
|
+
{ "hash_range_incl": ["aB3", "xY7"], "new_lines": [] },
|
|
47
|
+
{ "hash_range_incl": ["MQX", "ZPM"], "new_lines": [] }
|
|
48
48
|
] }
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -52,38 +52,38 @@ Examples:
|
|
|
52
52
|
|
|
53
53
|
Wrong:
|
|
54
54
|
```json
|
|
55
|
-
{ "
|
|
55
|
+
{ "hash_range_incl": ["F4T", "F4T"], "new_lines": ["F4T│import { x } from \"./x\";"] }
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
Right:
|
|
59
59
|
```json
|
|
60
|
-
{ "
|
|
60
|
+
{ "hash_range_incl": ["F4T", "F4T"], "new_lines": ["import { x } from \"./x\";"] }
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
`
|
|
63
|
+
`hash_range_incl` uses the hash anchor. `new_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
64
64
|
|
|
65
|
-
⚠️ Common mistake: `
|
|
65
|
+
⚠️ Common mistake: `hash_range_incl` is only the 3-character HASH, not the full `HASH│content` line.
|
|
66
66
|
|
|
67
67
|
Wrong:
|
|
68
68
|
```json
|
|
69
|
-
{ "
|
|
69
|
+
{ "hash_range_incl": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "new_lines": [...] }
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
Right:
|
|
73
73
|
```json
|
|
74
|
-
{ "
|
|
74
|
+
{ "hash_range_incl": ["F4T", "F4T"], "new_lines": [...] }
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
Rules:
|
|
78
|
-
- `
|
|
78
|
+
- `hash_range_incl` is a pair `[start, end]`. A single-line replace is `hash_range_incl: ["X", "X"]`.
|
|
79
79
|
- To delete a range, use `new_lines: []`.
|
|
80
|
-
- `
|
|
80
|
+
- `hash_range_incl` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
|
|
81
81
|
- `new_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix, no `+`/`-` diff markers.
|
|
82
82
|
- Don't add `""` for spacing unless you actually want a new blank line.
|
|
83
83
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
84
84
|
- All edits in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
85
85
|
- If `new_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
86
|
-
- The `
|
|
86
|
+
- The `hash_range_incl` is inclusive — both anchors and every line between them are replaced. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
|
|
87
87
|
|
|
88
88
|
On success, the response text is empty (or contains only warnings if present). Call `read` to get fresh anchors for follow-up edits.
|
|
89
89
|
|
|
@@ -92,9 +92,9 @@ Error recovery:
|
|
|
92
92
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
93
93
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
94
94
|
- `[E_BAD_SHAPE]` — malformed request or edit item (missing fields, wrong types, unknown fields).
|
|
95
|
-
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` format detected. Use `{
|
|
95
|
+
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_range` format detected. Use `{hash_range_incl, new_lines}` instead.
|
|
96
96
|
- `[E_EDIT_CONFLICT]` — two edits overlap on the same line range. Make edits non-overlapping.
|
|
97
97
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
98
|
-
- `[E_BARE_HASH_PREFIX]` — a `new_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `
|
|
98
|
+
- `[E_BARE_HASH_PREFIX]` — a `new_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_incl` uses hashes, `new_lines` does not.
|
|
99
99
|
- `[E_INVALID_PATCH]` — diff prefixes (`+`/`-`) in `new_lines`. Use literal content only.
|
|
100
|
-
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
100
|
+
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
package/src/constants.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const
|
|
3
|
-
export const
|
|
4
|
-
export const
|
|
5
|
-
export const
|
|
6
|
-
export const
|
|
1
|
+
export const AUTO_READ_MAX = 2000;
|
|
2
|
+
export const ANCHOR_BUDGET = 50 * 1024;
|
|
3
|
+
export const CTX_LINES = 0;
|
|
4
|
+
export const MAX_OUT = 12;
|
|
5
|
+
export const SNIFF_BYTES = 8192;
|
|
6
|
+
export const MAX_BYTES = 100 * 1024 * 1024;
|
package/src/file-kind.ts
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
2
2
|
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
-
import {
|
|
3
|
+
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const IMG_TYPES = new Set<string>([
|
|
6
6
|
"image/jpeg",
|
|
7
7
|
"image/png",
|
|
8
8
|
"image/gif",
|
|
9
9
|
"image/webp",
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const TEXT_TYPES = new Set<string>([
|
|
13
13
|
"application/rtf",
|
|
14
14
|
"application/xml",
|
|
15
15
|
"application/x-ms-regedit",
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
|
-
function
|
|
19
|
-
return mimeType.startsWith("text/") ||
|
|
18
|
+
function isTextType(mimeType: string): boolean {
|
|
19
|
+
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export type
|
|
22
|
+
export type FKind =
|
|
23
23
|
| { kind: "directory" }
|
|
24
24
|
| { kind: "image"; mimeType: string }
|
|
25
25
|
| { kind: "text" }
|
|
26
26
|
| { kind: "binary"; description: string };
|
|
27
27
|
|
|
28
|
-
export type
|
|
28
|
+
export type LFile =
|
|
29
29
|
| { kind: "directory" }
|
|
30
30
|
| { kind: "image"; mimeType: string }
|
|
31
31
|
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
32
32
|
| { kind: "binary"; description: string };
|
|
33
33
|
|
|
34
|
-
function
|
|
34
|
+
function hasNull(buffer: Uint8Array): boolean {
|
|
35
35
|
return buffer.includes(0);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export async function loadFileKindAndText(
|
|
39
39
|
filePath: string,
|
|
40
|
-
): Promise<
|
|
40
|
+
): Promise<LFile> {
|
|
41
41
|
const pathStat = await fsStat(filePath);
|
|
42
42
|
if (pathStat.isDirectory()) {
|
|
43
43
|
return { kind: "directory" };
|
|
@@ -48,20 +48,20 @@ export async function loadFileKindAndText(
|
|
|
48
48
|
description: "unsupported file type",
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
if (pathStat.size >
|
|
51
|
+
if (pathStat.size > MAX_BYTES) {
|
|
52
52
|
return {
|
|
53
53
|
kind: "binary",
|
|
54
|
-
description: `file exceeds ${
|
|
54
|
+
description: `file exceeds ${MAX_BYTES} byte limit`
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const fileHandle = await fsOpen(filePath, "r");
|
|
59
59
|
try {
|
|
60
|
-
const buffer = Buffer.alloc(
|
|
60
|
+
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
61
61
|
const { bytesRead } = await fileHandle.read(
|
|
62
62
|
buffer,
|
|
63
63
|
0,
|
|
64
|
-
|
|
64
|
+
SNIFF_BYTES,
|
|
65
65
|
0,
|
|
66
66
|
);
|
|
67
67
|
if (bytesRead === 0) {
|
|
@@ -72,9 +72,9 @@ export async function loadFileKindAndText(
|
|
|
72
72
|
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
73
73
|
if (
|
|
74
74
|
detectedMimeType !== undefined &&
|
|
75
|
-
!
|
|
75
|
+
!isTextType(detectedMimeType)
|
|
76
76
|
) {
|
|
77
|
-
if (
|
|
77
|
+
if (IMG_TYPES.has(detectedMimeType)) {
|
|
78
78
|
return { kind: "image", mimeType: detectedMimeType };
|
|
79
79
|
}
|
|
80
80
|
return {
|
|
@@ -82,7 +82,7 @@ export async function loadFileKindAndText(
|
|
|
82
82
|
description: detectedMimeType,
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
|
-
if (
|
|
85
|
+
if (hasNull(sample)) {
|
|
86
86
|
return {
|
|
87
87
|
kind: "binary",
|
|
88
88
|
description: "null bytes detected",
|
|
@@ -92,7 +92,7 @@ export async function loadFileKindAndText(
|
|
|
92
92
|
const decoder = new TextDecoder("utf-8");
|
|
93
93
|
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
94
94
|
let hadUtf8DecodeErrors = false;
|
|
95
|
-
const
|
|
95
|
+
const noteUtf8Err = (chunk?: Uint8Array): void => {
|
|
96
96
|
if (hadUtf8DecodeErrors) return;
|
|
97
97
|
try {
|
|
98
98
|
fatalDecoder.decode(chunk, { stream: chunk !== undefined });
|
|
@@ -105,7 +105,7 @@ export async function loadFileKindAndText(
|
|
|
105
105
|
}
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
noteUtf8Err(sample);
|
|
109
109
|
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
110
110
|
|
|
111
111
|
let position = bytesRead;
|
|
@@ -113,7 +113,7 @@ export async function loadFileKindAndText(
|
|
|
113
113
|
const { bytesRead: chunkBytesRead } = await fileHandle.read(
|
|
114
114
|
buffer,
|
|
115
115
|
0,
|
|
116
|
-
|
|
116
|
+
SNIFF_BYTES,
|
|
117
117
|
position,
|
|
118
118
|
);
|
|
119
119
|
if (chunkBytesRead === 0) {
|
|
@@ -121,18 +121,18 @@ export async function loadFileKindAndText(
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
124
|
-
if (
|
|
124
|
+
if (hasNull(chunk)) {
|
|
125
125
|
return {
|
|
126
126
|
kind: "binary",
|
|
127
127
|
description: "null bytes detected",
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
noteUtf8Err(chunk);
|
|
131
131
|
parts.push(decoder.decode(chunk, { stream: true }));
|
|
132
132
|
position += chunkBytesRead;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
noteUtf8Err();
|
|
136
136
|
parts.push(decoder.decode());
|
|
137
137
|
|
|
138
138
|
return {
|
|
@@ -145,7 +145,7 @@ export async function loadFileKindAndText(
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
export async function classifyFileKind(filePath: string): Promise<
|
|
148
|
+
export async function classifyFileKind(filePath: string): Promise<FKind> {
|
|
149
149
|
const loaded = await loadFileKindAndText(filePath);
|
|
150
150
|
switch (loaded.kind) {
|
|
151
151
|
case "directory":
|
|
@@ -157,4 +157,4 @@ export async function classifyFileKind(filePath: string): Promise<FileKind> {
|
|
|
157
157
|
case "text":
|
|
158
158
|
return { kind: "text" };
|
|
159
159
|
}
|
|
160
|
-
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { constants } from "fs";
|
|
2
|
+
import { lineHashes } from "./hashline";
|
|
3
|
+
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
4
|
+
import { toCwd } from "./path-utils";
|
|
5
|
+
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
6
|
+
import { abortIf } from "./runtime";
|
|
7
|
+
import { assertText, valAccess } from "./validation";
|
|
8
|
+
|
|
9
|
+
export interface NormFile {
|
|
10
|
+
absolutePath: string;
|
|
11
|
+
normalized: string;
|
|
12
|
+
bom: string;
|
|
13
|
+
originalEnding: "\r\n" | "\n";
|
|
14
|
+
fileHashes: string[];
|
|
15
|
+
hadUtf8DecodeErrors: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readNormFile(
|
|
19
|
+
path: string,
|
|
20
|
+
cwd: string,
|
|
21
|
+
signal: AbortSignal | undefined,
|
|
22
|
+
accessMode: number = constants.R_OK,
|
|
23
|
+
preloadedFile?: LFile,
|
|
24
|
+
): Promise<NormFile> {
|
|
25
|
+
const absolutePath = toCwd(path, cwd);
|
|
26
|
+
|
|
27
|
+
abortIf(signal);
|
|
28
|
+
await valAccess(absolutePath, path, accessMode);
|
|
29
|
+
|
|
30
|
+
abortIf(signal);
|
|
31
|
+
const file = preloadedFile ?? (await loadFileKindAndText(absolutePath));
|
|
32
|
+
assertText(file, path);
|
|
33
|
+
|
|
34
|
+
abortIf(signal);
|
|
35
|
+
const { bom, text: rawContent } = stripBOM(file.text);
|
|
36
|
+
const originalEnding = detectEnding(rawContent);
|
|
37
|
+
const normalized = toLF(rawContent);
|
|
38
|
+
const fileHashes = lineHashes(normalized);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
absolutePath,
|
|
42
|
+
normalized,
|
|
43
|
+
bom,
|
|
44
|
+
originalEnding,
|
|
45
|
+
fileHashes,
|
|
46
|
+
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/fs-write.ts
CHANGED
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
writeFile,
|
|
10
10
|
} from "fs/promises";
|
|
11
11
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
12
|
+
import { errCode } from "./validation";
|
|
12
13
|
|
|
13
|
-
export async function
|
|
14
|
+
export async function resolveTarget(path: string): Promise<string> {
|
|
14
15
|
const absolutePath = resolve(path);
|
|
15
16
|
const { root } = parse(absolutePath);
|
|
16
17
|
const parts = absolutePath
|
|
@@ -19,7 +20,7 @@ export async function resolveMutationTargetPath(path: string): Promise<string> {
|
|
|
19
20
|
.filter((part) => part.length > 0);
|
|
20
21
|
const visitedSymlinks = new Set<string>();
|
|
21
22
|
|
|
22
|
-
async function
|
|
23
|
+
async function resParts(
|
|
23
24
|
currentPath: string,
|
|
24
25
|
remainingParts: string[],
|
|
25
26
|
): Promise<string> {
|
|
@@ -33,7 +34,7 @@ export async function resolveMutationTargetPath(path: string): Promise<string> {
|
|
|
33
34
|
try {
|
|
34
35
|
const candidateStats = await lstat(candidatePath);
|
|
35
36
|
if (!candidateStats.isSymbolicLink()) {
|
|
36
|
-
return
|
|
37
|
+
return resParts(candidatePath, tail);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
if (visitedSymlinks.has(candidatePath)) {
|
|
@@ -53,32 +54,32 @@ export async function resolveMutationTargetPath(path: string): Promise<string> {
|
|
|
53
54
|
.slice(parse(linkTargetPath).root.length)
|
|
54
55
|
.split(sep)
|
|
55
56
|
.filter((part) => part.length > 0);
|
|
56
|
-
return
|
|
57
|
+
return resParts(parse(linkTargetPath).root, [
|
|
57
58
|
...targetParts,
|
|
58
59
|
...tail,
|
|
59
60
|
]);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
throw error;
|
|
61
|
+
} catch (error: unknown) {
|
|
62
|
+
if (errCode(error) === "ENOENT") {
|
|
63
|
+
return join(candidatePath, ...tail);
|
|
65
64
|
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
return
|
|
69
|
+
return resParts(root, parts);
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
export async function
|
|
72
|
+
export async function writeAtomic(
|
|
72
73
|
path: string,
|
|
73
74
|
content: string,
|
|
74
75
|
): Promise<void> {
|
|
75
|
-
const targetPath = await
|
|
76
|
+
const targetPath = await resolveTarget(path);
|
|
76
77
|
|
|
77
78
|
let existingStats: Awaited<ReturnType<typeof stat>> | null = null;
|
|
78
79
|
try {
|
|
79
80
|
existingStats = await stat(targetPath);
|
|
80
81
|
} catch (error: unknown) {
|
|
81
|
-
if ((error
|
|
82
|
+
if (errCode(error) !== "ENOENT") {
|
|
82
83
|
throw error;
|
|
83
84
|
}
|
|
84
85
|
}
|