path-class 0.2.0 → 0.3.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.
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
2
  export declare class Path {
3
3
  #private;
4
4
  constructor(path: string | URL | Path);
@@ -6,18 +6,49 @@ export declare class Path {
6
6
  join(...segments: string[]): Path;
7
7
  extendBasename(suffix: string): Path;
8
8
  get parent(): Path;
9
- get basename(): string;
9
+ /** @deprecated Alias for `.parent`. */
10
+ get dirname(): Path;
11
+ get basename(): Path;
12
+ get extension(): string;
13
+ /** @deprecated Alias for `.extension`. */
14
+ get extname(): string;
10
15
  exists(constraints?: {
11
16
  mustBe: "file" | "directory";
12
17
  }): Promise<boolean>;
13
18
  existsAsFile(): Promise<boolean>;
14
19
  existsAsDir(): Promise<boolean>;
15
- mkdir(options: Parameters<typeof mkdir>[1]): Promise<void>;
16
- copy(destination: string | URL | Path): Promise<void>;
20
+ /** Defaults to `recursive: true`. */
21
+ mkdir(options?: Parameters<typeof mkdir>[1]): Promise<Path>;
22
+ /** Returns the destination path. */
23
+ cp(destination: string | URL | Path, options?: Parameters<typeof cp>[2]): Promise<Path>;
24
+ rename(destination: string | URL | Path): Promise<void>;
25
+ /** Create a temporary dir inside the global temp dir for the current user. */
26
+ static makeTempDir(prefix?: string): Promise<Path>;
17
27
  trash(): Promise<void>;
28
+ rm(options?: Parameters<typeof rm>[1]): Promise<void>;
29
+ /**
30
+ * Equivalent to:
31
+ *
32
+ * .rm({ recursive: true, force: true, ...(options ?? {}) })
33
+ *
34
+ */
35
+ rm_rf(options?: Parameters<typeof rm>[1]): Promise<void>;
18
36
  fileText(): Promise<string>;
19
37
  fileJSON<T>(): Promise<T>;
20
- write(s: string): Promise<void>;
38
+ /** Returns the original `Path` (for chaining). */
39
+ write(data: Parameters<typeof writeFile>[1], options?: Parameters<typeof writeFile>[2]): Promise<Path>;
40
+ /**
41
+ * If only `data` is provided, this is equivalent to:
42
+ *
43
+ * .write(JSON.stringify(data, null, " "));
44
+ *
45
+ * `replacer` and `space` can also be specified, making this equivalent to:
46
+ *
47
+ * .write(JSON.stringify(data, replacer, space));
48
+ *
49
+ * Returns the original `Path` (for chaining).
50
+ */
51
+ writeJSON<T>(data: T, replacer?: Parameters<typeof JSON.stringify>[1], space?: Parameters<typeof JSON.stringify>[2]): Promise<Path>;
21
52
  static get homedir(): Path;
22
53
  static xdg: {
23
54
  cache: Path;
@@ -1,7 +1,16 @@
1
1
  // src/index.ts
2
- import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { basename, dirname, join } from "node:path";
2
+ import {
3
+ cp,
4
+ mkdir,
5
+ mkdtemp,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ writeFile
11
+ } from "node:fs/promises";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { basename, dirname, extname, join } from "node:path";
5
14
  import { fileURLToPath } from "node:url";
6
15
  import { default as trash } from "trash";
7
16
  import { xdgCache, xdgConfig, xdgData, xdgState } from "xdg-basedir";
@@ -44,8 +53,33 @@ var Path = class _Path {
44
53
  get parent() {
45
54
  return new _Path(dirname(this.#path));
46
55
  }
56
+ // Normally I'd stick with `node`'s name, but I think `.dirname` is a
57
+ // particularly poor name. So we support `.dirname` for discovery but mark it
58
+ // as deprecated, even if it will never be removed.
59
+ /** @deprecated Alias for `.parent`. */
60
+ get dirname() {
61
+ return this.parent;
62
+ }
47
63
  get basename() {
48
- return basename(this.#path);
64
+ return new _Path(basename(this.#path));
65
+ }
66
+ get extension() {
67
+ this.#mustNotHaveTrailingSlash();
68
+ return extname(this.#path);
69
+ }
70
+ // Normally I'd stick with `node`'s name, but I think `.extname` is a
71
+ // particularly poor name. So we support `.extname` for discovery but mark it
72
+ // as deprecated, even if it will never be removed.
73
+ /** @deprecated Alias for `.extension`. */
74
+ get extname() {
75
+ return this.extension;
76
+ }
77
+ #mustNotHaveTrailingSlash() {
78
+ if (this.#path.endsWith("/")) {
79
+ throw new Error(
80
+ "Path ends with a slash, which cannot be treated as a file."
81
+ );
82
+ }
49
83
  }
50
84
  async exists(constraints) {
51
85
  let stats;
@@ -62,6 +96,7 @@ var Path = class _Path {
62
96
  }
63
97
  switch (constraints?.mustBe) {
64
98
  case "file": {
99
+ this.#mustNotHaveTrailingSlash();
65
100
  if (stats.isFile()) {
66
101
  return true;
67
102
  }
@@ -84,16 +119,50 @@ var Path = class _Path {
84
119
  async existsAsDir() {
85
120
  return this.exists({ mustBe: "directory" });
86
121
  }
122
+ // I don't think `mkdir` is a great name, but it does match the
123
+ // well-established canonical commandline name. So in this case we keep the
124
+ // awkward abbreviation.
125
+ /** Defaults to `recursive: true`. */
87
126
  async mkdir(options) {
88
- await mkdir(this.#path, options);
127
+ const optionsObject = (() => {
128
+ if (typeof options === "string" || typeof options === "number") {
129
+ return { mode: options };
130
+ }
131
+ return options ?? {};
132
+ })();
133
+ await mkdir(this.#path, { recursive: true, ...optionsObject });
134
+ return this;
89
135
  }
90
136
  // TODO: check idempotency semantics when the destination exists and is a folder.
91
- async copy(destination) {
92
- await cp(this.#path, new _Path(destination).#path);
137
+ /** Returns the destination path. */
138
+ async cp(destination, options) {
139
+ await cp(this.#path, new _Path(destination).#path, options);
140
+ return new _Path(destination);
93
141
  }
94
142
  // TODO: check idempotency semantics when the destination exists and is a folder.
143
+ async rename(destination) {
144
+ await rename(this.#path, new _Path(destination).#path);
145
+ }
146
+ /** Create a temporary dir inside the global temp dir for the current user. */
147
+ static async makeTempDir(prefix) {
148
+ return new _Path(
149
+ await mkdtemp(new _Path(tmpdir()).join(prefix ?? "js-temp-").toString())
150
+ );
151
+ }
95
152
  async trash() {
96
- await trash(this.#path);
153
+ await trash(this.#path, { glob: false });
154
+ }
155
+ async rm(options) {
156
+ await rm(this.#path, options);
157
+ }
158
+ /**
159
+ * Equivalent to:
160
+ *
161
+ * .rm({ recursive: true, force: true, ...(options ?? {}) })
162
+ *
163
+ */
164
+ async rm_rf(options) {
165
+ await this.rm({ recursive: true, force: true, ...options ?? {} });
97
166
  }
98
167
  async fileText() {
99
168
  return readFile(this.#path, "utf-8");
@@ -101,8 +170,25 @@ var Path = class _Path {
101
170
  async fileJSON() {
102
171
  return JSON.parse(await this.fileText());
103
172
  }
104
- async write(s) {
105
- await writeFile(this.#path, s);
173
+ /** Returns the original `Path` (for chaining). */
174
+ async write(data, options) {
175
+ await writeFile(this.#path, data, options);
176
+ return this;
177
+ }
178
+ /**
179
+ * If only `data` is provided, this is equivalent to:
180
+ *
181
+ * .write(JSON.stringify(data, null, " "));
182
+ *
183
+ * `replacer` and `space` can also be specified, making this equivalent to:
184
+ *
185
+ * .write(JSON.stringify(data, replacer, space));
186
+ *
187
+ * Returns the original `Path` (for chaining).
188
+ */
189
+ async writeJSON(data, replacer = null, space = " ") {
190
+ await this.write(JSON.stringify(data, replacer, space));
191
+ return this;
106
192
  }
107
193
  static get homedir() {
108
194
  return new _Path(homedir());
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/index.ts"],
4
- "sourcesContent": ["import { cp, mkdir, readFile, stat, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { default as trash } from \"trash\";\nimport { xdgCache, xdgConfig, xdgData, xdgState } from \"xdg-basedir\";\n\n// TODO: classes for relative vs. absolute?\n\nexport class Path {\n // @ts-expect-error ts(2564): False positive. https://github.com/microsoft/TypeScript/issues/32194\n #path: string;\n constructor(path: string | URL | Path) {\n if (path instanceof Path) {\n this.#setNormalizedPath(path.#path);\n return;\n }\n if (path instanceof URL) {\n this.#setNormalizedPath(fileURLToPath(path));\n return;\n }\n if (typeof path === \"string\") {\n this.#setNormalizedPath(path);\n return;\n }\n throw new Error(\"Invalid path\");\n }\n\n #setNormalizedPath(path: string) {\n this.#path = join(path);\n }\n\n toString(): string {\n return this.#path;\n }\n\n /// Constructs a new path by appending the given path segments.\n // TODO: accept `Path` inputs?\n join(...segments: string[]): Path {\n return new Path(join(this.#path, ...segments));\n }\n\n extendBasename(suffix: string): Path {\n const joinedSuffix = join(suffix);\n if (joinedSuffix !== basename(joinedSuffix)) {\n throw new Error(\"Invalid suffix to extend file name.\");\n }\n // TODO: join basename and dirname instead?\n return new Path(this.#path + joinedSuffix);\n }\n\n get parent(): Path {\n return new Path(dirname(this.#path));\n }\n\n get basename(): string {\n return basename(this.#path);\n }\n\n async exists(constraints?: {\n mustBe: \"file\" | \"directory\";\n }): Promise<boolean> {\n let stats: Awaited<ReturnType<typeof stat>>;\n try {\n stats = await stat(this.#path);\n // biome-ignore lint/suspicious/noExplicitAny: TypeScript limitation\n } catch (e: any) {\n if (e.code === \"ENOENT\") {\n return false;\n }\n throw e;\n }\n if (!constraints?.mustBe) {\n return true;\n }\n switch (constraints?.mustBe) {\n case \"file\": {\n if (stats.isFile()) {\n return true;\n }\n throw new Error(`Path exists but is not a file: ${this.#path}`);\n }\n case \"directory\": {\n if (stats.isDirectory()) {\n return true;\n }\n throw new Error(`Path exists but is not a directory: ${this.#path}`);\n }\n default: {\n throw new Error(\"Invalid path type constraint\");\n }\n }\n }\n\n async existsAsFile(): Promise<boolean> {\n return this.exists({ mustBe: \"file\" });\n }\n\n async existsAsDir(): Promise<boolean> {\n return this.exists({ mustBe: \"directory\" });\n }\n\n async mkdir(options: Parameters<typeof mkdir>[1]): Promise<void> {\n await mkdir(this.#path, options);\n }\n\n // TODO: check idempotency semantics when the destination exists and is a folder.\n async copy(destination: string | URL | Path): Promise<void> {\n await cp(this.#path, new Path(destination).#path);\n }\n\n // TODO: check idempotency semantics when the destination exists and is a folder.\n async trash(): Promise<void> {\n await trash(this.#path);\n }\n\n async fileText(): Promise<string> {\n return readFile(this.#path, \"utf-8\");\n }\n\n async fileJSON<T>(): Promise<T> {\n return JSON.parse(await this.fileText());\n }\n\n async write(s: string): Promise<void> {\n await writeFile(this.#path, s);\n }\n\n static get homedir(): Path {\n return new Path(homedir());\n }\n\n static xdg = {\n cache: new Path(xdgCache ?? Path.homedir.join(\".cache\")),\n config: new Path(xdgConfig ?? Path.homedir.join(\".config\")),\n data: new Path(xdgData ?? Path.homedir.join(\".local/share\")),\n state: new Path(xdgState ?? Path.homedir.join(\".local/state\")),\n };\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import {\n cp,\n mkdir,\n mkdtemp,\n readFile,\n rename,\n rm,\n stat,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir, tmpdir } from \"node:os\";\nimport { basename, dirname, extname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { default as trash } from \"trash\";\nimport { xdgCache, xdgConfig, xdgData, xdgState } from \"xdg-basedir\";\n\n// TODO: classes for relative vs. absolute?\n\nexport class Path {\n // @ts-expect-error ts(2564): False positive. https://github.com/microsoft/TypeScript/issues/32194\n #path: string;\n constructor(path: string | URL | Path) {\n if (path instanceof Path) {\n this.#setNormalizedPath(path.#path);\n return;\n }\n if (path instanceof URL) {\n this.#setNormalizedPath(fileURLToPath(path));\n return;\n }\n if (typeof path === \"string\") {\n this.#setNormalizedPath(path);\n return;\n }\n throw new Error(\"Invalid path\");\n }\n\n #setNormalizedPath(path: string): void {\n this.#path = join(path);\n }\n\n toString(): string {\n return this.#path;\n }\n\n /// Constructs a new path by appending the given path segments.\n // TODO: accept `Path` inputs?\n join(...segments: string[]): Path {\n return new Path(join(this.#path, ...segments));\n }\n\n extendBasename(suffix: string): Path {\n const joinedSuffix = join(suffix);\n if (joinedSuffix !== basename(joinedSuffix)) {\n throw new Error(\"Invalid suffix to extend file name.\");\n }\n // TODO: join basename and dirname instead?\n return new Path(this.#path + joinedSuffix);\n }\n\n get parent(): Path {\n return new Path(dirname(this.#path));\n }\n\n // Normally I'd stick with `node`'s name, but I think `.dirname` is a\n // particularly poor name. So we support `.dirname` for discovery but mark it\n // as deprecated, even if it will never be removed.\n /** @deprecated Alias for `.parent`. */\n get dirname(): Path {\n return this.parent;\n }\n\n get basename(): Path {\n return new Path(basename(this.#path));\n }\n\n get extension(): string {\n this.#mustNotHaveTrailingSlash();\n return extname(this.#path);\n }\n\n // Normally I'd stick with `node`'s name, but I think `.extname` is a\n // particularly poor name. So we support `.extname` for discovery but mark it\n // as deprecated, even if it will never be removed.\n /** @deprecated Alias for `.extension`. */\n get extname(): string {\n return this.extension;\n }\n\n #mustNotHaveTrailingSlash(): void {\n if (this.#path.endsWith(\"/\")) {\n throw new Error(\n \"Path ends with a slash, which cannot be treated as a file.\",\n );\n }\n }\n\n async exists(constraints?: {\n mustBe: \"file\" | \"directory\";\n }): Promise<boolean> {\n let stats: Awaited<ReturnType<typeof stat>>;\n try {\n stats = await stat(this.#path);\n // biome-ignore lint/suspicious/noExplicitAny: TypeScript limitation\n } catch (e: any) {\n if (e.code === \"ENOENT\") {\n return false;\n }\n throw e;\n }\n if (!constraints?.mustBe) {\n return true;\n }\n switch (constraints?.mustBe) {\n case \"file\": {\n this.#mustNotHaveTrailingSlash();\n if (stats.isFile()) {\n return true;\n }\n throw new Error(`Path exists but is not a file: ${this.#path}`);\n }\n case \"directory\": {\n if (stats.isDirectory()) {\n return true;\n }\n throw new Error(`Path exists but is not a directory: ${this.#path}`);\n }\n default: {\n throw new Error(\"Invalid path type constraint\");\n }\n }\n }\n\n async existsAsFile(): Promise<boolean> {\n return this.exists({ mustBe: \"file\" });\n }\n\n async existsAsDir(): Promise<boolean> {\n return this.exists({ mustBe: \"directory\" });\n }\n\n // I don't think `mkdir` is a great name, but it does match the\n // well-established canonical commandline name. So in this case we keep the\n // awkward abbreviation.\n /** Defaults to `recursive: true`. */\n async mkdir(options?: Parameters<typeof mkdir>[1]): Promise<Path> {\n const optionsObject = (() => {\n if (typeof options === \"string\" || typeof options === \"number\") {\n return { mode: options };\n }\n return options ?? {};\n })();\n await mkdir(this.#path, { recursive: true, ...optionsObject });\n return this;\n }\n\n // TODO: check idempotency semantics when the destination exists and is a folder.\n /** Returns the destination path. */\n async cp(\n destination: string | URL | Path,\n options?: Parameters<typeof cp>[2],\n ): Promise<Path> {\n await cp(this.#path, new Path(destination).#path, options);\n return new Path(destination);\n }\n\n // TODO: check idempotency semantics when the destination exists and is a folder.\n async rename(destination: string | URL | Path): Promise<void> {\n await rename(this.#path, new Path(destination).#path);\n }\n\n /** Create a temporary dir inside the global temp dir for the current user. */\n static async makeTempDir(prefix?: string): Promise<Path> {\n return new Path(\n await mkdtemp(new Path(tmpdir()).join(prefix ?? \"js-temp-\").toString()),\n );\n }\n\n async trash(): Promise<void> {\n await trash(this.#path, { glob: false });\n }\n\n async rm(options?: Parameters<typeof rm>[1]): Promise<void> {\n await rm(this.#path, options);\n }\n\n /**\n * Equivalent to:\n *\n * .rm({ recursive: true, force: true, ...(options ?? {}) })\n *\n */\n async rm_rf(options?: Parameters<typeof rm>[1]): Promise<void> {\n await this.rm({ recursive: true, force: true, ...(options ?? {}) });\n }\n\n async fileText(): Promise<string> {\n return readFile(this.#path, \"utf-8\");\n }\n\n async fileJSON<T>(): Promise<T> {\n return JSON.parse(await this.fileText());\n }\n\n /** Returns the original `Path` (for chaining). */\n async write(\n data: Parameters<typeof writeFile>[1],\n options?: Parameters<typeof writeFile>[2],\n ): Promise<Path> {\n await writeFile(this.#path, data, options);\n return this;\n }\n\n /**\n * If only `data` is provided, this is equivalent to:\n *\n * .write(JSON.stringify(data, null, \" \"));\n *\n * `replacer` and `space` can also be specified, making this equivalent to:\n *\n * .write(JSON.stringify(data, replacer, space));\n *\n * Returns the original `Path` (for chaining).\n */\n async writeJSON<T>(\n data: T,\n replacer: Parameters<typeof JSON.stringify>[1] = null,\n space: Parameters<typeof JSON.stringify>[2] = \" \",\n ): Promise<Path> {\n await this.write(JSON.stringify(data, replacer, space));\n return this;\n }\n\n static get homedir(): Path {\n return new Path(homedir());\n }\n\n static xdg = {\n cache: new Path(xdgCache ?? Path.homedir.join(\".cache\")),\n config: new Path(xdgConfig ?? Path.homedir.join(\".config\")),\n data: new Path(xdgData ?? Path.homedir.join(\".local/share\")),\n state: new Path(xdgState ?? Path.homedir.join(\".local/state\")),\n };\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "path-class",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "author": "Lucas Garron <code@garron.net>",
5
5
  "type": "module",
6
6
  "main": "./dist/lib/path-class/index.js",
@@ -14,15 +14,20 @@
14
14
  "devDependencies": {
15
15
  "@biomejs/biome": "^2.2.5",
16
16
  "@cubing/dev-config": "^0.3.6",
17
+ "@types/bun": "^1.3.0",
17
18
  "esbuild": "^0.25.10"
18
19
  },
19
20
  "dependencies": {
20
- "@types/node": "^24.7.0",
21
+ "@types/node": "^24.7.1",
21
22
  "trash": "^10.0.0",
23
+ "typescript": "^5.9.3",
22
24
  "xdg-basedir": "^5.1.0"
23
25
  },
24
26
  "files": [
25
27
  "./dist/",
26
28
  "./src"
27
- ]
29
+ ],
30
+ "scripts": {
31
+ "prepublishOnly": "make prepublishOnly"
32
+ }
28
33
  }
@@ -0,0 +1,249 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFile } from "node:fs/promises";
3
+ import { Path } from ".";
4
+
5
+ test("constructor", async () => {
6
+ expect(new Path("foo").toString()).toEqual("foo");
7
+ expect(new Path("./relative").toString()).toEqual("relative");
8
+ expect(new Path("./relative/nested").toString()).toEqual("relative/nested");
9
+ expect(new Path("/absolute").toString()).toEqual("/absolute");
10
+ expect(new Path("/absolute/nested").toString()).toEqual("/absolute/nested");
11
+ expect(new Path("trailing/slash/").toString()).toEqual("trailing/slash/");
12
+ });
13
+
14
+ test("normalize", async () => {
15
+ expect(new Path("foo//bar").toString()).toEqual("foo/bar");
16
+ expect(new Path("foo////bar").toString()).toEqual("foo/bar");
17
+ expect(new Path("foo/bar/").toString()).toEqual("foo/bar/");
18
+ expect(new Path("foo/bar//").toString()).toEqual("foo/bar/");
19
+ expect(new Path("//absolute////bar").toString()).toEqual("/absolute/bar");
20
+ });
21
+
22
+ test("join", async () => {
23
+ expect(new Path("foo").join("bar").toString()).toEqual("foo/bar");
24
+ expect(new Path("foo/bar").join("bath", "kitchen/sink").toString()).toEqual(
25
+ "foo/bar/bath/kitchen/sink",
26
+ );
27
+ });
28
+
29
+ test("traverse", async () => {
30
+ expect(new Path("foo/bar").join("..").toString()).toEqual("foo");
31
+ expect(new Path("foo/bar").join(".").toString()).toEqual("foo/bar");
32
+ expect(new Path("foo/bar").join("../baz").toString()).toEqual("foo/baz");
33
+ expect(new Path("/absolute/path").join("../..").toString()).toEqual("/");
34
+ expect(new Path("/absolute/path").join("../../..").toString()).toEqual("/");
35
+ expect(new Path("/").join("..").toString()).toEqual("/");
36
+ });
37
+
38
+ test(".extendBasename(…)", async () => {
39
+ expect(
40
+ new Path("file.mp4").extendBasename(".hevc.qv65.mov").toString(),
41
+ ).toEqual("file.mp4.hevc.qv65.mov");
42
+ // Trailing dots should not be removed.
43
+ expect(
44
+ new Path("file.mp4.").extendBasename(".hevc.qv65.mov").toString(),
45
+ ).toEqual("file.mp4..hevc.qv65.mov");
46
+ });
47
+
48
+ test(".parent", async () => {
49
+ expect(new Path("/").parent.toString()).toEqual("/");
50
+ expect(new Path("dir").parent.toString()).toEqual(".");
51
+ expect(new Path("dir/").parent.toString()).toEqual(".");
52
+ });
53
+
54
+ test(".dirname", async () => {
55
+ expect(new Path("/").dirname.toString()).toEqual("/");
56
+ expect(new Path("dir").dirname.toString()).toEqual(".");
57
+ expect(new Path("dir/").dirname.toString()).toEqual(".");
58
+ });
59
+
60
+ test(".basename", async () => {
61
+ expect(new Path("/").basename.toString()).toEqual("."); // TODO?
62
+ expect(new Path("dir").basename.toString()).toEqual("dir");
63
+ expect(new Path("dir/").basename.toString()).toEqual("dir");
64
+ expect(Path.xdg.config.join("foo/bar.json").basename.toString()).toEqual(
65
+ "bar.json",
66
+ );
67
+ });
68
+
69
+ test(".extension", async () => {
70
+ expect(new Path("foo.txt").extension).toEqual(".txt");
71
+ expect(new Path("foo.").extension).toEqual(".");
72
+ expect(new Path("foo").extension).toEqual("");
73
+ expect(() => new Path("dir/").extension).toThrow();
74
+ expect(() => new Path("/").extension).toThrow();
75
+ });
76
+
77
+ test(".extname", async () => {
78
+ expect(new Path("foo.txt").extname).toEqual(".txt");
79
+ expect(new Path("foo.").extname).toEqual(".");
80
+ expect(new Path("foo").extname).toEqual("");
81
+ expect(() => new Path("dir/").extname).toThrow();
82
+ expect(() => new Path("/").extname).toThrow();
83
+ });
84
+
85
+ test(".existsAsFile()", async () => {
86
+ const filePath = (await Path.makeTempDir()).join("file.txt");
87
+ expect(await filePath.exists()).toBe(false);
88
+ expect(await filePath.exists({ mustBe: "file" })).toBe(false);
89
+ expect(await filePath.exists({ mustBe: "directory" })).toBe(false);
90
+ expect(await filePath.existsAsFile()).toBe(false);
91
+ await filePath.write("test");
92
+ expect(await filePath.exists()).toBe(true);
93
+ expect(await filePath.exists({ mustBe: "file" })).toBe(true);
94
+ expect(() => filePath.exists({ mustBe: "directory" })).toThrow(
95
+ /Path exists but is not a directory/,
96
+ );
97
+ expect(await filePath.existsAsFile()).toBe(true);
98
+ });
99
+
100
+ test(".existsAsDir()", async () => {
101
+ const filePath = await Path.makeTempDir();
102
+ expect(await filePath.exists()).toBe(true);
103
+ expect(() => filePath.exists({ mustBe: "file" })).toThrow(
104
+ /Path exists but is not a file/,
105
+ );
106
+ expect(await filePath.exists({ mustBe: "directory" })).toBe(true);
107
+ expect(await filePath.existsAsDir()).toBe(true);
108
+ await filePath.trash();
109
+ expect(await filePath.exists()).toBe(false);
110
+ expect(await filePath.exists({ mustBe: "file" })).toBe(false);
111
+ expect(await filePath.exists({ mustBe: "directory" })).toBe(false);
112
+ expect(await filePath.existsAsDir()).toBe(false);
113
+ });
114
+
115
+ test("mkdir (un-nested)", async () => {
116
+ const dir = (await Path.makeTempDir()).join("mkdir-test");
117
+ expect(await dir.exists()).toBe(false);
118
+ await dir.mkdir();
119
+ expect(await dir.exists()).toBe(true);
120
+ });
121
+
122
+ test("mkdir (nested)", async () => {
123
+ const dir = (await Path.makeTempDir()).join("mkdir-test/nested");
124
+ expect(await dir.exists()).toBe(false);
125
+ expect(() => dir.mkdir({ recursive: false })).toThrow("no such file");
126
+ await dir.mkdir();
127
+ expect(await dir.exists()).toBe(true);
128
+ });
129
+
130
+ test(".rename()", async () => {
131
+ const parentDir = await Path.makeTempDir();
132
+ const file1 = parentDir.join("file1.txt");
133
+ const file2 = parentDir.join("file2.txt");
134
+
135
+ await file1.write("hello world");
136
+ expect(await file1.exists()).toBe(true);
137
+ expect(await file2.exists()).toBe(false);
138
+
139
+ await file1.rename(file2);
140
+ expect(await file1.exists()).toBe(false);
141
+ expect(await file2.exists()).toBe(true);
142
+ });
143
+
144
+ test(".makeTempDir(…)", async () => {
145
+ const tempDir = await Path.makeTempDir();
146
+ expect(tempDir.toString()).toContain("/js-temp-");
147
+ expect(tempDir.basename.toString()).toStartWith("js-temp-");
148
+ expect(await tempDir.existsAsDir()).toBe(true);
149
+
150
+ const tempDir2 = await Path.makeTempDir("foo");
151
+ expect(tempDir2.toString()).not.toContain("/js-temp-");
152
+ expect(tempDir2.basename.toString()).toStartWith("foo");
153
+ });
154
+
155
+ test("trash", async () => {
156
+ const tempDir = await Path.makeTempDir();
157
+ expect(await tempDir.exists()).toBe(true);
158
+ await tempDir.trash();
159
+ expect(await tempDir.exists()).toBe(false);
160
+ });
161
+
162
+ test("rm (file)", async () => {
163
+ const file = (await Path.makeTempDir()).join("file.txt");
164
+ await file.write("");
165
+ expect(await file.existsAsFile()).toBe(true);
166
+ await file.rm();
167
+ expect(await file.existsAsFile()).toBe(false);
168
+ expect(await file.parent.existsAsDir()).toBe(true);
169
+ expect(async () => file.rm()).toThrowError(/ENOENT/);
170
+ });
171
+
172
+ test("rm (folder)", async () => {
173
+ const tempDir = await Path.makeTempDir();
174
+ const file = tempDir.join("file.txt");
175
+ await file.write("");
176
+ expect(await tempDir.existsAsDir()).toBe(true);
177
+ expect(async () => tempDir.rm()).toThrowError(/EACCES/);
178
+ await file.rm();
179
+ await tempDir.rm({ recursive: true });
180
+ expect(await tempDir.existsAsDir()).toBe(false);
181
+ expect(async () => tempDir.rm()).toThrowError(/ENOENT/);
182
+ });
183
+
184
+ test("rm_rf (file)", async () => {
185
+ const file = (await Path.makeTempDir()).join("file.txt");
186
+ await file.write("");
187
+ expect(await file.existsAsFile()).toBe(true);
188
+ await file.rm_rf();
189
+ expect(await file.existsAsFile()).toBe(false);
190
+ expect(await file.parent.existsAsDir()).toBe(true);
191
+ await file.rm_rf();
192
+ expect(await file.existsAsFile()).toBe(false);
193
+ });
194
+
195
+ test("rm_rf (folder)", async () => {
196
+ const tempDir = await Path.makeTempDir();
197
+ await tempDir.join("file.txt").write("");
198
+ expect(tempDir.toString()).toContain("/js-temp-");
199
+ expect(await tempDir.exists()).toBe(true);
200
+ await tempDir.rm_rf();
201
+ expect(await tempDir.exists()).toBe(false);
202
+ await tempDir.rm_rf();
203
+ expect(await tempDir.exists()).toBe(false);
204
+ });
205
+
206
+ test(".fileText()", async () => {
207
+ const file = (await Path.makeTempDir()).join("file.txt");
208
+ await file.write("hi");
209
+ await file.write("bye");
210
+
211
+ expect(await file.fileText()).toBe("bye");
212
+ expect(await readFile(file.toString(), "utf-8")).toBe("bye");
213
+ });
214
+
215
+ test(".fileJSON()", async () => {
216
+ const file = (await Path.makeTempDir()).join("file.json");
217
+ await file.write(JSON.stringify({ foo: "bar" }));
218
+
219
+ expect(await file.fileJSON()).toEqual<Record<string, string>>({ foo: "bar" });
220
+ expect(await file.fileJSON<Record<string, string>>()).toEqual({ foo: "bar" });
221
+ expect(await JSON.parse(await readFile(file.toString(), "utf-8"))).toEqual<
222
+ Record<string, string>
223
+ >({ foo: "bar" });
224
+ });
225
+
226
+ test(".write(…)", async () => {
227
+ const file = (await Path.makeTempDir()).join("file.json");
228
+ await file.write("foo");
229
+
230
+ expect(await file.fileText()).toEqual("foo");
231
+ });
232
+
233
+ test(".writeJSON(…)", async () => {
234
+ const file = (await Path.makeTempDir()).join("file.json");
235
+ await file.writeJSON({ foo: "bar" });
236
+
237
+ expect(await file.fileJSON()).toEqual<Record<string, string>>({ foo: "bar" });
238
+ });
239
+
240
+ test("homedir", async () => {
241
+ expect(Path.homedir.toString()).toEqual("/mock/home/dir");
242
+ });
243
+
244
+ test("XDG", async () => {
245
+ expect(Path.xdg.cache.toString()).toEqual("/mock/home/dir/.cache");
246
+ expect(Path.xdg.config.toString()).toEqual("/xdg/config");
247
+ expect(Path.xdg.data.toString()).toEqual("/mock/home/dir/.local/share");
248
+ expect(Path.xdg.state.toString()).toEqual("/mock/home/dir/.local/state");
249
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,15 @@
1
- import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { basename, dirname, join } from "node:path";
1
+ import {
2
+ cp,
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { basename, dirname, extname, join } from "node:path";
4
13
  import { fileURLToPath } from "node:url";
5
14
  import { default as trash } from "trash";
6
15
  import { xdgCache, xdgConfig, xdgData, xdgState } from "xdg-basedir";
@@ -26,7 +35,7 @@ export class Path {
26
35
  throw new Error("Invalid path");
27
36
  }
28
37
 
29
- #setNormalizedPath(path: string) {
38
+ #setNormalizedPath(path: string): void {
30
39
  this.#path = join(path);
31
40
  }
32
41
 
@@ -53,8 +62,37 @@ export class Path {
53
62
  return new Path(dirname(this.#path));
54
63
  }
55
64
 
56
- get basename(): string {
57
- return basename(this.#path);
65
+ // Normally I'd stick with `node`'s name, but I think `.dirname` is a
66
+ // particularly poor name. So we support `.dirname` for discovery but mark it
67
+ // as deprecated, even if it will never be removed.
68
+ /** @deprecated Alias for `.parent`. */
69
+ get dirname(): Path {
70
+ return this.parent;
71
+ }
72
+
73
+ get basename(): Path {
74
+ return new Path(basename(this.#path));
75
+ }
76
+
77
+ get extension(): string {
78
+ this.#mustNotHaveTrailingSlash();
79
+ return extname(this.#path);
80
+ }
81
+
82
+ // Normally I'd stick with `node`'s name, but I think `.extname` is a
83
+ // particularly poor name. So we support `.extname` for discovery but mark it
84
+ // as deprecated, even if it will never be removed.
85
+ /** @deprecated Alias for `.extension`. */
86
+ get extname(): string {
87
+ return this.extension;
88
+ }
89
+
90
+ #mustNotHaveTrailingSlash(): void {
91
+ if (this.#path.endsWith("/")) {
92
+ throw new Error(
93
+ "Path ends with a slash, which cannot be treated as a file.",
94
+ );
95
+ }
58
96
  }
59
97
 
60
98
  async exists(constraints?: {
@@ -75,6 +113,7 @@ export class Path {
75
113
  }
76
114
  switch (constraints?.mustBe) {
77
115
  case "file": {
116
+ this.#mustNotHaveTrailingSlash();
78
117
  if (stats.isFile()) {
79
118
  return true;
80
119
  }
@@ -100,18 +139,59 @@ export class Path {
100
139
  return this.exists({ mustBe: "directory" });
101
140
  }
102
141
 
103
- async mkdir(options: Parameters<typeof mkdir>[1]): Promise<void> {
104
- await mkdir(this.#path, options);
142
+ // I don't think `mkdir` is a great name, but it does match the
143
+ // well-established canonical commandline name. So in this case we keep the
144
+ // awkward abbreviation.
145
+ /** Defaults to `recursive: true`. */
146
+ async mkdir(options?: Parameters<typeof mkdir>[1]): Promise<Path> {
147
+ const optionsObject = (() => {
148
+ if (typeof options === "string" || typeof options === "number") {
149
+ return { mode: options };
150
+ }
151
+ return options ?? {};
152
+ })();
153
+ await mkdir(this.#path, { recursive: true, ...optionsObject });
154
+ return this;
105
155
  }
106
156
 
107
157
  // TODO: check idempotency semantics when the destination exists and is a folder.
108
- async copy(destination: string | URL | Path): Promise<void> {
109
- await cp(this.#path, new Path(destination).#path);
158
+ /** Returns the destination path. */
159
+ async cp(
160
+ destination: string | URL | Path,
161
+ options?: Parameters<typeof cp>[2],
162
+ ): Promise<Path> {
163
+ await cp(this.#path, new Path(destination).#path, options);
164
+ return new Path(destination);
110
165
  }
111
166
 
112
167
  // TODO: check idempotency semantics when the destination exists and is a folder.
168
+ async rename(destination: string | URL | Path): Promise<void> {
169
+ await rename(this.#path, new Path(destination).#path);
170
+ }
171
+
172
+ /** Create a temporary dir inside the global temp dir for the current user. */
173
+ static async makeTempDir(prefix?: string): Promise<Path> {
174
+ return new Path(
175
+ await mkdtemp(new Path(tmpdir()).join(prefix ?? "js-temp-").toString()),
176
+ );
177
+ }
178
+
113
179
  async trash(): Promise<void> {
114
- await trash(this.#path);
180
+ await trash(this.#path, { glob: false });
181
+ }
182
+
183
+ async rm(options?: Parameters<typeof rm>[1]): Promise<void> {
184
+ await rm(this.#path, options);
185
+ }
186
+
187
+ /**
188
+ * Equivalent to:
189
+ *
190
+ * .rm({ recursive: true, force: true, ...(options ?? {}) })
191
+ *
192
+ */
193
+ async rm_rf(options?: Parameters<typeof rm>[1]): Promise<void> {
194
+ await this.rm({ recursive: true, force: true, ...(options ?? {}) });
115
195
  }
116
196
 
117
197
  async fileText(): Promise<string> {
@@ -122,8 +202,33 @@ export class Path {
122
202
  return JSON.parse(await this.fileText());
123
203
  }
124
204
 
125
- async write(s: string): Promise<void> {
126
- await writeFile(this.#path, s);
205
+ /** Returns the original `Path` (for chaining). */
206
+ async write(
207
+ data: Parameters<typeof writeFile>[1],
208
+ options?: Parameters<typeof writeFile>[2],
209
+ ): Promise<Path> {
210
+ await writeFile(this.#path, data, options);
211
+ return this;
212
+ }
213
+
214
+ /**
215
+ * If only `data` is provided, this is equivalent to:
216
+ *
217
+ * .write(JSON.stringify(data, null, " "));
218
+ *
219
+ * `replacer` and `space` can also be specified, making this equivalent to:
220
+ *
221
+ * .write(JSON.stringify(data, replacer, space));
222
+ *
223
+ * Returns the original `Path` (for chaining).
224
+ */
225
+ async writeJSON<T>(
226
+ data: T,
227
+ replacer: Parameters<typeof JSON.stringify>[1] = null,
228
+ space: Parameters<typeof JSON.stringify>[2] = " ",
229
+ ): Promise<Path> {
230
+ await this.write(JSON.stringify(data, replacer, space));
231
+ return this;
127
232
  }
128
233
 
129
234
  static get homedir(): Path {
@@ -0,0 +1,19 @@
1
+ import { mock } from "bun:test";
2
+ import nodeOS from "node:os";
3
+
4
+ const nodeOSMocked = {
5
+ ...nodeOS,
6
+ homedir: () => "/mock/home/dir",
7
+ };
8
+ // biome-ignore lint/suspicious/noExplicitAny: This isn't worth wrangling types for.
9
+ (nodeOSMocked as any).default = nodeOSMocked; // Needed because `xdg-basedir` imports the default.
10
+
11
+ mock.module("node:os", () => {
12
+ return nodeOSMocked;
13
+ });
14
+
15
+ mock.module("os", () => {
16
+ return nodeOSMocked;
17
+ });
18
+
19
+ process.env = { XDG_CONFIG_HOME: "/xdg/config" };