File (-> FileInfo) refactoring

This commit is contained in:
Kir_Antipov 2023-01-25 18:55:31 +00:00
parent 79519efc22
commit b9dc319a1e
3 changed files with 519 additions and 68 deletions

242
src/utils/io/file-info.ts Normal file
View file

@ -0,0 +1,242 @@
import { $i } from "@/utils/collections";
import { FileNotFoundError } from "@/utils/errors";
import glob from "fast-glob";
import { ReadStream, createReadStream, existsSync, readFileSync as readFileNodeSync, statSync } from "node:fs";
import { readFile as readFileNode } from "node:fs/promises";
import { basename, dirname } from "node:path";
/**
* Represents a file and provides utility methods to access its properties.
*/
export class FileInfo {
/**
* The file path.
*/
private readonly _path: string;
/**
* Constructs a new {@link FileInfo} instance.
*
* @param path - The file path.
*/
constructor(path: string) {
this._path = path;
}
/**
* Casts the given value to a {@link FileInfo} instance.
*
* @param file - The file path, or a {@link FileInfo} instance.
*
* @returns A {@link FileInfo} instance, or `undefined` if the input could not be casted to such.
*/
static of(file: string | FileInfo): FileInfo {
if (file instanceof FileInfo) {
return file;
}
return new FileInfo(String(file));
}
/**
* Gets the file name.
*/
get name(): string {
return basename(this._path);
}
/**
* Gets the directory name of the file.
*/
get directoryName(): string {
return dirname(this._path);
}
/**
* Gets the file path.
*/
get path(): string {
return this._path;
}
/**
* Checks if the file exists in the file system.
*/
get exists(): boolean {
return existsSync(this._path);
}
/**
* Returns the size of the file in bytes.
*/
get size(): number {
return statSync(this._path).size;
}
/**
* Gets the file path.
*
* Used to automatically convert this instance to a `Blob`.
*/
get [Symbol.for("path")](): string {
return this._path;
}
/**
* Creates a readable stream from the file.
*
* @param encoding - The character encoding for the file.
*
* @returns A `ReadStream` instance.
*/
stream(encoding?: BufferEncoding): ReadStream {
return createReadStream(this._path, encoding);
}
/**
* Reads the file and returns its content as a buffer.
*
* @returns A `Promise` that resolves to a `Buffer` containing the file content.
*/
buffer(): Promise<Buffer> {
return readFileNode(this._path);
}
/**
* Reads the file and returns its content as a string.
*
* @param encoding - The character encoding for the file.
*
* @returns A `Promise` that resolves to a string containing the file content.
*/
async text(encoding?: BufferEncoding): Promise<string> {
return (await this.buffer()).toString(encoding);
}
/**
* Reads the file and returns its content as a JSON object.
*
* @template T - The type of the object.
*
* @param encoding - The character encoding for the file.
*
* @returns A `Promise` that resolves to a JSON object containing the file content.
*/
async json<T = unknown>(encoding?: BufferEncoding): Promise<T> {
return JSON.parse(await this.text(encoding));
}
/**
* Returns the file path.
*
* @returns The file path.
*/
toString() {
return this._path;
}
}
/**
* Compares two {@link FileInfo} objects or file paths for equality.
*
* @param left - {@link FileInfo} object or file path.
* @param right - {@link FileInfo} object or file path.
*
* @returns `true` if both {@link FileInfo} objects or file paths are equal; otherwise, `false`.
*/
export function fileEquals(left: FileInfo | string, right: FileInfo | string): boolean {
const leftPath = typeof left === "string" ? left : left?.path;
const rightPath = typeof right === "string" ? right : right?.path;
return leftPath === rightPath;
}
/**
* Asynchronously finds files that match the given pattern(s).
*
* @param pattern - A glob pattern or an array of glob patterns to match.
*
* @returns A `Promise` that resolves to an array of {@link FileInfo} objects.
*/
export async function findFiles(pattern: string | string[]): Promise<FileInfo[]> {
const patterns = Array.isArray(pattern) ? pattern : [pattern];
const files = await Promise.all(patterns.map(x => glob(x)));
return $i(files).flatMap(x => x).distinct().map(x => new FileInfo(x)).toArray();
}
/**
* Synchronously finds files that match the given pattern(s).
*
* @param pattern - A glob pattern or an array of glob patterns to match.
*
* @returns An array of {@link FileInfo} objects.
*/
export function findFilesSync(pattern: string | string[]): FileInfo[] {
const patterns = Array.isArray(pattern) ? pattern : [pattern];
const files = patterns.map(x => glob.sync(x));
return $i(files).flatMap(x => x).distinct().map(x => new FileInfo(x)).toArray();
}
/**
* Reads the contents of the first file matching the specified glob pattern asynchronously.
*
* @param pattern - The glob pattern to match.
*
* @returns A promise that resolves to a Buffer containing the file contents.
*
* @throws {FileNotFoundError} - If no files matching the pattern are found.
*/
export async function readFile(pattern: string): Promise<Buffer> {
const files = await glob(pattern);
if (!files?.length) {
throw new FileNotFoundError(pattern);
}
return await readFileNode(files[0]);
}
/**
* Reads the contents of the first file matching the specified glob pattern asynchronously and returns it as a string.
*
* @param pattern - The glob pattern to match.
* @param encoding - The optional encoding to use for reading the file. Defaults to `utf8`.
*
* @returns A promise that resolves to a string containing the file contents.
*
* @throws {FileNotFoundError} - If no files matching the pattern are found.
*/
export async function readAllText(pattern: string, encoding?: BufferEncoding): Promise<string> {
return (await readFile(pattern)).toString(encoding);
}
/**
* Reads the contents of the first file matching the specified glob pattern synchronously.
*
* @param pattern - The glob pattern to match.
*
* @returns A Buffer containing the file contents.
*
* @throws {FileNotFoundError} - If no files matching the pattern are found.
*/
export function readFileSync(pattern: string): Buffer {
const files = glob.sync(pattern);
if (!files?.length) {
throw new FileNotFoundError(pattern);
}
return readFileNodeSync(files[0]);
}
/**
* Reads the contents of the first file matching the specified glob pattern synchronously and returns it as a string.
*
* @param pattern - The glob pattern to match.
* @param encoding - The optional encoding to use for reading the file. Defaults to `utf-8`.
*
* @returns A string containing the file contents.
*
* @throws {FileNotFoundError} - If no files matching the pattern are found.
*/
export function readAllTextSync(pattern: string, encoding?: BufferEncoding): string {
return readFileSync(pattern).toString(encoding);
}

View file

@ -1,68 +0,0 @@
import fs from "fs";
import path from "path";
import glob from "fast-glob";
export type FileSelector = string | { primary?: string, secondary?: string };
export const gradleOutputSelector = {
primary: "build/libs/!(*-@(dev|sources|javadoc)).jar",
secondary: "build/libs/*-@(dev|sources|javadoc).jar"
};
export default class File {
public name: string;
public path: string;
public constructor(filePath: string) {
this.name = path.basename(filePath);
this.path = filePath;
Object.freeze(this);
}
public getStream(): fs.ReadStream {
return fs.createReadStream(this.path);
}
public async getBuffer(): Promise<Buffer> {
return new Promise((resolve, reject) => {
fs.readFile(this.path, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
})
});
}
public equals(file: unknown): boolean {
return file instanceof File && file.path === this.path;
}
public static async getFiles(files: FileSelector): Promise<File[]> {
if (!files || typeof files !== "string" && !files.primary && !files.secondary) {
return [];
}
if (typeof files === "string") {
return (await glob(files)).map(x => new File(x));
}
let results = [];
if (files.primary) {
results = (await glob(files.primary)).map(x => new File(x));
}
if (files.secondary) {
results = results.concat((await glob(files.secondary)).map(x => new File(x)));
}
return results.filter((x, i, self) => self.findIndex(y => x.equals(y)) === i);
}
public static async getRequiredFiles(files: FileSelector): Promise<File[] | never> {
const foundFiles = await File.getFiles(files);
if (foundFiles && foundFiles.length) {
return foundFiles;
}
throw new Error(`Specified files ('${typeof files === "string" ? files : [files.primary, files.secondary].filter(x => x).join(", ")}') were not found`);
}
}

View file

@ -0,0 +1,277 @@
import { statSync } from "node:fs";
import mockFs from "mock-fs";
import {
FileInfo,
fileEquals,
findFiles,
findFilesSync,
readAllText,
readAllTextSync,
readFile,
readFileSync,
} from "@/utils/io/file-info";
beforeEach(() => {
mockFs({
"path/to": {
"test.txt": "test",
"test.json": JSON.stringify({ foo: 42 }),
},
});
});
afterEach(() => {
mockFs.restore();
});
describe("FileInfo", () => {
describe("constructor", () => {
test("constructs a new instance with the given path", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.path).toBe("path/to/test.txt");
});
});
describe("of", () => {
test("constructs a new instance from the given path", () => {
const info = FileInfo.of("test.txt");
expect(info).toBeInstanceOf(FileInfo);
expect(info.path).toBe("test.txt");
});
test("returns the same instance for a FileInfo object", () => {
const info1 = new FileInfo("test.txt");
const info2 = FileInfo.of(info1);
expect(info2).toBe(info1);
});
});
describe("name", () => {
test("returns the file name", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.name).toBe("test.txt");
});
});
describe("directoryName", () => {
test("returns the directory name of the file", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.directoryName).toBe("path/to");
});
});
describe("path", () => {
test("returns the file path", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.path).toBe("path/to/test.txt");
});
});
describe("exists", () => {
test("returns true for existing files", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.exists).toBe(true);
});
test("returns false for non-existing files", () => {
const info = new FileInfo("path/to/not-test.txt");
expect(info.exists).toBe(false);
});
});
describe("size", () => {
test("returns the file size", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.size).toBe(statSync("path/to/test.txt").size);
});
test("throws if the file does not exist", () => {
const info = new FileInfo("path/to/not-test.txt");
expect(() => info.size).toThrow();
});
});
describe("stream", () => {
test("creates a readable stream", () => {
const info = new FileInfo("path/to/test.txt");
const stream = info.stream();
expect(stream).toBeDefined();
expect(stream.readable).toBe(true);
stream.close();
});
});
describe("buffer", () => {
test("returns a promise that resolves to a buffer", async () => {
const info = new FileInfo("path/to/test.txt");
const buffer = await info.buffer();
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.toString()).toBe("test");
});
test("throws if the file does not exist", async () => {
const info = new FileInfo("path/to/not-test.txt");
await expect(info.buffer()).rejects.toThrow();
});
});
describe("text", () => {
test("returns a promise that resolves to a string", async () => {
const info = new FileInfo("path/to/test.txt");
const text = await info.text();
expect(text).toBe("test");
});
test("throws if the file does not exist", async () => {
const info = new FileInfo("path/to/not-test.txt");
await expect(info.text()).rejects.toThrow();
});
});
describe("json", () => {
test("returns a promise that resolves to a json object", async () => {
const info = new FileInfo("path/to/test.json");
const json = await info.json();
expect(json).toEqual({ foo: 42 });
});
test("throws if the file does not exist", async () => {
const info = new FileInfo("path/to/not-test.json");
await expect(info.json()).rejects.toThrow();
});
});
describe("toString", () => {
test("returns the file path", () => {
const info = new FileInfo("path/to/test.txt");
expect(info.toString()).toBe("path/to/test.txt");
});
});
});
describe("fileEquals", () => {
test("returns true for equal file paths", () => {
expect(fileEquals("path/to/test.txt", "path/to/test.txt")).toBe(true);
expect(fileEquals(FileInfo.of("path/to/test.txt"), "path/to/test.txt")).toBe(true);
expect(fileEquals("path/to/test.txt", FileInfo.of("path/to/test.txt"))).toBe(true);
expect(fileEquals(FileInfo.of("path/to/test.txt"), FileInfo.of("path/to/test.txt"))).toBe(true);
});
test("returns false for different file paths", () => {
expect(fileEquals("path/to/test.txt", "path/to/not-test.txt")).toBe(false);
expect(fileEquals(FileInfo.of("path/to/test.txt"), "path/to/not-test.txt")).toBe(false);
expect(fileEquals("path/to/test.txt", FileInfo.of("path/to/not-test.txt"))).toBe(false);
expect(fileEquals(FileInfo.of("path/to/test.txt"), FileInfo.of("path/to/not-test.txt"))).toBe(false);
});
});
describe("findFiles", () => {
test("returns matching files for given pattern", async () => {
const files = await findFiles("path/to/test.*");
const paths = files.map(file => file.path);
expect(paths).toEqual(expect.arrayContaining([
"path/to/test.txt",
"path/to/test.json",
]));
});
test("respects the order of the given patterns", async () => {
const paths = ["path/to/test.json", "path/to/test.txt"];
const variants = [paths, [...paths].reverse()];
for (const variant of variants) {
const files = await findFiles(variant);
expect(files.map(x => x.path)).toEqual(variant);
}
});
});
describe("findFilesSync", () => {
test("returns matching files for given pattern", () => {
const files = findFilesSync("path/to/test.*");
const paths = files.map(file => file.path);
expect(paths).toEqual(expect.arrayContaining([
"path/to/test.txt",
"path/to/test.json",
]));
});
test("respects the order of the given patterns", () => {
const paths = ["path/to/test.json", "path/to/test.txt"];
const variants = [paths, [...paths].reverse()];
for (const variant of variants) {
const files = findFilesSync(variant);
expect(files.map(x => x.path)).toEqual(variant);
}
});
});
describe("readFile", () => {
test("reads the contents of the first matching file", async () => {
const content = await readFile("path/to/*.txt");
expect(Buffer.isBuffer(content)).toBe(true);
expect(content.toString()).toEqual("test");
});
test("throws if no files were found", async () => {
await expect(readFile("path/from/*.txt")).rejects.toThrow(/path\/from\/\*\.txt/);
});
});
describe("readFileSync", () => {
test("reads the contents of the first matching file", () => {
const content = readFileSync("path/to/*.txt");
expect(Buffer.isBuffer(content)).toBe(true);
expect(content.toString()).toEqual("test");
});
test("throws if no files were found", () => {
expect(() => readFileSync("path/from/*.txt")).toThrow(/path\/from\/\*\.txt/);
});
});
describe("readAllText", () => {
test("reads the contents of the first matching file as text", async () => {
const content = await readAllText("path/to/*.txt");
expect(content).toEqual("test");
});
test("throws if no files were found", async () => {
await expect(readAllText("path/from/*.txt")).rejects.toThrow(/path\/from\/\*\.txt/);
});
});
describe("readAllTextSync", () => {
test("reads the contents of the first matching file as text", () => {
const content = readAllTextSync("path/to/*.txt");
expect(content).toEqual("test");
});
test("throws if no files were found", () => {
expect(() => readAllTextSync("path/from/*.txt")).toThrow(/path\/from\/\*\.txt/);
});
});