diff --git a/_test/fixture/a/b/c/findme.md b/_test/fixture/fs/a/b/c/findme.md similarity index 100% rename from _test/fixture/a/b/c/findme.md rename to _test/fixture/fs/a/b/c/findme.md diff --git a/_test/fixture/a/b/c/findme.ts b/_test/fixture/fs/a/b/c/findme.ts similarity index 100% rename from _test/fixture/a/b/c/findme.ts rename to _test/fixture/fs/a/b/c/findme.ts diff --git a/_test/fixture/a/findme.md b/_test/fixture/fs/a/findme.md similarity index 100% rename from _test/fixture/a/findme.md rename to _test/fixture/fs/a/findme.md diff --git a/_test/fixture/a/findme.ts b/_test/fixture/fs/a/findme.ts similarity index 100% rename from _test/fixture/a/findme.ts rename to _test/fixture/fs/a/findme.ts diff --git a/_test/fixture/json/invalid.json b/_test/fixture/json/invalid.json new file mode 100644 index 00000000..81750b96 --- /dev/null +++ b/_test/fixture/json/invalid.json @@ -0,0 +1 @@ +{ \ No newline at end of file diff --git a/_test/fixture/json/valid.json b/_test/fixture/json/valid.json new file mode 100644 index 00000000..c8c4105e --- /dev/null +++ b/_test/fixture/json/valid.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/_test/fixture/ts/invalid.ts b/_test/fixture/ts/invalid.ts new file mode 100644 index 00000000..d2c911f8 --- /dev/null +++ b/_test/fixture/ts/invalid.ts @@ -0,0 +1,3 @@ +// deno-lint-ignore ban-ts-comment +// @ts-expect-error +invalid; diff --git a/_test/fixture/ts/invalidTypes.ts b/_test/fixture/ts/invalidTypes.ts new file mode 100644 index 00000000..d093c650 --- /dev/null +++ b/_test/fixture/ts/invalidTypes.ts @@ -0,0 +1,2 @@ +const foo: number = "Hello"; +console.log(foo); diff --git a/_test/fixture/ts/throws.ts b/_test/fixture/ts/throws.ts new file mode 100644 index 00000000..0e01f3a9 --- /dev/null +++ b/_test/fixture/ts/throws.ts @@ -0,0 +1 @@ +throw new Error(""); diff --git a/_test/fixture/ts/valid.ts b/_test/fixture/ts/valid.ts new file mode 100644 index 00000000..7f944f48 --- /dev/null +++ b/_test/fixture/ts/valid.ts @@ -0,0 +1,3 @@ +// deno-lint-ignore no-inferrable-types +const foo: string = "Hello"; +console.log(foo); diff --git a/fs/fileHandler.ts b/fs/fileHandler.ts new file mode 100644 index 00000000..2c6fdc0a --- /dev/null +++ b/fs/fileHandler.ts @@ -0,0 +1,115 @@ +export type FileHandler = { + read: (filePath: string) => unknown | Promise; + + // deno-lint-ignore no-explicit-any + write: (filePath: string, data: any) => Promise; +}; + +/** A helper class for handling files by extensions. */ +export abstract class FileExtensionHandler { + extensions: Ext[]; + + constructor(extensions?: Ext[]) { + this.extensions = extensions ?? []; + } + + /** @returns `true` if the file path ends with one of the extensions. */ + validateExtension(filePath: string): filePath is `${string}${Ext}` { + if (!this.extensions.length) return true; + + return this.extensions.some((ext) => filePath.endsWith(ext)); + } + + /** @throws {InvalidExtensionError} if the file path does not end with one of the extensions. */ + assertValidExtension( + filePath: string, + ): asserts filePath is `${string}${Ext}` { + if (!this.validateExtension(filePath)) { + throw new InvalidExtensionError(filePath, this.extensions); + } + } +} + +export type Evaluable = { + evaluate: (filePath: string) => unknown | Promise; +}; + +export class InvalidExtensionError extends TypeError { + constructor(filePath: string, extensions: string[]) { + const extensionsString = extensions + .map((e) => JSON.stringify(e)) + .join(", "); + + super( + "File path does not end with one of the extensions:\n" + + ` path: ${filePath}\n` + + ` extensions: ${extensionsString}`, + ); + this.name = "InvalidExtensionError"; + } +} + +export type FileHandlerMethodOptions = { + /** If `true`, the method will not validate the file extension. */ + force?: boolean; +}; + +/** A sample implementation with `FileHandler` and `FileExtensionHandler` which + * can serve as a base class for handlers of UTF8-encoded files. + * + * @example + * const handler = new PlainTextFileHandler(); + * + * await handler.read("./license"); // "BSD Zero Clause License ..." + * await handler.write("./readme.md", "# Hello, world!"); + * + * @example + * const handler = new PlainTextFileHandler([".txt"]); + * + * handler.validateExtension("foo.txt"); // true + * handler.validateExtension("foo.md"); // false + * await handler.read("./readme.md"); // throws InvalidExtensionError + * + * @example + * // TypeScript constraint: you must use `:` to explicitly specify + * // the type for assertions to work + * const handler: PlainTextFileHandler<".md"> = new PlainTextFileHandler([".md"]); + * + * handler.assertValidExtension("foo.txt"); // throws InvalidExtensionError + * + * @example + * import { cmd } from "../cli/utils.ts"; + * + * class TypeScriptFileHandler extends PlainTextFileHandler<"ts"> + * implements FileHandler, Evaluable { + * constructor() { + * super([".ts"]); + * } + * + * async evaluate(filePath: string) { + * const code = await this.read(filePath); + * + * return await cmd( + * ["deno", "eval", "-q", "--check", "--ext=ts", code] + * ); + * } + * } */ +export class PlainTextFileHandler + extends FileExtensionHandler + implements FileHandler { + constructor(extensions?: Ext[]) { + super(extensions); + } + + async read(filePath: string, opts?: FileHandlerMethodOptions) { + opts?.force || this.assertValidExtension(filePath); + + return await Deno.readTextFile(filePath); + } + + async write(filePath: string, data: string, opts?: FileHandlerMethodOptions) { + opts?.force || this.assertValidExtension(filePath); + + await Deno.writeTextFile(filePath, data); + } +} diff --git a/fs/findNearestFile.test.ts b/fs/findNearestFile.test.ts index 7e9bf286..88554eb7 100644 --- a/fs/findNearestFile.test.ts +++ b/fs/findNearestFile.test.ts @@ -3,7 +3,7 @@ import { assertEquals, assertRejects, describe, it } from "../_deps/testing.ts"; import { FIXTURE_DIR } from "../_constants.ts"; import { findNearestFile } from "./findNearestFile.ts"; -const A_DIR = resolve(FIXTURE_DIR, "a"); +const A_DIR = resolve(FIXTURE_DIR, "fs", "a"); const B_DIR = resolve(A_DIR, "b"); const C_DIR = resolve(B_DIR, "c"); diff --git a/fs/glob.test.ts b/fs/glob.test.ts index 97bd2bfb..b3d3f2db 100644 --- a/fs/glob.test.ts +++ b/fs/glob.test.ts @@ -3,9 +3,9 @@ import { assertArrayIncludes, describe, it } from "../_deps/testing.ts"; import { glob } from "./glob.ts"; import { FIXTURE_DIR } from "../_constants.ts"; -const GLOB = resolve(FIXTURE_DIR, "**", "*.ts"); +const GLOB = resolve(FIXTURE_DIR, "fs", "**", "*.ts"); -const A_DIR = resolve(FIXTURE_DIR, "a"); +const A_DIR = resolve(FIXTURE_DIR, "fs", "a"); const C_DIR = resolve(A_DIR, "b", "c"); const A_FILE = resolve(A_DIR, "findme.ts"); diff --git a/fs/globImport.test.ts b/fs/globImport.test.ts index 92ecd000..f9c3826f 100644 --- a/fs/globImport.test.ts +++ b/fs/globImport.test.ts @@ -10,9 +10,9 @@ import { import { FileHandlerError, globImport } from "./globImport.ts"; import { FIXTURE_DIR } from "../_constants.ts"; -const globPattern = resolve(FIXTURE_DIR, "**", "*.ts"); +const globPattern = resolve(FIXTURE_DIR, "fs", "**", "*.ts"); -const A_DIR = resolve(FIXTURE_DIR, "a"); +const A_DIR = resolve(FIXTURE_DIR, "fs", "a"); const C_DIR = resolve(A_DIR, "b", "c"); const A_MD = toFileUrl(resolve(A_DIR, "findme.md")).href; @@ -52,7 +52,7 @@ describe("fs.globImport", () => { it("can handle files by extension", async () => assertEquals( await globImport( - resolve(FIXTURE_DIR, "a", "**", "*.*"), + resolve(A_DIR, "**", "*.*"), { eager: true, fileHandler: { diff --git a/json/fileHandler.test.ts b/json/fileHandler.test.ts new file mode 100644 index 00000000..a85445e4 --- /dev/null +++ b/json/fileHandler.test.ts @@ -0,0 +1,25 @@ +import { FIXTURE_DIR } from "../_constants.ts"; +import { resolve } from "../_deps/path.ts"; +import { assertEquals, assertRejects, describe, it } from "../_deps/testing.ts"; +import { JsonFileHandler } from "./fileHandler.ts"; + +const valid = resolve(FIXTURE_DIR, "json", "valid.json"); +const invalid = resolve(FIXTURE_DIR, "json", "invalid.json"); + +describe(".read()", () => { + it("returns stdout", async () => + assertEquals( + await new JsonFileHandler().read(valid), + { foo: "bar" }, + )); + + it("throws on invalid file content", async () => + void await assertRejects( + () => new JsonFileHandler().read(invalid), + SyntaxError, + )); +}); + +describe(".write()", () => { + // TODO: stub Deno.writeTextFile() +}); diff --git a/json/fileHandler.ts b/json/fileHandler.ts new file mode 100644 index 00000000..762b8833 --- /dev/null +++ b/json/fileHandler.ts @@ -0,0 +1,74 @@ +import { FileExtensionHandler, FileHandler } from "../fs/fileHandler.ts"; +import { JsonValue } from "./types.ts"; + +export type JsonReadOptions = { + // deno-lint-ignore no-explicit-any + reviver?: ((this: any, key: string, value: any) => ReviverResult) | undefined; +}; + +// deno-lint-ignore no-explicit-any +export type JsonWriteOptions = { + // deno-lint-ignore no-explicit-any + replacer?: (this: any, key: string, value: any) => ReplacerResult; + space?: string | number; +}; + +/** + * @example + * const handler = new JsonFileHandler(); + * + * await handler.write("foo.json", { foo: "bar" }); + * // foo.json: '{\n "foo":"bar"\n}' (2 space indent by default) + * + * await handler.read("foo.json"); // { foo: "bar" } + * await handler.read("foo.yaml"); // throws InvalidExtensionError + * + * @example + * const handler = new JsonFileHandler({ write: { space: 0 } }); + * await handler.write("foo.json", { foo: "bar" }); + * // foo.json: '{"foo":"bar"}' (no spaces) + */ +export class JsonFileHandler extends FileExtensionHandler + implements FileHandler { + constructor( + public readonly options: { + read?: JsonReadOptions; + write?: JsonWriteOptions; + } = {}, + ) { + super([".json"]); + } + + async read(filePath: string): Promise; + async read( + filePath: string, + opts?: JsonReadOptions, + ): Promise; + async read(filePath: string, opts?: JsonReadOptions) { + const { reviver } = opts ?? this.options.read ?? {}; + + this.assertValidExtension(filePath); + const raw = await Deno.readTextFile(filePath); + + return JSON.parse(raw, reviver); + } + + async write(filePath: string, data: JsonValue): Promise; + // deno-lint-ignore no-explicit-any + async write( + filePath: string, + data: JsonValue, + opts: JsonWriteOptions, + ): Promise; + async write( + filePath: string, + data: JsonValue, + opts: JsonWriteOptions = {}, + ): Promise { + const { replacer, space = 2 } = opts ?? this.options.write ?? {}; + + this.assertValidExtension(filePath); + + await Deno.writeTextFile(filePath, JSON.stringify(data, replacer, space)); + } +} diff --git a/json/mod.ts b/json/mod.ts new file mode 100644 index 00000000..b47e50ed --- /dev/null +++ b/json/mod.ts @@ -0,0 +1,2 @@ +export * from "./types.ts"; +export * from "./utils.ts"; diff --git a/types/json.ts b/json/types.ts similarity index 100% rename from types/json.ts rename to json/types.ts diff --git a/json/utils.ts b/json/utils.ts new file mode 100644 index 00000000..551adfac --- /dev/null +++ b/json/utils.ts @@ -0,0 +1 @@ +export * from "./fileHandler.ts"; diff --git a/readme.md b/readme.md index cd297786..d9c516a7 100644 --- a/readme.md +++ b/readme.md @@ -12,13 +12,13 @@ Utility functions, classes, types, and scripts in uncompiled TS, for Deno. - [`script/makeReleaseNotes`](#scriptmakereleasenotes) - [`graph`](#graph) - [`io`](#io) +- [`json`](#json) - [`md`](#md) - [`script/evalCodeBlocks`](#scriptevalcodeblocks) - [`object`](#object) - [`path`](#path) - [`string`](#string) - [`ts`](#ts) -- [`types`](#types) ## `array` @@ -156,6 +156,24 @@ clipboard.copy("foo"); clipboard.paste(); // "foo" ``` +## `json` + +Utility types. + +```ts +import { + JsonArray, + JsonObject, + JsonPrimitive, + JsonValue, +} from "https://deno.land/x/handy/json/types.ts"; + +const a: JsonPrimitive = "some string"; // or number, boolean, null +const b: JsonArray = [1, ["2", true], { a: null }]; +const c: JsonObject = { a: 1, b: ["2", true], d: { e: null } }; +// JsonValue = any of the above +``` + ## `md` Markdown-related utilities. @@ -242,21 +260,3 @@ import { evaluate } from "https://deno.land/x/handy/ts/utils.ts"; await evaluate("console.log('Hello!')") .then((res) => res.stdout); // "Hello!" ``` - -## `types` - -Utility types. - -```ts -import { - JsonArray, - JsonObject, - JsonPrimitive, - JsonValue, -} from "https://deno.land/x/handy/types/json.ts"; - -const a: JsonPrimitive = "some string"; // or number, boolean, null -const b: JsonArray = [1, ["2", true], { a: null }]; -const c: JsonObject = { a: 1, b: ["2", true], d: { e: null } }; -// JsonValue = any of the above -``` diff --git a/ts/fileHandler.test.ts b/ts/fileHandler.test.ts new file mode 100644 index 00000000..2ba9db81 --- /dev/null +++ b/ts/fileHandler.test.ts @@ -0,0 +1,44 @@ +import { FIXTURE_DIR } from "../_constants.ts"; +import { resolve } from "../_deps/path.ts"; +import { assertEquals, assertRejects, describe, it } from "../_deps/testing.ts"; +import { CmdError } from "../cli/cmd.ts"; +import { TypeScriptFileHandler } from "./fileHandler.ts"; + +const valid = resolve(FIXTURE_DIR, "ts", "valid.ts"); +const invalid = resolve(FIXTURE_DIR, "ts", "invalid.ts"); +const invalidTypes = resolve(FIXTURE_DIR, "ts", "invalidTypes.ts"); +const throws = resolve(FIXTURE_DIR, "ts", "throws.ts"); + +describe("TypeScriptFileHandler.prototype.evaluate()", () => { + it("returns stdout", async () => + assertEquals( + await new TypeScriptFileHandler().evaluate(valid), + "Hello", + )); + + it("throws when code throws", async () => + void await assertRejects( + () => new TypeScriptFileHandler().evaluate(throws), + CmdError, + )); + + it("throws on invalid input", async () => + void await assertRejects( + () => new TypeScriptFileHandler().evaluate(invalid), + CmdError, + )); + + describe("type checking", () => { + it("is enabled by default", async () => + void await assertRejects( + () => new TypeScriptFileHandler().evaluate(invalidTypes), + )); + + it("can be disabled", async () => + void assertEquals( + await new TypeScriptFileHandler({ typeCheck: false }) + .evaluate(invalidTypes), + "Hello", + )); + }); +}); diff --git a/ts/fileHandler.ts b/ts/fileHandler.ts new file mode 100644 index 00000000..b4bff2bf --- /dev/null +++ b/ts/fileHandler.ts @@ -0,0 +1,30 @@ +import { + Evaluable, + FileHandler, + FileHandlerMethodOptions, + PlainTextFileHandler, +} from "../fs/fileHandler.ts"; +import { evaluate } from "./evaluate.ts"; + +/** Type checking is enabled by default. */ +export class TypeScriptFileHandler extends PlainTextFileHandler<".ts"> + implements FileHandler, Evaluable { + public readonly typeCheck: boolean; + + constructor(opts?: { typeCheck: boolean }) { + super([".ts"]); + + const { typeCheck = true } = opts ?? {}; + + this.typeCheck = typeCheck; + } + + async evaluate( + filePath: string, + opts?: FileHandlerMethodOptions, + ) { + const code = await this.read(filePath, opts); + + return await evaluate(code, { typeCheck: this.typeCheck }); + } +} diff --git a/ts/utils.ts b/ts/utils.ts index 1dd8e243..162ccbcb 100644 --- a/ts/utils.ts +++ b/ts/utils.ts @@ -1 +1,2 @@ export * from "./evaluate.ts"; +export * from "./fileHandler.ts"; diff --git a/types/mod.ts b/types/mod.ts deleted file mode 100644 index 0600fe3a..00000000 --- a/types/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./json.ts";