From 240ac3ef824bccedd3d8731ab11f3740495a4713 Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Sat, 18 Jan 2025 19:31:32 -0500 Subject: [PATCH] feat: `fromFileSystem` Signed-off-by: Lexus Drumgold --- .gitignore | 1 + README.md | 166 ++++++++++- __tests__/utils/build-path.mts | 37 +++ __tests__/utils/readdir.mts | 90 ++++++ package.json | 22 +- src/__snapshots__/index.e2e.snap | 7 + src/__tests__/.gitkeep | 0 src/__tests__/index.e2e.spec.mts | 12 + src/__tests__/util.spec.mts | 281 ++++++++++++++++++ src/index.mts | 2 + src/interfaces/__tests__/.gitkeep | 0 src/interfaces/__tests__/dirent.spec-d.mts | 33 ++ .../__tests__/file-system.spec-d.mts | 51 ++++ src/interfaces/__tests__/options.spec-d.mts | 63 ++++ src/interfaces/dirent.mts | 37 +++ src/interfaces/file-system.mts | 49 +++ src/interfaces/index.mts | 4 +- src/interfaces/options.mts | 78 +++++ src/internal/__snapshots__/to-path.snap | 9 + src/internal/__tests__/.gitkeep | 0 src/internal/__tests__/has-extension.spec.mts | 33 ++ src/internal/__tests__/to-path.spec.mts | 43 +++ .../__tests__/validate-url-string.spec.mts | 39 +++ src/internal/fs.browser.mts | 39 +++ src/internal/fs.d.mts | 14 + src/internal/has-extension.mts | 46 +++ src/internal/to-path.mts | 47 +++ src/internal/validate-url-string.mts | 36 +++ src/types/__tests__/extensions.spec-d.mts | 20 ++ src/types/__tests__/filter.spec-d.mts | 24 ++ src/types/__tests__/filters.spec-d.mts | 22 ++ src/types/__tests__/handle.spec-d.mts | 30 ++ src/types/__tests__/handles.spec-d.mts | 23 ++ src/types/__tests__/sort.spec-d.mts | 25 ++ src/types/extensions.mts | 11 + src/types/filter.mts | 18 ++ src/types/filters.mts | 27 ++ src/types/handle.mts | 44 +++ src/types/handles.mts | 30 ++ src/types/index.mts | 11 + src/types/sort.mts | 24 ++ src/util.mts | 220 ++++++++++++++ yarn.lock | 62 ++-- 43 files changed, 1797 insertions(+), 33 deletions(-) create mode 100644 __tests__/utils/build-path.mts create mode 100644 __tests__/utils/readdir.mts create mode 100644 src/__snapshots__/index.e2e.snap delete mode 100644 src/__tests__/.gitkeep create mode 100644 src/__tests__/index.e2e.spec.mts create mode 100644 src/__tests__/util.spec.mts delete mode 100644 src/interfaces/__tests__/.gitkeep create mode 100644 src/interfaces/__tests__/dirent.spec-d.mts create mode 100644 src/interfaces/__tests__/file-system.spec-d.mts create mode 100644 src/interfaces/__tests__/options.spec-d.mts create mode 100644 src/interfaces/dirent.mts create mode 100644 src/interfaces/file-system.mts create mode 100644 src/interfaces/options.mts create mode 100644 src/internal/__snapshots__/to-path.snap delete mode 100644 src/internal/__tests__/.gitkeep create mode 100644 src/internal/__tests__/has-extension.spec.mts create mode 100644 src/internal/__tests__/to-path.spec.mts create mode 100644 src/internal/__tests__/validate-url-string.spec.mts create mode 100644 src/internal/fs.browser.mts create mode 100644 src/internal/fs.d.mts create mode 100644 src/internal/has-extension.mts create mode 100644 src/internal/to-path.mts create mode 100644 src/internal/validate-url-string.mts create mode 100644 src/types/__tests__/extensions.spec-d.mts create mode 100644 src/types/__tests__/filter.spec-d.mts create mode 100644 src/types/__tests__/filters.spec-d.mts create mode 100644 src/types/__tests__/handle.spec-d.mts create mode 100644 src/types/__tests__/handles.spec-d.mts create mode 100644 src/types/__tests__/sort.spec-d.mts create mode 100644 src/types/extensions.mts create mode 100644 src/types/filter.mts create mode 100644 src/types/filters.mts create mode 100644 src/types/handle.mts create mode 100644 src/types/handles.mts create mode 100644 src/types/index.mts create mode 100644 src/types/sort.mts create mode 100644 src/util.mts diff --git a/.gitignore b/.gitignore index c99a700..fca2c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ codecov # ------------------------------------------------------------------------------ **/*config.*.timestamp* **/.temp/ +**/*.scratch.* **/scratch.* diff --git a/README.md b/README.md index aa35beb..1316cbd 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,25 @@ Create file system trees - [Install](#install) - [Use](#use) - [API](#api) + - [`fromFileSystem([options])`](#fromfilesystemoptions) + - [`Options`](#options) + - [`Dirent`](#dirent) + - [`Extensions`](#extensions) + - [`FileSystem`](#filesystem) + - [`Filter`](#filter) + - [`Filters`](#filters) + - [`Handle<[T]>`](#handlet) + - [`Handles`](#handles) + - [`Sort`](#sort) - [Syntax tree](#syntax-tree) - [Types](#types) - - [Interfaces](#interfaces) - [Contribute](#contribute) ## What is this? -**TODO**: what is this? +This package is a utility to create [file system trees][fst]. + +This utility that uses file system adapters to recursively read a directory, and create a tree from its contents. ## Install @@ -64,7 +75,140 @@ In browsers with [`esm.sh`][esmsh]: ## API -**TODO**: api +This package exports the following identifiers: + +- [`fromFileSystem`](#fromfilesystemoptions) + +There is no default export. + +### `fromFileSystem([options])` + +Create a file system tree. + +#### Parameters + +- `options` ([`Options`](#options), optional) — tree options + +#### Returns + +([`Root`][fst-root]) file system tree + +### `Options` + +Options for creating a file system tree (TypeScript interface). + +#### Properties + +- `content` (`boolean`, optional) — include file content (populates the `value` field of each [`file` node][fst-file]) +- `depth` (`number`, optional) — maximum search depth (inclusive) +- `extensions` ([`Extensions`](#extensions), optional) — list of file extensions to filter matched files by +- `filters` ([`Filters`](#filters), optional) — path filters to determine if nodes should be added to the tree +- `fs` ([`Partial`](#filesystem), optional) — file system adapter +- `handles` ([`Handles`](#handles), optional) — node handlers +- `root` (`URL | string`, optional) — module id of root directory + - **default**: [`pathe.cwd() + pathe.sep`][pathe] +- `sort` ([`Sort`](#sort), optional) — function used to sort child nodes + +### `Dirent` + +Directory content entry (TypeScript interface). + +This interface can be augmented to register custom methods and properties. + +```ts +declare module '@flex-development/fst-util-from-fs' { + interface Dirent { + parentPath: string + } +} +``` + +#### Properties + +- `isDirectory` (`(this: void) => boolean`) — check if the dirent describes a directory +- `name` (`string`) — directory content name. if the dirent refers to a file, the file extension should be included + +### `Extensions` + +Union of options to filter matched files by file extension (TypeScript type). + +```ts +type Extensions = Set | readonly string[] | string +``` + +### `FileSystem` + +File system adapter (TypeScript interface). + +#### Properties + +- `readFileSync` (`(this: void, path: string, encoding: 'utf8') => string`, optional) — + get the contents of the file at `path` +- `readdirSync` (`(this: void, path: string, options: { withFileTypes: true }) => readonly Dirent[]`) — + read the contents of the directory at `path` + +### `Filter` + +Determine if a node for `x` should be added to a file system tree. + +#### Parameters + +- `x` (`string`) — path to directory or file + +#### Returns + +(`boolean`) `true` if node for `x` should be added, `false` otherwise + +### `Filters` + +Path filters to determine if nodes should be added to the tree (TypeScript type). + +#### Properties + +- `directory` ([`Filter`](#filter), optional) — determine if a `directory` node should be added to the tree +- `file` ([`Filter`](#filter), optional) — determine if a `file` node should be added to the tree + +### `Handle<[T]>` + +Handle `node`. + +#### Type Parameters + +- `T` ([`Child`][fst-child]) — [fst][] child node + +#### Parameters + +- `node` (`T`) — directory or file node +- `dirent` ([`Dirent`](#dirent)) — dirent object representing directory or file +- `parent` ([`Parent`][fst-parent]) — parent node +- `tree` ([`Root`][fst-root]) — file system tree +- `fs` ([`FileSystem`](#filesystem)) — file system adapter + +#### Returns + +(`null | undefined | void`) nothing + +### `Handles` + +Path filters to determine if nodes should be added to the tree (TypeScript type). + +#### Properties + +- `directory` ([`Handle`](#handlet), optional) — [directory node][fst-directory] handler +- `file` ([`Handle`](#handlet), optional) — [file node][fst-file] handler + +### `Sort` + +Compare node `a` to `b`. + +#### Parameters + +- `a` ([`Child`][fst-child]) — current child node +- `b` ([`Child`][fst-child]) — next child node + +#### Returns + +(`number`) comparison result ## Syntax tree @@ -74,10 +218,6 @@ The syntax tree is [fst][]. This package is fully typed with [TypeScript][]. -### Interfaces - -**TODO**: interfaces - ## Contribute See [`CONTRIBUTING.md`](CONTRIBUTING.md). @@ -89,8 +229,20 @@ community you agree to abide by its terms. [esmsh]: https://esm.sh +[fst-child]: https://github.com/flex-development/fst#child + +[fst-directory]: https://github.com/flex-development/fst#directory + +[fst-file]: https://github.com/flex-development/fst#file + +[fst-parent]: https://github.com/flex-development/fst#parent + +[fst-root]: https://github.com/flex-development/fst#root + [fst]: https://github.com/flex-development/fst +[pathe]: https://github.com/flex-development/pathe + [typescript]: https://www.typescriptlang.org [yarn]: https://yarnpkg.com diff --git a/__tests__/utils/build-path.mts b/__tests__/utils/build-path.mts new file mode 100644 index 0000000..5e8b34f --- /dev/null +++ b/__tests__/utils/build-path.mts @@ -0,0 +1,37 @@ +/** + * @file Test Utilities - buildPath + * @module tests/utils/buildPath + */ + +import type { Directory, DirectoryContent, Root } from '@flex-development/fst' +import pathe from '@flex-development/pathe' + +/** + * Get the path to `node`. + * + * @see {@linkcode DirectoryContent} + * @see {@linkcode Directory} + * @see {@linkcode Root} + * + * @this {void} + * + * @param {DirectoryContent} node + * Current node + * @param {Directory | Root} parent + * Parent of `node` + * @param {(Directory | Root)[]} ancestors + * List of ancestor nodes where the last node is the grandparent of `node` + * @return {undefined} + */ +function buildPath( + this: void, + node: DirectoryContent, + parent: Directory | Root, + ancestors: (Directory | Root)[] +): string { + return pathe.join(...[...ancestors, parent, node].map(node => { + return 'path' in node ? node.path : node.name + })) +} + +export default buildPath diff --git a/__tests__/utils/readdir.mts b/__tests__/utils/readdir.mts new file mode 100644 index 0000000..37ba631 --- /dev/null +++ b/__tests__/utils/readdir.mts @@ -0,0 +1,90 @@ +/** + * @file Test Utilities - readdir + * @module tests/utils/readdir + */ + +import toPath from '#internal/to-path' +import pathe from '@flex-development/pathe' +import fs from 'node:fs' + +export { readdir as default, type ReadResult } + +/** + * Read directory result. + */ +type ReadResult = { + /** + * List of direcory paths. + */ + directories: string[] + + /** + * List of files. + */ + files: string[] +} + +/** + * Get the contents of the directory at `dir`. + * + * @this {void} + * + * @param {string} dir + * Directory URL or path to directory + * @param {number | null | undefined} depth + * Maximum search depth (inclusive) + * @param {ReadResult | null | undefined} [ctx] + * Read directory context + * @return {ReadResult} + * Read directory result + */ +function readdir( + this: void, + dir: URL | string, + depth?: number | null | undefined, + ctx?: ReadResult | null | undefined +): ReadResult { + ctx ??= { directories: [], files: [] } + + if ( + depth === null || + depth === undefined || + typeof depth === 'number' && depth > 0 + ) { + dir = toPath(dir) + + /** + * List of subdirectories. + * + * @const {string[]} subdirectories + */ + const subdirectories: string[] = [] + + for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) { + /** + * Relative path to directory or file. + * + * @const {string} path + */ + const path: string = pathe.join(dir, dirent.name) + + if (dirent.isDirectory()) { + subdirectories.push(path) + } else { + ctx.files.push(path) + } + } + + if (typeof depth === 'number') { + depth-- + if (depth <= 0) return ctx + } + + for (const path of subdirectories) { + ctx.directories.push(path) + readdir(path, depth, ctx) + } + } + + return ctx +} diff --git a/package.json b/package.json index 6acc68e..fb6e3ce 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,7 @@ "default": "./dist/interfaces/*.d.mts" }, "#internal/fs": { - "types": { - "fst-util-from-fs": "./src/internal/fs.d.mts", - "default": "./dist/internal/fs.d.mts" - }, + "types": "./src/internal/fs.d.mts", "browser": { "fst-util-from-fs": "./src/internal/fs.browser.mts", "default": "./dist/internal/fs.browser.mjs" @@ -65,12 +62,20 @@ "fst-util-from-fs": "./src/internal/*.mts", "default": "./dist/internal/*.mjs" }, - "#tests/*": "./__tests__/*.mts" + "#tests/*": "./__tests__/*.mts", + "#types/*": { + "fst-util-from-fs": "./src/types/*.mts", + "default": "./dist/types/*.d.mts" + }, + "#util": { + "fst-util-from-fs": "./src/util.mts", + "default": "./dist/util.mjs" + } }, "module": "./dist/index.mjs", "types": "./dist/index.d.mts", "scripts": { - "build": "yarn clean:build; tsc -p tsconfig.build.json --noEmit false; trash ./dist/{interfaces,types}/*.{mjs,mjs.map}", + "build": "yarn clean:build; tsc -p tsconfig.build.json --noEmit false; trash ./dist/internal/*.d.mts && trash ./dist/{interfaces,types}/*.mjs", "check:ci": "yarn dedupe --check && yarn check:format && yarn check:lint && yarn check:spelling && yarn typecheck && yarn test:cov && yarn pack && yarn check:types:build && attw package.tgz && yarn clean:pack", "check:format": "dprint check --incremental=false", "check:lint": "eslint --exit-on-fatal-error --max-warnings 0 .", @@ -122,8 +127,8 @@ "@eslint/js": "9.18.0", "@flex-development/commitlint-config": "1.0.1", "@flex-development/grease": "3.0.0-alpha.9", - "@flex-development/is-builtin": "3.2.0", "@flex-development/tutils": "6.0.0-alpha.25", + "@flex-development/unist-util-visit": "1.1.0", "@stylistic/eslint-plugin": "2.13.0", "@tsconfig/strictest": "2.0.5", "@types/chai": "5.0.1", @@ -132,11 +137,13 @@ "@types/is-ci": "3.0.4", "@types/node": "22.10.6", "@types/node-notifier": "8.0.5", + "@types/unist": "3.0.3", "@typescript-eslint/eslint-plugin": "8.19.2-alpha.12", "@typescript-eslint/parser": "8.19.2-alpha.12", "@vates/toggle-scripts": "1.0.0", "@vitest/coverage-v8": "3.0.0-beta.4", "@vitest/ui": "3.0.0-beta.4", + "convert-hrtime": "5.0.0", "cross-env": "7.0.3", "cspell": "8.17.2", "dprint": "0.48.0", @@ -237,6 +244,7 @@ "typescript": "5.7.3", "typescript-eslint": "8.19.2-alpha.12", "unified": "11.0.5", + "unist-util-size": "4.0.0", "vfile": "6.0.3", "vitest": "3.0.0-beta.4", "yaml-eslint-parser": "1.2.3" diff --git a/src/__snapshots__/index.e2e.snap b/src/__snapshots__/index.e2e.snap new file mode 100644 index 0000000..01e0acb --- /dev/null +++ b/src/__snapshots__/index.e2e.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`e2e:fst-util-from-fs > should expose public api 1`] = ` +[ + "fromFileSystem", +] +`; diff --git a/src/__tests__/.gitkeep b/src/__tests__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/__tests__/index.e2e.spec.mts b/src/__tests__/index.e2e.spec.mts new file mode 100644 index 0000000..8f618d5 --- /dev/null +++ b/src/__tests__/index.e2e.spec.mts @@ -0,0 +1,12 @@ +/** + * @file E2E Tests - api + * @module fst-util-from-fs/tests/e2e/api + */ + +import * as testSubject from '@flex-development/fst-util-from-fs' + +describe('e2e:fst-util-from-fs', () => { + it('should expose public api', () => { + expect(Object.keys(testSubject)).toMatchSnapshot() + }) +}) diff --git a/src/__tests__/util.spec.mts b/src/__tests__/util.spec.mts new file mode 100644 index 0000000..f2cfd2a --- /dev/null +++ b/src/__tests__/util.spec.mts @@ -0,0 +1,281 @@ +/** + * @file Unit Tests - fromFileSystem + * @module fst-util-from-fs/tests/unit/fromFileSystem + */ + +import toPath from '#internal/to-path' +import buildPath from '#tests/utils/build-path' +import readdir, { type ReadResult } from '#tests/utils/readdir' +import testSubject from '#util' +import type { Directory, File, FstNode, Root } from '@flex-development/fst' +import type { + Handle, + Options +} from '@flex-development/fst-util-from-fs' +import pathe from '@flex-development/pathe' +import type { Fn, Predicate } from '@flex-development/tutils' +import { + visit, + type Index +} from '@flex-development/unist-util-visit' +import { ok } from 'devlop' +import fs from 'node:fs' +import type { Node } from 'unist' +import { size } from 'unist-util-size' + +describe('unit:fromFileSystem', () => { + let check: (this: void, tree: Root, read: ReadResult) => undefined + let isDirectory: Predicate<[Node]> + let isFile: Predicate<[Node]> + + beforeAll(() => { + isDirectory = (node: Node): boolean => node.type === 'directory' + isFile = (node: Node): boolean => node.type === 'file' + + check = function check( + this: void, + tree: Root, + read: ReadResult + ): undefined { + return void visit(tree, visitor) + + /** + * @this {void} + * + * @param {FstNode} node + * Current node + * @param {Index | undefined} index + * Index of `node` in `parent.children` + * @param {Directory | Root | undefined} parent + * Parent of `node` + * @param {(Directory | Root)[]} ancestors + * List of ancestor nodes where the last node + * is the grandparent of `node` + * @return {undefined} + */ + function visitor( + this: void, + node: FstNode, + index: Index | undefined, + parent: Directory | Root | undefined, + ancestors: (Directory | Root)[] + ): undefined { + if (node.type !== 'root') { + ok(parent, 'expected `parent`') + + /** + * Path to {@linkcode node}. + * + * @const {string} path + */ + const path: string = buildPath(node, parent, ancestors) + + if (node.type === 'directory') { + expect(path).to.be.oneOf(read.directories) + expect(path).to.not.be.oneOf(read.files) + } else { + expect(path).to.be.oneOf(read.files) + expect(path).to.not.be.oneOf(read.directories) + } + } + + return void node + } + } + }) + + it.each>([ + [{ depth: -13 }], + [{ depth: 0 }] + ])('should return empty tree (%#)', options => { + // Act + const result = testSubject(options) + + // Expect + expect(result).to.have.property('children').be.an('array') + expect(result).to.have.property('path', pathe.cwd() + pathe.sep) + expect(result).to.have.property('type', 'root') + expect(size(result)).to.eq(0) + }) + + it.each<[ + ...Parameters, + Fn, Fn<[Root], undefined>> + ]>([ + [ + { + depth: 1 + }, + function assertion( + this: void, + options: Options | null | undefined + ): Fn<[Root], undefined> { + ok(options, 'expected `options`') + ok(options.depth, 'expected `options.depth`') + + /** + * Read directory result. + * + * @const {ReadResult} read + */ + const read: ReadResult = readdir(pathe.cwd(), options.depth) + + return assert + + /** + * @this {void} + * + * @param {Root} tree + * File systrem tree result + * @return {undefined} + */ + function assert(this: void, tree: Root): undefined { + expect(size(tree)).to.eq(read.directories.length + read.files.length) + expect(size(tree, isDirectory)).to.eq(read.directories.length) + expect(size(tree, isFile)).to.eq(read.files.length) + + return void check(tree, read) + } + } + ], + [ + { + content: true, + extensions: ['spec.mts', 'spec-d.mts'], + handles: { directory: vi.fn(), file: vi.fn() }, + root: 'src' + }, + function assertion( + this: void, + options: Options | null | undefined + ): Fn<[Root], undefined> { + ok(options, 'expected `options`') + ok(Array.isArray(options.extensions), 'expected `options.extensions`') + ok(options.handles, 'expected `options.handles`') + ok(options.root, 'expected `options.root`') + ok(options.handles.directory, 'expected `options.handles.directory`') + ok(options.handles.file, 'expected `options.handles.file`') + + /** + * Directory node handler. + * + * @const {Handle} directory + */ + const directory: Handle = options.handles.directory + + /** + * Regular expression matching expected file extensions. + * + * @const {RegExp} ext + */ + const ext: RegExp = /\.spec(?:-d)?\.mts$/ + + /** + * File node handler. + * + * @const {Handle} file + */ + const file: Handle = options.handles.file + + /** + * Read directory result. + * + * @const {ReadResult} read + */ + const read: ReadResult = readdir(options.root, options.depth) + + return assert + + /** + * @this {void} + * + * @param {Root} tree + * File systrem tree result + * @return {undefined} + */ + function assert(this: void, tree: Root): undefined { + /** + * Number of directory nodes in {@linkcode tree}. + * + * @const {number} directories + */ + const directories: number = size(tree, isDirectory) + + /** + * Number of file nodes in {@linkcode tree}. + * + * @const {number} files + */ + const files: number = size(tree, isFile) + + expect(directories).to.eq(read.directories.length) + expect(directory).toHaveBeenCalledTimes(read.directories.length) + expect(file).toHaveBeenCalledTimes(files) + expect(files).to.be.lt(read.files.length) + + return void check(tree, read), void visit(tree, visitor) + + /** + * @this {void} + * + * @param {FstNode} node + * Current node + * @param {Index | undefined} index + * Index of `node` in `parent.children` + * @param {Directory | Root | undefined} parent + * Parent of `node` + * @param {(Directory | Root)[]} ancestors + * List of ancestor nodes where the last node + * is the grandparent of `node` + * @return {undefined} + */ + function visitor( + this: void, + node: FstNode, + index: Index | undefined, + parent: Directory | Root | undefined, + ancestors: (Directory | Root)[] + ): undefined { + if (node.type === 'file') { + ok(parent, 'expected `parent`') + + /** + * Path to {@linkcode node}. + * + * @const {string} path + */ + const path: string = buildPath(node, parent, ancestors) + + /** + * File content. + * + * @const {string} value + */ + const value: string = fs.readFileSync(path, 'utf8') + + expect(node).to.have.property('name').match(ext) + expect(node).to.have.property('value', value) + } + + return void node + } + } + } + ] + ])('should return non-empty tree (%#)', (options, assert) => { + // Arrange + const root: URL | string = options?.root ?? pathe.cwd() + const path: string = toPath(root).replace(/[/\\]+$/, '') + pathe.sep + + // Act + const result = testSubject(options) + + // Expect + expect(result).to.have.property('children').be.an('array') + expect(result).to.have.property('path', path) + expect(result).to.have.property('type', 'root') + + // Assert + assert(options)(result) + }) +}) diff --git a/src/index.mts b/src/index.mts index ad4630b..7a8f3df 100644 --- a/src/index.mts +++ b/src/index.mts @@ -4,3 +4,5 @@ */ export type * from '#interfaces/index' +export type * from '#types/index' +export { default as fromFileSystem } from '#util' diff --git a/src/interfaces/__tests__/.gitkeep b/src/interfaces/__tests__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/interfaces/__tests__/dirent.spec-d.mts b/src/interfaces/__tests__/dirent.spec-d.mts new file mode 100644 index 0000000..15d57e7 --- /dev/null +++ b/src/interfaces/__tests__/dirent.spec-d.mts @@ -0,0 +1,33 @@ +/** + * @file Type Tests - Dirent + * @module fst-util-from-fs/interfaces/tests/unit-d/Dirent + */ + +import type TestSubject from '#interfaces/dirent' +import type { EmptyArray } from '@flex-development/tutils' + +describe('unit-d:interfaces/Dirent', () => { + it('should match [name: string]', () => { + expectTypeOf().toHaveProperty('name').toEqualTypeOf() + }) + + describe('isDirectory', () => { + type Subject = TestSubject['isDirectory'] + + it('should match [this: void]', () => { + expectTypeOf().thisParameter.toEqualTypeOf() + }) + + describe('parameters', () => { + it('should be callable with []', () => { + expectTypeOf().parameters.toEqualTypeOf() + }) + }) + + describe('returns', () => { + it('should return boolean', () => { + expectTypeOf().returns.toEqualTypeOf() + }) + }) + }) +}) diff --git a/src/interfaces/__tests__/file-system.spec-d.mts b/src/interfaces/__tests__/file-system.spec-d.mts new file mode 100644 index 0000000..a050ad2 --- /dev/null +++ b/src/interfaces/__tests__/file-system.spec-d.mts @@ -0,0 +1,51 @@ +/** + * @file Type Tests - FileSystem + * @module fst-util-from-fs/interfaces/tests/unit-d/FileSystem + */ + +import type TestSubject from '#interfaces/file-system' +import type { Dirent } from '@flex-development/fst-util-from-fs' + +describe('unit-d:interfaces/FileSystem', () => { + describe('readFileSync', () => { + type Subject = NonNullable + + it('should match [this: void]', () => { + expectTypeOf().thisParameter.toEqualTypeOf() + }) + + describe('parameters', () => { + it('should be callable with [string, "utf8"]', () => { + expectTypeOf().parameters.toEqualTypeOf<[string, 'utf8']>() + }) + }) + + describe('returns', () => { + it('should return string', () => { + expectTypeOf().returns.toEqualTypeOf() + }) + }) + }) + + describe('readdirSync', () => { + type Subject = TestSubject['readdirSync'] + + it('should match [this: void]', () => { + expectTypeOf().thisParameter.toEqualTypeOf() + }) + + describe('parameters', () => { + it('should be callable with [string, { withFileTypes: true }]', () => { + expectTypeOf() + .parameters + .toEqualTypeOf<[string, { withFileTypes: true }]>() + }) + }) + + describe('returns', () => { + it('should return readonly Dirent[]', () => { + expectTypeOf().returns.toEqualTypeOf() + }) + }) + }) +}) diff --git a/src/interfaces/__tests__/options.spec-d.mts b/src/interfaces/__tests__/options.spec-d.mts new file mode 100644 index 0000000..6b25909 --- /dev/null +++ b/src/interfaces/__tests__/options.spec-d.mts @@ -0,0 +1,63 @@ +/** + * @file Type Tests - Options + * @module fst-util-from-fs/interfaces/tests/unit-d/Options + */ + +import type TestSubject from '#interfaces/options' +import type { + FileSystem, + Filters, + Handles, + Sort +} from '@flex-development/fst-util-from-fs' +import type { Nilable } from '@flex-development/tutils' + +describe('unit-d:interfaces/Options', () => { + it('should match [content?: boolean | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('content') + .toEqualTypeOf> + }) + + it('should match [depth?: number | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('depth') + .toEqualTypeOf> + }) + + it('should match [extensions?: Set | readonly string[] | string | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('extensions') + .toEqualTypeOf | readonly string[] | string>> + }) + + it('should match [filters?: Filters | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('filters') + .toEqualTypeOf> + }) + + it('should match [fs?: Partial | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('fs') + .toEqualTypeOf>> + }) + + it('should match [handles?: Handles | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('handles') + .toEqualTypeOf> + }) + + it('should match [root?: URL | string | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('root') + .toEqualTypeOf> + }) + + it('should match [sort?: Sort | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('sort') + .toEqualTypeOf> + }) +}) diff --git a/src/interfaces/dirent.mts b/src/interfaces/dirent.mts new file mode 100644 index 0000000..ac323e7 --- /dev/null +++ b/src/interfaces/dirent.mts @@ -0,0 +1,37 @@ +/** + * @file Interfaces - Dirent + * @module fst-util-from-fs/interfaces/Dirent + */ + +/** + * Directory content entry. + * + * This interface can be augmented to register custom methods and properties. + * + * @example + * declare module '@flex-development/fst-util-from-fs' { + * interface Dirent { + * parentPath: string + * } + * } + */ +interface Dirent { + /** + * Check if the dirent describes a directory. + * + * @this {void} + * + * @return {boolean} + * `true` if dirent describes directory, `false` otherwise + */ + isDirectory(this: void): boolean + + /** + * Directory content name. + * + * If the dirent refers to a file, the file extension should be included. + */ + name: string +} + +export type { Dirent as default } diff --git a/src/interfaces/file-system.mts b/src/interfaces/file-system.mts new file mode 100644 index 0000000..84b3b43 --- /dev/null +++ b/src/interfaces/file-system.mts @@ -0,0 +1,49 @@ +/** + * @file Interfaces - FileSystem + * @module fst-util-from-fs/interfaces/FileSystem + */ + +import type { Dirent } from '@flex-development/fst-util-from-fs' + +/** + * File system adapter. + */ +interface FileSystem { + /** + * Get the contents of the file at `path`. + * + * @this {void} + * + * @param {string} path + * Path to file to read + * @param {'utf8'} encoding + * Buffer encoding + * @return {string} + * File contents + */ + readFileSync?(this: void, path: string, encoding: 'utf8'): string + + /** + * Read the contents of the directory at `path`. + * + * @see {@linkcode Dirent} + * + * @this {void} + * + * @param {string} path + * Path to directory to read + * @param {{ withFileTypes: true }} options + * Read options + * @param {true} options.withFileTypes + * Return a list of dirent objects instead of strings or `Buffer`s + * @return {ReadonlyArray} + * Directory content list + */ + readdirSync( + this: void, + path: string, + options: { withFileTypes: true } + ): readonly Dirent[] +} + +export type { FileSystem as default } diff --git a/src/interfaces/index.mts b/src/interfaces/index.mts index 8fcd9d1..afe1f9a 100644 --- a/src/interfaces/index.mts +++ b/src/interfaces/index.mts @@ -3,4 +3,6 @@ * @module fst-util-from-fs/interfaces */ -export type {} +export type { default as Dirent } from '#interfaces/dirent' +export type { default as FileSystem } from '#interfaces/file-system' +export type { default as Options } from '#interfaces/options' diff --git a/src/interfaces/options.mts b/src/interfaces/options.mts new file mode 100644 index 0000000..15c5c6d --- /dev/null +++ b/src/interfaces/options.mts @@ -0,0 +1,78 @@ +/** + * @file Interfaces - Options + * @module fst-util-from-fs/interfaces/Options + */ + +import type { + Extensions, + FileSystem, + Filters, + Handles, + Sort +} from '@flex-development/fst-util-from-fs' + +/** + * Options for creating a file system tree. + */ +interface Options { + /** + * Include file content. + * + * > 👉 **Note**: Populates the `value` field of each `file` node. + */ + content?: boolean | null | undefined + + /** + * Maximum search depth (inclusive). + */ + depth?: number | null | undefined + + /** + * List of file extensions to filter matched files by. + * + * > 👉 **Note**: This is alternative way to exclude files from the tree. + * + * @see {@linkcode Extensions} + */ + extensions?: Extensions | null | undefined + + /** + * Path filters to determine if nodes should be added to the tree. + * + * @see {@linkcode Filters} + */ + filters?: Filters | null | undefined + + /** + * File system adapter. + * + * @see {@linkcode FileSystem} + */ + fs?: Partial | null | undefined + + /** + * Node handlers. + * + * @see {@linkcode Handles} + */ + handles?: Handles | null | undefined + + /** + * Module id of root directory. + * + * @default + * pathe.cwd() + pathe.sep + */ + root?: URL | string | null | undefined + + /** + * Function used to sort child nodes. + * + * By default, nodes are sorted by `type` and `name`. + * + * @see {@linkcode Sort} + */ + sort?: Sort | null | undefined +} + +export type { Options as default } diff --git a/src/internal/__snapshots__/to-path.snap b/src/internal/__snapshots__/to-path.snap new file mode 100644 index 0000000..e8b3c16 --- /dev/null +++ b/src/internal/__snapshots__/to-path.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`unit:internal/toPath > should return \`input\` as path ("../util.mts") 1`] = `"../util.mts"`; + +exports[`unit:internal/toPath > should return \`input\` as path ("/build.config.mts") 1`] = `"/build.config.mts"`; + +exports[`unit:internal/toPath > should return \`input\` as path ("file:///") 1`] = `"/"`; + +exports[`unit:internal/toPath > should return \`input\` as path ("src/index.mts") 1`] = `"src/index.mts"`; diff --git a/src/internal/__tests__/.gitkeep b/src/internal/__tests__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/internal/__tests__/has-extension.spec.mts b/src/internal/__tests__/has-extension.spec.mts new file mode 100644 index 0000000..0f43e5f --- /dev/null +++ b/src/internal/__tests__/has-extension.spec.mts @@ -0,0 +1,33 @@ +/** + * @file Unit Tests - hasExtension + * @module fst-util-from-fs/internal/tests/unit/hasExtension + */ + +import testSubject from '#internal/has-extension' +import pathe from '@flex-development/pathe' + +describe('unit:internal/hasExtension', () => { + it('should return `false` if `x` does not pass `extensions` filter', () => { + expect(testSubject('index.cts', 'mts')).to.be.false + }) + + it.each>([ + [import.meta.url, ['spec-d.mts', 'spec.mts']], + [pathe.resolve('src/index.mts'), 'mts'] + ])('should return `true` if `x` passes `extensions` filter` (%#)', ( + x, + extensions + ) => { + expect(testSubject(x, extensions)).to.be.true + }) + + it.each>([ + [import.meta.url, null], + [pathe.resolve('src/index.mts'), []] + ])('should return `true` if no `extensions` are provided (%#)', ( + x, + extensions + ) => { + expect(testSubject(x, extensions)).to.be.true + }) +}) diff --git a/src/internal/__tests__/to-path.spec.mts b/src/internal/__tests__/to-path.spec.mts new file mode 100644 index 0000000..04c2a51 --- /dev/null +++ b/src/internal/__tests__/to-path.spec.mts @@ -0,0 +1,43 @@ +/** + * @file Unit Tests - toPath + * @module fst-util-from-fs/internal/tests/unit/toPath + */ + +import testSubject from '#internal/to-path' +import { codes, isNodeError, type NodeError } from '@flex-development/errnode' +import pathe from '@flex-development/pathe' + +describe('unit:internal/toPath', () => { + it.each<[URL | string]>([ + ['../util.mts'], + ['src/index.mts'], + [new URL('file:///')], + [pathe.sep + 'build.config.mts'] + ])('should return `input` as path (%j)', input => { + // Act + const result = testSubject(input) + + // Expect + expect(result).toMatchSnapshot() + }) + + it.each<[URL | string]>([ + ['node:test'], + [new URL('node:test/reporters')] + ])('should throw if `input` is not a path or `file:` URL (%j)', input => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject(input) + } catch (e: unknown) { + error = e as typeof error + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_URL_SCHEME) + expect(error).to.have.property('message').match(/of scheme file/) + }) +}) diff --git a/src/internal/__tests__/validate-url-string.spec.mts b/src/internal/__tests__/validate-url-string.spec.mts new file mode 100644 index 0000000..1df35c8 --- /dev/null +++ b/src/internal/__tests__/validate-url-string.spec.mts @@ -0,0 +1,39 @@ +/** + * @file Unit Tests - validateURLString + * @module fst-util-from-fs/internal/tests/unit/validateURLString + */ + +import testSubject from '#internal/validate-url-string' +import { codes, isNodeError, type NodeError } from '@flex-development/errnode' + +describe('unit:internal/validateURLString', () => { + let name: string + + beforeAll(() => { + name = 'value' + }) + + it('should return `true` if `value` is a `URL`', () => { + expect(testSubject(new URL(import.meta.url), name)).to.be.true + }) + + it('should return `true` if `value` is a string', () => { + expect(testSubject(import.meta.url, name)).to.be.true + }) + + it('should throw if `value` is not a `URL` or string', () => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject(null, name) + } catch (e: unknown) { + error = e as typeof error + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_ARG_TYPE) + }) +}) diff --git a/src/internal/fs.browser.mts b/src/internal/fs.browser.mts new file mode 100644 index 0000000..a8ee986 --- /dev/null +++ b/src/internal/fs.browser.mts @@ -0,0 +1,39 @@ +/** + * @file Internal - fs/browser + * @module fst-util-from-fs/internal/fs/browser + */ + +import type { FileSystem } from '@flex-development/fst-util-from-fs' + +/** + * File system API. + * + * @internal + * + * @const {Required} fs + */ +const fs: Required = { + /** + * Get the contents of a file. + * + * @return {never} + * Never; not implemented + * @throws {Error} + */ + readFileSync(): never { + throw new Error('[readFileSync] not implemented') + }, + + /** + * Read the contents of a directory. + * + * @return {never} + * Never; not implemented + * @throws {Error} + */ + readdirSync(): never { + throw new Error('[readdirSync] not implemented') + } +} + +export default fs diff --git a/src/internal/fs.d.mts b/src/internal/fs.d.mts new file mode 100644 index 0000000..9314a21 --- /dev/null +++ b/src/internal/fs.d.mts @@ -0,0 +1,14 @@ +declare module '#internal/fs' { + import type { FileSystem } from '@flex-development/fst-util-from-fs' + + /** + * File system API. + * + * @internal + * + * @const {Required} fs + */ + const fs: Required + + export default fs +} diff --git a/src/internal/has-extension.mts b/src/internal/has-extension.mts new file mode 100644 index 0000000..c3a7ba8 --- /dev/null +++ b/src/internal/has-extension.mts @@ -0,0 +1,46 @@ +/** + * @file Internal - hasExtension + * @module fst-util-from-fs/internal/hasExtension + */ + +import type { Extensions } from '@flex-development/fst-util-from-fs' +import pathe from '@flex-development/pathe' + +/** + * Check if `input` ends with an extension in `extensions`. + * + * @see {@linkcode Extensions} + * + * @internal + * + * @param {string} x + * The path or `file:` URL to check + * @param {Extensions | null | undefined} [extensions] + * File extensions to filter matched files by + * @return {boolean} + * `true` if `x` ends with an extension in `extensions`, + * or no `extensions` are provided + */ +function hasExtension( + x: string, + extensions: Extensions | null | undefined +): boolean { + if (typeof extensions === 'string') extensions = [extensions] + + if (extensions) { + /** + * List of file extensions. + * + * @const {string[]} filter + */ + const list: string[] = [...extensions] + + if (list.length) { + return list.map(pathe.formatExt).some(ext => x.endsWith(ext)) + } + } + + return true +} + +export default hasExtension diff --git a/src/internal/to-path.mts b/src/internal/to-path.mts new file mode 100644 index 0000000..061e4af --- /dev/null +++ b/src/internal/to-path.mts @@ -0,0 +1,47 @@ +/** + * @file Internal - toPath + * @module fst-util-from-fs/internal/toPath + */ + +import validateURLString from '#internal/validate-url-string' +import { + ERR_INVALID_URL_SCHEME, + type ErrInvalidUrlScheme +} from '@flex-development/errnode' +import pathe from '@flex-development/pathe' + +/** + * Convert `input` to a path. + * + * > 👉 **Note**: `input` is assumed to be a path if it is a string and cannot + * > be parsed to a URL (checked using {@linkcode pathe.isURL}). + * + * @see {@linkcode ErrInvalidUrlScheme} + * + * @internal + * + * @this {void} + * + * @param {URL | string} input + * The {@linkcode URL}, URL string, or path to convert + * @return {string} + * `input` as path + * @throws {ErrInvalidUrlScheme} + * If `input` is not a path or `file:` URL + */ +function toPath( + this: void, + input: URL | string +): string { + validateURLString(input, 'input') + + if (typeof input === 'string') { + if (!pathe.isURL(input)) return pathe.toPosix(input) + input = new URL(input) + } + + if (input.protocol === 'file:') return pathe.fileURLToPath(input) + throw new ERR_INVALID_URL_SCHEME('file') +} + +export default toPath diff --git a/src/internal/validate-url-string.mts b/src/internal/validate-url-string.mts new file mode 100644 index 0000000..39e1028 --- /dev/null +++ b/src/internal/validate-url-string.mts @@ -0,0 +1,36 @@ +/** + * @file Internal - validateURLString + * @module fst-util-from-fs/internal/validateURLString + */ + +import { + ERR_INVALID_ARG_TYPE, + type ErrInvalidArgType +} from '@flex-development/errnode' +import pathe from '@flex-development/pathe' + +/** + * Check if `value` is a {@linkcode URL} object or string. + * + * @see {@linkcode ErrInvalidArgType} + * + * @internal + * + * @param {unknown} value + * Value to check + * @param {string} name + * Name of invalid argument or property + * @return {value is URL | string} + * `true` if `value` is `URL` object or string + * @throws {ErrInvalidArgType} + * If `value` is not `URL` object or string + */ +function validateURLString( + value: unknown, + name: string +): value is URL | string { + if (typeof value === 'string' || pathe.isURL(value)) return true + throw new ERR_INVALID_ARG_TYPE(name, ['URL', 'string'], value) +} + +export default validateURLString diff --git a/src/types/__tests__/extensions.spec-d.mts b/src/types/__tests__/extensions.spec-d.mts new file mode 100644 index 0000000..1f30449 --- /dev/null +++ b/src/types/__tests__/extensions.spec-d.mts @@ -0,0 +1,20 @@ +/** + * @file Type Tests - Extensions + * @module fst-util-from-fs/types/tests/unit-d/Extensions + */ + +import type TestSubject from '#types/extensions' + +describe('unit-d:types/Extensions', () => { + it('should extract Set', () => { + expectTypeOf().extract>().not.toBeNever() + }) + + it('should extract readonly string[]', () => { + expectTypeOf().extract().not.toBeNever() + }) + + it('should extract string', () => { + expectTypeOf().extract().not.toBeNever() + }) +}) diff --git a/src/types/__tests__/filter.spec-d.mts b/src/types/__tests__/filter.spec-d.mts new file mode 100644 index 0000000..388ba8e --- /dev/null +++ b/src/types/__tests__/filter.spec-d.mts @@ -0,0 +1,24 @@ +/** + * @file Type Tests - Filter + * @module fst-util-from-fs/types/tests/unit-d/Filter + */ + +import type TestSubject from '#types/filter' + +describe('unit-d:types/Filter', () => { + it('should match [this: void]', () => { + expectTypeOf().thisParameter.toEqualTypeOf() + }) + + describe('parameters', () => { + it('should be callable with [string]', () => { + expectTypeOf().parameters.toEqualTypeOf<[string]>() + }) + }) + + describe('returns', () => { + it('should return boolean', () => { + expectTypeOf().returns.toEqualTypeOf() + }) + }) +}) diff --git a/src/types/__tests__/filters.spec-d.mts b/src/types/__tests__/filters.spec-d.mts new file mode 100644 index 0000000..4fc05ff --- /dev/null +++ b/src/types/__tests__/filters.spec-d.mts @@ -0,0 +1,22 @@ +/** + * @file Type Tests - Filters + * @module fst-util-from-fs/types/tests/unit-d/Filters + */ + +import type TestSubject from '#types/filters' +import type { Filter } from '@flex-development/fst-util-from-fs' +import type { Nilable } from '@flex-development/tutils' + +describe('unit-d:types/Filters', () => { + it('should match [directory?: Filter | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('directory') + .toEqualTypeOf> + }) + + it('should match [file?: Filter | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('file') + .toEqualTypeOf> + }) +}) diff --git a/src/types/__tests__/handle.spec-d.mts b/src/types/__tests__/handle.spec-d.mts new file mode 100644 index 0000000..93eba31 --- /dev/null +++ b/src/types/__tests__/handle.spec-d.mts @@ -0,0 +1,30 @@ +/** + * @file Type Tests - Handle + * @module fst-util-from-fs/types/tests/unit-d/Handle + */ + +import type TestSubject from '#types/handle' +import type { Child, AnyParent as Parent, Root } from '@flex-development/fst' +import type { Dirent, FileSystem } from '@flex-development/fst-util-from-fs' + +describe('unit-d:types/Handle', () => { + it('should match [this: void]', () => { + expectTypeOf().thisParameter.toEqualTypeOf() + }) + + describe('parameters', () => { + it('should be callable with [T, Dirent, Parent, Root, FileSystem]', () => { + expectTypeOf() + .parameters + .toEqualTypeOf<[Child, Dirent, Parent, Root, FileSystem]>() + }) + }) + + describe('returns', () => { + it('should return null | undefined | void', () => { + expectTypeOf() + .returns + .toEqualTypeOf() + }) + }) +}) diff --git a/src/types/__tests__/handles.spec-d.mts b/src/types/__tests__/handles.spec-d.mts new file mode 100644 index 0000000..e2b9f8e --- /dev/null +++ b/src/types/__tests__/handles.spec-d.mts @@ -0,0 +1,23 @@ +/** + * @file Type Tests - Handles + * @module fst-util-from-fs/types/tests/unit-d/Handles + */ + +import type TestSubject from '#types/handles' +import type { Directory, File } from '@flex-development/fst' +import type { Handle } from '@flex-development/fst-util-from-fs' +import type { Nilable } from '@flex-development/tutils' + +describe('unit-d:types/Handles', () => { + it('should match [directory?: Handle | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('directory') + .toEqualTypeOf>> + }) + + it('should match [file?: Handle | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('file') + .toEqualTypeOf>> + }) +}) diff --git a/src/types/__tests__/sort.spec-d.mts b/src/types/__tests__/sort.spec-d.mts new file mode 100644 index 0000000..3f6eec1 --- /dev/null +++ b/src/types/__tests__/sort.spec-d.mts @@ -0,0 +1,25 @@ +/** + * @file Type Tests - Sort + * @module fst-util-from-fs/types/tests/unit-d/Sort + */ + +import type TestSubject from '#types/sort' +import type { Child } from '@flex-development/fst' + +describe('unit-d:types/Sort', () => { + it('should match [this: void]', () => { + expectTypeOf().thisParameter.toEqualTypeOf() + }) + + describe('parameters', () => { + it('should be callable with [Child, Child]', () => { + expectTypeOf().parameters.toEqualTypeOf<[Child, Child]>() + }) + }) + + describe('returns', () => { + it('should return number', () => { + expectTypeOf().returns.toEqualTypeOf() + }) + }) +}) diff --git a/src/types/extensions.mts b/src/types/extensions.mts new file mode 100644 index 0000000..c34e3c5 --- /dev/null +++ b/src/types/extensions.mts @@ -0,0 +1,11 @@ +/** + * @file Type Aliases - Extensions + * @module fst-util-from-fs/types/Extensions + */ + +/** + * Union of options to filter matched files by file extension. + */ +type Extensions = Set | readonly string[] | string + +export type { Extensions as default } diff --git a/src/types/filter.mts b/src/types/filter.mts new file mode 100644 index 0000000..c428259 --- /dev/null +++ b/src/types/filter.mts @@ -0,0 +1,18 @@ +/** + * @file Type Aliases - Filter + * @module fst-util-from-fs/types/Filter + */ + +/** + * Determine if a node for `x` should be added to a file system tree. + * + * @this {void} + * + * @param {string} x + * Path to directory or file + * @return {boolean} + * `true` if node for `x` should be added, `false` otherwise + */ +type Filter = (this: void, x: string) => boolean + +export type { Filter as default } diff --git a/src/types/filters.mts b/src/types/filters.mts new file mode 100644 index 0000000..e75a560 --- /dev/null +++ b/src/types/filters.mts @@ -0,0 +1,27 @@ +/** + * @file Type Aliases - Filters + * @module fst-util-from-fs/types/Filters + */ + +import type { Filter } from '@flex-development/fst-util-from-fs' + +/** + * Filter configuration. + */ +type Filters = { + /** + * Determine if a `directory` node should be added to the tree. + * + * @see {@linkcode Filter} + */ + directory?: Filter | null | undefined + + /** + * Determine if a `file` node should be added to the tree. + * + * @see {@linkcode Filter} + */ + file?: Filter | null | undefined +} + +export type { Filters as default } diff --git a/src/types/handle.mts b/src/types/handle.mts new file mode 100644 index 0000000..6b398c6 --- /dev/null +++ b/src/types/handle.mts @@ -0,0 +1,44 @@ +/** + * @file Type Aliases - Handle + * @module fst-util-from-fs/types/Handle + */ + +import type { Child, AnyParent as Parent, Root } from '@flex-development/fst' +import type { Dirent, FileSystem } from '@flex-development/fst-util-from-fs' + +/** + * Handle `node`. + * + * @see {@linkcode Child} + * @see {@linkcode Dirent} + * @see {@linkcode FileSystem} + * @see {@linkcode Parent} + * @see {@linkcode Root} + * + * @template {Child} [T=Child] + * Child node + * + * @this {void} + * + * @param {T} node + * Directory or file node + * @param {Dirent} dirent + * Dirent object representing directory or file + * @param {Parent} parent + * Parent node + * @param {Root} tree + * File system tree + * @param {FileSystem} fs + * File system adapter + * @return {null | undefined | void} + */ +type Handle = ( + this: void, + node: T, + dirent: Dirent, + parent: Parent, + tree: Root, + fs: FileSystem +) => null | undefined | void + +export type { Handle as default } diff --git a/src/types/handles.mts b/src/types/handles.mts new file mode 100644 index 0000000..07d9e6f --- /dev/null +++ b/src/types/handles.mts @@ -0,0 +1,30 @@ +/** + * @file Type Aliases - Handles + * @module fst-util-from-fs/types/Handles + */ + +import type { Directory, File } from '@flex-development/fst' +import type { Handle } from '@flex-development/fst-util-from-fs' + +/** + * Node handler registry. + */ +type Handles = { + /** + * Directory node handler. + * + * @see {@linkcode Directory} + * @see {@linkcode Handle} + */ + directory?: Handle | null | undefined + + /** + * File node handler. + * + * @see {@linkcode File} + * @see {@linkcode Handle} + */ + file?: Handle | null | undefined +} + +export type { Handles as default } diff --git a/src/types/index.mts b/src/types/index.mts new file mode 100644 index 0000000..96ff3c8 --- /dev/null +++ b/src/types/index.mts @@ -0,0 +1,11 @@ +/** + * @file Entry Point - Type Aliases + * @module fst-util-from-fs/types + */ + +export type { default as Extensions } from '#types/extensions' +export type { default as Filter } from '#types/filter' +export type { default as Filters } from '#types/filters' +export type { default as Handle } from '#types/handle' +export type { default as Handles } from '#types/handles' +export type { default as Sort } from '#types/sort' diff --git a/src/types/sort.mts b/src/types/sort.mts new file mode 100644 index 0000000..dea0e20 --- /dev/null +++ b/src/types/sort.mts @@ -0,0 +1,24 @@ +/** + * @file Type Aliases - Sort + * @module fst-util-from-fs/types/Sort + */ + +import type { Child } from '@flex-development/fst' + +/** + * Compare node `a` to `b`. + * + * @see {@linkcode Child} + * + * @this {void} + * + * @param {Child} a + * Current child node + * @param {Child} b + * Next child node + * @return {number} + * Comparison result + */ +type Sort = (this: void, a: Child, b: Child) => number + +export type { Sort as default } diff --git a/src/util.mts b/src/util.mts new file mode 100644 index 0000000..a15caf2 --- /dev/null +++ b/src/util.mts @@ -0,0 +1,220 @@ +/** + * @file fromFileSystem + * @module fst-util-from-fs/util + */ + +import dfs from '#internal/fs' +import hasExtension from '#internal/has-extension' +import toPath from '#internal/to-path' +import type { + Child, + Directory, + File, + AnyParent as Parent, + Root +} from '@flex-development/fst' +import type { + Dirent, + FileSystem, + Filters, + Handles, + Options +} from '@flex-development/fst-util-from-fs' +import pathe from '@flex-development/pathe' +import { u } from '@flex-development/unist-util-builder' +import { ok } from 'devlop' + +/** + * Create a file system tree. + * + * @see {@linkcode Options} + * @see {@linkcode Root} + * + * @this {void} + * + * @param {Options | null | undefined} [options] + * Tree options + * @return {Root} + * File system tree + */ +function fromFileSystem( + this: void, + options?: Options | null | undefined +): Root { + options ??= {} + + /** + * Path filters to determine if nodes should be added to the tree. + * + * @const {Required} filters + */ + const filters: Required = { + directory: filter, + file: filter, + ...options.filters + } + + /** + * File system adapter. + * + * @const {Required} fs + */ + const fs: Required = { ...dfs, ...options.fs } + + /** + * Node handlers. + * + * @const {Handles} handles + */ + const handles: Handles = { ...options.handles } + + /** + * File system tree. + * + * @const {Root} tree + */ + const tree: Root = u('root', { children: [], path: pathe.cwd() }) + + if (options.root !== null && options.root !== undefined) { + tree.path = toPath(options.root) + } + + tree.path = tree.path.replace(/[/\\]+$/, '') + pathe.sep + populate(tree, '', options.depth) + + return tree + + /** + * @this {void} + * + * @param {string} x + * Relative path to directory or file + * @return {boolean} + * `true` if node for `x` should be added to `tree`, `false` otherwise + */ + function filter(this: void, x: string): boolean { + return void x, true + } + + /** + * @template {Parent} T + * Parent node + * + * @this {void} + * + * @param {T} parent + * Current parent node + * @param {string} dir + * Current directory path, relative to {@linkcode tree.path} + * @param {number | null | undefined} depth + * Maximum search depth (inclusive) + * @return {undefined} + */ + function populate( + this: void, + parent: T, + dir: string, + depth: number | null | undefined + ): undefined { + ok(options, 'expected `options`') + + /** + * Directory path. + * + * @const {string} path + */ + const path: string = pathe.join(tree.path, dir) + + /** + * List of directories under {@linkcode path}. + * + * @const {[string, Dirent][]} subdirectories + */ + const subdirectories: [x: string, dirent: Dirent][] = [] + + if ( + depth === null || + depth === undefined || + typeof depth === 'number' && depth > 0 + ) { + for (const dirent of fs.readdirSync(path, { withFileTypes: true })) { + /** + * Relative path to directory or file. + * + * @const {string} x + */ + const x: string = pathe.join(dir, dirent.name) + + if (dirent.isDirectory()) { + filters.directory!(x) && subdirectories.push([x, dirent]) + } else { + if (filters.file!(x) && hasExtension(x, options.extensions)) { + /** + * File node. + * + * @const {File} node + */ + const node: File = u('file', { name: dirent.name, value: null }) + + if (options.content && typeof fs.readFileSync === 'function') { + node.value = fs.readFileSync(pathe.join(tree.path, x), 'utf8') + } + + parent.children.push(node) + handles.file?.(node, dirent, parent, tree, fs) + } + } + } + + // stop search at specified depth + if (typeof depth === 'number') { + depth-- + if (depth <= 0) return void depth + } + + // add subdirectory nodes + for (const [x, dirent] of subdirectories) { + /** + * Subdirectory node. + * + * @const {Directory} node + */ + const node: Directory = u('directory', { + children: [], + name: dirent.name + }) + + populate(node, x, depth) + parent.children.push(node) + handles.directory?.(node, dirent, parent, tree, fs) + } + } + + return void (parent.children.sort(options.sort ?? sort), parent) + } + + /** + * @this {void} + * + * @param {Child} a + * Current child node + * @param {Child} b + * Next child node + * @return {number} + * Comparison result + */ + function sort(a: Child, b: Child): number { + /** + * Comparison result. + * + * @var {number} result + */ + let result: number = { directory: -1, file: 1 }[a.type] + + if (a.type === b.type) result = a.name.localeCompare(b.name) + + return result + } +} + +export default fromFileSystem diff --git a/yarn.lock b/yarn.lock index 8b1fd22..f8a57b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1378,13 +1378,6 @@ __metadata: languageName: node linkType: hard -"@flex-development/builtin-modules@npm:2.1.2": - version: 2.1.2 - resolution: "@flex-development/builtin-modules@npm:2.1.2::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Fbuiltin-modules%2F2.1.2%2Ff4d6b61c135889cf80f743e370b1576ee25dfac2" - checksum: 10/62ce59ebf6710e750fc306cf641674ea29c5c4a0d33e3360d99120248fbb7643a944301f438e59e8207ba79319bff566e5a1adb0dc6ac92433097b1fc36e57a8 - languageName: node - linkType: hard - "@flex-development/commitlint-config@npm:1.0.1": version: 1.0.1 resolution: "@flex-development/commitlint-config@npm:1.0.1::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Fcommitlint-config%2F1.0.1%2F802e285802fe8188c2f02f68114ca31a20b6eb6d" @@ -1489,11 +1482,11 @@ __metadata: "@flex-development/errnode": "npm:3.1.1" "@flex-development/fst": "flex-development/fst#commit=9fae8b6708a1e392bb874a68aa28730fe6cefdf5" "@flex-development/grease": "npm:3.0.0-alpha.9" - "@flex-development/is-builtin": "npm:3.2.0" "@flex-development/pathe": "npm:4.0.1" "@flex-development/tutils": "npm:6.0.0-alpha.25" "@flex-development/unist-util-builder": "npm:2.0.0" "@flex-development/unist-util-types": "npm:1.6.1" + "@flex-development/unist-util-visit": "npm:1.1.0" "@stylistic/eslint-plugin": "npm:2.13.0" "@tsconfig/strictest": "npm:2.0.5" "@types/chai": "npm:5.0.1" @@ -1502,11 +1495,13 @@ __metadata: "@types/is-ci": "npm:3.0.4" "@types/node": "npm:22.10.6" "@types/node-notifier": "npm:8.0.5" + "@types/unist": "npm:3.0.3" "@typescript-eslint/eslint-plugin": "npm:8.19.2-alpha.12" "@typescript-eslint/parser": "npm:8.19.2-alpha.12" "@vates/toggle-scripts": "npm:1.0.0" "@vitest/coverage-v8": "npm:3.0.0-beta.4" "@vitest/ui": "npm:3.0.0-beta.4" + convert-hrtime: "npm:5.0.0" cross-env: "npm:7.0.3" cspell: "npm:8.17.2" devlop: "npm:1.1.0" @@ -1608,6 +1603,7 @@ __metadata: typescript: "npm:5.7.3" typescript-eslint: "npm:8.19.2-alpha.12" unified: "npm:11.0.5" + unist-util-size: "npm:4.0.0" vfile: "npm:6.0.3" vitest: "npm:3.0.0-beta.4" yaml-eslint-parser: "npm:1.2.3" @@ -1696,15 +1692,6 @@ __metadata: languageName: node linkType: hard -"@flex-development/is-builtin@npm:3.2.0": - version: 3.2.0 - resolution: "@flex-development/is-builtin@npm:3.2.0::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Fis-builtin%2F3.2.0%2F555baad0f0c1a9b329b3b6e5aaa763490d2394ae" - dependencies: - "@flex-development/builtin-modules": "npm:2.1.2" - checksum: 10/3f52c5bd289b9518720b31d50458f1f9416523e502969f659174d8054b208b17a38d88e5d2e51402f56ca07c4f7d63e786745f6ced558a8f478a55ca0e97d67a - languageName: node - linkType: hard - "@flex-development/mkbuild@npm:1.0.0-alpha.23": version: 1.0.0-alpha.23 resolution: "@flex-development/mkbuild@npm:1.0.0-alpha.23::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Fmkbuild%2F1.0.0-alpha.23%2F01fc88d62df2030b9fcc7a780061d20542e086a9" @@ -1938,6 +1925,15 @@ __metadata: languageName: node linkType: hard +"@flex-development/unist-util-types@npm:1.3.1": + version: 1.3.1 + resolution: "@flex-development/unist-util-types@npm:1.3.1::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Funist-util-types%2F1.3.1%2F44deb809fdc2921405035513bd54c4605213c7bc" + peerDependencies: + "@types/unist": ">=3.0.2" + checksum: 10/110ec112363147784c6f4a32473a1a44a76d28b775fe3d37bbe46b09e7c9f1f67dd0c8d7b9ec5bbe5b8f4d915cbbb826e3b1efc70c0cb5ca020c34b72351c4c5 + languageName: node + linkType: hard + "@flex-development/unist-util-types@npm:1.6.1": version: 1.6.1 resolution: "@flex-development/unist-util-types@npm:1.6.1::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Funist-util-types%2F1.6.1%2F0deabf0a30d32dc9ae23aea394ef67b8f0b806b9" @@ -1947,6 +1943,19 @@ __metadata: languageName: node linkType: hard +"@flex-development/unist-util-visit@npm:1.1.0": + version: 1.1.0 + resolution: "@flex-development/unist-util-visit@npm:1.1.0::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Funist-util-visit%2F1.1.0%2Fb565477b68a3adb5b2ce14da1e013d30e52cfe15" + dependencies: + "@flex-development/tutils": "npm:6.0.0-alpha.25" + "@flex-development/unist-util-types": "npm:1.3.1" + unist-util-is: "npm:6.0.0" + peerDependencies: + "@types/unist": ">=3.0.2" + checksum: 10/faa5501fb0649043ee9dfad9874d200647973b49da1f0a76a62cd4d92ca7a7410ac0d1c078fa06a6768b9686949c32d3f86ba9d416d0a84440e13979f8e30397 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -3961,6 +3970,13 @@ __metadata: languageName: node linkType: hard +"convert-hrtime@npm:5.0.0": + version: 5.0.0 + resolution: "convert-hrtime@npm:5.0.0" + checksum: 10/5245ad1ac6dd57b2d87624ae0eeac1d2a74812a6631208c09368bef787a28e7dbfa736cddaa9c8a0c425cb240437ea506afec7b9684ff617004d06a551f26c87 + languageName: node + linkType: hard + "core-js-compat@npm:^3.38.1": version: 3.39.0 resolution: "core-js-compat@npm:3.39.0" @@ -11169,7 +11185,7 @@ __metadata: languageName: node linkType: hard -"unist-util-is@npm:^6.0.0": +"unist-util-is@npm:6.0.0, unist-util-is@npm:^6.0.0": version: 6.0.0 resolution: "unist-util-is@npm:6.0.0" dependencies: @@ -11206,6 +11222,16 @@ __metadata: languageName: node linkType: hard +"unist-util-size@npm:4.0.0": + version: 4.0.0 + resolution: "unist-util-size@npm:4.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10/5084502e61062b5bd74fc487f4a989c4420f447a38ec333f767cfc7f3836abc612b6c7a6c5ff6942f7b801dd3af85712acce31ca2fde12a4f08e6f2850ccf42a + languageName: node + linkType: hard + "unist-util-stringify-position@npm:^2.0.0": version: 2.0.3 resolution: "unist-util-stringify-position@npm:2.0.3"