@effectionx/fs 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/fs.test.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { describe, it, beforeEach } from "@effectionx/bdd";
2
+ import { expect } from "expect";
3
+ import { each, until } from "effection";
4
+ import * as path from "node:path";
5
+ import * as fsp from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import {
9
+ exists,
10
+ ensureDir,
11
+ ensureFile,
12
+ emptyDir,
13
+ rm,
14
+ readTextFile,
15
+ writeTextFile,
16
+ walk,
17
+ globToRegExp,
18
+ } from "./mod.ts";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const testDir = path.join(__dirname, "test-tmp");
22
+
23
+ describe("@effectionx/fs", () => {
24
+ beforeEach(function* () {
25
+ // Clean up test directory before each test
26
+ yield* until(fsp.rm(testDir, { recursive: true, force: true }));
27
+ yield* until(fsp.mkdir(testDir, { recursive: true }));
28
+ });
29
+
30
+ describe("exists", () => {
31
+ it("returns true for existing file", function* () {
32
+ const filePath = path.join(testDir, "exists.txt");
33
+ yield* until(fsp.writeFile(filePath, "hello"));
34
+
35
+ expect(yield* exists(filePath)).toBe(true);
36
+ });
37
+
38
+ it("returns false for non-existing file", function* () {
39
+ const filePath = path.join(testDir, "does-not-exist.txt");
40
+
41
+ expect(yield* exists(filePath)).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("ensureDir", () => {
46
+ it("creates directory if it does not exist", function* () {
47
+ const dirPath = path.join(testDir, "new-dir", "nested");
48
+
49
+ yield* ensureDir(dirPath);
50
+
51
+ const stat = yield* until(fsp.stat(dirPath));
52
+ expect(stat.isDirectory()).toBe(true);
53
+ });
54
+
55
+ it("does not error if directory already exists", function* () {
56
+ const dirPath = path.join(testDir, "existing-dir");
57
+ yield* until(fsp.mkdir(dirPath));
58
+
59
+ yield* ensureDir(dirPath);
60
+
61
+ const stat = yield* until(fsp.stat(dirPath));
62
+ expect(stat.isDirectory()).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe("ensureFile", () => {
67
+ it("creates file and parent directories", function* () {
68
+ const filePath = path.join(testDir, "new-dir", "new-file.txt");
69
+
70
+ yield* ensureFile(filePath);
71
+
72
+ const stat = yield* until(fsp.stat(filePath));
73
+ expect(stat.isFile()).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe("emptyDir", () => {
78
+ it("removes all contents of directory", function* () {
79
+ const dirPath = path.join(testDir, "to-empty");
80
+ yield* until(fsp.mkdir(dirPath));
81
+ yield* until(fsp.writeFile(path.join(dirPath, "file1.txt"), "1"));
82
+ yield* until(fsp.writeFile(path.join(dirPath, "file2.txt"), "2"));
83
+
84
+ yield* emptyDir(dirPath);
85
+
86
+ const contents = yield* until(fsp.readdir(dirPath));
87
+ expect(contents).toHaveLength(0);
88
+ });
89
+
90
+ it("creates directory if it does not exist", function* () {
91
+ const dirPath = path.join(testDir, "new-empty-dir");
92
+
93
+ yield* emptyDir(dirPath);
94
+
95
+ const stat = yield* until(fsp.stat(dirPath));
96
+ expect(stat.isDirectory()).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("readTextFile / writeTextFile", () => {
101
+ it("reads and writes text files", function* () {
102
+ const filePath = path.join(testDir, "text.txt");
103
+ const content = "Hello, World!";
104
+
105
+ yield* writeTextFile(filePath, content);
106
+ const result = yield* readTextFile(filePath);
107
+
108
+ expect(result).toBe(content);
109
+ });
110
+ });
111
+
112
+ describe("rm", () => {
113
+ it("removes a file", function* () {
114
+ const filePath = path.join(testDir, "to-remove.txt");
115
+ yield* until(fsp.writeFile(filePath, "delete me"));
116
+
117
+ yield* rm(filePath);
118
+
119
+ expect(yield* exists(filePath)).toBe(false);
120
+ });
121
+
122
+ it("removes a directory recursively", function* () {
123
+ const dirPath = path.join(testDir, "to-remove-dir");
124
+ yield* until(fsp.mkdir(dirPath));
125
+ yield* until(fsp.writeFile(path.join(dirPath, "file.txt"), "nested"));
126
+
127
+ yield* rm(dirPath, { recursive: true });
128
+
129
+ expect(yield* exists(dirPath)).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("walk", () => {
134
+ it("walks directory tree", function* () {
135
+ // Create test structure
136
+ yield* until(
137
+ fsp.mkdir(path.join(testDir, "walk-test", "sub"), { recursive: true }),
138
+ );
139
+ yield* until(
140
+ fsp.writeFile(path.join(testDir, "walk-test", "file1.txt"), "1"),
141
+ );
142
+ yield* until(
143
+ fsp.writeFile(path.join(testDir, "walk-test", "sub", "file2.txt"), "2"),
144
+ );
145
+
146
+ const entries: string[] = [];
147
+ for (const entry of yield* each(walk(path.join(testDir, "walk-test")))) {
148
+ entries.push(entry.name);
149
+ yield* each.next();
150
+ }
151
+
152
+ expect(entries).toContain("sub");
153
+ expect(entries).toContain("file1.txt");
154
+ expect(entries).toContain("file2.txt");
155
+ });
156
+
157
+ it("respects includeFiles option", function* () {
158
+ yield* until(
159
+ fsp.mkdir(path.join(testDir, "walk-files"), { recursive: true }),
160
+ );
161
+ yield* until(
162
+ fsp.writeFile(path.join(testDir, "walk-files", "file.txt"), "1"),
163
+ );
164
+
165
+ const entries: string[] = [];
166
+ for (const entry of yield* each(
167
+ walk(path.join(testDir, "walk-files"), { includeFiles: false }),
168
+ )) {
169
+ entries.push(entry.name);
170
+ yield* each.next();
171
+ }
172
+
173
+ expect(entries).not.toContain("file.txt");
174
+ });
175
+ });
176
+
177
+ describe("globToRegExp", () => {
178
+ it("matches simple wildcards", function* () {
179
+ const regex = globToRegExp("*.ts");
180
+ expect(regex.test("file.ts")).toBe(true);
181
+ expect(regex.test("file.js")).toBe(false);
182
+ });
183
+
184
+ it("matches double star glob", function* () {
185
+ const regex = globToRegExp("src/**/*.ts");
186
+ expect(regex.test("src/file.ts")).toBe(true);
187
+ expect(regex.test("src/nested/file.ts")).toBe(true);
188
+ expect(regex.test("other/file.ts")).toBe(false);
189
+ });
190
+
191
+ it("matches character classes", function* () {
192
+ const regex = globToRegExp("file[0-9].txt");
193
+ expect(regex.test("file1.txt")).toBe(true);
194
+ expect(regex.test("filea.txt")).toBe(false);
195
+ });
196
+
197
+ it("matches braces", function* () {
198
+ const regex = globToRegExp("*.{ts,js}");
199
+ expect(regex.test("file.ts")).toBe(true);
200
+ expect(regex.test("file.js")).toBe(true);
201
+ expect(regex.test("file.txt")).toBe(false);
202
+ });
203
+ });
204
+ });
package/mod.ts ADDED
@@ -0,0 +1,526 @@
1
+ import * as fsp from "node:fs/promises";
2
+ import type { Stats } from "node:fs";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ all,
7
+ resource,
8
+ spawn,
9
+ until,
10
+ type Operation,
11
+ type Stream,
12
+ createSignal,
13
+ } from "effection";
14
+
15
+ /**
16
+ * Convert a path or URL to a file path string
17
+ */
18
+ export function toPath(pathOrUrl: string | URL): string {
19
+ return pathOrUrl instanceof URL ? fileURLToPath(pathOrUrl) : pathOrUrl;
20
+ }
21
+
22
+ /**
23
+ * Get file or directory stats
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { stat } from "@effectionx/fs";
28
+ *
29
+ * const stats = yield* stat("./file.txt");
30
+ * console.log(stats.isFile());
31
+ * ```
32
+ */
33
+ export function stat(pathOrUrl: string | URL): Operation<Stats> {
34
+ return until(fsp.stat(toPath(pathOrUrl)));
35
+ }
36
+
37
+ /**
38
+ * Get file or directory stats without following symlinks
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { lstat } from "@effectionx/fs";
43
+ *
44
+ * const stats = yield* lstat("./symlink");
45
+ * console.log(stats.isSymbolicLink());
46
+ * ```
47
+ */
48
+ export function lstat(pathOrUrl: string | URL): Operation<Stats> {
49
+ return until(fsp.lstat(toPath(pathOrUrl)));
50
+ }
51
+
52
+ /**
53
+ * Check if a file or directory exists
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * import { exists } from "@effectionx/fs";
58
+ *
59
+ * if (yield* exists("./config.json")) {
60
+ * console.log("Config file found");
61
+ * }
62
+ * ```
63
+ */
64
+ export function* exists(pathOrUrl: string | URL): Operation<boolean> {
65
+ try {
66
+ yield* until(fsp.access(toPath(pathOrUrl)));
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Ensure a directory exists, creating it recursively if needed
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * import { ensureDir } from "@effectionx/fs";
79
+ *
80
+ * yield* ensureDir("./data/cache");
81
+ * ```
82
+ */
83
+ export function* ensureDir(pathOrUrl: string | URL): Operation<void> {
84
+ yield* until(fsp.mkdir(toPath(pathOrUrl), { recursive: true }));
85
+ }
86
+
87
+ /**
88
+ * Ensure a file exists, creating parent directories and the file if needed
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { ensureFile } from "@effectionx/fs";
93
+ *
94
+ * yield* ensureFile("./data/config.json");
95
+ * ```
96
+ */
97
+ export function* ensureFile(pathOrUrl: string | URL): Operation<void> {
98
+ const filePath = toPath(pathOrUrl);
99
+ try {
100
+ yield* until(fsp.access(filePath));
101
+ } catch {
102
+ yield* until(fsp.mkdir(path.dirname(filePath), { recursive: true }));
103
+ yield* until(fsp.writeFile(filePath, ""));
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Read the contents of a directory
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * import { readdir } from "@effectionx/fs";
113
+ *
114
+ * const entries = yield* readdir("./src");
115
+ * ```
116
+ */
117
+ export function readdir(pathOrUrl: string | URL): Operation<string[]> {
118
+ return until(fsp.readdir(toPath(pathOrUrl)));
119
+ }
120
+
121
+ /**
122
+ * Empty a directory by removing all its contents.
123
+ * Creates the directory if it doesn't exist.
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * import { emptyDir } from "@effectionx/fs";
128
+ *
129
+ * yield* emptyDir("./dist");
130
+ * ```
131
+ */
132
+ export function* emptyDir(pathOrUrl: string | URL): Operation<void> {
133
+ const dirPath = toPath(pathOrUrl);
134
+
135
+ try {
136
+ const entries = yield* readdir(dirPath);
137
+ yield* all(
138
+ entries.map((entry) =>
139
+ rm(path.join(dirPath, entry), { recursive: true, force: true }),
140
+ ),
141
+ );
142
+ } catch (error) {
143
+ // If directory doesn't exist, create it
144
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
145
+ yield* until(fsp.mkdir(dirPath, { recursive: true }));
146
+ } else {
147
+ throw error;
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Remove a file or directory
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * import { rm } from "@effectionx/fs";
158
+ *
159
+ * yield* rm("./temp", { recursive: true });
160
+ * ```
161
+ */
162
+ export function rm(
163
+ pathOrUrl: string | URL,
164
+ options?: { recursive?: boolean; force?: boolean },
165
+ ): Operation<void> {
166
+ return until(fsp.rm(toPath(pathOrUrl), options));
167
+ }
168
+
169
+ /**
170
+ * Copy a file
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * import { copyFile } from "@effectionx/fs";
175
+ *
176
+ * yield* copyFile("./source.txt", "./dest.txt");
177
+ * ```
178
+ */
179
+ export function copyFile(
180
+ src: string | URL,
181
+ dest: string | URL,
182
+ ): Operation<void> {
183
+ return until(fsp.copyFile(toPath(src), toPath(dest)));
184
+ }
185
+
186
+ /**
187
+ * Read a file as text
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * import { readTextFile } from "@effectionx/fs";
192
+ *
193
+ * const content = yield* readTextFile("./config.json");
194
+ * ```
195
+ */
196
+ export function readTextFile(pathOrUrl: string | URL): Operation<string> {
197
+ return until(fsp.readFile(toPath(pathOrUrl), "utf-8"));
198
+ }
199
+
200
+ /**
201
+ * Write text to a file
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * import { writeTextFile } from "@effectionx/fs";
206
+ *
207
+ * yield* writeTextFile("./output.txt", "Hello, World!");
208
+ * ```
209
+ */
210
+ export function writeTextFile(
211
+ pathOrUrl: string | URL,
212
+ content: string,
213
+ ): Operation<void> {
214
+ return until(fsp.writeFile(toPath(pathOrUrl), content));
215
+ }
216
+
217
+ /**
218
+ * Entry returned by walk()
219
+ */
220
+ export interface WalkEntry {
221
+ /** Full path to the entry */
222
+ path: string;
223
+ /** Name of the entry (basename) */
224
+ name: string;
225
+ /** Whether the entry is a file */
226
+ isFile: boolean;
227
+ /** Whether the entry is a directory */
228
+ isDirectory: boolean;
229
+ /** Whether the entry is a symbolic link */
230
+ isSymlink: boolean;
231
+ }
232
+
233
+ /**
234
+ * Options for walk()
235
+ */
236
+ export interface WalkOptions {
237
+ /** Include directories in results (default: true) */
238
+ includeDirs?: boolean;
239
+ /** Include files in results (default: true) */
240
+ includeFiles?: boolean;
241
+ /** Include symbolic links in results (default: true) */
242
+ includeSymlinks?: boolean;
243
+ /** Only include entries matching these patterns */
244
+ match?: RegExp[];
245
+ /** Exclude entries matching these patterns */
246
+ skip?: RegExp[];
247
+ /** Maximum depth to traverse (default: Infinity) */
248
+ maxDepth?: number;
249
+ /** Follow symbolic links (default: false) */
250
+ followSymlinks?: boolean;
251
+ }
252
+
253
+ /**
254
+ * Walk a directory tree and yield entries as a Stream
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * import { walk } from "@effectionx/fs";
259
+ * import { each } from "effection";
260
+ *
261
+ * for (const entry of yield* each(walk("./src"))) {
262
+ * if (entry.isFile && entry.name.endsWith(".ts")) {
263
+ * console.log(entry.path);
264
+ * }
265
+ * yield* each.next();
266
+ * }
267
+ * ```
268
+ */
269
+ export function walk(
270
+ root: string | URL,
271
+ options: WalkOptions = {},
272
+ ): Stream<WalkEntry, void> {
273
+ const {
274
+ includeDirs = true,
275
+ includeFiles = true,
276
+ includeSymlinks = true,
277
+ match,
278
+ skip,
279
+ maxDepth = Number.POSITIVE_INFINITY,
280
+ followSymlinks = false,
281
+ } = options;
282
+
283
+ const rootPath = toPath(root);
284
+
285
+ function shouldInclude(entry: WalkEntry): boolean {
286
+ if (skip?.some((re) => re.test(entry.path))) {
287
+ return false;
288
+ }
289
+ if (match && !match.some((re) => re.test(entry.path))) {
290
+ return false;
291
+ }
292
+ return true;
293
+ }
294
+
295
+ return resource(function* (provide) {
296
+ const signal = createSignal<WalkEntry, void>();
297
+
298
+ function* walkDir(dir: string, depth: number): Operation<void> {
299
+ if (depth > maxDepth) return;
300
+
301
+ const entries = yield* until(fsp.readdir(dir, { withFileTypes: true }));
302
+
303
+ for (const entry of entries) {
304
+ const fullPath = path.join(dir, entry.name);
305
+
306
+ let isSymlink = entry.isSymbolicLink();
307
+ let isDirectory = entry.isDirectory();
308
+ let isFile = entry.isFile();
309
+
310
+ // If following symlinks, resolve the target type
311
+ if (isSymlink && followSymlinks) {
312
+ try {
313
+ const stats = yield* stat(fullPath);
314
+ isDirectory = stats.isDirectory();
315
+ isFile = stats.isFile();
316
+ } catch {
317
+ // Broken symlink, skip
318
+ continue;
319
+ }
320
+ }
321
+
322
+ const walkEntry: WalkEntry = {
323
+ path: fullPath,
324
+ name: entry.name,
325
+ isFile,
326
+ isDirectory,
327
+ isSymlink,
328
+ };
329
+
330
+ if (isDirectory) {
331
+ if (includeDirs && shouldInclude(walkEntry)) {
332
+ signal.send(walkEntry);
333
+ }
334
+ yield* walkDir(fullPath, depth + 1);
335
+ } else if (isSymlink) {
336
+ if (includeSymlinks && shouldInclude(walkEntry)) {
337
+ signal.send(walkEntry);
338
+ }
339
+ } else if (isFile) {
340
+ if (includeFiles && shouldInclude(walkEntry)) {
341
+ signal.send(walkEntry);
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ yield* spawn(function* () {
348
+ yield* walkDir(rootPath, 0);
349
+ signal.close();
350
+ });
351
+
352
+ yield* provide(yield* signal);
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Expand glob patterns and yield matching paths as a Stream
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * import { expandGlob } from "@effectionx/fs";
362
+ * import { each } from "effection";
363
+ *
364
+ * for (const entry of yield* each(expandGlob("./src/*.ts"))) {
365
+ * console.log(entry.path);
366
+ * yield* each.next();
367
+ * }
368
+ * ```
369
+ */
370
+ export function expandGlob(
371
+ glob: string,
372
+ options: {
373
+ root?: string;
374
+ exclude?: string[];
375
+ includeDirs?: boolean;
376
+ followSymlinks?: boolean;
377
+ } = {},
378
+ ): Stream<WalkEntry, void> {
379
+ const {
380
+ root = ".",
381
+ exclude = [],
382
+ includeDirs = true,
383
+ followSymlinks = false,
384
+ } = options;
385
+
386
+ // Convert glob to regex
387
+ const globRegex = globToRegExp(glob, { extended: true, globstar: true });
388
+ const excludeRegexes = exclude.map((e) =>
389
+ globToRegExp(e, { extended: true, globstar: true }),
390
+ );
391
+
392
+ return walk(root, {
393
+ includeDirs,
394
+ includeFiles: true,
395
+ followSymlinks,
396
+ match: [globRegex],
397
+ skip: excludeRegexes,
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Convert a glob pattern to a RegExp
403
+ *
404
+ * @example
405
+ * ```ts
406
+ * import { globToRegExp } from "@effectionx/fs";
407
+ *
408
+ * const regex = globToRegExp("*.ts");
409
+ * console.log(regex.test("file.ts")); // true
410
+ * ```
411
+ */
412
+ export function globToRegExp(
413
+ glob: string,
414
+ options?: { extended?: boolean; globstar?: boolean },
415
+ ): RegExp {
416
+ const { extended = true, globstar = true } = options ?? {};
417
+
418
+ let pattern = "";
419
+ let inGroup = false;
420
+
421
+ for (let i = 0; i < glob.length; i++) {
422
+ const c = glob[i];
423
+ const next = glob[i + 1];
424
+
425
+ switch (c) {
426
+ case "/":
427
+ case "$":
428
+ case "^":
429
+ case "+":
430
+ case ".":
431
+ case "(":
432
+ case ")":
433
+ case "=":
434
+ case "!":
435
+ case "|":
436
+ pattern += `\\${c}`;
437
+ break;
438
+
439
+ case "?":
440
+ if (extended) {
441
+ pattern += ".";
442
+ } else {
443
+ pattern += "\\?";
444
+ }
445
+ break;
446
+
447
+ case "[":
448
+ case "]":
449
+ if (extended) {
450
+ pattern += c;
451
+ } else {
452
+ pattern += `\\${c}`;
453
+ }
454
+ break;
455
+
456
+ case "{":
457
+ if (extended) {
458
+ inGroup = true;
459
+ pattern += "(";
460
+ } else {
461
+ pattern += "\\{";
462
+ }
463
+ break;
464
+
465
+ case "}":
466
+ if (extended) {
467
+ inGroup = false;
468
+ pattern += ")";
469
+ } else {
470
+ pattern += "\\}";
471
+ }
472
+ break;
473
+
474
+ case ",":
475
+ if (inGroup) {
476
+ pattern += "|";
477
+ } else {
478
+ pattern += "\\,";
479
+ }
480
+ break;
481
+
482
+ case "*":
483
+ if (globstar && next === "*") {
484
+ // **
485
+ i++; // skip next *
486
+ const prevChar = glob[i - 2];
487
+ const nextChar = glob[i + 1];
488
+
489
+ if (
490
+ (prevChar === undefined || prevChar === "/") &&
491
+ (nextChar === undefined || nextChar === "/")
492
+ ) {
493
+ // Match any path segment
494
+ pattern += "(?:[^/]*(?:/|$))*";
495
+ if (nextChar === "/") i++; // skip trailing /
496
+ } else {
497
+ // ** not at segment boundary, treat as *
498
+ pattern += ".*";
499
+ }
500
+ } else {
501
+ // Single *
502
+ pattern += "[^/]*";
503
+ }
504
+ break;
505
+
506
+ case "\\":
507
+ // Escape next character
508
+ if (next) {
509
+ pattern += `\\${next}`;
510
+ i++;
511
+ }
512
+ break;
513
+
514
+ default:
515
+ pattern += c;
516
+ }
517
+ }
518
+
519
+ return new RegExp(`^${pattern}$`);
520
+ }
521
+
522
+ // Re-export URL utilities for convenience
523
+ export {
524
+ fileURLToPath as fromFileUrl,
525
+ pathToFileURL as toFileUrl,
526
+ } from "node:url";