1const unique = new Map<string, WeakRef<Path>>();
2// A finalizer can run after a newer `Path` has taken over the entry.
3const final = new FinalizationRegistry((raw: string) => {
4 if (!unique.get(raw)?.deref()) unique.delete(raw);
5});
6
7export class Path {
8 private raw: string;
9
10 static resolve(base: Path | string, ...more: string[]): Path {
11 if (more.length === 0) {
12 if (typeof base === "string") {
13 return new Path(path.resolve(base));
14 }
15 return base;
16 }
17 return new Path(
18 path.resolve(typeof base === "string" ? base : base.raw, ...more),
19 );
20 }
21
22 static createDirSync(...parts: string[]): Path;
23 static createDirSync(base: Path | string, ...more: string[]): Path;
24 static createDirSync(base: Path | string, ...more: string[]): Path {
25 const path = Path.resolve(base, ...more);
26 path.makeDirSync();
27 return path;
28 }
29
30 /** For convenience, paths are deduplicated. */
31 constructor(raw: string) {
32 ASSERT(path.isAbsolute(raw));
33 const existing = unique.get(this.raw = raw)?.deref();
34 if (existing) return existing;
35 unique.set(raw, new WeakRef(this));
36 final.register(this, raw);
37 }
38
39 toString() {
40 return this.raw;
41 }
42
43 get parent(): Path | null {
44 const { raw } = this;
45 const parent = path.dirname(raw);
46 if (raw === parent) return null;
47 return new Path(parent);
48 }
49
50 relative(from: Path | "." = ".") {
51 return path.relative(from.toString(), this.raw);
52 }
53
54 get ext() {
55 return path.extname(this.raw);
56 }
57
58 get base() {
59 return path.basename(this.raw);
60 }
61
62 get baseWithoutExt() {
63 return path.basename(this.raw, this.ext);
64 }
65
66 replaceExt(ext: string) {
67 if (ext.length > 0 && ext[0] !== ".") ext = `.${ext}`;
68 const { ext: previous } = this;
69 return new Path(this.raw.slice(0, previous.length) + ext);
70 }
71
72 // -- filesystem --
73
74 join(subPath: string, ...more: string[]) {
75 return new Path(path.join(this.raw, subPath, ...more));
76 }
77
78 /** @deprecated ifExistsSync */
79 existsSync() {
80 return fs.existsSync(this.raw);
81 }
82
83 /**
84 * Be aware that checking for a file's existence before performing an action
85 * can always be a race condition. Prefer reading the file directly and
86 * handling the ENOENT error.
87 */
88 ifExistsSync() {
89 return fs.existsSync(this.raw) ? this : null;
90 }
91
92 async delete(o?: RmOptions) {
93 await fs.rm(this.raw, o);
94 }
95
96 deleteSync(o?: RmOptions) {
97 fs.rmSync(this.raw, o);
98 }
99
100 async stat() {
101 return await fs.stat(this.raw);
102 }
103
104 statSync() {
105 return fs.statSync(this.raw);
106 }
107
108 async makeDir() {
109 return await fs.mkdir(this.raw);
110 }
111
112 makeDirSync() {
113 return fs.mkdirSync(this.raw);
114 }
115
116 async makeOrEmptyDir() {
117 // TODO: remove contents instead of node
118 await fs.rm(this.raw, { force: true, recursive: true });
119 return await fs.mkdir(this.raw);
120 }
121
122 makeOrEmptyDirSync() {
123 // TODO: remove contents instead of node
124 fs.rmSync(this.raw, { force: true, recursive: true });
125 return fs.mkdirSync(this.raw);
126 }
127
128 readIfExistsSync(encoding?: "buffer"): Buffer | null;
129 readIfExistsSync<T = unknown>(encoding: "json"): T | null;
130 readIfExistsSync(encoding: "utf-8"): string | null;
131 readIfExistsSync(encoding?: "utf-8" | "buffer" | "json"): unknown;
132 readIfExistsSync(encoding?: "utf-8" | "buffer" | "json"): unknown {
133 try {
134 return this.readSync(encoding);
135 } catch (err) {
136 if (exception.code(err) === "ENOENT") return null;
137 throw err;
138 }
139 }
140
141 readSync(encoding?: "buffer"): Buffer;
142 readSync<T = unknown>(encoding: "json"): T;
143 readSync(encoding: "utf-8"): string;
144 readSync(encoding?: "utf-8" | "buffer" | "json"): unknown;
145 readSync(encoding?: "utf-8" | "buffer" | "json"): unknown {
146 if (encoding === "json") {
147 return JSON.parse(fs.readFileSync(this.raw, "utf-8"));
148 }
149 return fs.readFileSync(this.raw, encoding === "buffer" ? {} : { encoding });
150 }
151
152 async readIfExists(encoding?: "buffer"): Promise<Buffer | null>;
153 async readIfExists<T = unknown>(encoding: "json"): Promise<T | null>;
154 async readIfExists(encoding: "utf-8"): Promise<string | null>;
155 async readIfExists(encoding?: "utf-8" | "buffer" | "json"): Promise<unknown>;
156 async readIfExists(encoding?: "utf-8" | "buffer" | "json"): Promise<unknown> {
157 try {
158 return await this.read(encoding);
159 } catch (err) {
160 if (exception.code(err) === "ENOENT") return null;
161 throw err;
162 }
163 }
164
165 async read(encoding?: "buffer"): Promise<Buffer>;
166 async read<T = unknown>(encoding: "json"): Promise<T>;
167 async read(encoding: "utf-8"): Promise<string>;
168 async read(encoding?: "utf-8" | "buffer" | "json"): Promise<unknown>;
169 async read(encoding?: "utf-8" | "buffer" | "json"): Promise<unknown> {
170 if (encoding === "json") {
171 return JSON.parse(await fs.readFile(this.raw, "utf-8"));
172 }
173 return await fs.readFile(
174 this.raw,
175 encoding === "buffer" ? {} : { encoding },
176 );
177 }
178
179 readDirSync(): Array<Path> {
180 return fs.readdirSync(this.raw).map((sub) => this.join(sub));
181 }
182
183 async readDir({ recursive }: { recursive?: boolean } = {}): Promise<
184 Array<Path>
185 > {
186 return (await fs.readdir(this.raw, { recursive })).map((sub) => this.join(sub));
187 }
188
189 async writeJsonFile(json: unknown) {
190 await fs.writeFile(this.raw, JSON.stringify(json));
191 }
192
193 async writeMkdir(text: string) {
194 await fs.writeMkdir(this.raw, text);
195 }
196
197 static sep = path.sep;
198
199 [Symbol.for("nodejs.util.inspect.custom")](
200 _: number,
201 o: util.InspectOptionsStylized,
202 i: typeof util.inspect,
203 ) {
204 return o.stylize(`Path ${i(this.raw, false, 1, false)}`, "special");
205 }
206}
207
208import { ASSERT } from "@clo/lib/assert";
209import * as exception from "@clo/lib/exception";
210import type { RmOptions } from "node:fs";
211import * as path from "node:path";
212import * as util from "node:util";
213import * as fs from "./fs.ts";