path-class 0.12.1 → 0.13.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/src/Path.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import {
2
3
  appendFile,
3
4
  chmod,
@@ -19,6 +20,7 @@ import {
19
20
  import { homedir, tmpdir } from "node:os";
20
21
  import { basename, dirname, extname, join } from "node:path";
21
22
  import { cwd } from "node:process";
23
+ import { createInterface } from "node:readline/promises";
22
24
  import { Readable } from "node:stream";
23
25
  import { fileURLToPath, pathToFileURL } from "node:url";
24
26
  import {
@@ -34,6 +36,7 @@ import type {
34
36
  readFileType,
35
37
  statType,
36
38
  } from "./modifiedNodeTypes";
39
+ import { stringifyIfPath } from "./stringifyfIfPath";
37
40
 
38
41
  // Note that (non-static) functions in this file are defined using `function(…)
39
42
  // { … }` rather than arrow functions, specifically because we want `this` to
@@ -74,6 +77,30 @@ export function resolutionPrefix(pathString: string): ResolutionPrefix {
74
77
  return ResolutionPrefix.Bare;
75
78
  }
76
79
 
80
+ const DEFAULT_TEMP_PREFIX = "js-temp-";
81
+ const DEFAULT_TEMP_FILE_NAME = "file";
82
+
83
+ function preserveRelativeResolutionPrefix(
84
+ pathString: string,
85
+ from: string,
86
+ ): string {
87
+ if (
88
+ resolutionPrefix(from) === ResolutionPrefix.Relative &&
89
+ resolutionPrefix(pathString) !== ResolutionPrefix.Relative
90
+ ) {
91
+ // We don't have to handle the case of `"."`, as it already starts with `"."`
92
+ return `./${pathString}`;
93
+ }
94
+ return pathString;
95
+ }
96
+
97
+ function joinPreservingRelativeResolutionPrefix(
98
+ base: string,
99
+ relative: string[],
100
+ ): string {
101
+ return preserveRelativeResolutionPrefix(join(base, ...relative), base);
102
+ }
103
+
77
104
  export class Path {
78
105
  // @ts-expect-error ts(2564): False positive. https://github.com/microsoft/TypeScript/issues/32194
79
106
  #path: string;
@@ -85,6 +112,27 @@ export class Path {
85
112
  this.#setNormalizedPath(s);
86
113
  }
87
114
 
115
+ static #pathlikeToString(path: string | URL | Path): string {
116
+ if (path instanceof Path) {
117
+ return path.#path;
118
+ }
119
+ if (path instanceof URL) {
120
+ return fileURLToPath(path);
121
+ }
122
+ if (typeof path === "string") {
123
+ // TODO: allow turning off this heuristic?
124
+ if (path.startsWith("file:///")) {
125
+ return fileURLToPath(path);
126
+ }
127
+ return path;
128
+ }
129
+ throw new Error("Invalid path");
130
+ }
131
+
132
+ // Preserves the `ResolutionPrefix` status when possible.
133
+ #setNormalizedPath(path: string): void {
134
+ this.#path = joinPreservingRelativeResolutionPrefix(path, []);
135
+ }
88
136
  static fromString(s: string): Path {
89
137
  if (typeof s !== "string") {
90
138
  throw new Error(
@@ -124,31 +172,80 @@ export class Path {
124
172
  return new Path(new URL(Path.#pathlikeToString(path), baseURL));
125
173
  }
126
174
 
127
- static #pathlikeToString(path: string | URL | Path): string {
128
- if (path instanceof Path) {
129
- return path.#path;
175
+ /**
176
+ * Convenience function. The following are equivalent:
177
+ *
178
+ * B.resolve(A);
179
+ * Path.resolve(A, B);
180
+ *
181
+ */
182
+ resolve(path: string | URL | Path): Path {
183
+ return Path.resolve(path, this);
184
+ }
185
+
186
+ /**
187
+ * Computes the relative path from an ancestor (this) to another path.
188
+ *
189
+ * - If the path is a descendant *or is the same path*, this returns a
190
+ * relative path. Call {@link Path.asBare `.asBare()`} on the output if
191
+ * needed.
192
+ * - The output is `null` unless the input paths are:
193
+ * - Both absolute paths.
194
+ * - Both relative paths. Resolve the paths before passing to this function
195
+ * if needed.
196
+ * - Trailing slashes:
197
+ * - By default, the ancestor path must have a trailing slash for the
198
+ * function to be called. Pass `{ requireTrailingSlashForAncestor: false
199
+ * }` if needed. Note that recombining the ancestor path with the output
200
+ * using {@link Path.prototype.resolve `.resolve(…)` } does not result in
201
+ * the original input path if the ancestor path did not have a trailing
202
+ * slash.
203
+ * - For the descendant/same path check, trailing slashes are ignored. In
204
+ * particular, if the ancestor path has a trailing slash and the
205
+ * descendant path is the same path without a trailing slash, this is
206
+ * still considered to be the same path.
207
+ * - The output has trailing slash if and only if:
208
+ * - the input descendant does, or
209
+ * - the output is the absolute path `/`.
210
+ */
211
+ descendantRelativePath(
212
+ potentialDescendant: string | URL | Path,
213
+ options?: { requireTrailingSlashForAncestor: boolean },
214
+ ): Path | null {
215
+ const requireTrailingSlashForAncestor =
216
+ options?.requireTrailingSlashForAncestor ?? true;
217
+ if (requireTrailingSlashForAncestor && !this.hasTrailingSlash()) {
218
+ throw new Error(
219
+ "Ancestor must have a trailing slash. Pass `{ requireTrailingSlashForAncestor: false }` if needed.",
220
+ );
130
221
  }
131
- if (path instanceof URL) {
132
- return fileURLToPath(path);
222
+
223
+ const other = new Path(potentialDescendant);
224
+ if (this.isAbsolutePath() !== other.isAbsolutePath()) {
225
+ return null;
133
226
  }
134
- if (typeof path === "string") {
135
- // TODO: allow turning off this heuristic?
136
- if (path.startsWith("file:///")) {
137
- return fileURLToPath(path);
227
+
228
+ // Leading slashes are okay, as they will result in a `""` component for
229
+ // absolute paths (and we don't compare absolute paths to relative paths.)
230
+ const thisParts = this.toggleTrailingSlash(false).#path.split("/");
231
+ const otherParts = other.toggleTrailingSlash(false).#path.split("/");
232
+
233
+ if (otherParts.length < thisParts.length) {
234
+ return null;
235
+ }
236
+ for (let i = 0; i < thisParts.length; i++) {
237
+ console.log(i, thisParts[i], otherParts[i]);
238
+ if (thisParts[i] !== otherParts[i]) {
239
+ return null;
138
240
  }
139
- return path;
140
241
  }
141
- throw new Error("Invalid path");
242
+ return new Path("./")
243
+ .join(...otherParts.slice(thisParts.length))
244
+ .toggleTrailingSlash(other.hasTrailingSlash());
142
245
  }
143
246
 
144
- // Preserves the `ResolutionPrefix` status when possible.
145
- #setNormalizedPath(path: string): void {
146
- const prefix = resolutionPrefix(path);
147
- this.#path = join(path);
148
- if (prefix === ResolutionPrefix.Relative && !this.#path.startsWith(".")) {
149
- // We don't have to handle the case of `"."`, as it already starts with `"."`
150
- this.#path = `./${this.#path}`;
151
- }
247
+ unresolve(path: string | URL | Path): Path {
248
+ return Path.resolve(path, this);
152
249
  }
153
250
 
154
251
  isAbsolutePath(): boolean {
@@ -235,7 +332,9 @@ export class Path {
235
332
  }
236
333
  return s;
237
334
  });
238
- return new Path(join(this.#path, ...segmentStrings));
335
+ return new Path(
336
+ joinPreservingRelativeResolutionPrefix(this.#path, segmentStrings),
337
+ );
239
338
  }
240
339
 
241
340
  /**
@@ -494,7 +593,47 @@ export class Path {
494
593
  * */
495
594
  static async makeTempDir(prefix?: string): Promise<AsyncDisposablePath> {
496
595
  return new AsyncDisposablePath(
497
- await mkdtemp(new Path(tmpdir()).join(prefix ?? "js-temp-").toString()),
596
+ await mkdtemp(
597
+ new Path(tmpdir()).join(prefix ?? DEFAULT_TEMP_PREFIX).toString(),
598
+ ),
599
+ );
600
+ }
601
+
602
+ /**
603
+ * Return a path:
604
+ *
605
+ * - whose parent dir is a temp dir that *has* been created, but
606
+ * - which has itself not yet been created.
607
+ *
608
+ * Note that this path can actually also be used to create dir, but it is most
609
+ * convenient to get a path for a temporary file that can be written to, while
610
+ * having a disposal implementation that cleans everything up:
611
+ *
612
+ * await using tempFile = await Path.tempFilePath({ basename: "foo.txt" });
613
+ * await tempFile.write("hello world!");
614
+ * // …
615
+ *
616
+ * Note that that the following are equivalent when *not* using `await using`:
617
+ *
618
+ * await Path.tempFilePath({ basename: "foo.txt" });
619
+ * (await Path.makeTempDir()).join("file.txt");
620
+ *
621
+ * However, it is recommended to use `await using` to ensure cleanup.
622
+ */
623
+ static async tempFilePath(options: {
624
+ tempDirPrefix?: string;
625
+ basename?: string | Path;
626
+ }): Promise<AsyncDisposablePath> {
627
+ const tempDir = new Path(
628
+ await mkdtemp(
629
+ new Path(tmpdir())
630
+ .join(options?.tempDirPrefix ?? DEFAULT_TEMP_PREFIX)
631
+ .toString(),
632
+ ),
633
+ );
634
+ return new AsyncDisposablePath(
635
+ tempDir.join(options?.basename ?? DEFAULT_TEMP_FILE_NAME),
636
+ { disposePathInstead: tempDir },
498
637
  );
499
638
  }
500
639
 
@@ -524,6 +663,18 @@ export class Path {
524
663
  return readFile(this.#path, "utf-8");
525
664
  }
526
665
 
666
+ /**
667
+ * Yields one line from the text of the line at a time.
668
+ *
669
+ * This uses streams, so it can be considerably more efficient than calling e.g. `.split("\n")` on the output of {@link readText `.readText()`}.
670
+ *
671
+ * Note that this function does not have a `.readLinesSync()` counterpart.
672
+ */
673
+ async *readLines(): AsyncIterable<string> {
674
+ const stream = createReadStream(this.#path, "utf-8");
675
+ yield* createInterface({ input: stream, terminal: false });
676
+ }
677
+
527
678
  /**
528
679
  * Reads JSON from the given file and parses it. No validation is performed
529
680
  * (beyond JSON parsing).
@@ -697,25 +848,22 @@ export class Path {
697
848
  }
698
849
 
699
850
  export class AsyncDisposablePath extends Path {
700
- async [Symbol.asyncDispose]() {
701
- await this.rm_rf();
851
+ #options?: { disposePathInstead: Path };
852
+ constructor(
853
+ path: ConstructorParameters<typeof Path>[0],
854
+ options?: { disposePathInstead: Path | string },
855
+ ) {
856
+ super(path);
857
+ if (options) {
858
+ this.#options = {
859
+ disposePathInstead: new Path(options.disposePathInstead),
860
+ };
861
+ }
702
862
  }
703
- }
704
863
 
705
- /**
706
- * This function is useful to serialize any `Path`s in a structure to pass on to
707
- * functions that do not know about the `Path` class, e.g.
708
- *
709
- * function process(args: (string | Path)[]) {
710
- * const argsAsStrings = args.map(stringifyIfPath);
711
- * }
712
- *
713
- */
714
- export function stringifyIfPath<T>(value: T | Path): T | string {
715
- if (value instanceof Path) {
716
- return value.toString();
717
- }
718
- return value;
864
+ async [Symbol.asyncDispose]() {
865
+ await (this.#options?.disposePathInstead ?? this).rm_rf();
866
+ }
719
867
  }
720
868
 
721
869
  export function mustNotHaveTrailingSlash(path: Path): void {
@@ -725,8 +873,3 @@ export function mustNotHaveTrailingSlash(path: Path): void {
725
873
  );
726
874
  }
727
875
  }
728
-
729
- const tmp = await Path.makeTempDir();
730
- (await tmp.join("foo.json").write("foo")).rename(
731
- tmp.join("sdfsD", "sdfsdfsdf", "sdfsdf.json"),
732
- );
package/src/index.ts CHANGED
@@ -2,5 +2,6 @@ export {
2
2
  AsyncDisposablePath,
3
3
  Path,
4
4
  ResolutionPrefix,
5
- stringifyIfPath,
6
5
  } from "./Path";
6
+
7
+ export { stringifyIfPath } from "./stringifyfIfPath";
@@ -0,0 +1,9 @@
1
+ import { expect, test } from "bun:test";
2
+ import { Path } from "./Path";
3
+ import { stringifyIfPath } from "./stringifyfIfPath";
4
+
5
+ test.concurrent(".stringifyIfPath(…)", async () => {
6
+ expect(stringifyIfPath(Path.homedir)).toBe("/mock/home/dir");
7
+ expect(stringifyIfPath("/mock/home/dir")).toBe("/mock/home/dir");
8
+ expect(stringifyIfPath(4)).toBe(4);
9
+ });
@@ -0,0 +1,17 @@
1
+ import { Path } from "./Path";
2
+
3
+ /**
4
+ * This function is useful to serialize any `Path`s in a structure to pass on to
5
+ * functions that do not know about the `Path` class, e.g.
6
+ *
7
+ * function process(args: (string | Path)[]) {
8
+ * const argsAsStrings = args.map(stringifyIfPath);
9
+ * }
10
+ *
11
+ */
12
+ export function stringifyIfPath<T>(value: Exclude<T, Path> | Path): T | string {
13
+ if (value instanceof Path) {
14
+ return value.toString();
15
+ }
16
+ return value;
17
+ }