Repository: nodejs/node
Pull request: fs: allow `exclude` option in globs to accept glob patterns

This patch extends the `exclude` option in glob functions (`fs.glob`, `fs.globSync`, and `fsPromises.glob`) to support glob patterns.

Resolves #56254

Signed-off-by: Daeyeon Jeong <daeyeon.dev@gmail.com>

Changed files:
filename: doc/api/fs.md
patch: @@ -1074,6 +1074,9 @@ behavior is similar to `cp dir1/ dir2/`.
 <!-- YAML
 added: v22.0.0
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/56489
+    description: Add support for `exclude` option to accept glob patterns.
   - version: v22.2.0
     pr-url: https://github.com/nodejs/node/pull/52837
     description: Add support for `withFileTypes` as an option.
@@ -1084,7 +1087,8 @@ changes:
 * `pattern` {string|string\[]}
 * `options` {Object}
   * `cwd` {string} current working directory. **Default:** `process.cwd()`
-  * `exclude` {Function} Function to filter out files/directories. Return
+  * `exclude` {Function|string\[]} Function to filter out files/directories or a
+    list of glob patterns to be excluded. If a function is provided, return
     `true` to exclude the item, `false` to include it. **Default:** `undefined`.
   * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents,
     `false` otherwise. **Default:** `false`.
@@ -3120,6 +3124,9 @@ descriptor. See [`fs.utimes()`][].
 <!-- YAML
 added: v22.0.0
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/56489
+    description: Add support for `exclude` option to accept glob patterns.
   - version: v22.2.0
     pr-url: https://github.com/nodejs/node/pull/52837
     description: Add support for `withFileTypes` as an option.
@@ -3131,7 +3138,8 @@ changes:
 
 * `options` {Object}
   * `cwd` {string} current working directory. **Default:** `process.cwd()`
-  * `exclude` {Function} Function to filter out files/directories. Return
+  * `exclude` {Function|string\[]} Function to filter out files/directories or a
+    list of glob patterns to be excluded. If a function is provided, return
     `true` to exclude the item, `false` to include it. **Default:** `undefined`.
   * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents,
     `false` otherwise. **Default:** `false`.
@@ -5656,6 +5664,9 @@ Synchronous version of [`fs.futimes()`][]. Returns `undefined`.
 <!-- YAML
 added: v22.0.0
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/56489
+    description: Add support for `exclude` option to accept glob patterns.
   - version: v22.2.0
     pr-url: https://github.com/nodejs/node/pull/52837
     description: Add support for `withFileTypes` as an option.
@@ -5666,7 +5677,8 @@ changes:
 * `pattern` {string|string\[]}
 * `options` {Object}
   * `cwd` {string} current working directory. **Default:** `process.cwd()`
-  * `exclude` {Function} Function to filter out files/directories. Return
+  * `exclude` {Function|string\[]} Function to filter out files/directories or a
+    list of glob patterns to be excluded. If a function is provided, return
     `true` to exclude the item, `false` to include it. **Default:** `undefined`.
   * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents,
     `false` otherwise. **Default:** `false`.

filename: lib/internal/fs/glob.js
patch: @@ -2,6 +2,7 @@
 
 const {
   ArrayFrom,
+  ArrayIsArray,
   ArrayPrototypeAt,
   ArrayPrototypeFlatMap,
   ArrayPrototypeMap,
@@ -24,12 +25,18 @@ const {
   isMacOS,
 } = require('internal/util');
 const {
-  validateFunction,
   validateObject,
   validateString,
   validateStringArray,
 } = require('internal/validators');
 const { DirentFromStats } = require('internal/fs/utils');
+const {
+  codes: {
+    ERR_INVALID_ARG_TYPE,
+  },
+  hideStackFrames,
+} = require('internal/errors');
+const assert = require('internal/assert');
 
 let minimatch;
 function lazyMinimatch() {
@@ -63,6 +70,45 @@ function getDirentSync(path) {
   return new DirentFromStats(basename(path), stat, dirname(path));
 }
 
+/**
+ * @callback validateStringArrayOrFunction
+ * @param {*} value
+ * @param {string} name
+ */
+const validateStringArrayOrFunction = hideStackFrames((value, name) => {
+  if (ArrayIsArray(value)) {
+    for (let i = 0; i < value.length; ++i) {
+      if (typeof value[i] !== 'string') {
+        throw new ERR_INVALID_ARG_TYPE(`${name}[${i}]`, 'string', value[i]);
+      }
+    }
+    return;
+  }
+  if (typeof value !== 'function') {
+    throw new ERR_INVALID_ARG_TYPE(name, ['string[]', 'function'], value);
+  }
+});
+
+/**
+ * @param {string} pattern
+ * @param {options} options
+ * @returns {Minimatch}
+ */
+function createMatcher(pattern, options = kEmptyObject) {
+  const opts = {
+    __proto__: null,
+    nocase: isWindows || isMacOS,
+    windowsPathsNoEscape: true,
+    nonegate: true,
+    nocomment: true,
+    optimizationLevel: 2,
+    platform: process.platform,
+    nocaseMagicOnly: true,
+    ...options,
+  };
+  return new (lazyMinimatch().Minimatch)(pattern, opts);
+}
+
 class Cache {
   #cache = new SafeMap();
   #statsCache = new SafeMap();
@@ -188,24 +234,56 @@ class Pattern {
   }
 }
 
+class ResultSet extends SafeSet {
+  #root = '.';
+  #isExcluded = () => false;
+  constructor(i) { super(i); } // eslint-disable-line no-useless-constructor
+
+  setup(root, isExcludedFn) {
+    this.#root = root;
+    this.#isExcluded = isExcludedFn;
+  }
+
+  add(value) {
+    if (this.#isExcluded(resolve(this.#root, value))) {
+      return false;
+    }
+    super.add(value);
+    return true;
+  }
+}
+
 class Glob {
   #root;
   #exclude;
   #cache = new Cache();
-  #results = new SafeSet();
+  #results = new ResultSet();
   #queue = [];
   #subpatterns = new SafeMap();
   #patterns;
   #withFileTypes;
+  #isExcluded = () => false;
   constructor(pattern, options = kEmptyObject) {
     validateObject(options, 'options');
     const { exclude, cwd, withFileTypes } = options;
-    if (exclude != null) {
-      validateFunction(exclude, 'options.exclude');
-    }
     this.#root = cwd ?? '.';
-    this.#exclude = exclude;
     this.#withFileTypes = !!withFileTypes;
+    if (exclude != null) {
+      validateStringArrayOrFunction(exclude, 'options.exclude');
+      if (ArrayIsArray(exclude)) {
+        assert(typeof this.#root === 'string');
+        // Convert the path part of exclude patterns to absolute paths for
+        // consistent comparison before instantiating matchers.
+        const matchers = exclude
+          .map((pattern) => resolve(this.#root, pattern))
+          .map((pattern) => createMatcher(pattern));
+        this.#isExcluded = (value) =>
+          matchers.some((matcher) => matcher.match(value));
+        this.#results.setup(this.#root, this.#isExcluded);
+      } else {
+        this.#exclude = exclude;
+      }
+    }
     let patterns;
     if (typeof pattern === 'object') {
       validateStringArray(pattern, 'patterns');
@@ -214,17 +292,7 @@ class Glob {
       validateString(pattern, 'patterns');
       patterns = [pattern];
     }
-    this.matchers = ArrayPrototypeMap(patterns, (pattern) => new (lazyMinimatch().Minimatch)(pattern, {
-      __proto__: null,
-      nocase: isWindows || isMacOS,
-      windowsPathsNoEscape: true,
-      nonegate: true,
-      nocomment: true,
-      optimizationLevel: 2,
-      platform: process.platform,
-      nocaseMagicOnly: true,
-    }));
-
+    this.matchers = ArrayPrototypeMap(patterns, (pattern) => createMatcher(pattern));
     this.#patterns = ArrayPrototypeFlatMap(this.matchers, (matcher) => ArrayPrototypeMap(matcher.set,
                                                                                          (pattern, i) => new Pattern(
                                                                                            pattern,
@@ -255,6 +323,9 @@ class Glob {
     );
   }
   #addSubpattern(path, pattern) {
+    if (this.#isExcluded(path)) {
+      return;
+    }
     if (!this.#subpatterns.has(path)) {
       this.#subpatterns.set(path, [pattern]);
     } else {
@@ -273,6 +344,9 @@ class Glob {
     const isLast = pattern.isLast(isDirectory);
     const isFirst = pattern.isFirst();
 
+    if (this.#isExcluded(fullpath)) {
+      return;
+    }
     if (isFirst && isWindows && typeof pattern.at(0) === 'string' && StringPrototypeEndsWith(pattern.at(0), ':')) {
       // Absolute path, go to root
       this.#addSubpattern(`${pattern.at(0)}\\`, pattern.child(new SafeSet().add(1)));
@@ -461,6 +535,9 @@ class Glob {
     const isLast = pattern.isLast(isDirectory);
     const isFirst = pattern.isFirst();
 
+    if (this.#isExcluded(fullpath)) {
+      return;
+    }
     if (isFirst && isWindows && typeof pattern.at(0) === 'string' && StringPrototypeEndsWith(pattern.at(0), ':')) {
       // Absolute path, go to root
       this.#addSubpattern(`${pattern.at(0)}\\`, pattern.child(new SafeSet().add(1)));
@@ -489,8 +566,9 @@ class Glob {
       if (stat && (p || isDirectory)) {
         const result = join(path, p);
         if (!this.#results.has(result)) {
-          this.#results.add(result);
-          yield this.#withFileTypes ? stat : result;
+          if (this.#results.add(result)) {
+            yield this.#withFileTypes ? stat : result;
+          }
         }
       }
       if (pattern.indexes.size === 1 && pattern.indexes.has(last)) {
@@ -501,8 +579,9 @@ class Glob {
       // If pattern ends with **, add to results
       // if path is ".", add it only if pattern starts with "." or pattern is exactly "**"
       if (!this.#results.has(path)) {
-        this.#results.add(path);
-        yield this.#withFileTypes ? stat : path;
+        if (this.#results.add(path)) {
+          yield this.#withFileTypes ? stat : path;
+        }
       }
     }
 
@@ -551,8 +630,9 @@ class Glob {
           } else if (!fromSymlink && index === last) {
             // If ** is last, add to results
             if (!this.#results.has(entryPath)) {
-              this.#results.add(entryPath);
-              yield this.#withFileTypes ? entry : entryPath;
+              if (this.#results.add(entryPath)) {
+                yield this.#withFileTypes ? entry : entryPath;
+              }
             }
           }
 
@@ -562,8 +642,9 @@ class Glob {
           if (nextMatches && nextIndex === last && !isLast) {
             // If next pattern is the last one, add to results
             if (!this.#results.has(entryPath)) {
-              this.#results.add(entryPath);
-              yield this.#withFileTypes ? entry : entryPath;
+              if (this.#results.add(entryPath)) {
+                yield this.#withFileTypes ? entry : entryPath;
+              }
             }
           } else if (nextMatches && entry.isDirectory()) {
             // Pattern matched, meaning two patterns forward
@@ -598,15 +679,17 @@ class Glob {
               if (!this.#cache.seen(path, pattern, nextIndex)) {
                 this.#cache.add(path, pattern.child(new SafeSet().add(nextIndex)));
                 if (!this.#results.has(path)) {
-                  this.#results.add(path);
-                  yield this.#withFileTypes ? this.#cache.statSync(fullpath) : path;
+                  if (this.#results.add(path)) {
+                    yield this.#withFileTypes ? this.#cache.statSync(fullpath) : path;
+                  }
                 }
               }
               if (!this.#cache.seen(path, pattern, nextIndex) || !this.#cache.seen(parent, pattern, nextIndex)) {
                 this.#cache.add(parent, pattern.child(new SafeSet().add(nextIndex)));
                 if (!this.#results.has(parent)) {
-                  this.#results.add(parent);
-                  yield this.#withFileTypes ? this.#cache.statSync(join(this.#root, parent)) : parent;
+                  if (this.#results.add(parent)) {
+                    yield this.#withFileTypes ? this.#cache.statSync(join(this.#root, parent)) : parent;
+                  }
                 }
               }
             }
@@ -621,8 +704,9 @@ class Glob {
             // If current pattern is ".", proceed to test next pattern
             if (nextIndex === last) {
               if (!this.#results.has(entryPath)) {
-                this.#results.add(entryPath);
-                yield this.#withFileTypes ? entry : entryPath;
+                if (this.#results.add(entryPath)) {
+                  yield this.#withFileTypes ? entry : entryPath;
+                }
               }
             } else {
               subPatterns.add(nextIndex + 1);
@@ -634,8 +718,9 @@ class Glob {
           // add next pattern to potential patterns, or to results if it's the last pattern
           if (index === last) {
             if (!this.#results.has(entryPath)) {
-              this.#results.add(entryPath);
-              yield this.#withFileTypes ? entry : entryPath;
+              if (this.#results.add(entryPath)) {
+                yield this.#withFileTypes ? entry : entryPath;
+              }
             }
           } else if (entry.isDirectory()) {
             subPatterns.add(nextIndex);

filename: test/parallel/test-fs-glob.mjs
patch: @@ -386,3 +386,97 @@ describe('fsPromises glob - withFileTypes', function() {
     });
   }
 });
+
+// [pattern, exclude option, expected result]
+const pattern2 = [
+  ['a/{b,c}*', ['a/*c'], ['a/b', 'a/cb']],
+  ['a/{a,b,c}*', ['a/*bc*', 'a/cb'], ['a/b', 'a/c']],
+  ['a/**/[cg]', ['**/c'], ['a/abcdef/g', 'a/abcfed/g']],
+  ['a/**/[cg]', ['./**/c'], ['a/abcdef/g', 'a/abcfed/g']],
+  ['a/**/[cg]', ['a/**/[cg]/../c'], ['a/abcdef/g', 'a/abcfed/g']],
+  ['a/*/+(c|g)/*', ['**/./h'], ['a/b/c/d']],
+  [
+    'a/**/[cg]/../[cg]',
+    ['a/ab{cde,cfe}*'],
+    [
+      'a/b/c',
+      'a/c',
+      'a/c/d/c',
+      ...(common.isWindows ? [] : ['a/symlink/a/b/c']),
+    ],
+  ],
+  [
+    `${absDir}/*`,
+    [`${absDir}/asdf`, `${absDir}/ba*`],
+    [`${absDir}/foo`, `${absDir}/quux`, `${absDir}/qwer`, `${absDir}/rewq`],
+  ],
+  [
+    `${absDir}/*`,
+    [`${absDir}/asdf`, `**/ba*`],
+    [
+      `${absDir}/bar`,
+      `${absDir}/baz`,
+      `${absDir}/foo`,
+      `${absDir}/quux`,
+      `${absDir}/qwer`,
+      `${absDir}/rewq`,
+    ],
+  ],
+  [
+    [`${absDir}/*`, 'a/**/[cg]'],
+    [`${absDir}/*{a,q}*`, './a/*{c,b}*/*'],
+    [`${absDir}/foo`, 'a/c', ...(common.isWindows ? [] : ['a/symlink/a/b/c'])],
+  ],
+];
+
+describe('globSync - exclude', function() {
+  for (const [pattern, exclude] of Object.entries(patterns).map(([k, v]) => [k, v.filter(Boolean)])) {
+    test(`${pattern} - exclude: ${exclude}`, () => {
+      const actual = globSync(pattern, { cwd: fixtureDir, exclude }).sort();
+      assert.strictEqual(actual.length, 0);
+    });
+  }
+  for (const [pattern, exclude, expected] of pattern2) {
+    test(`${pattern} - exclude: ${exclude}`, () => {
+      const actual = globSync(pattern, { cwd: fixtureDir, exclude }).sort();
+      const normalized = expected.filter(Boolean).map((item) => item.replaceAll('/', sep)).sort();
+      assert.deepStrictEqual(actual, normalized);
+    });
+  }
+});
+
+describe('glob - exclude', function() {
+  const promisified = promisify(glob);
+  for (const [pattern, exclude] of Object.entries(patterns).map(([k, v]) => [k, v.filter(Boolean)])) {
+    test(`${pattern} - exclude: ${exclude}`, async () => {
+      const actual = (await promisified(pattern, { cwd: fixtureDir, exclude })).sort();
+      assert.strictEqual(actual.length, 0);
+    });
+  }
+  for (const [pattern, exclude, expected] of pattern2) {
+    test(`${pattern} - exclude: ${exclude}`, async () => {
+      const actual = (await promisified(pattern, { cwd: fixtureDir, exclude })).sort();
+      const normalized = expected.filter(Boolean).map((item) => item.replaceAll('/', sep)).sort();
+      assert.deepStrictEqual(actual, normalized);
+    });
+  }
+});
+
+describe('fsPromises glob - exclude', function() {
+  for (const [pattern, exclude] of Object.entries(patterns).map(([k, v]) => [k, v.filter(Boolean)])) {
+    test(`${pattern} - exclude: ${exclude}`, async () => {
+      const actual = [];
+      for await (const item of asyncGlob(pattern, { cwd: fixtureDir, exclude })) actual.push(item);
+      actual.sort();
+      assert.strictEqual(actual.length, 0);
+    });
+  }
+  for (const [pattern, exclude, expected] of pattern2) {
+    test(`${pattern} - exclude: ${exclude}`, async () => {
+      const actual = [];
+      for await (const item of asyncGlob(pattern, { cwd: fixtureDir, exclude })) actual.push(item);
+      const normalized = expected.filter(Boolean).map((item) => item.replaceAll('/', sep)).sort();
+      assert.deepStrictEqual(actual.sort(), normalized);
+    });
+  }
+});