Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions _test/fixture/json/invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{
3 changes: 3 additions & 0 deletions _test/fixture/json/valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"foo": "bar"
}
3 changes: 3 additions & 0 deletions _test/fixture/ts/invalid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// deno-lint-ignore ban-ts-comment
// @ts-expect-error
invalid;
2 changes: 2 additions & 0 deletions _test/fixture/ts/invalidTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const foo: number = "Hello";
console.log(foo);
1 change: 1 addition & 0 deletions _test/fixture/ts/throws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error("");
3 changes: 3 additions & 0 deletions _test/fixture/ts/valid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// deno-lint-ignore no-inferrable-types
const foo: string = "Hello";
console.log(foo);
115 changes: 115 additions & 0 deletions fs/fileHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
export type FileHandler = {
read: (filePath: string) => unknown | Promise<unknown>;

// deno-lint-ignore no-explicit-any
write: (filePath: string, data: any) => Promise<void>;
};

/** A helper class for handling files by extensions. */
export abstract class FileExtensionHandler<Ext extends string = string> {
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<unknown>;
};

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<Ext extends string = string>
extends FileExtensionHandler<Ext>
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);
}
}
2 changes: 1 addition & 1 deletion fs/findNearestFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
4 changes: 2 additions & 2 deletions fs/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 3 additions & 3 deletions fs/globImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
25 changes: 25 additions & 0 deletions json/fileHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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()
});
74 changes: 74 additions & 0 deletions json/fileHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { FileExtensionHandler, FileHandler } from "../fs/fileHandler.ts";
import { JsonValue } from "./types.ts";

export type JsonReadOptions<ReviverResult = JsonValue> = {
// deno-lint-ignore no-explicit-any
reviver?: ((this: any, key: string, value: any) => ReviverResult) | undefined;
};

// deno-lint-ignore no-explicit-any
export type JsonWriteOptions<ReplacerResult = any> = {
// 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<JsonValue>;
async read<T = JsonValue>(
filePath: string,
opts?: JsonReadOptions<T>,
): Promise<JsonValue>;
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<void>;
// deno-lint-ignore no-explicit-any
async write<ReplacerResult = any>(
filePath: string,
data: JsonValue,
opts: JsonWriteOptions<ReplacerResult>,
): Promise<void>;
async write(
filePath: string,
data: JsonValue,
opts: JsonWriteOptions = {},
): Promise<void> {
const { replacer, space = 2 } = opts ?? this.options.write ?? {};

this.assertValidExtension(filePath);

await Deno.writeTextFile(filePath, JSON.stringify(data, replacer, space));
}
}
2 changes: 2 additions & 0 deletions json/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types.ts";
export * from "./utils.ts";
File renamed without changes.
1 change: 1 addition & 0 deletions json/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./fileHandler.ts";
38 changes: 19 additions & 19 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
44 changes: 44 additions & 0 deletions ts/fileHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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",
));
});
});
Loading