| 1 | const unique = new Map<string, WeakRef<Path>>(); |
| 2 | const final = new FinalizationRegistry((raw: string) => unique.delete(raw)); |
| 3 | |
| 4 | export 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 | |
| 211 | import { ASSERT } from "@clo/lib/assert"; |
| 212 | import * as exception from "@clo/lib/exception"; |
| 213 | import type { RmOptions } from "node:fs"; |
| 214 | import * as path from "node:path"; |
| 215 | import * as util from "node:util"; |
| 216 | import * as fs from "./fs.ts"; |