From 230ecb598854c192fb2ad1972b4ad70236d971ab Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sat, 11 Feb 2023 22:36:18 -0500 Subject: [PATCH] feat: path API (#97) --- README.md | 51 ++- mod.test.ts | 194 +++++----- mod.ts | 11 +- src/command.ts | 9 +- src/common.ts | 13 - src/deps.test.ts | 22 ++ src/deps.ts | 2 +- src/path.test.ts | 491 +++++++++++++++++++++++++ src/path.ts | 846 ++++++++++++++++++++++++++++++++++++++++++++ src/request.test.ts | 15 +- src/request.ts | 79 +++-- 11 files changed, 1554 insertions(+), 179 deletions(-) create mode 100644 src/path.test.ts create mode 100644 src/path.ts diff --git a/README.md b/README.md index cc7f29b..de8321d 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ Cross platform shell tools for Deno inspired by [zx](https://github.com/google/z ## Differences with zx -1. Minimal globals or global configuration. - - Only a default instance of `$`, but it's not mandatory to use this. -1. No custom CLI. 1. Cross platform shell. - Makes more code work on Windows. - Allows exporting the shell's environment to the current process. - Uses [deno_task_shell](https://github.com/denoland/deno_task_shell)'s parser. +1. Minimal globals or global configuration. + - Only a default instance of `$`, but it's not mandatory to use this. +1. No custom CLI. 1. Good for application code in addition to use as a shell script replacement. 1. Named after my cat. @@ -470,6 +470,49 @@ pb.with(() => { }); ``` +## Path API + +The path API offers an immutable `PathReference` class, which is a similar concept to Rust's `PathBuf` struct. + +To create a `PathReference`, do the following: + +```ts +// create a `PathReference` +let srcDir = $.path("src"); +// get information about the path +srcDir.isDir(); // false +// do actions on it +srcDir.mkdir(); +srcDir.isDir(); // true + +srcDir.isRelative(); // true +srcDir = srcDir.resolve(); // resolve the path to be absolute +srcDir.isRelative(); // false +srcDir.isAbsolute(); // true + +// join to get other paths and do actions on them +const textFile = srcDir.join("file.txt"); +textFile.writeTextSync("some text"); +console.log(textFile.textSync()); // "some text" + +const jsonFile = srcDir.join("subDir", "file.json"); +jsonFile.parentOrThrow().mkdir(); +jsonFile.writeJsonSync({ + someValue: 5, +}); +console.log(jsonFile.jsonSync().someValue); // 5 +``` + +It also works to provide these paths to commands: + +```ts +const srcDir = $.path("src").resolve(); + +await $`echo ${srcDir}`; +``` + +There are a lot of helper methods here, so check the documentation for more details. + ## Helper functions Changing the current working directory of the current process: @@ -541,7 +584,7 @@ This line will appear without any indentation. Empty lines (like the one above) will not affect the common indentation. ``` -Re-export of deno_std's path: +Re-export of deno_std's path (though you might want to just use the path API described above): ```ts $.path.basename("./deno/std/path/mod.ts"); // mod.ts diff --git a/mod.test.ts b/mod.test.ts index 158fba4..12464bd 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,7 +1,13 @@ import { readAll } from "./src/deps.ts"; import $, { build$, CommandBuilder, CommandContext, CommandHandler } from "./mod.ts"; -import { safeLstat } from "./src/common.ts"; -import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "./src/deps.test.ts"; +import { + assert, + assertEquals, + assertRejects, + assertStringIncludes, + assertThrows, + withTempDir, +} from "./src/deps.test.ts"; import { Buffer, colors, path, readerFromStreamReader } from "./src/deps.ts"; Deno.test("should get stdout when piped", async () => { @@ -812,7 +818,7 @@ Deno.test("streaming api errors while streaming", async () => { } { - const child = $`echo 1 && echo 2 && sleep 0.1 && exit 1`.stdout("piped").spawn(); + const child = $`echo 1 && echo 2 && sleep 0.5 && exit 1`.stdout("piped").spawn(); const stdout = child.stdout(); const result = await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable);'` @@ -887,8 +893,7 @@ Deno.test("shebang support", async (t) => { }; step("with -S", async () => { - await Deno.writeTextFile( - $.path.join(dir, "file.ts"), + dir.join("file.ts").writeTextSync( [ "#!/usr/bin/env -S deno run", "console.log(5);", @@ -901,8 +906,7 @@ Deno.test("shebang support", async (t) => { }); step("without -S and invalid", async () => { - await Deno.writeTextFile( - $.path.join(dir, "file2.ts"), + dir.join("file2.ts").writeTextSync( [ "#!/usr/bin/env deno run", "console.log(5);", @@ -920,15 +924,13 @@ Deno.test("shebang support", async (t) => { }); step("without -S, but valid", async () => { - await Deno.writeTextFile( - $.path.join(dir, "echo_stdin.ts"), + dir.join("echo_stdin.ts").writeTextSync( [ "#!/usr/bin/env -S deno run --unstable --allow-run", "await new Deno.Command('deno', { args: ['run', ...Deno.args] }).spawn();", ].join("\n"), ); - await Deno.writeTextFile( - $.path.join(dir, "file3.ts"), + dir.join("file3.ts").writeTextSync( [ "#!/usr/bin/env ./echo_stdin.ts", "console.log('Hello')", @@ -1078,22 +1080,22 @@ Deno.test("environment should be evaluated at command execution", async () => { Deno.test("test remove", async () => { await withTempDir(async (dir) => { - const emptyDir = dir + "/hello"; - const someFile = dir + "/a.txt"; - const notExists = dir + "/notexists"; + const emptyDir = dir.join("hello"); + const someFile = dir.join("a.txt"); + const notExists = dir.join("notexists"); - Deno.mkdirSync(emptyDir); - Deno.writeTextFileSync(someFile, ""); + emptyDir.mkdirSync(); + someFile.writeTextSync(""); // Remove empty directory or file await $`rm ${emptyDir}`; await $`rm ${someFile}`; - assertEquals($.existsSync(dir + "/hello"), false); - assertEquals($.existsSync(dir + "/a.txt"), false); + assertEquals(emptyDir.existsSync(), false); + assertEquals(someFile.existsSync(), false); // Remove a non-empty directory - const nonEmptyDir = dir + "/a"; - Deno.mkdirSync(nonEmptyDir + "/b", { recursive: true }); + const nonEmptyDir = dir.join("a"); + nonEmptyDir.join("b").mkdirSync({ recursive: true }); { const error = await $`rm ${nonEmptyDir}`.noThrow().stderr("piped").spawn() .then((r) => r.stderr); @@ -1104,7 +1106,7 @@ Deno.test("test remove", async () => { } { await $`rm -r ${nonEmptyDir}`; - assertEquals($.existsSync(nonEmptyDir), false); + assertEquals(nonEmptyDir.existsSync(), false); } // Remove a directory that does not exist @@ -1126,19 +1128,6 @@ Deno.test("test remove", async () => { }); }); -async function withTempDir(action: (path: string) => Promise) { - const dirPath = Deno.makeTempDirSync(); - try { - await action(dirPath); - } finally { - try { - await Deno.remove(dirPath, { recursive: true }); - } catch { - // ignore - } - } -} - Deno.test("test mkdir", async () => { await withTempDir(async (dir) => { await $`mkdir ${dir}/a`; @@ -1171,30 +1160,30 @@ Deno.test("test mkdir", async () => { Deno.test("copy test", async () => { await withTempDir(async (dir) => { - const file1 = path.join(dir, "file1.txt"); - const file2 = path.join(dir, "file2.txt"); - Deno.writeTextFileSync(file1, "test"); + const file1 = dir.join("file1.txt"); + const file2 = dir.join("file2.txt"); + file1.writeTextSync("test"); await $`cp ${file1} ${file2}`; - assert($.existsSync(file1)); - assert($.existsSync(file2)); + assert(file1.existsSync()); + assert(file2.existsSync()); - const destDir = path.join(dir, "dest"); - Deno.mkdirSync(destDir); + const destDir = dir.join("dest"); + destDir.mkdirSync(); await $`cp ${file1} ${file2} ${destDir}`; - assert($.existsSync(file1)); - assert($.existsSync(file2)); - assert($.existsSync(path.join(destDir, "file1.txt"))); - assert($.existsSync(path.join(destDir, "file2.txt"))); + assert(file1.existsSync()); + assert(file2.existsSync()); + assert(destDir.join("file1.txt").existsSync()); + assert(destDir.join("file2.txt").existsSync()); - const newFile = path.join(dir, "new.txt"); - Deno.writeTextFileSync(newFile, "test"); + const newFile = dir.join("new.txt"); + newFile.writeTextSync("test"); await $`cp ${newFile} ${destDir}`; - assert(await isDir(destDir)); - assert($.existsSync(newFile)); - assert($.existsSync(path.join(destDir, "new.txt"))); + assert(destDir.isDir()); + assert(newFile.existsSync()); + assert(destDir.join("new.txt").existsSync()); assertEquals( await getStdErr($`cp ${file1} ${file2} non-existent`), @@ -1205,18 +1194,18 @@ Deno.test("copy test", async () => { assertStringIncludes(await getStdErr($`cp ${file1} ""`), "cp: missing destination file operand after"); // recursive test - Deno.mkdirSync(path.join(destDir, "sub_dir")); - Deno.writeTextFileSync(path.join(destDir, "sub_dir", "sub.txt"), "test"); - const destDir2 = path.join(dir, "dest2"); + destDir.join("sub_dir").mkdirSync(); + destDir.join("sub_dir", "sub.txt").writeTextSync("test"); + const destDir2 = dir.join("dest2"); assertEquals(await getStdErr($`cp ${destDir} ${destDir2}`), "cp: source was a directory; maybe specify -r\n"); - assert(!$.existsSync(destDir2)); + assert(!destDir2.existsSync()); await $`cp -r ${destDir} ${destDir2}`; - assert($.existsSync(destDir2)); - assert($.existsSync(path.join(destDir2, "file1.txt"))); - assert($.existsSync(path.join(destDir2, "file2.txt"))); - assert($.existsSync(path.join(destDir2, "sub_dir", "sub.txt"))); + assert(destDir2.existsSync()); + assert(destDir2.join("file1.txt").existsSync()); + assert(destDir2.join("file2.txt").existsSync()); + assert(destDir2.join("sub_dir", "sub.txt").existsSync()); // copy again await $`cp -r ${destDir} ${destDir2}`; @@ -1227,46 +1216,40 @@ Deno.test("copy test", async () => { }); Deno.test("cp test2", async () => { - await withTempDir(async (dir) => { - const originalDir = Deno.cwd(); - try { - Deno.chdir(dir); - await $`mkdir -p a/d1`; - await $`mkdir -p a/d2`; - Deno.createSync("a/d1/f").close(); - await $`cp a/d1/f a/d2`; - assert($.existsSync("a/d2/f")); - } finally { - Deno.chdir(originalDir); - } + await withTempDir(async () => { + await $`mkdir -p a/d1`; + await $`mkdir -p a/d2`; + Deno.createSync("a/d1/f").close(); + await $`cp a/d1/f a/d2`; + assert($.existsSync("a/d2/f")); }); }); Deno.test("move test", async () => { await withTempDir(async (dir) => { - const file1 = path.join(dir, "file1.txt"); - const file2 = path.join(dir, "file2.txt"); - Deno.writeTextFileSync(file1, "test"); + const file1 = dir.join("file1.txt"); + const file2 = dir.join("file2.txt"); + file1.writeTextSync("test"); await $`mv ${file1} ${file2}`; - assert(!$.existsSync(file1)); - assert($.existsSync(file2)); + assert(!file1.existsSync()); + assert(file2.existsSync()); - const destDir = path.join(dir, "dest"); - Deno.writeTextFileSync(file1, "test"); // recreate - Deno.mkdirSync(destDir); + const destDir = dir.join("dest"); + file1.writeTextSync("test"); // recreate + destDir.mkdirSync(); await $`mv ${file1} ${file2} ${destDir}`; - assert(!$.existsSync(file1)); - assert(!$.existsSync(file2)); - assert($.existsSync(path.join(destDir, "file1.txt"))); - assert($.existsSync(path.join(destDir, "file2.txt"))); + assert(!file1.existsSync()); + assert(!file2.existsSync()); + assert(destDir.join("file1.txt").existsSync()); + assert(destDir.join("file2.txt").existsSync()); - const newFile = path.join(dir, "new.txt"); - Deno.writeTextFileSync(newFile, "test"); + const newFile = dir.join("new.txt"); + newFile.writeTextSync("test"); await $`mv ${newFile} ${destDir}`; - assert(await isDir(destDir)); - assert(!$.existsSync(newFile)); - assert($.existsSync(path.join(destDir, "new.txt"))); + assert(destDir.isDir()); + assert(!newFile.existsSync()); + assert(destDir.join("new.txt").existsSync()); assertEquals( await getStdErr($`mv ${file1} ${file2} non-existent`), @@ -1307,11 +1290,6 @@ async function getStdErr(cmd: CommandBuilder) { return await cmd.noThrow().stderr("piped").then((r) => r.stderr); } -async function isDir(path: string) { - const info = await safeLstat(path); - return info?.isDirectory ? true : false; -} - Deno.test("$.commandExists", async () => { assertEquals(await $.commandExists("some-fake-command"), false); assertEquals(await $.commandExists("deno"), true); @@ -1361,24 +1339,18 @@ Empty lines (like the one above) will not affect the common indentation.`.trim() }); Deno.test("touch test", async () => { - await withTempDir(async (dir) => { - const originalDir = Deno.cwd(); - try { - Deno.chdir(dir); - await $`touch a`; - assert($.existsSync("a")); - await $`touch a`; - assert($.existsSync("a")); - - await $`touch b c`; - assert($.existsSync("b")); - assert($.existsSync("c")); - - assertEquals(await getStdErr($`touch`), "touch: missing file operand\n"); - - assertEquals(await getStdErr($`touch --test hello`), "touch: unsupported flag: --test\n"); - } finally { - Deno.chdir(originalDir); - } + await withTempDir(async () => { + await $`touch a`; + assert($.existsSync("a")); + await $`touch a`; + assert($.existsSync("a")); + + await $`touch b c`; + assert($.existsSync("b")); + assert($.existsSync("c")); + + assertEquals(await getStdErr($`touch`), "touch: missing file operand\n"); + + assertEquals(await getStdErr($`touch --test hello`), "touch: unsupported flag: --test\n"); }); }); diff --git a/mod.ts b/mod.ts index df31fba..f189a9c 100644 --- a/mod.ts +++ b/mod.ts @@ -25,9 +25,10 @@ import { select, SelectOptions, } from "./src/console/mod.ts"; -import { colors, fs, outdent, path, which, whichSync } from "./src/deps.ts"; +import { colors, fs, outdent, path as stdPath, which, whichSync } from "./src/deps.ts"; import { wasmInstance } from "./src/lib/mod.ts"; import { RequestBuilder, withProgressBarFactorySymbol } from "./src/request.ts"; +import { createPathReference } from "./src/path.ts"; export { CommandBuilder, CommandResult } from "./src/command.ts"; export type { CommandContext, CommandHandler, CommandPipeReader, CommandPipeWriter } from "./src/command_handler.ts"; @@ -227,8 +228,10 @@ export interface $BuiltInProperties { commandExistsSync(commandName: string): boolean; /** Re-export of deno_std's `fs` module. */ fs: typeof fs; - /** Re-export of deno_std's `path` module. */ - path: typeof path; + /** Helper function for creating path references, which provide an easier way for + * working with paths, directories, and files on the file system. Also, a re-export + * of deno_std's `path` module as properties on this object. */ + path: typeof createPathReference & typeof stdPath; /** * Logs with potential indentation (`$.logIndent`) * and output of commands or request responses. @@ -538,7 +541,7 @@ function buildInitial$State( const helperObject = { fs, - path, + path: Object.assign(createPathReference, stdPath), cd, escapeArg, stripAnsi(text: string) { diff --git a/src/command.ts b/src/command.ts index e700ba8..1bc6225 100644 --- a/src/command.ts +++ b/src/command.ts @@ -24,6 +24,7 @@ import { parseCommand, spawn } from "./shell.ts"; import { cpCommand, mvCommand } from "./commands/cp_mv.ts"; import { isShowingProgressBars } from "./console/progress/interval.ts"; import { touchCommand } from "./commands/touch.ts"; +import { PathReference } from "./path.ts"; type BufferStdio = "inherit" | "null" | "streamed" | Buffer; @@ -292,9 +293,13 @@ export class CommandBuilder implements PromiseLike { } /** Sets the current working directory to use when executing this command. */ - cwd(dirPath: string | URL) { + cwd(dirPath: string | URL | PathReference) { return this.#newWithState((state) => { - state.cwd = dirPath instanceof URL ? path.fromFileUrl(dirPath) : path.resolve(dirPath); + state.cwd = dirPath instanceof URL + ? path.fromFileUrl(dirPath) + : dirPath instanceof PathReference + ? dirPath.resolve().toString() + : path.resolve(dirPath); }); } diff --git a/src/common.ts b/src/common.ts index b031019..7ff6867 100644 --- a/src/common.ts +++ b/src/common.ts @@ -177,19 +177,6 @@ export async function safeLstat(path: string) { } } -/** lstat that doesn't throw when the path is not found. */ -export function safeLstatSync(path: string) { - try { - return Deno.lstatSync(path); - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return undefined; - } else { - throw err; - } - } -} - export function getFileNameFromUrl(url: string | URL) { const parsedUrl = url instanceof URL ? url : new URL(url); const fileName = parsedUrl.pathname.split("/").at(-1); diff --git a/src/deps.test.ts b/src/deps.test.ts index 08134d6..5cff8bf 100644 --- a/src/deps.test.ts +++ b/src/deps.test.ts @@ -1,3 +1,5 @@ +import { createPathReference, PathReference } from "./path.ts"; + export { assert, assertEquals, @@ -7,3 +9,23 @@ export { } from "https://deno.land/std@0.171.0/testing/asserts.ts"; export { writableStreamFromWriter } from "https://deno.land/std@0.171.0/streams/writable_stream_from_writer.ts"; export { serve } from "https://deno.land/std@0.171.0/http/server.ts"; + +/** + * Creates a temporary directory, changes the cwd to this directory, + * then cleans up and restores the cwd when complete. + */ +export async function withTempDir(action: (path: PathReference) => Promise | void) { + const originalDirPath = Deno.cwd(); + const dirPath = Deno.makeTempDirSync(); + Deno.chdir(dirPath); + try { + await action(createPathReference(dirPath).resolve()); + } finally { + try { + await Deno.remove(dirPath, { recursive: true }); + } catch { + // ignore + } + Deno.chdir(originalDirPath); + } +} diff --git a/src/deps.ts b/src/deps.ts index 156dc07..2d69bec 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -5,7 +5,7 @@ export { BufReader } from "https://deno.land/std@0.171.0/io/buf_reader.ts"; export * as path from "https://deno.land/std@0.171.0/path/mod.ts"; export { readAll } from "https://deno.land/std@0.171.0/streams/read_all.ts"; export { readerFromStreamReader } from "https://deno.land/std@0.171.0/streams/reader_from_stream_reader.ts"; -export { writeAllSync } from "https://deno.land/std@0.171.0/streams/write_all.ts"; +export { writeAll, writeAllSync } from "https://deno.land/std@0.171.0/streams/write_all.ts"; export { default as localDataDir } from "https://deno.land/x/dir@1.5.1/data_local_dir/mod.ts"; export { outdent } from "https://deno.land/x/outdent@v0.8.0/src/index.ts"; export { RealEnvironment as DenoWhichRealEnvironment, which, whichSync } from "https://deno.land/x/which@0.2.1/mod.ts"; diff --git a/src/path.test.ts b/src/path.test.ts new file mode 100644 index 0000000..3c3b035 --- /dev/null +++ b/src/path.test.ts @@ -0,0 +1,491 @@ +import { assert, assertEquals, assertRejects, assertThrows, withTempDir } from "./deps.test.ts"; +import { createPathReference } from "./path.ts"; +import { path as stdPath } from "./deps.ts"; + +Deno.test("join", () => { + const path = createPathReference("src"); + const newPath = path.join("other", "test"); + assertEquals(path.toString(), "src"); + assertEquals(newPath.toString(), stdPath.join("src", "other", "test")); +}); + +Deno.test("resolve", () => { + const path = createPathReference("src").resolve(); + assertEquals(path.toString(), stdPath.resolve("src")); +}); + +Deno.test("normalize", () => { + const path = createPathReference("src").normalize(); + assertEquals(path.toString(), stdPath.normalize("src")); +}); + +Deno.test("isDir", () => { + assert(createPathReference("src").isDir()); + assert(!createPathReference("mod.ts").isDir()); + assert(!createPathReference("nonExistent").isDir()); +}); + +Deno.test("isFile", () => { + assert(!createPathReference("src").isFile()); + assert(createPathReference("mod.ts").isFile()); + assert(!createPathReference("nonExistent").isFile()); +}); + +Deno.test("isSymlink", async () => { + await withTempDir(() => { + const path = createPathReference("file.txt").writeTextSync(""); + const newPath = path.createAbsoluteSymlinkAtSync("test.txt"); + assert(newPath.isSymlink()); + assert(!path.isSymlink()); + }); +}); + +Deno.test("isAbsolute", () => { + assert(!createPathReference("src").isAbsolute()); + assert(createPathReference("src").resolve().isAbsolute()); +}); + +Deno.test("isRelative", () => { + assert(createPathReference("src").isRelative()); + assert(!createPathReference("src").resolve().isRelative()); +}); + +Deno.test("parent", () => { + const parent = createPathReference("src").parent()!; + assertEquals(parent.toString(), Deno.cwd()); + const lastParent = Array.from(parent.ancestors()).at(-1)!; + assertEquals(lastParent.parent(), undefined); +}); + +Deno.test("parentOrThrow", () => { + const parent = createPathReference("src").parentOrThrow(); + assertEquals(parent.toString(), Deno.cwd()); + const lastParent = Array.from(parent.ancestors()).at(-1)!; + assertThrows(() => lastParent.parentOrThrow(), Error); +}); + +Deno.test("ancestors", () => { + const srcDir = createPathReference("src").resolve(); + let lastDir = srcDir; + for (const ancestor of srcDir.ancestors()) { + assert(ancestor.toString().length < lastDir.toString().length); + lastDir = ancestor; + } +}); + +Deno.test("stat", async () => { + const stat1 = await createPathReference("src").stat(); + assertEquals(stat1?.isDirectory, true); + const stat2 = await createPathReference("nonExistent").stat(); + assertEquals(stat2, undefined); + + await withTempDir(async () => { + const dir = createPathReference("temp.txt").writeTextSync(""); + const destinationPath = await dir.createAbsoluteSymlinkAt("other.txt"); + const stat3 = await destinationPath.stat(); + assertEquals(stat3!.isFile, true); + assertEquals(stat3!.isSymlink, false); + }); +}); + +Deno.test("statSync", async () => { + const stat1 = createPathReference("src").statSync(); + assertEquals(stat1?.isDirectory, true); + const stat2 = createPathReference("nonExistent").statSync(); + assertEquals(stat2, undefined); + + await withTempDir(() => { + const dir = createPathReference("temp.txt").writeTextSync(""); + const destinationPath = dir.createAbsoluteSymlinkAtSync("other.txt"); + const stat3 = destinationPath.statSync(); + assertEquals(stat3!.isFile, true); + assertEquals(stat3!.isSymlink, false); + }); +}); + +Deno.test("lstat", async () => { + const stat1 = await createPathReference("src").lstat(); + assertEquals(stat1?.isDirectory, true); + const stat2 = await createPathReference("nonExistent").lstat(); + assertEquals(stat2, undefined); + + await withTempDir(async () => { + const dir = createPathReference("temp.txt").writeTextSync(""); + const destinationPath = await dir.createRelativeSymlinkAt("other.txt"); + const stat3 = await destinationPath.lstat(); + assertEquals(stat3!.isSymlink, true); + }); +}); + +Deno.test("lstatSync", async () => { + const stat1 = createPathReference("src").lstatSync(); + assertEquals(stat1?.isDirectory, true); + const stat2 = createPathReference("nonExistent").lstatSync(); + assertEquals(stat2, undefined); + + await withTempDir(() => { + const dir = createPathReference("temp.txt").writeTextSync(""); + const destinationPath = dir.createRelativeSymlinkAtSync("other.txt"); + const stat3 = destinationPath.lstatSync(); + assertEquals(stat3!.isSymlink, true); + }); +}); + +Deno.test("withExtname", () => { + let path = createPathReference("src").resolve(); + path = path.join("temp", "other"); + assertEquals(path.basename(), "other"); + assertEquals(path.extname(), undefined); + path = path.withExtname("test"); + assertEquals(path.basename(), "other.test"); + path = path.withExtname("test2"); + assertEquals(path.basename(), "other.test2"); + assertEquals(path.extname(), ".test2"); + path = path.withExtname(".txt"); + assertEquals(path.basename(), "other.txt"); + assertEquals(path.extname(), ".txt"); +}); + +Deno.test("withBasename", () => { + let path = createPathReference("src").resolve(); + path = path.join("temp", "other"); + assertEquals(path.basename(), "other"); + path = path.withBasename("test"); + assertEquals(path.basename(), "test"); + path = path.withBasename("other.asdf"); + assertEquals(path.basename(), "other.asdf"); +}); + +Deno.test("relative", () => { + const path1 = createPathReference("src"); + const path2 = createPathReference(".github"); + assertEquals( + path1.relative(path2), + Deno.build.os === "windows" ? "..\\.github" : "../.github", + ); +}); + +Deno.test("exists", async () => { + await withTempDir(async () => { + const file = createPathReference("file"); + assert(!await file.exists()); + assert(!file.existsSync()); + file.writeTextSync(""); + assert(await file.exists()); + assert(file.existsSync()); + }); +}); + +Deno.test("realpath", async () => { + await withTempDir(async () => { + let file = createPathReference("file").resolve(); + file.writeTextSync(""); + // need to do realPathSync for GH actions CI + file = file.realPathSync(); + const symlink = file.createAbsoluteSymlinkAtSync("other"); + assertEquals( + (await symlink.realPath()).toString(), + file.toString(), + ); + assertEquals( + symlink.realPathSync().toString(), + file.toString(), + ); + }); +}); + +Deno.test("mkdir", async () => { + await withTempDir(async () => { + const path = createPathReference("dir"); + await path.mkdir(); + assert(path.isDir()); + path.removeSync(); + assert(!path.isDir()); + path.mkdirSync(); + assert(path.isDir()); + const nestedDir = path.join("subdir", "subsubdir", "sub"); + await assertRejects(() => nestedDir.mkdir()); + assertThrows(() => nestedDir.mkdirSync()); + assert(!nestedDir.parentOrThrow().existsSync()); + await nestedDir.mkdir({ recursive: true }); + assert(nestedDir.existsSync()); + await path.remove({ recursive: true }); + assert(!path.existsSync()); + nestedDir.mkdirSync({ recursive: true }); + assert(nestedDir.existsSync()); + }); +}); + +Deno.test("createAbsoluteSymlinkTo", async () => { + await withTempDir(async () => { + const destFile = createPathReference("temp.txt").writeTextSync(""); + const otherFile = destFile.parentOrThrow().join("other.txt"); + await otherFile.createAbsoluteSymlinkTo(destFile); + const stat = await otherFile.stat(); + assertEquals(stat!.isFile, true); + assertEquals(stat!.isSymlink, false); + assert(otherFile.isSymlink()); + }); +}); + +Deno.test("createAbsoluteSymlinkToSync", async () => { + await withTempDir(() => { + const destFile = createPathReference("temp.txt").writeTextSync(""); + const otherFile = destFile.parentOrThrow().join("other.txt"); + otherFile.createAbsoluteSymlinkToSync(destFile); + const stat = otherFile.statSync(); + assertEquals(stat!.isFile, true); + assertEquals(stat!.isSymlink, false); + assert(otherFile.isSymlink()); + }); +}); + +Deno.test("readDir", async () => { + await withTempDir(async () => { + const dir = createPathReference(".").resolve(); + dir.join("file1").writeTextSync(""); + dir.join("file2").writeTextSync(""); + + const entries1 = []; + for await (const entry of dir.readDir()) { + entries1.push(entry.name); + } + entries1.sort(); + const entries2 = []; + for (const entry of dir.readDirSync()) { + entries2.push(entry.name); + } + entries2.sort(); + + for (const entries of [entries1, entries2]) { + assertEquals(entries.length, 2); + assert(entries[0].endsWith("file1")); + assert(entries[1].endsWith("file2")); + } + }); +}); + +Deno.test("bytes", async () => { + await withTempDir(async () => { + const file = createPathReference("file.txt"); + const bytes = new TextEncoder().encode("asdf"); + file.writeSync(bytes); + assertEquals(file.bytesSync(), bytes); + assertEquals(await file.bytes(), bytes); + const nonExistent = createPathReference("not-exists"); + assertThrows(() => nonExistent.bytesSync()); + await assertRejects(() => nonExistent.bytes()); + }); +}); + +Deno.test("maybeBytes", async () => { + await withTempDir(async () => { + const file = createPathReference("file.txt"); + const bytes = new TextEncoder().encode("asdf"); + await file.write(bytes); + assertEquals(file.maybeBytesSync(), bytes); + assertEquals(await file.maybeBytes(), bytes); + const nonExistent = createPathReference("not-exists"); + assertEquals(await nonExistent.maybeText(), undefined); + assertEquals(nonExistent.maybeTextSync(), undefined); + }); +}); + +Deno.test("text", async () => { + await withTempDir(async () => { + const file = createPathReference("file.txt"); + file.writeTextSync("asdf"); + assertEquals(file.maybeTextSync(), "asdf"); + assertEquals(await file.maybeText(), "asdf"); + const nonExistent = createPathReference("not-exists"); + assertThrows(() => nonExistent.textSync()); + await assertRejects(() => nonExistent.text()); + }); +}); + +Deno.test("maybeText", async () => { + await withTempDir(async () => { + const file = createPathReference("file.txt"); + file.writeTextSync("asdf"); + assertEquals(file.maybeTextSync(), "asdf"); + assertEquals(await file.maybeText(), "asdf"); + const nonExistent = createPathReference("not-exists"); + assertEquals(await nonExistent.maybeText(), undefined); + assertEquals(nonExistent.maybeTextSync(), undefined); + }); +}); + +Deno.test("json", async () => { + await withTempDir(async () => { + const file = createPathReference("file.txt"); + file.writeJsonSync({ test: 123 }); + let data = file.jsonSync(); + assertEquals(data, { test: 123 }); + data = await file.json(); + assertEquals(data, { test: 123 }); + }); +}); + +Deno.test("maybeJson", async () => { + await withTempDir(async () => { + const file = createPathReference("file.json"); + file.writeJsonSync({ test: 123 }); + let data = file.maybeJsonSync(); + assertEquals(data, { test: 123 }); + data = await file.maybeJson(); + assertEquals(data, { test: 123 }); + const nonExistent = createPathReference("not-exists"); + data = nonExistent.maybeJsonSync(); + assertEquals(data, undefined); + data = await nonExistent.maybeJson(); + assertEquals(data, undefined); + + file.writeTextSync("1 23 532lkjladf asd"); + assertThrows(() => file.maybeJsonSync(), Error, "Failed parsing JSON in 'file.json'."); + await assertRejects(() => file.maybeJson(), Error, "Failed parsing JSON in 'file.json'."); + }); +}); + +Deno.test("writeJson", async () => { + await withTempDir(async () => { + const path = createPathReference("file.json"); + await path.writeJson({ + prop: "test", + }); + assertEquals(path.textSync(), `{"prop":"test"}\n`); + await path.writeJson({ + prop: 1, + }); + // should truncate + assertEquals(path.textSync(), `{"prop":1}\n`); + + path.writeJsonSync({ + asdf: "test", + }); + assertEquals(path.textSync(), `{"asdf":"test"}\n`); + path.writeJsonSync({ + asdf: 1, + }); + // should truncate + assertEquals(path.textSync(), `{"asdf":1}\n`); + }); +}); + +Deno.test("writeJsonPretty", async () => { + await withTempDir(async () => { + const path = createPathReference("file.json"); + await path.writeJsonPretty({ + prop: "test", + }); + assertEquals(path.textSync(), `{\n "prop": "test"\n}\n`); + await path.writeJsonPretty({ + prop: 1, + }); + // should truncate + assertEquals(path.textSync(), `{\n "prop": 1\n}\n`); + + path.writeJsonPrettySync({ + asdf: "test", + }); + assertEquals(path.textSync(), `{\n "asdf": "test"\n}\n`); + path.writeJsonPrettySync({ + asdf: 1, + }); + // should truncate + assertEquals(path.textSync(), `{\n "asdf": 1\n}\n`); + }); +}); + +Deno.test("create", async () => { + await withTempDir(async () => { + const path = createPathReference("file.txt").writeTextSync("text"); + let file = await path.create(); + file.writeTextSync("asdf"); + file.close(); + path.removeSync(); + file = await path.create(); + file.close(); + file = path.createSync(); + file.writeTextSync("asdf"); + file.close(); + path.removeSync(); + file = path.createSync(); + file.close(); + }); +}); + +Deno.test("createNew", async () => { + await withTempDir(async () => { + const path = createPathReference("file.txt").writeTextSync("text"); + await assertRejects(() => path.createNew()); + path.removeSync(); + let file = await path.createNew(); + file.close(); + assertThrows(() => path.createNewSync()); + path.removeSync(); + file = path.createNewSync(); + file.close(); + }); +}); + +Deno.test("open", async () => { + await withTempDir(async () => { + const path = createPathReference("file.txt").writeTextSync("text"); + let file = await path.open({ write: true }); + await file.writeText("1"); + file.writeTextSync("2"); + file.close(); + file = path.openSync({ write: true, append: true }); + await file.writeBytes(new TextEncoder().encode("3")); + file.close(); + assertEquals(path.textSync(), "12xt3"); + }); +}); + +Deno.test("remove", async () => { + await withTempDir(async () => { + const path = createPathReference("file.txt").writeTextSync("text"); + assert(path.existsSync()); + assert(!path.removeSync().existsSync()); + path.writeTextSync("asdf"); + assert(path.existsSync()); + assert(!(await path.remove()).existsSync()); + }); +}); + +Deno.test("copyFile", async () => { + await withTempDir(async () => { + const path = createPathReference("file.txt").writeTextSync("text"); + const newPath = await path.copyFile("other.txt"); + assert(path.existsSync()); + assert(newPath.existsSync()); + assertEquals(newPath.textSync(), "text"); + const newPath2 = path.copyFileSync("other2.txt"); + assert(newPath2.existsSync()); + assertEquals(newPath2.textSync(), "text"); + }); +}); + +Deno.test("rename", async () => { + await withTempDir(async () => { + const path = createPathReference("file.txt").writeTextSync(""); + const newPath = path.renameSync("other.txt"); + assert(!path.existsSync()); + assert(newPath.existsSync()); + path.writeTextSync(""); + const newPath2 = await path.rename("other2.txt"); + assert(!path.existsSync()); + assert(newPath2.existsSync()); + }); +}); + +Deno.test("pipeTo", async () => { + await withTempDir(async () => { + const largeText = "asdf".repeat(100_000); + const textFile = createPathReference("file.txt").writeTextSync(largeText); + const otherFilePath = textFile.parentOrThrow().join("other.txt"); + const otherFile = otherFilePath.openSync({ write: true, create: true }); + await textFile.pipeTo(otherFile.writable); + assertEquals(otherFilePath.textSync(), largeText); + }); +}); diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 0000000..94cd42c --- /dev/null +++ b/src/path.ts @@ -0,0 +1,846 @@ +import { path as stdPath, writeAll, writeAllSync } from "./deps.ts"; + +const PERIOD_CHAR_CODE = ".".charCodeAt(0); + +export function createPathReference(path: string | URL) { + return new PathReference(path); +} + +/** + * Holds a reference to a path providing helper methods. + */ +export class PathReference { + readonly #path: string; + + constructor(path: string | URL) { + this.#path = path instanceof URL ? stdPath.fromFileUrl(path) : path; + } + + /** Gets the string representation of this path. */ + toString() { + return this.#path; + } + + /** Joins the provided path segments onto this path. */ + join(...pathSegments: string[]) { + return new PathReference(stdPath.join(this.#path, ...pathSegments)); + } + + /** Resolves this path to an absolute path along with the provided path segments. */ + resolve(...pathSegments: string[]) { + return new PathReference(stdPath.resolve(this.#path, ...pathSegments)); + } + + /** + * Normalizes the `path`, resolving `'..'` and `'.'` segments. + * Note that resolving these segments does not necessarily mean that all will be eliminated. + * A `'..'` at the top-level will be preserved, and an empty path is canonically `'.'`. + */ + normalize() { + return new PathReference(stdPath.normalize(this.#path)); + } + + /** Follows symlinks and gets if this path is a directory. */ + isDir() { + return this.statSync()?.isDirectory ?? false; + } + + /** Follows symlinks and gets if this path is a file. */ + isFile() { + return this.statSync()?.isFile ?? false; + } + + /** Gets if this path is a symlink. */ + isSymlink() { + return this.lstatSync()?.isSymlink ?? false; + } + + /** Gets if this path is an absolute path. */ + isAbsolute() { + return stdPath.isAbsolute(this.#path); + } + + /** Gets if this path is relative. */ + isRelative() { + return !this.isAbsolute(); + } + + /** Resolves the `Deno.FileInfo` of this path following symlinks. */ + async stat(): Promise { + try { + return await Deno.stat(this.#path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return undefined; + } else { + throw err; + } + } + } + + /** Synchronously resolves the `Deno.FileInfo` of this + * path following symlinks. */ + statSync(): Deno.FileInfo | undefined { + try { + return Deno.statSync(this.#path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return undefined; + } else { + throw err; + } + } + } + + /** Resolves the `Deno.FileInfo` of this path without + * following symlinks. */ + async lstat(): Promise { + try { + return await Deno.lstat(this.#path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return undefined; + } else { + throw err; + } + } + } + + /** Synchronously resolves the `Deno.FileInfo` of this path + * without following symlinks. */ + lstatSync(): Deno.FileInfo | undefined { + try { + return Deno.lstatSync(this.#path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return undefined; + } else { + throw err; + } + } + } + + /** + * Gets the directory path. In most cases, it is recommended + * to use `.parent()` instead since it will give you a `PathReference`. + */ + dirname() { + return stdPath.dirname(this.#path); + } + + /** Gets the file or directory name of the path. */ + basename() { + return stdPath.basename(this.#path); + } + + /** Resolves the path getting all its ancestor directories in order. */ + *ancestors() { + let ancestor = this.parent(); + while (ancestor != null) { + yield ancestor; + ancestor = ancestor.parent(); + } + } + + /** Gets the parent directory or returns undefined if the parent is the root directory. */ + parent() { + const resolvedPath = this.resolve(); + const dirname = resolvedPath.dirname(); + if (dirname === resolvedPath.#path) { + return undefined; + } else { + return new PathReference(dirname); + } + } + + /** Gets the parent or throws if the current directory was the root. */ + parentOrThrow() { + const parent = this.parent(); + if (parent == null) { + throw new Error(`Cannot get the parent directory of '${this.#path}'.`); + } + return parent; + } + + /** + * Returns the extension of the path with leading period or undefined + * if there is no extension. + */ + extname() { + const extName = stdPath.extname(this.#path); + return extName.length === 0 ? undefined : extName; + } + + /** Gets a new path reference with the provided extension. */ + withExtname(ext: string) { + const currentExt = this.extname(); + const hasLeadingPeriod = ext.charCodeAt(0) === PERIOD_CHAR_CODE; + if (!hasLeadingPeriod) { + ext = "." + ext; + } + return new PathReference(this.#path.substring(0, this.#path.length - (currentExt?.length ?? 0)) + ext); + } + + /** Gets a new path reference with the provided file or directory name. */ + withBasename(basename: string) { + const currentBaseName = this.basename(); + return new PathReference(this.#path.substring(0, this.#path.length - currentBaseName.length) + basename); + } + + /** Gets the relative path from this path to the specified path. */ + relative(to: string | URL | PathReference) { + const toPathRef = ensurePathRef(to); + console.log(this.resolve().#path, toPathRef.resolve().#path); + console.log(stdPath.relative(this.resolve().#path, toPathRef.resolve().#path)); + return stdPath.relative(this.resolve().#path, toPathRef.resolve().#path); + } + + /** Gets if the path exists. Beaware of TOCTOU issues. */ + exists() { + return this.lstat().then((info) => info != null); + } + + /** Synchronously gets if the path exists. Beaware of TOCTOU issues. */ + existsSync() { + return this.lstatSync() != null; + } + + /** Resolves to the absolute normalized path, with symbolic links resolved. */ + realPath() { + return Deno.realPath(this.#path).then((path) => new PathReference(path)); + } + + /** Synchronously resolves to the absolute normalized path, with symbolic links resolved. */ + realPathSync() { + return new PathReference(Deno.realPathSync(this.#path)); + } + + /** Creates a directory at this path. */ + async mkdir(options?: Deno.MkdirOptions) { + await Deno.mkdir(this.#path, options); + return this; + } + + /** Synchronously creates a directory at this path. */ + mkdirSync(options?: Deno.MkdirOptions) { + Deno.mkdirSync(this.#path, options); + return this; + } + + /** + * Creates a symlink at the provided path to the provided target returning the target path. + */ + async createAbsoluteSymlinkTo(target: string | URL | PathReference, opts?: Deno.SymlinkOptions) { + const from = this.resolve(); + const to = ensurePathRef(target).resolve(); + await createSymlink({ + toPath: to, + fromPath: from, + text: to.#path, + type: opts?.type, + }); + return to; + } + + /** + * Synchronously creates a symlink at the provided path to the provided target returning the target path. + * @returns The resolved target path. + */ + createAbsoluteSymlinkToSync(target: string | URL | PathReference, opts?: Deno.SymlinkOptions) { + const from = this.resolve(); + const to = ensurePathRef(target).resolve(); + createSymlinkSync({ + toPath: to, + fromPath: from, + text: to.#path, + type: opts?.type, + }); + return to; + } + + /** + * Creates a symlink at the specified path which points to the current path + * using an absolute path. + * @param linkPath The path to create a symlink at which points at the current path. + * @returns The destination path. + */ + async createAbsoluteSymlinkAt(linkPath: string | URL | PathReference, opts?: Deno.SymlinkOptions) { + const linkPathRef = ensurePathRef(linkPath).resolve(); + const thisResolved = this.resolve(); + await createSymlink({ + toPath: thisResolved, + fromPath: linkPathRef, + text: thisResolved.#path, + type: opts?.type, + }); + return linkPathRef; + } + + /** + * Creates a symlink at the specified path which points to the current path + * using an absolute path. + * @param linkPath The path to create a symlink at which points at the current path. + * @returns The destination path. + */ + createAbsoluteSymlinkAtSync( + linkPath: string | URL | PathReference, + opts?: Deno.SymlinkOptions, + ) { + const linkPathRef = ensurePathRef(linkPath).resolve(); + const thisResolved = this.resolve(); + createSymlinkSync({ + toPath: thisResolved, + fromPath: linkPathRef, + text: thisResolved.#path, + type: opts?.type, + }); + return linkPathRef; + } + + /** + * Creates a symlink at the specified path which points to the current path + * using a relative path. + * @param linkPath The path to create a symlink at which points at the current path. + * @returns The destination path. + */ + async createRelativeSymlinkAt( + linkPath: string | URL | PathReference, + opts?: Deno.SymlinkOptions, + ) { + const { + linkPathRef, + thisResolved, + relativePath, + } = this.#getRelativeSymlinkAtParts(linkPath); + await createSymlink({ + toPath: thisResolved, + fromPath: linkPathRef, + text: relativePath, + type: opts?.type, + }); + return linkPathRef; + } + + /** + * Synchronously creates a symlink at the specified path which points to the current + * path using a relative path. + * @param linkPath The path to create a symlink at which points at the current path. + * @returns The destination path. + */ + createRelativeSymlinkAtSync( + linkPath: string | URL | PathReference, + opts?: Deno.SymlinkOptions, + ) { + const { + linkPathRef, + thisResolved, + relativePath, + } = this.#getRelativeSymlinkAtParts(linkPath); + createSymlinkSync({ + toPath: thisResolved, + fromPath: linkPathRef, + text: relativePath, + type: opts?.type, + }); + return linkPathRef; + } + + #getRelativeSymlinkAtParts(linkPath: string | URL | PathReference) { + const linkPathRef = ensurePathRef(linkPath).resolve(); + const thisResolved = this.resolve(); + let relativePath: string; + if (linkPathRef.dirname() === thisResolved.dirname()) { + // we don't want it to do `../basename` + relativePath = linkPathRef.basename(); + } else { + relativePath = linkPathRef.relative(thisResolved); + } + return { + thisResolved, + linkPathRef, + relativePath, + }; + } + + /** Reads the entries in the directory. */ + readDir() { + return Deno.readDir(this.#path); + } + + /** Synchronously reads the entries in the directory. */ + readDirSync() { + return Deno.readDirSync(this.#path); + } + + /** Reads the bytes from the file. */ + bytes(options?: Deno.ReadFileOptions) { + return Deno.readFile(this.#path, options); + } + + /** Synchronously reads the bytes from the file. */ + bytesSync() { + return Deno.readFileSync(this.#path); + } + + /** Calls `.bytes()`, but returns undefined if the path doesn't exist. */ + maybeBytes(options?: Deno.ReadFileOptions) { + return notFoundToUndefined(() => this.bytes(options)); + } + + /** Calls `.bytesSync()`, but returns undefined if the path doesn't exist. */ + maybeBytesSync() { + return notFoundToUndefinedSync(() => this.bytesSync()); + } + + /** Reads the text from the file. */ + text(options?: Deno.ReadFileOptions) { + return Deno.readTextFile(this.#path, options); + } + + /** Synchronously reads the text from the file. */ + textSync() { + return Deno.readTextFileSync(this.#path); + } + + /** Calls `.text()`, but returns undefined when the path doesn't exist. + * @remarks This still errors for other kinds of errors reading a file. + */ + maybeText(options?: Deno.ReadFileOptions) { + return notFoundToUndefined(() => this.text(options)); + } + + /** Calls `.textSync()`, but returns undefined when the path doesn't exist. + * @remarks This still errors for other kinds of errors reading a file. + */ + maybeTextSync() { + return notFoundToUndefinedSync(() => this.textSync()); + } + + /** Reads and parses the file as JSON, throwing if it doesn't exist or is not valid JSON. */ + async json(options?: Deno.ReadFileOptions) { + return this.#parseJson(await this.text(options)); + } + + /** Synchronously reads and parses the file as JSON, throwing if it doesn't + * exist or is not valid JSON. */ + jsonSync() { + return this.#parseJson(this.textSync()); + } + + #parseJson(text: string) { + try { + return JSON.parse(text) as T; + } catch (err) { + throw new Error(`Failed parsing JSON in '${this.toString()}'.`, { + cause: err, + }); + } + } + + /** + * Calls `.json()`, but returns undefined if the file doesn't exist. + * @remarks This method will still throw if the file cannot be parsed as JSON. + */ + maybeJson(options?: Deno.ReadFileOptions) { + return notFoundToUndefined(() => this.json(options)); + } + + /** + * Calls `.jsonSync()`, but returns undefined if the file doesn't exist. + * @remarks This method will still throw if the file cannot be parsed as JSON. + */ + maybeJsonSync() { + return notFoundToUndefinedSync(() => this.jsonSync()); + } + + /** Writes out the provided bytes to the file. */ + async write(data: Uint8Array, options?: Deno.WriteFileOptions) { + await Deno.writeFile(this.#path, data, options); + return this; + } + + /** Synchronously writes out the provided bytes to the file. */ + writeSync(data: Uint8Array, options?: Deno.WriteFileOptions) { + Deno.writeFileSync(this.#path, data, options); + return this; + } + + /** Writes out the provided text to the file. */ + async writeText(text: string, options?: Deno.WriteFileOptions) { + await Deno.writeTextFile(this.#path, text, options); + return this; + } + + /** Synchronously writes out the provided text to the file. */ + writeTextSync(text: string, options?: Deno.WriteFileOptions) { + Deno.writeTextFileSync(this.#path, text, options); + return this; + } + + /** Writes out the provided object as compact JSON. */ + async writeJson(obj: unknown, options?: Deno.WriteFileOptions) { + const text = JSON.stringify(obj); + await this.#writeTextWithEndNewLine(text, options); + return this; + } + + /** Synchronously writes out the provided object as compact JSON. */ + writeJsonSync(obj: unknown, options?: Deno.WriteFileOptions) { + const text = JSON.stringify(obj); + this.#writeTextWithEndNewLineSync(text, options); + return this; + } + + /** Writes out the provided object as formatted JSON. */ + async writeJsonPretty(obj: unknown, options?: Deno.WriteFileOptions) { + const text = JSON.stringify(obj, undefined, 2); + await this.#writeTextWithEndNewLine(text, options); + return this; + } + + /** Synchronously writes out the provided object as formatted JSON. */ + writeJsonPrettySync(obj: unknown, options?: Deno.WriteFileOptions) { + const text = JSON.stringify(obj, undefined, 2); + this.#writeTextWithEndNewLineSync(text, options); + return this; + } + + async #writeTextWithEndNewLine(text: string, options: Deno.WriteFileOptions | undefined) { + const file = await this.open({ write: true, create: true, truncate: true, ...options }); + try { + await file.writeText(text); + await file.writeText("\n"); + } finally { + try { + file.close(); + } catch { + // ignore + } + } + } + + #writeTextWithEndNewLineSync(text: string, options: Deno.WriteFileOptions | undefined) { + const file = this.openSync({ write: true, create: true, truncate: true, ...options }); + try { + file.writeTextSync(text); + file.writeTextSync("\n"); + } finally { + try { + file.close(); + } catch { + // ignore + } + } + } + + /** Changes the permissions of the file or directory. */ + async chmod(mode: number) { + await Deno.chmod(this.#path, mode); + return this; + } + + /** Synchronously changes the permissions of the file or directory. */ + chmodSync(mode: number) { + Deno.chmodSync(this.#path, mode); + return this; + } + + /** Changes the ownership permissions of the file. */ + async chown(uid: number | null, gid: number | null) { + await Deno.chown(this.#path, uid, gid); + return this; + } + + /** Synchronously changes the ownership permissions of the file. */ + chownSync(uid: number | null, gid: number | null) { + Deno.chownSync(this.#path, uid, gid); + return this; + } + + /** Creates a new file or opens the existing one. */ + create() { + return Deno.create(this.#path) + .then((file) => new FsFileWrapper(file)); + } + + /** Synchronously creates a new file or opens the existing one. */ + createSync() { + return new FsFileWrapper(Deno.createSync(this.#path)); + } + + /** Creates a file throwing if a file previously existed. */ + createNew() { + return this.open({ + createNew: true, + read: true, + write: true, + }); + } + + /** Synchronously creates a file throwing if a file previously existed. */ + createNewSync() { + return this.openSync({ + createNew: true, + read: true, + write: true, + }); + } + + /** Opens a file. */ + open(options?: Deno.OpenOptions) { + return Deno.open(this.#path, options) + .then((file) => new FsFileWrapper(file)); + } + + /** Opens a file synchronously. */ + openSync(options?: Deno.OpenOptions) { + return new FsFileWrapper(Deno.openSync(this.#path, options)); + } + + /** Removes the file or directory from the file system. */ + async remove(options?: Deno.RemoveOptions) { + await Deno.remove(this.#path, options); + return this; + } + + /** Removes the file or directory from the file system synchronously. */ + removeSync(options?: Deno.RemoveOptions) { + Deno.removeSync(this.#path, options); + return this; + } + + /** + * Copies the file returning a promise that resolves to + * the destination path. + */ + copyFile(destinationPath: string | URL | PathReference) { + const pathRef = ensurePathRef(destinationPath); + return Deno.copyFile(this.#path, pathRef.#path) + .then(() => pathRef); + } + + /** + * Copies the file returning a promise that resolves to + * the destination path synchronously. + */ + copyFileSync(destinationPath: string | URL | PathReference) { + const pathRef = ensurePathRef(destinationPath); + Deno.copyFileSync(this.#path, pathRef.#path); + return pathRef; + } + + /** + * Renames the file or directory returning a promise that resolves to + * the renamed path. + */ + rename(newPath: string | URL | PathReference) { + const pathRef = ensurePathRef(newPath); + return Deno.rename(this.#path, pathRef.#path).then(() => pathRef); + } + + /** + * Renames the file or directory returning a promise that resolves to + * the renamed path synchronously. + */ + renameSync(newPath: string | URL | PathReference) { + const pathRef = ensurePathRef(newPath); + Deno.renameSync(this.#path, pathRef.#path); + return pathRef; + } + + /** Opens the file and pipes it to the writable stream. */ + async pipeTo(dest: WritableStream, options?: PipeOptions) { + const file = await Deno.open(this.#path, { read: true }); + try { + await file.readable.pipeTo(dest, options); + } finally { + try { + file.close(); + } catch { + // ignore + } + } + } +} + +function ensurePathRef(path: string | URL | PathReference) { + return path instanceof PathReference ? path : new PathReference(path); +} + +async function createSymlink(opts: { + fromPath: PathReference; + toPath: PathReference; + text: string; + type: "file" | "dir" | undefined; +}) { + let kind = opts.type; + if (kind == null && Deno.build.os === "windows") { + const info = await opts.toPath.lstat(); + if (info?.isDirectory) { + kind = "dir"; + } else if (info?.isFile) { + kind = "file"; + } else { + throw new Deno.errors.NotFound( + `The target path '${opts.toPath.toString()}' did not exist or path kind could not be determined. ` + + `When the path doesn't exist, you need to specify a symlink type on Windows.`, + ); + } + } + + await Deno.symlink( + opts.text, + opts.fromPath.toString(), + kind == null ? undefined : { + type: kind, + }, + ); +} + +function createSymlinkSync(opts: { + fromPath: PathReference; + toPath: PathReference; + text: string; + type: "file" | "dir" | undefined; +}) { + let kind = opts.type; + if (kind == null && Deno.build.os === "windows") { + const info = opts.toPath.lstatSync(); + if (info?.isDirectory) { + kind = "dir"; + } else if (info?.isFile) { + kind = "file"; + } else { + throw new Deno.errors.NotFound( + `The target path '${opts.toPath.toString()}' did not exist or path kind could not be determined. ` + + `When the path doesn't exist, you need to specify a symlink type on Windows.`, + ); + } + } + + Deno.symlinkSync( + opts.text, + opts.fromPath.toString(), + kind == null ? undefined : { + type: kind, + }, + ); +} + +export class FsFileWrapper implements Deno.FsFile { + #file: Deno.FsFile; + + constructor(file: Deno.FsFile) { + this.#file = file; + } + + /** Gets the inner `Deno.FsFile` that this wraps. */ + get inner() { + return this.#file; + } + + /** Writes the provided text to this file. */ + writeText(text: string) { + return this.writeBytes(new TextEncoder().encode(text)); + } + + /** Synchronously writes the provided text to this file. */ + writeTextSync(text: string) { + return this.writeBytesSync(new TextEncoder().encode(text)); + } + + /** Writes the provided bytes to the file. */ + async writeBytes(bytes: Uint8Array) { + await writeAll(this.#file, bytes); + return this; + } + + /** Synchronously writes the provided bytes to the file. */ + writeBytesSync(bytes: Uint8Array) { + writeAllSync(this.#file, bytes); + return this; + } + + // below is Deno.FsFile implementation... could probably be something + // done in the constructor instead. + + get rid() { + return this.#file.rid; + } + + get readable() { + return this.#file.readable; + } + + get writable() { + return this.#file.writable; + } + + write(p: Uint8Array): Promise { + return this.#file.write(p); + } + + writeSync(p: Uint8Array): number { + return this.#file.writeSync(p); + } + + truncate(len?: number | undefined): Promise { + return this.#file.truncate(len); + } + + truncateSync(len?: number | undefined): void { + return this.#file.truncateSync(len); + } + + read(p: Uint8Array): Promise { + return this.#file.read(p); + } + + readSync(p: Uint8Array): number | null { + return this.#file.readSync(p); + } + + seek(offset: number, whence: Deno.SeekMode): Promise { + return this.#file.seek(offset, whence); + } + + seekSync(offset: number, whence: Deno.SeekMode): number { + return this.#file.seekSync(offset, whence); + } + + stat(): Promise { + return this.#file.stat(); + } + + statSync(): Deno.FileInfo { + return this.#file.statSync(); + } + + close(): void { + return this.#file.close(); + } +} + +async function notFoundToUndefined(action: () => Promise) { + try { + return await action(); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return undefined; + } else { + throw err; + } + } +} + +function notFoundToUndefinedSync(action: () => T) { + try { + return action(); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return undefined; + } else { + throw err; + } + } +} diff --git a/src/request.test.ts b/src/request.test.ts index 95f764c..001fc34 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -116,11 +116,8 @@ Deno.test("$.request", (t) => { .url(new URL("/text-file", serverUrl)) .showProgress() .pipeToPath(testFilePath); - // ensure this only returns a string and not string | URL - // so that it's easier to work with - const _assertString: string = downloadedFilePath; - assertEquals(Deno.readTextFileSync(testFilePath), "text".repeat(1000)); - assertEquals(downloadedFilePath, path.resolve(testFilePath)); + assertEquals(downloadedFilePath.textSync(), "text".repeat(1000)); + assertEquals(downloadedFilePath.toString(), path.resolve(testFilePath)); } { // test default path @@ -129,8 +126,8 @@ Deno.test("$.request", (t) => { .url(new URL("/text-file", serverUrl)) .showProgress() .pipeToPath(); - assertEquals(Deno.readTextFileSync("text-file"), "text".repeat(1000)); - assertEquals(downloadedFilePath, path.resolve("text-file")); + assertEquals(downloadedFilePath.textSync(), "text".repeat(1000)); + assertEquals(downloadedFilePath.toString(), path.resolve("text-file")); } { await assertRejects( @@ -159,8 +156,8 @@ Deno.test("$.request", (t) => { .url(new URL("/text-file", serverUrl)) .showProgress() .pipeToPath(tempDir); - assertEquals(Deno.readTextFileSync(path.join(tempDir, "text-file")), "text".repeat(1000)); - assertEquals(downloadedFilePath, path.join(tempDir, "text-file")); + assertEquals(downloadedFilePath.textSync(), "text".repeat(1000)); + assertEquals(downloadedFilePath.toString(), path.join(tempDir, "text-file")); } } finally { try { diff --git a/src/request.ts b/src/request.ts index 98b5f92..d68ef9e 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,6 +1,6 @@ -import { Delay, delayToMs, filterEmptyRecordValues, getFileNameFromUrl, safeLstatSync } from "./common.ts"; +import { Delay, delayToMs, filterEmptyRecordValues, getFileNameFromUrl } from "./common.ts"; import { ProgressBar } from "./console/mod.ts"; -import { path } from "./deps.ts"; +import { PathReference } from "./path.ts"; interface RequestBuilderState { noThrow: boolean | number[]; @@ -286,9 +286,9 @@ export class RequestBuilder implements PromiseLike { } /** Pipes the response body to the provided writable stream. */ - async pipeTo(dest: WritableStream) { + async pipeTo(dest: WritableStream, options?: PipeOptions) { const response = await this.fetch(); - return await response.pipeTo(dest); + return await response.pipeTo(dest, options); } /** @@ -297,19 +297,25 @@ export class RequestBuilder implements PromiseLike { * @remarks The path will be derived from the request's url * and downloaded to the current working directory. * - * @returns The path of the downloaded file + * @returns The path reference of the downloaded file. */ - async pipeToPath(options?: Deno.WriteFileOptions): Promise; + async pipeToPath(options?: Deno.WriteFileOptions): Promise; /** * Pipes the response body to a file. * * @remarks If no path is provided then it will be derived from the * request's url and downloaded to the current working directory. * - * @returns The path of the downloaded file + * @returns The path reference of the downloaded file. */ - async pipeToPath(path?: string | URL | undefined, options?: Deno.WriteFileOptions): Promise; - async pipeToPath(filePathOrOptions?: string | URL | Deno.WriteFileOptions, maybeOptions?: Deno.WriteFileOptions) { + async pipeToPath( + path?: string | URL | PathReference | undefined, + options?: Deno.WriteFileOptions, + ): Promise; + async pipeToPath( + filePathOrOptions?: string | URL | PathReference | Deno.WriteFileOptions, + maybeOptions?: Deno.WriteFileOptions, + ) { // Do not derive from the response url because that could cause the server // to be able to overwrite whatever file it wants locally, which would be // a security issue. @@ -317,8 +323,7 @@ export class RequestBuilder implements PromiseLike { // while the response is being fetched. const { filePath, options } = resolvePipeToPathParams(filePathOrOptions, maybeOptions, this.#state?.url); const response = await this.fetch(); - await response.pipeToPath(filePath, options); - return filePath; + return await response.pipeToPath(filePath, options); } /** Pipes the response body through the provided transform. */ @@ -493,8 +498,8 @@ export class RequestResult { } /** Pipes the response body to the provided writable stream. */ - pipeTo(dest: WritableStream) { - return this.#getDownloadBody().pipeTo(dest); + pipeTo(dest: WritableStream, options?: PipeOptions) { + return this.#getDownloadBody().pipeTo(dest, options); } /** @@ -506,9 +511,9 @@ export class RequestResult { * @remarks If the path is a directory, then the file name will be derived * from the request's url and the file will be downloaded to the provided directory * - * @returns The path of the downloaded file + * @returns The path reference of the downloaded file */ - async pipeToPath(options?: Deno.WriteFileOptions): Promise; + async pipeToPath(options?: Deno.WriteFileOptions): Promise; /** * Pipes the response body to a file. * @@ -518,38 +523,43 @@ export class RequestResult { * @remarks If the path is a directory, then the file name will be derived * from the request's url and the file will be downloaded to the provided directory * - * @returns The path of the downloaded file + * @returns The path reference of the downloaded file */ - async pipeToPath(path?: string | URL | undefined, options?: Deno.WriteFileOptions): Promise; - async pipeToPath(filePathOrOptions?: string | URL | Deno.WriteFileOptions, maybeOptions?: Deno.WriteFileOptions) { + async pipeToPath( + path?: string | URL | PathReference | undefined, + options?: Deno.WriteFileOptions, + ): Promise; + async pipeToPath( + filePathOrOptions?: string | URL | PathReference | Deno.WriteFileOptions, + maybeOptions?: Deno.WriteFileOptions, + ) { // resolve the file path using the original url because it would be a security issue // to allow the server to select which file path to save the file to if using the // response url const { filePath, options } = resolvePipeToPathParams(filePathOrOptions, maybeOptions, this.#originalUrl); const body = this.#getDownloadBody(); try { - const file = await Deno.open(filePath, { + const file = await filePath.open({ write: true, create: true, ...(options ?? {}), }); try { - await body.pipeTo(file.writable); - } catch (err) { + await body.pipeTo(file.writable, { + preventClose: true, + }); + } finally { try { file.close(); } catch { // do nothing } - throw err; } } catch (err) { await this.#response.body?.cancel(); throw err; } - // It's not necessary to close the file after because - // it will be automatically closed via pipeTo return filePath; } @@ -638,14 +648,17 @@ export async function makeRequest(state: RequestBuilderState) { } function resolvePipeToPathParams( - pathOrOptions: string | URL | Deno.WriteFileOptions | undefined, + pathOrOptions: string | URL | PathReference | Deno.WriteFileOptions | undefined, maybeOptions: Deno.WriteFileOptions | undefined, originalUrl: string | URL | undefined, ) { - let filePath: string | undefined; + let filePath: PathReference | undefined; let options: Deno.WriteFileOptions | undefined; if (typeof pathOrOptions === "string" || pathOrOptions instanceof URL) { - filePath = resolvePathOrUrl(pathOrOptions); + filePath = new PathReference(pathOrOptions).resolve(); + options = maybeOptions; + } else if (pathOrOptions instanceof PathReference) { + filePath = pathOrOptions.resolve(); options = maybeOptions; } else if (typeof pathOrOptions === "object") { options = pathOrOptions; @@ -653,21 +666,17 @@ function resolvePipeToPathParams( options = maybeOptions; } if (filePath === undefined) { - filePath = getFileNameFromUrlOrThrow(originalUrl); - } else if (safeLstatSync(filePath)?.isDirectory) { - filePath = path.join(filePath, getFileNameFromUrlOrThrow(originalUrl)); + filePath = new PathReference(getFileNameFromUrlOrThrow(originalUrl)); + } else if (filePath.isDir()) { + filePath = filePath.join(getFileNameFromUrlOrThrow(originalUrl)); } - filePath = resolvePathOrUrl(filePath); + filePath = filePath.resolve(); return { filePath, options, }; - function resolvePathOrUrl(pathOrUrl: string | URL) { - return path.resolve(typeof pathOrUrl === "string" ? pathOrUrl : path.fromFileUrl(pathOrUrl)); - } - function getFileNameFromUrlOrThrow(url: string | URL | undefined) { const fileName = url == null ? undefined : getFileNameFromUrl(url); if (fileName == null) {