Skip to content

Commit 27964c6

Browse files
authored
Merge pull request #8180 from NomicFoundation/optimize-get-all-files-matching
Optimize `getAllFilesMatching` and `getAllDirectoriesMatching`
2 parents d852757 + 6c4d62a commit 27964c6

14 files changed

Lines changed: 629 additions & 108 deletions

File tree

.changeset/tough-lines-begin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@nomicfoundation/hardhat-utils": patch
3+
"hardhat": patch
4+
---
5+
6+
Optimize file system traversal helpers

cspell.dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dedup
2929
defi
3030
Defi
3131
dessant
32+
dirents
3233
distros
3334
DISTROS
3435
dorny

packages/hardhat-utils/src/fs.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
IsDirectoryError,
2121
DirectoryNotEmptyError,
2222
} from "./errors/fs.js";
23+
import {
24+
isDirectoryDirentAware,
25+
readdirWithFileTypesOrEmpty,
26+
} from "./internal/fs.js";
2327

2428
/**
2529
* Determines the canonical pathname for a given path, resolving any symbolic
@@ -59,12 +63,12 @@ export async function getAllFilesMatching(
5963
matches?: (absolutePathToFile: string) => Promise<boolean> | boolean,
6064
directoryFilter?: (absolutePathToDir: string) => Promise<boolean> | boolean,
6165
): Promise<string[]> {
62-
const dirContent = await readdirOrEmpty(dirFrom);
66+
const dirContent = await readdirWithFileTypesOrEmpty(dirFrom);
6367

6468
const results = await Promise.all(
65-
dirContent.map(async (file) => {
66-
const absolutePathToFile = path.join(dirFrom, file);
67-
if (await isDirectory(absolutePathToFile)) {
69+
dirContent.map(async (dirent) => {
70+
const absolutePathToFile = path.join(dirFrom, dirent.name);
71+
if (await isDirectoryDirentAware(absolutePathToFile, dirent)) {
6872
if (
6973
directoryFilter === undefined ||
7074
(await directoryFilter(absolutePathToFile))
@@ -107,12 +111,12 @@ export async function getAllDirectoriesMatching(
107111
dirFrom: string,
108112
matches?: (absolutePathToDir: string) => Promise<boolean> | boolean,
109113
): Promise<string[]> {
110-
const dirContent = await readdirOrEmpty(dirFrom);
114+
const dirContent = await readdirWithFileTypesOrEmpty(dirFrom);
111115

112116
const results = await Promise.all(
113-
dirContent.map(async (file) => {
114-
const absolutePathToFile = path.join(dirFrom, file);
115-
if (!(await isDirectory(absolutePathToFile))) {
117+
dirContent.map(async (dirent) => {
118+
const absolutePathToFile = path.join(dirFrom, dirent.name);
119+
if (!(await isDirectoryDirentAware(absolutePathToFile, dirent))) {
116120
return [];
117121
}
118122

@@ -494,17 +498,24 @@ export async function readdir(absolutePathToDir: string): Promise<string[]> {
494498
}
495499

496500
/**
497-
* Wrapper around `readdir` that returns an empty array if the directory doesn't exist.
501+
* Reads a directory and returns its content as an array of strings, returning
502+
* an empty array if the directory doesn't exist.
498503
*
499-
* @see readdir
504+
* @param absolutePathToDir The path to the directory.
505+
* @returns An array of strings with the names of the files and directories in the directory, and an empty array if the directory doesn't exist.
506+
* @throws NotADirectoryError if the path is not a directory.
507+
* @throws FileSystemAccessError for any other error.
500508
*/
501-
async function readdirOrEmpty(dirFrom: string): Promise<string[]> {
509+
export async function readdirOrEmpty(
510+
absolutePathToDir: string,
511+
): Promise<string[]> {
502512
try {
503-
return await readdir(dirFrom);
513+
return await readdir(absolutePathToDir);
504514
} catch (error) {
505515
if (error instanceof FileNotFoundError) {
506516
return [];
507517
}
518+
508519
throw error;
509520
}
510521
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Dirent } from "node:fs";
2+
3+
import fsPromises from "node:fs/promises";
4+
5+
import { ensureNodeErrnoExceptionError } from "../error.js";
6+
import { FileSystemAccessError, NotADirectoryError } from "../errors/fs.js";
7+
import { isDirectory } from "../fs.js";
8+
9+
/**
10+
* Like `readdirOrEmpty`, but returns `Dirent` entries to know if an entry is a
11+
* directory or not without an extra `lstat` syscall.
12+
*/
13+
export async function readdirWithFileTypesOrEmpty(
14+
dirFrom: string,
15+
): Promise<Dirent[]> {
16+
try {
17+
return await fsPromises.readdir(dirFrom, { withFileTypes: true });
18+
} catch (e) {
19+
ensureNodeErrnoExceptionError(e);
20+
21+
if (e.code === "ENOENT") {
22+
return [];
23+
}
24+
25+
if (e.code === "ENOTDIR") {
26+
throw new NotADirectoryError(dirFrom, e);
27+
}
28+
29+
throw new FileSystemAccessError(e.message, e);
30+
}
31+
}
32+
33+
/**
34+
* Determines if a dirent refers to a directory, falling back to `lstat` only
35+
* when the dirent type is unknown.
36+
*/
37+
export async function isDirectoryDirentAware(
38+
absolutePath: string,
39+
dirent: Dirent,
40+
): Promise<boolean> {
41+
if (dirent.isDirectory()) {
42+
return true;
43+
}
44+
45+
if (
46+
dirent.isFile() ||
47+
dirent.isSymbolicLink() ||
48+
dirent.isBlockDevice() ||
49+
dirent.isCharacterDevice() ||
50+
dirent.isFIFO() ||
51+
dirent.isSocket()
52+
) {
53+
return false;
54+
}
55+
56+
return await isDirectory(absolutePath);
57+
}

packages/hardhat-utils/test/fs.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Dirent } from "node:fs";
2+
13
import assert from "node:assert/strict";
24
import fsPromises from "node:fs/promises";
35
import path from "node:path";
@@ -32,10 +34,35 @@ import {
3234
readJsonFileAsStream,
3335
writeJsonFileAsStream,
3436
mkdtemp,
37+
readdirOrEmpty,
3538
} from "../src/fs.js";
3639

3740
import { useTmpDir } from "./helpers/fs.js";
3841

42+
function withUnknownDirentType(dirent: Dirent): Dirent {
43+
const cloned: Dirent = Object.create(dirent);
44+
cloned.isDirectory = () => false;
45+
cloned.isFile = () => false;
46+
cloned.isSymbolicLink = () => false;
47+
cloned.isBlockDevice = () => false;
48+
cloned.isCharacterDevice = () => false;
49+
cloned.isFIFO = () => false;
50+
cloned.isSocket = () => false;
51+
return cloned;
52+
}
53+
54+
function withKnownFileDirentType(dirent: Dirent): Dirent {
55+
const cloned: Dirent = Object.create(dirent);
56+
cloned.isDirectory = () => false;
57+
cloned.isFile = () => true;
58+
cloned.isSymbolicLink = () => false;
59+
cloned.isBlockDevice = () => false;
60+
cloned.isCharacterDevice = () => false;
61+
cloned.isFIFO = () => false;
62+
cloned.isSocket = () => false;
63+
return cloned;
64+
}
65+
3966
describe("File system utils", () => {
4067
const getTmpDir = useTmpDir("fs");
4168

@@ -209,6 +236,78 @@ describe("File system utils", () => {
209236
);
210237
});
211238

239+
it("Should recurse into directories when the dirent type is unknown", async (t) => {
240+
const originalReaddir = fsPromises.readdir;
241+
const originalLstat = fsPromises.lstat;
242+
243+
const unknownDirPath = path.join(getTmpDir(), "dir-with-files");
244+
const knownFilePath = path.join(getTmpDir(), "file-1.txt");
245+
const lstatPaths: string[] = [];
246+
247+
t.mock.method(fsPromises, "readdir", async (...args: any[]) => {
248+
const [absolutePathToDir, options] = args;
249+
const dirents: Dirent[] = await Reflect.apply(
250+
originalReaddir,
251+
fsPromises,
252+
args,
253+
);
254+
255+
if (
256+
absolutePathToDir !== getTmpDir() ||
257+
options?.withFileTypes !== true
258+
) {
259+
return dirents;
260+
}
261+
262+
return dirents.map((dirent) => {
263+
if (dirent.name === path.basename(unknownDirPath)) {
264+
return withUnknownDirentType(dirent);
265+
}
266+
267+
if (dirent.name === path.basename(knownFilePath)) {
268+
return withKnownFileDirentType(dirent);
269+
}
270+
271+
return dirent;
272+
});
273+
});
274+
275+
t.mock.method(fsPromises, "lstat", async (...args: any[]) => {
276+
lstatPaths.push(String(args[0]));
277+
return await Reflect.apply(originalLstat, fsPromises, args);
278+
});
279+
280+
const files = await getAllFilesMatching(getTmpDir());
281+
282+
assert.deepEqual(
283+
new Set(files),
284+
new Set([
285+
path.join(getTmpDir(), "file-1.txt"),
286+
path.join(getTmpDir(), "file-2.txt"),
287+
path.join(getTmpDir(), "file-3.json"),
288+
path.join(getTmpDir(), "dir-with-files", "inner-file-1.json"),
289+
path.join(getTmpDir(), "dir-with-files", "inner-file-2.txt"),
290+
path.join(getTmpDir(), "dir-with-extension.txt", "inner-file-3.txt"),
291+
path.join(getTmpDir(), "dir-with-extension.txt", "inner-file-4.json"),
292+
path.join(getTmpDir(), "dir-WithCasing", "file-WithCASING"),
293+
path.join(
294+
getTmpDir(),
295+
"dir-with-files",
296+
"dir-within-dir",
297+
"file-deep",
298+
),
299+
]),
300+
);
301+
assert.ok(
302+
lstatPaths.includes(unknownDirPath),
303+
`expected lstat to be used for unknown dirent path ${unknownDirPath}`,
304+
);
305+
assert.ok(
306+
!lstatPaths.includes(knownFilePath),
307+
`expected lstat to not be used for known file path ${knownFilePath}`,
308+
);
309+
});
310+
212311
it("Should throw NotADirectoryError if the path is not a directory", async () => {
213312
const dirPath = path.join(getTmpDir(), "file-1.txt");
214313

@@ -361,6 +460,58 @@ describe("File system utils", () => {
361460
[path.join(getTmpDir(), "dir-with-files")],
362461
);
363462
});
463+
464+
it("Should match directories when the dirent type is unknown", async (t) => {
465+
const originalReaddir = fsPromises.readdir;
466+
const originalLstat = fsPromises.lstat;
467+
468+
const unknownDirPath = path.join(getTmpDir(), "dir-with-files");
469+
const lstatPaths: string[] = [];
470+
471+
t.mock.method(fsPromises, "readdir", async (...args: any[]) => {
472+
const [absolutePathToDir, options] = args;
473+
const dirents: Dirent[] = await Reflect.apply(
474+
originalReaddir,
475+
fsPromises,
476+
args,
477+
);
478+
479+
if (
480+
absolutePathToDir !== getTmpDir() ||
481+
options?.withFileTypes !== true
482+
) {
483+
return dirents;
484+
}
485+
486+
return dirents.map((dirent) =>
487+
dirent.name === path.basename(unknownDirPath)
488+
? withUnknownDirentType(dirent)
489+
: dirent,
490+
);
491+
});
492+
493+
t.mock.method(fsPromises, "lstat", async (...args: any[]) => {
494+
lstatPaths.push(String(args[0]));
495+
return await Reflect.apply(originalLstat, fsPromises, args);
496+
});
497+
498+
const dirs = await getAllDirectoriesMatching(getTmpDir());
499+
500+
assert.deepEqual(
501+
new Set(dirs),
502+
new Set([
503+
path.join(getTmpDir(), "dir-empty"),
504+
path.join(getTmpDir(), "dir-with-files"),
505+
path.join(getTmpDir(), "dir-with-subdir"),
506+
path.join(getTmpDir(), "dir-with-extension.txt"),
507+
path.join(getTmpDir(), "dir-WithCasing"),
508+
]),
509+
);
510+
assert.ok(
511+
lstatPaths.includes(unknownDirPath),
512+
`expected lstat to be used for unknown dirent path ${unknownDirPath}`,
513+
);
514+
});
364515
});
365516

366517
describe("getFileTrueCase", () => {
@@ -935,6 +1086,52 @@ describe("File system utils", () => {
9351086
});
9361087
});
9371088

1089+
describe("readdirOrEmpty", () => {
1090+
it("Should return the files in a directory", async () => {
1091+
const dirPath = path.join(getTmpDir(), "dir");
1092+
await mkdir(dirPath);
1093+
1094+
const files = ["file1.txt", "file2.txt", "file3.json"];
1095+
for (const file of files) {
1096+
await createFile(path.join(dirPath, file));
1097+
}
1098+
1099+
const subDirPath = path.join(dirPath, "subdir");
1100+
await mkdir(subDirPath);
1101+
await createFile(path.join(subDirPath, "file4.txt"));
1102+
1103+
// should include the subdir but not its content as it's not recursive
1104+
assert.deepEqual(
1105+
new Set(await readdirOrEmpty(dirPath)),
1106+
new Set(["file1.txt", "file2.txt", "file3.json", "subdir"]),
1107+
);
1108+
});
1109+
1110+
it("Should return an empty array if the directory doesn't exist", async () => {
1111+
const dirPath = path.join(getTmpDir(), "not-exists");
1112+
1113+
assert.deepEqual(await readdirOrEmpty(dirPath), []);
1114+
});
1115+
1116+
it("Should throw NotADirectoryError if the path is not a directory", async () => {
1117+
const filePath = path.join(getTmpDir(), "file");
1118+
await createFile(filePath);
1119+
1120+
await assert.rejects(readdirOrEmpty(filePath), {
1121+
name: "NotADirectoryError",
1122+
message: `Path ${filePath} is not a directory`,
1123+
});
1124+
});
1125+
1126+
it("Should throw FileSystemAccessError if a different error is thrown", async () => {
1127+
const invalidPath = "\0";
1128+
1129+
await assert.rejects(readdirOrEmpty(invalidPath), {
1130+
name: "FileSystemAccessError",
1131+
});
1132+
});
1133+
});
1134+
9381135
describe("mkdir", () => {
9391136
it("Should create a directory", async () => {
9401137
const dirPath = path.join(getTmpDir(), "dir");

0 commit comments

Comments
 (0)