├── .editorconfig ├── .eslintrc.yaml ├── .gitattributes ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .npmrc ├── LICENSE ├── Makefile ├── README.md ├── index.test.ts ├── index.ts ├── package-lock.json ├── package.json ├── snapshots └── index.test.ts.snap ├── tsconfig.json ├── updates.config.js ├── vite.config.ts └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [Makefile] 13 | indent_style = tab 14 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: 3 | - silverwind 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.snap linguist-language=JavaScript linguist-generated 3 | fixtures/** linguist-generated 4 | vendor/** linguist-vendored 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | node: [18, 20, 22] 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | runs-on: ${{matrix.os}} 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: ${{matrix.node}} 17 | - run: make lint test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /dist 3 | /node_modules 4 | /npm-debug.log* 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | audit=false 2 | fund=false 3 | package-lock=true 4 | save-exact=true 5 | update-notifier=false 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) silverwind 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_FILES := index.ts 2 | DIST_FILES := dist/index.js 3 | 4 | node_modules: package-lock.json 5 | npm install --no-save 6 | @touch node_modules 7 | 8 | .PHONY: deps 9 | deps: node_modules 10 | 11 | .PHONY: lint 12 | lint: node_modules 13 | npx eslint --ext js,jsx,ts,tsx --color . 14 | npx tsc 15 | 16 | .PHONY: lint-fix 17 | lint-fix: node_modules 18 | npx eslint --ext js,jsx,ts,tsx --color . --fix 19 | npx tsc 20 | 21 | .PHONY: test 22 | test: node_modules 23 | npx vitest 24 | 25 | .PHONY: test-update 26 | test-update: node_modules 27 | npx vitest -u 28 | 29 | .PHONY: build 30 | build: node_modules $(DIST_FILES) 31 | 32 | $(DIST_FILES): $(SOURCE_FILES) package-lock.json vite.config.ts 33 | npx vite build 34 | 35 | .PHONY: publish 36 | publish: node_modules 37 | git push -u --tags origin master 38 | npm publish 39 | 40 | .PHONY: update 41 | update: node_modules 42 | npx updates -cu 43 | rm -rf node_modules package-lock.json 44 | npm install 45 | @touch node_modules 46 | 47 | .PHONY: path 48 | patch: node_modules lint test build 49 | npx versions patch package.json package-lock.json 50 | @$(MAKE) --no-print-directory publish 51 | 52 | .PHONY: minor 53 | minor: node_modules lint test build 54 | npx versions minor package.json package-lock.json 55 | @$(MAKE) --no-print-directory publish 56 | 57 | .PHONY: major 58 | major: node_modules lint test build 59 | npx versions major package.json package-lock.json 60 | @$(MAKE) --no-print-directory publish 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rrdir 2 | [![](https://img.shields.io/npm/v/rrdir.svg?style=flat)](https://www.npmjs.org/package/rrdir) [![](https://img.shields.io/npm/dm/rrdir.svg)](https://www.npmjs.org/package/rrdir) [![](https://packagephobia.com/badge?p=rrdir)](https://packagephobia.com/result?p=rrdir) 3 | 4 | > Recursive directory reader with a delightful API 5 | 6 | `rrdir` recursively reads a directory and returns entries within via an async iterator or async/sync as Array. It can typically iterate millions of files in a matter of seconds. Memory usage is `O(1)` for the async iterator and `O(n)` for the Array variants. 7 | 8 | Contrary to other similar modules, this module is optionally able to read any path including ones that contain invalid UTF-8 sequences. 9 | 10 | ## Usage 11 | ```console 12 | npm i rrdir 13 | ``` 14 | ```js 15 | import {rrdir, rrdirAsync, rrdirSync} from "rrdir"; 16 | 17 | for await (const entry of rrdir("dir")) { 18 | // => {path: 'dir/file', directory: false, symlink: false} 19 | } 20 | 21 | const entries = await rrdirAsync("dir"); 22 | // => [{path: 'dir/file', directory: false, symlink: false}] 23 | 24 | const entries = rrdirSync("dir"); 25 | // => [{path: 'dir/file', directory: false, symlink: false}] 26 | 27 | ``` 28 | 29 | ## API 30 | ### `rrdir(dir, [options])` 31 | ### `rrdirAsync(dir, [options])` 32 | ### `rrdirSync(dir, [options])` 33 | 34 | `rrdir` is an async iterator which yields `entry`. `rrdirAsync` and `rrdirSync` return an Array of `entry`. 35 | 36 | #### `dir` *String* | *Uint8Array* 37 | 38 | The directory to read, either absolute or relative. Pass a `Uint8Array` to switch the module into `Uint8Array` mode which is required to be able to read every file, like for example files with names that are invalid UTF-8 sequences. 39 | 40 | #### `options` *Object* 41 | 42 | - `stats` *boolean*: Whether to include `entry.stats`. Will reduce performance. Default: `false`. 43 | - `followSymlinks` *boolean*: Whether to follow symlinks for both recursion and `stat` calls. Default: `false`. 44 | - `exclude` *Array*: Path globs to exclude, e.g. `["**.js"]`. Default: `undefined`. 45 | - `include` *Array*: Path globs to include, e.g. `["**.map"]`. Default: `undefined`. 46 | - `strict` *boolean*: Whether to throw immediately when reading an entry fails. Default: `false`. 47 | - `insensitive` *boolean*: Whether `include` and `exclude` match case-insensitively. Default: `false`. 48 | 49 | #### `entry` *Object* 50 | 51 | - `path` *string* | *Uint8Array*: The path to the entry, will be relative if `dir` is given relative. If `dir` is a `Uint8Array`, this will be too. Always present. 52 | - `directory` *boolean*: Boolean indicating whether the entry is a directory. `undefined` on error. 53 | - `symlink` *boolean*: Boolean indicating whether the entry is a symbolic link. `undefined` on error. 54 | - `stats` *Object*: A [`fs.stats`](https://nodejs.org/api/fs.html#fs_class_fs_stats) object, present when `options.stats` is set. `undefined` on error. 55 | - `err` *Error*: Any error encountered while reading this entry. `undefined` on success. 56 | 57 | © [silverwind](https://github.com/silverwind), distributed under BSD licence 58 | -------------------------------------------------------------------------------- /index.test.ts: -------------------------------------------------------------------------------- 1 | import {rrdir, rrdirAsync, rrdirSync, type Entry, type RRDirOpts} from "./index.ts"; 2 | import {join, sep, relative} from "node:path"; 3 | import {writeFile, mkdir, symlink, rm} from "node:fs/promises"; 4 | import {mkdtempSync} from "node:fs"; 5 | import {platform, tmpdir} from "node:os"; 6 | 7 | const encoder = new TextEncoder(); 8 | const toUint8Array = encoder.encode.bind(encoder); 9 | const decoder = new TextDecoder(); 10 | const toString: (input: AllowSharedBufferSource) => string = decoder.decode.bind(decoder); 11 | const sepUint8Array = toUint8Array(sep); 12 | const uint8ArrayContains = (arr: Uint8Array, subArr: Uint8Array) => arr.toString().includes(subArr.toString()); 13 | 14 | // this Uint8Array does not round-trip through utf8 en/decoding and throws EILSEQ in darwin 15 | const weirdUint8Array = Uint8Array.from([0x78, 0xf6, 0x6c, 0x78]); 16 | const weirdString = toString(weirdUint8Array); 17 | 18 | // node on windows apparently sometimes can not follow symlink directories 19 | const isWindows = platform() === "win32"; 20 | const skipSymlink = isWindows; 21 | 22 | const skipWeird = platform() === "darwin" || isWindows; 23 | const testDir = mkdtempSync(join(tmpdir(), "rrdir-")); 24 | 25 | function joinUint8Array(a: Uint8Array | string, b: Uint8Array | string) { 26 | return Uint8Array.from([ 27 | ...(a instanceof Uint8Array ? a : toUint8Array(a)), 28 | ...sepUint8Array, 29 | ...(b instanceof Uint8Array ? b : toUint8Array(b)), 30 | ]); 31 | } 32 | 33 | beforeAll(async () => { 34 | await mkdir(join(testDir, "test")); 35 | await mkdir(join(testDir, "test/dir")); 36 | await mkdir(join(testDir, "test/dir2")); 37 | await writeFile(join(testDir, "test/file"), "test"); 38 | await writeFile(join(testDir, "test/dir/file"), "test"); 39 | await writeFile(join(testDir, "test/dir2/file"), "test"); 40 | await writeFile(join(testDir, "test/dir2/UPPER"), "test"); 41 | await writeFile(join(testDir, "test/dir2/exclude.txt"), "test"); 42 | await writeFile(join(testDir, "test/dir2/exclude.md"), "test"); 43 | await writeFile(join(testDir, "test/dir2/exclude.css"), "test"); 44 | // @ts-expect-error - bug in @types/node 45 | if (!skipWeird) await writeFile(joinUint8Array(join(testDir, "test"), weirdUint8Array), "test"); 46 | await symlink(join(testDir, "test/file"), join(testDir, "test/filesymlink")); 47 | await symlink(join(testDir, "test/dir"), join(testDir, "test/dirsymlink")); 48 | }); 49 | 50 | afterAll(async () => { 51 | await rm(testDir, {recursive: true}); 52 | }); 53 | 54 | function sort(entries: Entry[] = []) { 55 | entries.sort((a, b) => { 56 | if (!("path" in a) || !("path" in b)) return 0; 57 | const aString = a.path instanceof Uint8Array ? toString(a.path) : a.path; 58 | const bString = b.path instanceof Uint8Array ? toString(b.path) : b.path; 59 | return aString.localeCompare(bString); 60 | }); 61 | return entries; 62 | } 63 | 64 | function normalize(results: Entry[]) { 65 | const ret = []; 66 | for (const item of sort(results)) { 67 | if (typeof item?.path === "string") { 68 | item.path = relative(testDir, item.path).replaceAll("\\", "/"); 69 | } 70 | if ((item?.path as string)?.endsWith?.("lx")) continue; // weird "test/x�lx" files on github actions linux 71 | ret.push(item); 72 | } 73 | return ret; 74 | } 75 | 76 | function makeTest(dir: string | Uint8Array, opts?: RRDirOpts, expected?: any) { 77 | if (typeof dir === "string") { 78 | dir = join(testDir, dir); 79 | } else { 80 | dir = joinUint8Array(testDir, dir); 81 | } 82 | return async () => { 83 | let iteratorResults = []; 84 | for await (const result of rrdir(dir, opts)) iteratorResults.push(result); 85 | let asyncResults = await rrdirAsync(dir, opts); 86 | let syncResults = rrdirSync(dir, opts); 87 | 88 | if (typeof expected === "function") { 89 | expected(iteratorResults); 90 | expected(asyncResults); 91 | expected(syncResults); 92 | } else { 93 | iteratorResults = normalize(iteratorResults); 94 | asyncResults = normalize(asyncResults); 95 | syncResults = normalize(syncResults); 96 | expect(iteratorResults).toMatchSnapshot(); 97 | expect(syncResults).toEqual(iteratorResults); 98 | expect(asyncResults).toEqual(iteratorResults); 99 | } 100 | }; 101 | } 102 | 103 | test("basic", makeTest("test")); 104 | test("basic slash", makeTest("test/")); 105 | 106 | if (!skipSymlink) { 107 | test("followSymlinks", makeTest("test", {followSymlinks: true})); 108 | } 109 | 110 | test("stats", makeTest("test", {stats: true}, (results: Entry[]) => { 111 | for (const {path, stats} of results) { 112 | if ((path as string)?.includes?.(weirdString)) continue; 113 | expect(stats).toBeTruthy(); 114 | } 115 | })); 116 | 117 | test("stats Uint8Array", makeTest(toUint8Array("test"), {stats: true}, (results: Entry[]) => { 118 | for (const {stats} of results) { 119 | expect(stats).toBeTruthy(); 120 | } 121 | })); 122 | 123 | test("nostats", makeTest("test", {stats: false}, (results: Entry[]) => { 124 | for (const entry of results) expect(entry.stats).toEqual(undefined); 125 | })); 126 | 127 | test("exclude", makeTest("test", {exclude: ["**/dir"]})); 128 | test("exclude 2", makeTest("test", {exclude: ["**/dir2"]})); 129 | test("exclude 3", makeTest("test", {exclude: ["**/dir*"]})); 130 | test("exclude 4", makeTest("test", {exclude: ["**/dir", "**/dir2"]})); 131 | test("exclude 5", makeTest("test", {exclude: ["**"]}, [])); 132 | test("exclude 6", makeTest("test", {exclude: ["**.txt"]}, [])); 133 | test("exclude 7", makeTest("test", {exclude: ["**.txt", "**.md"]}, [])); 134 | 135 | test("exclude stats", makeTest("test", {exclude: ["**/dir", "**/dir2"], stats: true}, (results: Entry[]) => { 136 | const file = results.find(entry => entry.path === join(testDir, "test/file")); 137 | expect(file.stats.isFile()).toEqual(true); 138 | })); 139 | 140 | // does not work on windows, likely a picomatch bug 141 | if (!isWindows) { 142 | test("include", makeTest("test", {include: [join(testDir, "**/f*")]})); 143 | } 144 | 145 | test("include 2", makeTest("test", {include: ["**"]})); 146 | test("include 3", makeTest("test", {include: ["**/dir2/**"]})); 147 | test("include 4", makeTest("test", {include: ["**/dir/"]})); 148 | test("include 5", makeTest("test", {include: ["**/dir"]})); 149 | test("include 6", makeTest("test", {include: ["**.txt"]}, [])); 150 | test("insensitive", makeTest("test", {include: ["**/u*"], insensitive: true})); 151 | test("exclude include", makeTest("test", {exclude: ["**/dir2"], include: ["**/file"]})); 152 | 153 | test("error", makeTest("notfound", undefined, (results: Entry[]) => { 154 | expect(results.length).toEqual(1); 155 | expect(results[0].path).toMatch(/notfound$/); 156 | expect(results[0].err).toBeTruthy(); 157 | })); 158 | 159 | test("error strict", async () => { 160 | await expect(rrdir("notfound", {strict: true}).next()).rejects.toThrow(); 161 | await expect(rrdirAsync("notfound", {strict: true})).rejects.toThrow(); 162 | expect(() => rrdirSync("notfound", {strict: true})).toThrow(); 163 | }); 164 | 165 | test("Uint8Array", makeTest(toUint8Array("test"), undefined, (results: Entry[]) => { 166 | for (const entry of results) { 167 | expect(entry.path instanceof Uint8Array).toEqual(true); 168 | } 169 | })); 170 | 171 | if (!skipWeird) { 172 | test("weird as string", makeTest("test", {include: ["**/x*"]}, (results: Entry[]) => { 173 | expect(uint8ArrayContains(toUint8Array(results[0].path), weirdUint8Array)).toEqual(false); 174 | })); 175 | 176 | test("weird as Uint8Array", makeTest(toUint8Array("test"), {include: ["**/x*"]}, (results: Entry[]) => { 177 | expect(uint8ArrayContains(results[0].path as Uint8Array, weirdUint8Array)).toEqual(true); 178 | })); 179 | } 180 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {readdir, stat, lstat} from "node:fs/promises"; 2 | import {readdirSync, statSync, lstatSync} from "node:fs"; 3 | import {sep, resolve} from "node:path"; 4 | import picomatch from "picomatch"; 5 | import type {Stats, Dirent} from "node:fs"; 6 | import type {Matcher} from "picomatch"; 7 | 8 | const encoder = new TextEncoder(); 9 | const toUint8Array = encoder.encode.bind(encoder); 10 | const decoder = new TextDecoder(); 11 | const toString = decoder.decode.bind(decoder); 12 | const sepUint8Array = toUint8Array(sep); 13 | 14 | export type Encoding = "utf8" | "buffer"; 15 | export type Dir = string | Uint8Array; 16 | export type DirNodeCompatible = string | Buffer; 17 | 18 | export type RRDirOpts = { 19 | strict?: boolean, 20 | stats?: boolean, 21 | followSymlinks?: boolean, 22 | include?: string[], 23 | exclude?: string[], 24 | insensitive?: boolean, 25 | } 26 | 27 | type InternalOpts = { 28 | includeMatcher?: Matcher, 29 | excludeMatcher?: Matcher, 30 | encoding?: Encoding, 31 | } 32 | 33 | export type Entry = { 34 | /** The path to the entry, will be relative if `dir` is given relative. If `dir` is a `Uint8Array`, this will be too. Always present. */ 35 | path: Dir, 36 | /** Boolean indicating whether the entry is a directory. `undefined` on error. */ 37 | directory?: boolean, 38 | /** Boolean indicating whether the entry is a symbolic link. `undefined` on error. */ 39 | symlink?: boolean, 40 | /** A [`fs.stats`](https://nodejs.org/api/fs.html#fs_class_fs_stats) object, present when `options.stats` is set. `undefined` on error. */ 41 | stats?: Stats, 42 | /** Any error encountered while reading this entry. `undefined` on success. */ 43 | err?: Error, 44 | } 45 | 46 | const getEncoding = (dir: Dir) => dir instanceof Uint8Array ? "buffer" : "utf8"; 47 | 48 | const defaultOpts: RRDirOpts = { 49 | strict: false, 50 | stats: false, 51 | followSymlinks: false, 52 | exclude: undefined, 53 | include: undefined, 54 | insensitive: false, 55 | }; 56 | 57 | function makePath({name}: Dirent, dir: Dir, encoding: Encoding) { 58 | if (encoding === "buffer") { 59 | return dir === "." ? name : Uint8Array.from([...dir, ...sepUint8Array, ...name]); 60 | } else { 61 | return dir === "." ? name : `${dir as string}${sep}${name}`; 62 | } 63 | } 64 | 65 | function build(dirent: Dirent, path: Dir, stats: Stats, opts: RRDirOpts) { 66 | return { 67 | path, 68 | directory: (stats || dirent).isDirectory(), 69 | symlink: (stats || dirent).isSymbolicLink(), 70 | ...(opts.stats ? {stats} : {}), 71 | }; 72 | } 73 | 74 | function makeMatchers({include, exclude, insensitive}: RRDirOpts) { 75 | const opts = { 76 | dot: true, 77 | flags: insensitive ? "i" : undefined, 78 | }; 79 | 80 | // resolve the path to an absolute one because picomatch can not deal properly 81 | // with relative paths that start with ./ or .\ 82 | // https://github.com/micromatch/picomatch/issues/121 83 | return { 84 | includeMatcher: include?.length ? (path: string) => picomatch(include, opts)(resolve(path)) : null, 85 | excludeMatcher: exclude?.length ? (path: string) => picomatch(exclude, opts)(resolve(path)) : null, 86 | }; 87 | } 88 | 89 | export async function* rrdir(dir: Dir, opts: RRDirOpts = {}, {includeMatcher, excludeMatcher, encoding}: InternalOpts = {}): AsyncGenerator { 90 | if (includeMatcher === undefined) { 91 | opts = {...defaultOpts, ...opts}; 92 | ({includeMatcher, excludeMatcher} = makeMatchers(opts)); 93 | if (typeof dir === "string" && /[/\\]$/.test(dir)) dir = dir.substring(0, dir.length - 1); 94 | encoding = getEncoding(dir); 95 | } 96 | 97 | let dirents: Dirent[] = []; 98 | try { 99 | // @ts-expect-error -- bug in @types/node 100 | dirents = await readdir(dir as DirNodeCompatible, {encoding, withFileTypes: true}); 101 | } catch (err) { 102 | if (opts.strict) throw err; 103 | yield {path: dir, err}; 104 | } 105 | if (!dirents.length) return; 106 | 107 | for (const dirent of dirents) { 108 | const path = makePath(dirent, dir, encoding); 109 | if (excludeMatcher?.(encoding === "buffer" ? toString(path) : path)) continue; 110 | 111 | const isSymbolicLink: boolean = opts.followSymlinks && dirent.isSymbolicLink(); 112 | const encodedPath: string = encoding === "buffer" ? toString(path) : path; 113 | const isIncluded: boolean = !includeMatcher || includeMatcher(encodedPath); 114 | let stats: Stats; 115 | 116 | if (isIncluded) { 117 | if (opts.stats || isSymbolicLink) { 118 | try { 119 | stats = await (opts.followSymlinks ? stat : lstat)(path as DirNodeCompatible); 120 | } catch (err) { 121 | if (opts.strict) throw err; 122 | yield {path, err}; 123 | } 124 | } 125 | 126 | yield build(dirent, path, stats, opts); 127 | } 128 | 129 | let recurse = false; 130 | if (isSymbolicLink) { 131 | if (!stats) try { stats = await stat(path as DirNodeCompatible); } catch {} 132 | if (stats && stats.isDirectory()) recurse = true; 133 | } else if (dirent.isDirectory()) { 134 | recurse = true; 135 | } 136 | 137 | if (recurse) yield* rrdir(path, opts, {includeMatcher, excludeMatcher, encoding}); 138 | } 139 | } 140 | 141 | export async function rrdirAsync(dir: Dir, opts: RRDirOpts = {}, {includeMatcher, excludeMatcher, encoding}: InternalOpts = {}): Promise { 142 | if (includeMatcher === undefined) { 143 | opts = {...defaultOpts, ...opts}; 144 | ({includeMatcher, excludeMatcher} = makeMatchers(opts)); 145 | if (typeof dir === "string" && /[/\\]$/.test(dir)) dir = dir.substring(0, dir.length - 1); 146 | encoding = getEncoding(dir); 147 | } 148 | 149 | const results: Entry[] = []; 150 | let dirents: Dirent[] = []; 151 | try { 152 | // @ts-expect-error -- bug in @types/node 153 | dirents = await readdir(dir, {encoding, withFileTypes: true}); 154 | } catch (err) { 155 | if (opts.strict) throw err; 156 | results.push({path: dir, err}); 157 | } 158 | if (!dirents.length) return results; 159 | 160 | await Promise.all(dirents.map(async dirent => { 161 | const path = makePath(dirent, dir, encoding); 162 | if (excludeMatcher?.(encoding === "buffer" ? toString(path) : path)) return; 163 | 164 | const isSymbolicLink: boolean = opts.followSymlinks && dirent.isSymbolicLink(); 165 | const encodedPath: string = encoding === "buffer" ? toString(path) : path; 166 | const isIncluded: boolean = !includeMatcher || includeMatcher(encodedPath); 167 | let stats: Stats; 168 | 169 | if (isIncluded) { 170 | if (opts.stats || isSymbolicLink) { 171 | try { 172 | stats = await (opts.followSymlinks ? stat : lstat)(path as DirNodeCompatible); 173 | } catch (err) { 174 | if (opts.strict) throw err; 175 | results.push({path, err}); 176 | } 177 | } 178 | 179 | results.push(build(dirent, path, stats, opts)); 180 | } 181 | 182 | let recurse = false; 183 | if (isSymbolicLink) { 184 | if (!stats) try { stats = await stat(path as DirNodeCompatible); } catch {} 185 | if (stats && stats.isDirectory()) recurse = true; 186 | } else if (dirent.isDirectory()) { 187 | recurse = true; 188 | } 189 | 190 | if (recurse) results.push(...await rrdirAsync(path, opts, {includeMatcher, excludeMatcher, encoding})); 191 | })); 192 | 193 | return results; 194 | } 195 | 196 | export function rrdirSync(dir: Dir, opts: RRDirOpts = {}, {includeMatcher, excludeMatcher, encoding}: InternalOpts = {}): Entry[] { 197 | if (includeMatcher === undefined) { 198 | opts = {...defaultOpts, ...opts}; 199 | ({includeMatcher, excludeMatcher} = makeMatchers(opts)); 200 | if (typeof dir === "string" && /[/\\]$/.test(dir)) dir = dir.substring(0, dir.length - 1); 201 | encoding = getEncoding(dir); 202 | } 203 | 204 | const results: Entry[] = []; 205 | let dirents: Dirent[] = []; 206 | try { 207 | // @ts-expect-error -- bug in @types/node 208 | dirents = readdirSync(dir as DirNodeCompatible, {encoding, withFileTypes: true}); 209 | } catch (err) { 210 | if (opts.strict) throw err; 211 | results.push({path: dir, err}); 212 | } 213 | if (!dirents.length) return results; 214 | 215 | for (const dirent of dirents) { 216 | const path = makePath(dirent, dir, encoding); 217 | if (excludeMatcher?.(encoding === "buffer" ? toString(path) : path)) continue; 218 | 219 | const isSymbolicLink: boolean = opts.followSymlinks && dirent.isSymbolicLink(); 220 | const encodedPath: string = encoding === "buffer" ? toString(path) : path; 221 | const isIncluded: boolean = !includeMatcher || includeMatcher(encodedPath); 222 | let stats: Stats; 223 | 224 | if (isIncluded) { 225 | if (opts.stats || isSymbolicLink) { 226 | try { 227 | stats = (opts.followSymlinks ? statSync : lstatSync)(path as DirNodeCompatible); 228 | } catch (err) { 229 | if (opts.strict) throw err; 230 | results.push({path, err}); 231 | } 232 | } 233 | results.push(build(dirent, path, stats, opts)); 234 | } 235 | 236 | let recurse = false; 237 | if (isSymbolicLink) { 238 | if (!stats) try { stats = statSync(path as DirNodeCompatible); } catch {} 239 | if (stats && stats.isDirectory()) recurse = true; 240 | } else if (dirent.isDirectory()) { 241 | recurse = true; 242 | } 243 | 244 | if (recurse) results.push(...rrdirSync(path, opts, {includeMatcher, excludeMatcher, encoding})); 245 | } 246 | 247 | return results; 248 | } 249 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rrdir", 3 | "version": "13.2.1", 4 | "description": "Recursive directory reader with a delightful API", 5 | "author": "silverwind ", 6 | "repository": "silverwind/rrdir", 7 | "license": "BSD-2-Clause", 8 | "type": "module", 9 | "sideEffects": false, 10 | "main": "./dist/index.js", 11 | "exports": "./dist/index.js", 12 | "types": "./dist/index.d.ts", 13 | "files": [ 14 | "dist" 15 | ], 16 | "engines": { 17 | "node": ">=18" 18 | }, 19 | "dependencies": { 20 | "picomatch": "^4.0.2" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "22.13.4", 24 | "@types/picomatch": "3.0.2", 25 | "eslint": "8.57.0", 26 | "eslint-config-silverwind": "99.0.0", 27 | "typescript-config-silverwind": "7.0.0", 28 | "updates": "16.4.2", 29 | "versions": "12.1.3", 30 | "vite": "6.1.0", 31 | "vite-config-silverwind": "4.0.0", 32 | "vitest": "3.0.5", 33 | "vitest-config-silverwind": "10.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /snapshots/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`basic 1`] = ` 4 | [ 5 | { 6 | "directory": true, 7 | "path": "test/dir", 8 | "symlink": false, 9 | }, 10 | { 11 | "directory": false, 12 | "path": "test/dir/file", 13 | "symlink": false, 14 | }, 15 | { 16 | "directory": true, 17 | "path": "test/dir2", 18 | "symlink": false, 19 | }, 20 | { 21 | "directory": false, 22 | "path": "test/dir2/exclude.css", 23 | "symlink": false, 24 | }, 25 | { 26 | "directory": false, 27 | "path": "test/dir2/exclude.md", 28 | "symlink": false, 29 | }, 30 | { 31 | "directory": false, 32 | "path": "test/dir2/exclude.txt", 33 | "symlink": false, 34 | }, 35 | { 36 | "directory": false, 37 | "path": "test/dir2/file", 38 | "symlink": false, 39 | }, 40 | { 41 | "directory": false, 42 | "path": "test/dir2/UPPER", 43 | "symlink": false, 44 | }, 45 | { 46 | "directory": false, 47 | "path": "test/dirsymlink", 48 | "symlink": true, 49 | }, 50 | { 51 | "directory": false, 52 | "path": "test/file", 53 | "symlink": false, 54 | }, 55 | { 56 | "directory": false, 57 | "path": "test/filesymlink", 58 | "symlink": true, 59 | }, 60 | ] 61 | `; 62 | 63 | exports[`basic slash 1`] = ` 64 | [ 65 | { 66 | "directory": true, 67 | "path": "test/dir", 68 | "symlink": false, 69 | }, 70 | { 71 | "directory": false, 72 | "path": "test/dir/file", 73 | "symlink": false, 74 | }, 75 | { 76 | "directory": true, 77 | "path": "test/dir2", 78 | "symlink": false, 79 | }, 80 | { 81 | "directory": false, 82 | "path": "test/dir2/exclude.css", 83 | "symlink": false, 84 | }, 85 | { 86 | "directory": false, 87 | "path": "test/dir2/exclude.md", 88 | "symlink": false, 89 | }, 90 | { 91 | "directory": false, 92 | "path": "test/dir2/exclude.txt", 93 | "symlink": false, 94 | }, 95 | { 96 | "directory": false, 97 | "path": "test/dir2/file", 98 | "symlink": false, 99 | }, 100 | { 101 | "directory": false, 102 | "path": "test/dir2/UPPER", 103 | "symlink": false, 104 | }, 105 | { 106 | "directory": false, 107 | "path": "test/dirsymlink", 108 | "symlink": true, 109 | }, 110 | { 111 | "directory": false, 112 | "path": "test/file", 113 | "symlink": false, 114 | }, 115 | { 116 | "directory": false, 117 | "path": "test/filesymlink", 118 | "symlink": true, 119 | }, 120 | ] 121 | `; 122 | 123 | exports[`exclude 1`] = ` 124 | [ 125 | { 126 | "directory": true, 127 | "path": "test/dir2", 128 | "symlink": false, 129 | }, 130 | { 131 | "directory": false, 132 | "path": "test/dir2/exclude.css", 133 | "symlink": false, 134 | }, 135 | { 136 | "directory": false, 137 | "path": "test/dir2/exclude.md", 138 | "symlink": false, 139 | }, 140 | { 141 | "directory": false, 142 | "path": "test/dir2/exclude.txt", 143 | "symlink": false, 144 | }, 145 | { 146 | "directory": false, 147 | "path": "test/dir2/file", 148 | "symlink": false, 149 | }, 150 | { 151 | "directory": false, 152 | "path": "test/dir2/UPPER", 153 | "symlink": false, 154 | }, 155 | { 156 | "directory": false, 157 | "path": "test/dirsymlink", 158 | "symlink": true, 159 | }, 160 | { 161 | "directory": false, 162 | "path": "test/file", 163 | "symlink": false, 164 | }, 165 | { 166 | "directory": false, 167 | "path": "test/filesymlink", 168 | "symlink": true, 169 | }, 170 | ] 171 | `; 172 | 173 | exports[`exclude 2 1`] = ` 174 | [ 175 | { 176 | "directory": true, 177 | "path": "test/dir", 178 | "symlink": false, 179 | }, 180 | { 181 | "directory": false, 182 | "path": "test/dir/file", 183 | "symlink": false, 184 | }, 185 | { 186 | "directory": false, 187 | "path": "test/dirsymlink", 188 | "symlink": true, 189 | }, 190 | { 191 | "directory": false, 192 | "path": "test/file", 193 | "symlink": false, 194 | }, 195 | { 196 | "directory": false, 197 | "path": "test/filesymlink", 198 | "symlink": true, 199 | }, 200 | ] 201 | `; 202 | 203 | exports[`exclude 3 1`] = ` 204 | [ 205 | { 206 | "directory": false, 207 | "path": "test/file", 208 | "symlink": false, 209 | }, 210 | { 211 | "directory": false, 212 | "path": "test/filesymlink", 213 | "symlink": true, 214 | }, 215 | ] 216 | `; 217 | 218 | exports[`exclude 4 1`] = ` 219 | [ 220 | { 221 | "directory": false, 222 | "path": "test/dirsymlink", 223 | "symlink": true, 224 | }, 225 | { 226 | "directory": false, 227 | "path": "test/file", 228 | "symlink": false, 229 | }, 230 | { 231 | "directory": false, 232 | "path": "test/filesymlink", 233 | "symlink": true, 234 | }, 235 | ] 236 | `; 237 | 238 | exports[`exclude 5 1`] = `[]`; 239 | 240 | exports[`exclude 6 1`] = ` 241 | [ 242 | { 243 | "directory": true, 244 | "path": "test/dir", 245 | "symlink": false, 246 | }, 247 | { 248 | "directory": false, 249 | "path": "test/dir/file", 250 | "symlink": false, 251 | }, 252 | { 253 | "directory": true, 254 | "path": "test/dir2", 255 | "symlink": false, 256 | }, 257 | { 258 | "directory": false, 259 | "path": "test/dir2/exclude.css", 260 | "symlink": false, 261 | }, 262 | { 263 | "directory": false, 264 | "path": "test/dir2/exclude.md", 265 | "symlink": false, 266 | }, 267 | { 268 | "directory": false, 269 | "path": "test/dir2/file", 270 | "symlink": false, 271 | }, 272 | { 273 | "directory": false, 274 | "path": "test/dir2/UPPER", 275 | "symlink": false, 276 | }, 277 | { 278 | "directory": false, 279 | "path": "test/dirsymlink", 280 | "symlink": true, 281 | }, 282 | { 283 | "directory": false, 284 | "path": "test/file", 285 | "symlink": false, 286 | }, 287 | { 288 | "directory": false, 289 | "path": "test/filesymlink", 290 | "symlink": true, 291 | }, 292 | ] 293 | `; 294 | 295 | exports[`exclude 7 1`] = ` 296 | [ 297 | { 298 | "directory": true, 299 | "path": "test/dir", 300 | "symlink": false, 301 | }, 302 | { 303 | "directory": false, 304 | "path": "test/dir/file", 305 | "symlink": false, 306 | }, 307 | { 308 | "directory": true, 309 | "path": "test/dir2", 310 | "symlink": false, 311 | }, 312 | { 313 | "directory": false, 314 | "path": "test/dir2/exclude.css", 315 | "symlink": false, 316 | }, 317 | { 318 | "directory": false, 319 | "path": "test/dir2/file", 320 | "symlink": false, 321 | }, 322 | { 323 | "directory": false, 324 | "path": "test/dir2/UPPER", 325 | "symlink": false, 326 | }, 327 | { 328 | "directory": false, 329 | "path": "test/dirsymlink", 330 | "symlink": true, 331 | }, 332 | { 333 | "directory": false, 334 | "path": "test/file", 335 | "symlink": false, 336 | }, 337 | { 338 | "directory": false, 339 | "path": "test/filesymlink", 340 | "symlink": true, 341 | }, 342 | ] 343 | `; 344 | 345 | exports[`exclude include 1`] = ` 346 | [ 347 | { 348 | "directory": false, 349 | "path": "test/dir/file", 350 | "symlink": false, 351 | }, 352 | { 353 | "directory": false, 354 | "path": "test/file", 355 | "symlink": false, 356 | }, 357 | ] 358 | `; 359 | 360 | exports[`followSymlinks 1`] = ` 361 | [ 362 | { 363 | "directory": true, 364 | "path": "test/dir", 365 | "symlink": false, 366 | }, 367 | { 368 | "directory": false, 369 | "path": "test/dir/file", 370 | "symlink": false, 371 | }, 372 | { 373 | "directory": true, 374 | "path": "test/dir2", 375 | "symlink": false, 376 | }, 377 | { 378 | "directory": false, 379 | "path": "test/dir2/exclude.css", 380 | "symlink": false, 381 | }, 382 | { 383 | "directory": false, 384 | "path": "test/dir2/exclude.md", 385 | "symlink": false, 386 | }, 387 | { 388 | "directory": false, 389 | "path": "test/dir2/exclude.txt", 390 | "symlink": false, 391 | }, 392 | { 393 | "directory": false, 394 | "path": "test/dir2/file", 395 | "symlink": false, 396 | }, 397 | { 398 | "directory": false, 399 | "path": "test/dir2/UPPER", 400 | "symlink": false, 401 | }, 402 | { 403 | "directory": true, 404 | "path": "test/dirsymlink", 405 | "symlink": false, 406 | }, 407 | { 408 | "directory": false, 409 | "path": "test/dirsymlink/file", 410 | "symlink": false, 411 | }, 412 | { 413 | "directory": false, 414 | "path": "test/file", 415 | "symlink": false, 416 | }, 417 | { 418 | "directory": false, 419 | "path": "test/filesymlink", 420 | "symlink": false, 421 | }, 422 | ] 423 | `; 424 | 425 | exports[`include 1`] = ` 426 | [ 427 | { 428 | "directory": false, 429 | "path": "test/dir/file", 430 | "symlink": false, 431 | }, 432 | { 433 | "directory": false, 434 | "path": "test/dir2/file", 435 | "symlink": false, 436 | }, 437 | { 438 | "directory": false, 439 | "path": "test/file", 440 | "symlink": false, 441 | }, 442 | { 443 | "directory": false, 444 | "path": "test/filesymlink", 445 | "symlink": true, 446 | }, 447 | ] 448 | `; 449 | 450 | exports[`include 2 1`] = ` 451 | [ 452 | { 453 | "directory": true, 454 | "path": "test/dir", 455 | "symlink": false, 456 | }, 457 | { 458 | "directory": false, 459 | "path": "test/dir/file", 460 | "symlink": false, 461 | }, 462 | { 463 | "directory": true, 464 | "path": "test/dir2", 465 | "symlink": false, 466 | }, 467 | { 468 | "directory": false, 469 | "path": "test/dir2/exclude.css", 470 | "symlink": false, 471 | }, 472 | { 473 | "directory": false, 474 | "path": "test/dir2/exclude.md", 475 | "symlink": false, 476 | }, 477 | { 478 | "directory": false, 479 | "path": "test/dir2/exclude.txt", 480 | "symlink": false, 481 | }, 482 | { 483 | "directory": false, 484 | "path": "test/dir2/file", 485 | "symlink": false, 486 | }, 487 | { 488 | "directory": false, 489 | "path": "test/dir2/UPPER", 490 | "symlink": false, 491 | }, 492 | { 493 | "directory": false, 494 | "path": "test/dirsymlink", 495 | "symlink": true, 496 | }, 497 | { 498 | "directory": false, 499 | "path": "test/file", 500 | "symlink": false, 501 | }, 502 | { 503 | "directory": false, 504 | "path": "test/filesymlink", 505 | "symlink": true, 506 | }, 507 | ] 508 | `; 509 | 510 | exports[`include 3 1`] = ` 511 | [ 512 | { 513 | "directory": true, 514 | "path": "test/dir2", 515 | "symlink": false, 516 | }, 517 | { 518 | "directory": false, 519 | "path": "test/dir2/exclude.css", 520 | "symlink": false, 521 | }, 522 | { 523 | "directory": false, 524 | "path": "test/dir2/exclude.md", 525 | "symlink": false, 526 | }, 527 | { 528 | "directory": false, 529 | "path": "test/dir2/exclude.txt", 530 | "symlink": false, 531 | }, 532 | { 533 | "directory": false, 534 | "path": "test/dir2/file", 535 | "symlink": false, 536 | }, 537 | { 538 | "directory": false, 539 | "path": "test/dir2/UPPER", 540 | "symlink": false, 541 | }, 542 | ] 543 | `; 544 | 545 | exports[`include 4 1`] = `[]`; 546 | 547 | exports[`include 5 1`] = ` 548 | [ 549 | { 550 | "directory": true, 551 | "path": "test/dir", 552 | "symlink": false, 553 | }, 554 | ] 555 | `; 556 | 557 | exports[`include 6 1`] = ` 558 | [ 559 | { 560 | "directory": false, 561 | "path": "test/dir2/exclude.txt", 562 | "symlink": false, 563 | }, 564 | ] 565 | `; 566 | 567 | exports[`insensitive 1`] = ` 568 | [ 569 | { 570 | "directory": false, 571 | "path": "test/dir2/UPPER", 572 | "symlink": false, 573 | }, 574 | ] 575 | `; 576 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "typescript-config-silverwind", 3 | "compilerOptions": { 4 | "types": [ 5 | "jest-extended", 6 | "vite/client", 7 | "vitest/globals", 8 | ], 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /updates.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | exclude: [ 3 | "eslint", // migrate to flat config first 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vite"; 2 | import {nodeLib} from "vite-config-silverwind"; 3 | 4 | export default defineConfig(nodeLib({ 5 | url: import.meta.url, 6 | build: { 7 | target: "node18", 8 | }, 9 | })); 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vitest/config"; 2 | import {backend} from "vitest-config-silverwind"; 3 | 4 | export default defineConfig(backend({ 5 | url: import.meta.url, 6 | })); 7 | --------------------------------------------------------------------------------