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