Skip to content

Commit

Permalink
feat(node/fs): add fs.glob, fs.globSync, and fs.promises.glob
Browse files Browse the repository at this point in the history
  • Loading branch information
DonIsaac committed Jan 23, 2025
1 parent 7830e15 commit af20fc8
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 4 deletions.
74 changes: 74 additions & 0 deletions src/js/internal/fs/glob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { GlobScanOptions } from "bun";
const { validateObject } = require("internal/validators");

interface GlobOptions {
/** @default process.cwd() */
cwd?: string;
exclude?: (ent: string) => boolean;
/**
* Should glob return paths as {@link Dirent} objects. `false` for strings.
* @default false */
withFileTypes?: boolean;
}

interface ExtendedGlobOptions extends GlobScanOptions {
exclude(ent: string): boolean;
}

async function* glob(pattern: string | string[], options: GlobOptions): AsyncGenerator<string> {
if ($isArray(pattern)) {
throw new TypeError("fs.glob does not support arrays of patterns yet. Please open an issue on GitHub.");
}
const globOptions = mapOptions(options);
let it = new Bun.Glob(pattern).scan(globOptions);
const exclude = globOptions.exclude;

for await (const ent of it) {
if (exclude(ent)) continue;
yield ent;
}
}

function* globSync(pattern: string | string[], options: GlobOptions): Generator<string> {
if ($isArray(pattern)) {
throw new TypeError("fs.glob does not support arrays of patterns yet. Please open an issue on GitHub.");
}
const globOptions = mapOptions(options);
const g = new Bun.Glob(pattern);
const exclude = globOptions.exclude;
for (const ent of g.scanSync(globOptions)) {
if (exclude(ent)) continue;
yield ent;
}
}

function mapOptions(options: GlobOptions): ExtendedGlobOptions {
// forcing callers to pass a default object prevents internal glob functions
// from becoming megamorphic
$assert(!$isUndefinedOrNull(options) && typeof options === "object", "wrapper methods must pass an options object.");
validateObject(options, "options");

const exclude = options.exclude ?? no;
if (typeof exclude !== "function") {
throw $ERR_INVALID_ARG_TYPE("options.exclude", "function", exclude);
}

if (options.withFileTypes) {
throw new TypeError("fs.glob does not support options.withFileTypes yet. Please open an issue on GitHub.");
}

return {
// NOTE: this is subtly different from Glob's default behavior.
// `process.cwd()` may be overridden by JS code, but native code will used the
// cached `getcwd` on BunProcess.
cwd: options?.cwd ?? process.cwd(),
// https://github.com/nodejs/node/blob/a9546024975d0bfb0a8ae47da323b10fb5cbb88b/lib/internal/fs/glob.js#L655
followSymlinks: true,
exclude,
};
}

// `var` avoids TDZ checks.
var no = _ => false;

export default { glob, globSync };
2 changes: 1 addition & 1 deletion src/js/internal/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function validateLinkHeaderValue(hints) {
}
hideFromStack(validateLinkHeaderValue);
// TODO: do it in NodeValidator.cpp
function validateObject(value, name) {
function validateObject(value: unknown, name: string): asserts value is object {
if (typeof value !== "object" || value === null) throw $ERR_INVALID_ARG_TYPE(name, "object", value);
}
hideFromStack(validateObject);
Expand Down
7 changes: 4 additions & 3 deletions src/js/node/fs.promises.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Hardcoded module "node:fs/promises"
import type { Dirent } from "fs";
const types = require("node:util/types");
const EventEmitter = require("node:events");
const fs = $zig("node_fs_binding.zig", "createBinding");
const { glob } = require("internal/fs/glob");
const constants = $processBindingConstants.fs;

var PromisePrototypeFinally = Promise.prototype.finally; //TODO
Expand All @@ -22,7 +22,7 @@ const kDeserialize = Symbol("kDeserialize");
const kEmptyObject = ObjectFreeze({ __proto__: null });
const kFlag = Symbol("kFlag");

const { validateObject, validateInteger } = require("internal/validators");
const { validateInteger } = require("internal/validators");

function watch(
filename: string | Buffer | URL,
Expand Down Expand Up @@ -112,7 +112,7 @@ function cp(src, dest, options) {
}

async function opendir(dir: string, options) {
return new (require('node:fs').Dir)(1, dir, options);
return new (require("node:fs").Dir)(1, dir, options);
}

const private_symbols = {
Expand Down Expand Up @@ -152,6 +152,7 @@ const exports = {
fdatasync: asyncWrap(fs.fdatasync, "fdatasync"),
ftruncate: asyncWrap(fs.ftruncate, "ftruncate"),
futimes: asyncWrap(fs.futimes, "futimes"),
glob,
lchmod: asyncWrap(fs.lchmod, "lchmod"),
lchown: asyncWrap(fs.lchown, "lchown"),
link: asyncWrap(fs.link, "link"),
Expand Down
22 changes: 22 additions & 0 deletions src/js/node/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const EventEmitter = require("node:events");
const promises = require("node:fs/promises");
const types = require("node:util/types");

const kEmptyObject = Object.freeze(Object.create(null));

const isDate = types.isDate;

// Private exports
Expand Down Expand Up @@ -1076,6 +1078,24 @@ class Dir {
}
}

function glob(pattern: string | string[], options, callback) {
if (typeof options === "function") {
callback = options;
options = undefined;
}
if (typeof callback !== "function") {
throw $ERR_INVALID_ARG_TYPE("callback", "function", callback);
}

Array.fromAsync(require("internal/fs/glob").glob(pattern, options ?? kEmptyObject))
.then(result => callback(null, result))
.catch(callback);
}

function globSync(pattern: string | string[], options): string[] {
return Array.from(require("internal/fs/glob").globSync(pattern, options ?? kEmptyObject));
}

var exports = {
appendFile,
appendFileSync,
Expand Down Expand Up @@ -1109,6 +1129,8 @@ var exports = {
ftruncateSync,
futimes,
futimesSync,
glob,
globSync,
lchown,
lchownSync,
lchmod,
Expand Down
17 changes: 17 additions & 0 deletions test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ export async function makeTree(base: string, tree: DirectoryTree) {
}
}

/**
* Recursively create files within a new temporary directory.
*
* @param basename prefix of the new temporary directory
* @param files directory tree. Each key is a folder or file, and each value is the contents of the file. Use objects for directories.
* @returns an absolute path to the new temporary directory
*
* @example
* ```ts
* const dir = tempDirWithFiles("my-test", {
* "index.js": `import foo from "./src/foo";`,
* "src": {
* "foo.js": `export default "foo";`,
* },
* });
* ```
*/
export function tempDirWithFiles(basename: string, files: DirectoryTree): string {
const base = fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), basename + "_"));
makeTree(base, files);
Expand Down
127 changes: 127 additions & 0 deletions test/js/node/fs/glob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @note `fs.glob` et. al. are powered by {@link Bun.Glob}, which is extensively
* tested elsewhere. These tests check API compatibility with Node.js.
*/
import fs from "node:fs";
import { describe, beforeAll, afterAll, it, expect } from "bun:test";
import { tempDirWithFiles } from "harness";

let tmp: string;
beforeAll(() => {
tmp = tempDirWithFiles("fs-glob", {
"foo.txt": "foo",
a: {
"bar.txt": "bar",
"baz.js": "baz",
},
});
});

afterAll(() => {
return fs.promises.rmdir(tmp, { recursive: true });
});

describe("fs.glob", () => {
it("has a length of 3", () => {
expect(fs).toHaveProperty("glob");
expect(typeof fs.glob).toEqual("function");
expect(fs.glob).toHaveLength(3);
});

it("is named 'glob'", () => {
expect(fs.glob.name).toEqual("glob");
});

it("when successful, passes paths to the callback", done => {
fs.glob("*.txt", { cwd: tmp }, (err, paths) => {
expect(err).toBeNull();
expect(paths.sort()).toStrictEqual(["foo.txt"]);
done();
});
});

it("can filter out files", done => {
const exclude = (path: string) => path.endsWith(".js");
fs.glob("a/*", { cwd: tmp, exclude }, (err, paths) => {
if (err) done(err);
expect(paths).toStrictEqual(["a/bar.txt"]);
done();
});
});

describe("invalid arguments", () => {
it("throws if no callback is provided", () => {
expect(() => fs.glob("*.txt")).toThrow(TypeError);
expect(() => fs.glob("*.txt", undefined)).toThrow(TypeError);
expect(() => fs.glob("*.txt", { cwd: tmp })).toThrow(TypeError);
expect(() => fs.glob("*.txt", { cwd: tmp }, undefined)).toThrow(TypeError);
});
});
}); // </fs.glob>

describe("fs.globSync", () => {
it("has a length of 2", () => {
expect(fs).toHaveProperty("globSync");
expect(typeof fs.globSync).toBe("function");
expect(fs.globSync).toHaveLength(2);
});

it("is named 'globSync'", () => {
expect(fs.globSync.name).toEqual("globSync");
});

it.each([
["*.txt", ["foo.txt"]],
["a/**", ["a/bar.txt", "a/baz.js"]],
])("fs.glob(%p, { cwd: /tmp/fs-glob }) === %p", (pattern, expected) => {
expect(fs.globSync(pattern, { cwd: tmp }).sort()).toStrictEqual(expected);
});

describe("when process.cwd() is set", () => {
let oldProcessCwd: () => string;
beforeAll(() => {
oldProcessCwd = process.cwd;
process.cwd = () => tmp;
});
afterAll(() => {
process.cwd = oldProcessCwd;
});

it("respects the new cwd", () => {
expect(fs.globSync("*.txt")).toStrictEqual(["foo.txt"]);
});
});

it("can filter out files", () => {
const exclude = (path: string) => path.endsWith(".js");
expect(fs.globSync("a/*", { cwd: tmp, exclude })).toStrictEqual(["a/bar.txt"]);
});

describe("invalid arguments", () => {
// TODO: GlobSet
it("does not support arrays of patterns yet", () => {
expect(() => fs.globSync(["*.txt"])).toThrow(TypeError);
});
});
}); // </fs.globSync>

describe("fs.promises.glob", () => {
it("has a length of 2", () => {
expect(fs.promises).toHaveProperty("glob");
expect(typeof fs.promises.glob).toBe("function");
expect(fs.promises.glob).toHaveLength(2);
});

it("is named 'glob'", () => {
expect(fs.promises.glob.name).toEqual("glob");
});

it("returns an AsyncIterable over matched paths", async () => {
const iter = fs.promises.glob("*.txt", { cwd: tmp });
// FIXME: .toHaveProperty does not support symbol keys
expect(iter[Symbol.asyncIterator]).toBeDefined();
for await (const path of iter) {
expect(path).toMatch(/\.txt$/);
}
});
}); // </fs.promises.glob>

0 comments on commit af20fc8

Please sign in to comment.