diff --git a/ext/node/polyfills/fs.ts b/ext/node/polyfills/fs.ts index 7d7c490126d04b..92d920257c7546 100644 --- a/ext/node/polyfills/fs.ts +++ b/ext/node/polyfills/fs.ts @@ -155,6 +155,7 @@ import { core, primordials } from "ext:core/mod.js"; const { ArrayBufferIsView, + ArrayIsArray, BigInt, DatePrototypeGetTime, DateUTC, @@ -175,6 +176,8 @@ const { Promise, PromisePrototypeThen, PromiseResolve, + RegExpPrototype, + RegExpPrototypeTest, SafeMap, StringPrototypeToString, SymbolAsyncIterator, @@ -3129,10 +3132,107 @@ function asyncIterableToCallback( next(); } +// Mirrors Node's `validateIgnoreOption` / +// `createIgnoreMatcher` from `lib/internal/fs/watchers.js`. +// Accepts a string (minimatch glob), RegExp, function, or array of those. +// Returns a function `(filename) => boolean` (or `null` if `ignore` is nullish). +type IgnoreOption = + | string + | RegExp + | ((filename: string) => boolean) + | (string | RegExp | ((filename: string) => boolean))[] + | undefined + | null; + +// deno-lint-ignore no-explicit-any +let _lazyMinimatch: any = null; +function getMinimatch() { + _lazyMinimatch ??= core.createLazyLoader("ext:deno_node/deps/minimatch.js"); + return _lazyMinimatch(); +} + +function validateIgnoreOptionElement(value: unknown, name: string) { + if (typeof value === "string") { + if (value.length === 0) { + throw new ERR_INVALID_ARG_VALUE( + name, + value, + "must be a non-empty string", + ); + } + return; + } + if (ObjectPrototypeIsPrototypeOf(RegExpPrototype, value)) return; + if (typeof value === "function") return; + throw new ERR_INVALID_ARG_TYPE( + name, + ["string", "RegExp", "Function"], + value, + ); +} + +function validateIgnoreOption(value: unknown, name: string) { + if (value == null) return; + if (ArrayIsArray(value)) { + for (let i = 0; i < value.length; i++) { + validateIgnoreOptionElement(value[i], `${name}[${i}]`); + } + return; + } + validateIgnoreOptionElement(value, name); +} + +function createIgnoreMatcher( + ignore: IgnoreOption, +): ((filename: string) => boolean) | null { + if (ignore == null) return null; + const matchers = ArrayIsArray(ignore) ? ignore : [ignore]; + const compiled: Array<(filename: string) => boolean> = []; + + for (let i = 0; i < matchers.length; i++) { + const matcher = matchers[i]; + if (typeof matcher === "string") { + const { Minimatch } = getMinimatch().default; + const mm = new Minimatch(matcher, { + nocase: isMacOS || isWindows, + windowsPathsNoEscape: true, + nonegate: true, + nocomment: true, + optimizationLevel: 2, + platform: isWindows ? "win32" : "posix", + // Allow patterns without slashes to match the basename + // e.g. '*.log' matches 'subdir/file.log'. + matchBase: true, + }); + ArrayPrototypePush( + compiled, + // deno-lint-ignore prefer-primordials + (filename: string) => mm.match(filename), + ); + } else if (ObjectPrototypeIsPrototypeOf(RegExpPrototype, matcher)) { + ArrayPrototypePush( + compiled, + (filename: string) => RegExpPrototypeTest(matcher as RegExp, filename), + ); + } else { + // Function + ArrayPrototypePush(compiled, matcher as (filename: string) => boolean); + } + } + + return (filename: string) => { + for (let i = 0; i < compiled.length; i++) { + if (compiled[i](filename)) return true; + } + return false; + }; +} + type watchOptions = { persistent?: boolean; recursive?: boolean; encoding?: string; + ignore?: IgnoreOption; }; type watchListener = (eventType: string, filename: string) => void; @@ -3171,6 +3271,8 @@ function watch( const watchPath = getValidatedPath(filename).toString(); const recursive = options?.recursive || false; + validateIgnoreOption(options?.ignore, "options.ignore"); + const ignoreMatcher = createIgnoreMatcher(options?.ignore); const iterator: Deno.FsWatcher = Deno.watchFs(watchPath, { recursive, }); @@ -3187,6 +3289,9 @@ function watch( const filename = recursive ? relative(resolvedWatchPath, val.paths[0]) : basename(val.paths[0]); + if (ignoreMatcher !== null && ignoreMatcher(filename)) { + return; + } fsWatcher.emit( "change", convertDenoFsEventToNodeFsEvent(val.kind), @@ -3227,12 +3332,15 @@ function watchPromise( recursive?: boolean; encoding?: string; signal?: AbortSignal; + ignore?: IgnoreOption; }, ): AsyncIterable<{ eventType: string; filename: string | Buffer | null }> { // deno-lint-ignore prefer-primordials const watchPath = getValidatedPath(filename).toString(); const recursive = options?.recursive ?? false; + validateIgnoreOption(options?.ignore, "options.ignore"); + const ignoreMatcher = createIgnoreMatcher(options?.ignore); const watcher = Deno.watchFs(watchPath, { recursive, }); @@ -3255,20 +3363,25 @@ function watchPromise( async next(): Promise< IteratorResult<{ eventType: string; filename: string | Buffer | null }> > { - // deno-lint-ignore prefer-primordials - const iterResult = await fsIterable.next(); - if (iterResult.done) return iterResult; + while (true) { + // deno-lint-ignore prefer-primordials + const iterResult = await fsIterable.next(); + if (iterResult.done) return iterResult; - const eventType = convertDenoFsEventToNodeFsEvent( - iterResult.value.kind, - ); - const fname = recursive - ? relative(resolvedWatchPath, iterResult.value.paths[0]) - : basename(iterResult.value.paths[0]); - return { - value: { eventType, filename: fname }, - done: false, - }; + const eventType = convertDenoFsEventToNodeFsEvent( + iterResult.value.kind, + ); + const fname = recursive + ? relative(resolvedWatchPath, iterResult.value.paths[0]) + : basename(iterResult.value.paths[0]); + if (ignoreMatcher !== null && ignoreMatcher(fname)) { + continue; + } + return { + value: { eventType, filename: fname }, + done: false, + }; + } }, // deno-lint-ignore no-explicit-any return(value?: any): Promise> { diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 8ddb94991ed8a2..657cfc614ed8d2 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -1089,6 +1089,11 @@ "parallel/test-fs-promises-readfile-empty.js": {}, "parallel/test-fs-promises-readfile-with-fd.js": {}, "parallel/test-fs-promises-statfs-validate-path.js": {}, + "parallel/test-fs-promises-watch-ignore-function.mjs": {}, + "parallel/test-fs-promises-watch-ignore-glob.mjs": {}, + "parallel/test-fs-promises-watch-ignore-invalid.mjs": {}, + "parallel/test-fs-promises-watch-ignore-mixed.mjs": {}, + "parallel/test-fs-promises-watch-ignore-regexp.mjs": {}, "parallel/test-fs-promises-watch-iterator.js": {}, "parallel/test-fs-promises-write-optional-params.js": {}, "parallel/test-fs-promises-writefile-typedarray.js": {}, @@ -1190,6 +1195,15 @@ "parallel/test-fs-utimes-y2K38.js": {}, "parallel/test-fs-utimes.js": {}, "parallel/test-fs-watch-file-enoent-after-deletion.js": {}, + "parallel/test-fs-watch-ignore-function.js": {}, + "parallel/test-fs-watch-ignore-glob.js": {}, + "parallel/test-fs-watch-ignore-invalid.js": {}, + "parallel/test-fs-watch-ignore-mixed.js": {}, + "parallel/test-fs-watch-ignore-recursive-glob-subdirectories.js": {}, + "parallel/test-fs-watch-ignore-recursive-glob.js": {}, + "parallel/test-fs-watch-ignore-recursive-mixed.js": {}, + "parallel/test-fs-watch-ignore-recursive-regexp.js": {}, + "parallel/test-fs-watch-ignore-regexp.js": {}, "parallel/test-fs-watch-recursive-add-file-to-existing-subfolder.js": {}, "parallel/test-fs-watch-recursive-add-folder.js": {}, "parallel/test-fs-watch-recursive-delete.js": {},