@jerryan/pi-hashline-edit 0.7.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.
@@ -0,0 +1,167 @@
1
+ import { open as fsOpen, stat as fsStat } from "fs/promises";
2
+ import { fileTypeFromBuffer } from "file-type";
3
+
4
+ const IMAGE_MIME_TYPES = new Set<string>([
5
+ "image/jpeg",
6
+ "image/png",
7
+ "image/gif",
8
+ "image/webp",
9
+ ]);
10
+
11
+ const TEXT_LIKE_MIME_TYPES = new Set<string>([
12
+ "application/rtf",
13
+ "application/xml",
14
+ "application/x-ms-regedit",
15
+ ]);
16
+
17
+ function isTextLikeMimeType(mimeType: string): boolean {
18
+ return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
19
+ }
20
+
21
+ const FILE_TYPE_SNIFF_BYTES = 8192;
22
+
23
+ export type FileKind =
24
+ | { kind: "directory" }
25
+ | { kind: "image"; mimeType: string }
26
+ | { kind: "text" }
27
+ | { kind: "binary"; description: string };
28
+
29
+ export type LoadedFile =
30
+ | { kind: "directory" }
31
+ | { kind: "image"; mimeType: string }
32
+ | { kind: "text"; text: string }
33
+ | { kind: "binary"; description: string };
34
+
35
+ function hasNullByte(buffer: Uint8Array): boolean {
36
+ return buffer.includes(0);
37
+ }
38
+
39
+ function decodeUtf8Chunk(decoder: TextDecoder, buffer: Uint8Array): string | null {
40
+ try {
41
+ return decoder.decode(buffer, { stream: true });
42
+ } catch (error: unknown) {
43
+ if (error instanceof TypeError) {
44
+ return null;
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ function finishUtf8(decoder: TextDecoder): string | null {
51
+ try {
52
+ return decoder.decode();
53
+ } catch (error: unknown) {
54
+ if (error instanceof TypeError) {
55
+ return null;
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ export async function loadFileKindAndText(filePath: string): Promise<LoadedFile> {
62
+ const pathStat = await fsStat(filePath);
63
+ if (pathStat.isDirectory()) {
64
+ return { kind: "directory" };
65
+ }
66
+ if (!pathStat.isFile()) {
67
+ return {
68
+ kind: "binary",
69
+ description: "unsupported file type",
70
+ };
71
+ }
72
+
73
+ const fileHandle = await fsOpen(filePath, "r");
74
+ try {
75
+ const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
76
+ const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
77
+ if (bytesRead === 0) {
78
+ return { kind: "text", text: "" };
79
+ }
80
+
81
+ const sample = buffer.subarray(0, bytesRead);
82
+ const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
83
+ if (detectedMimeType !== undefined && !isTextLikeMimeType(detectedMimeType)) {
84
+ if (IMAGE_MIME_TYPES.has(detectedMimeType)) {
85
+ return { kind: "image", mimeType: detectedMimeType };
86
+ }
87
+ return {
88
+ kind: "binary",
89
+ description: detectedMimeType,
90
+ };
91
+ }
92
+ if (hasNullByte(sample)) {
93
+ return {
94
+ kind: "binary",
95
+ description: "null bytes detected",
96
+ };
97
+ }
98
+
99
+ const decoder = new TextDecoder("utf-8", { fatal: true });
100
+ const parts: string[] = [];
101
+ const sampleText = decodeUtf8Chunk(decoder, sample);
102
+ if (sampleText === null) {
103
+ return {
104
+ kind: "binary",
105
+ description: "invalid UTF-8",
106
+ };
107
+ }
108
+ parts.push(sampleText);
109
+
110
+ let position = bytesRead;
111
+ while (true) {
112
+ const { bytesRead: chunkBytesRead } = await fileHandle.read(
113
+ buffer,
114
+ 0,
115
+ FILE_TYPE_SNIFF_BYTES,
116
+ position,
117
+ );
118
+ if (chunkBytesRead === 0) {
119
+ break;
120
+ }
121
+
122
+ const chunk = buffer.subarray(0, chunkBytesRead);
123
+ if (hasNullByte(chunk)) {
124
+ return {
125
+ kind: "binary",
126
+ description: "null bytes detected",
127
+ };
128
+ }
129
+ const chunkText = decodeUtf8Chunk(decoder, chunk);
130
+ if (chunkText === null) {
131
+ return {
132
+ kind: "binary",
133
+ description: "invalid UTF-8",
134
+ };
135
+ }
136
+ parts.push(chunkText);
137
+ position += chunkBytesRead;
138
+ }
139
+
140
+ const tail = finishUtf8(decoder);
141
+ if (tail === null) {
142
+ return {
143
+ kind: "binary",
144
+ description: "invalid UTF-8",
145
+ };
146
+ }
147
+ parts.push(tail);
148
+
149
+ return { kind: "text", text: parts.join("") };
150
+ } finally {
151
+ await fileHandle.close();
152
+ }
153
+ }
154
+
155
+ export async function classifyFileKind(filePath: string): Promise<FileKind> {
156
+ const loaded = await loadFileKindAndText(filePath);
157
+ switch (loaded.kind) {
158
+ case "directory":
159
+ return loaded;
160
+ case "image":
161
+ return loaded;
162
+ case "binary":
163
+ return loaded;
164
+ case "text":
165
+ return { kind: "text" };
166
+ }
167
+ }
@@ -0,0 +1,76 @@
1
+ import { randomUUID } from "crypto";
2
+ import { lstat, mkdir, readlink, rename, stat, writeFile } from "fs/promises";
3
+ import { dirname, join, parse, resolve, sep } from "path";
4
+
5
+ export async function resolveMutationTargetPath(path: string): Promise<string> {
6
+ const absolutePath = resolve(path);
7
+ const { root } = parse(absolutePath);
8
+ const parts = absolutePath.slice(root.length).split(sep).filter((part) => part.length > 0);
9
+ const visitedSymlinks = new Set<string>();
10
+
11
+ async function resolveFromParts(currentPath: string, remainingParts: string[]): Promise<string> {
12
+ if (remainingParts.length === 0) {
13
+ return currentPath;
14
+ }
15
+
16
+ const [nextPart, ...tail] = remainingParts;
17
+ const candidatePath = join(currentPath, nextPart);
18
+
19
+ try {
20
+ const candidateStats = await lstat(candidatePath);
21
+ if (!candidateStats.isSymbolicLink()) {
22
+ return resolveFromParts(candidatePath, tail);
23
+ }
24
+
25
+ if (visitedSymlinks.has(candidatePath)) {
26
+ const error = new Error(`Too many symbolic links while resolving ${path}`) as NodeJS.ErrnoException;
27
+ error.code = "ELOOP";
28
+ throw error;
29
+ }
30
+ visitedSymlinks.add(candidatePath);
31
+
32
+ const linkTargetPath = resolve(dirname(candidatePath), await readlink(candidatePath));
33
+ const targetParts = linkTargetPath
34
+ .slice(parse(linkTargetPath).root.length)
35
+ .split(sep)
36
+ .filter((part) => part.length > 0);
37
+ return resolveFromParts(parse(linkTargetPath).root, [...targetParts, ...tail]);
38
+ } catch (error: unknown) {
39
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
40
+ return join(candidatePath, ...tail);
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ return resolveFromParts(root, parts);
47
+ }
48
+
49
+ export async function writeFileAtomically(
50
+ path: string,
51
+ content: string,
52
+ ): Promise<void> {
53
+ const targetPath = await resolveMutationTargetPath(path);
54
+
55
+ let existingStats: Awaited<ReturnType<typeof stat>> | null = null;
56
+ try {
57
+ existingStats = await stat(targetPath);
58
+ } catch (error: unknown) {
59
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ if (existingStats && existingStats.nlink > 1) {
65
+ await writeFile(targetPath, content, "utf-8");
66
+ return;
67
+ }
68
+
69
+ const dir = dirname(targetPath);
70
+ const tempPath = join(dir, `.tmp-${randomUUID()}`);
71
+ await mkdir(dir, { recursive: true });
72
+ const mode = existingStats ? existingStats.mode & 0o7777 : 0o600;
73
+ await writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
74
+
75
+ await rename(tempPath, targetPath);
76
+ }