diff --git a/README.md b/README.md index d4a9b1df..da51e695 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ You can specify the exact backend you wish to use by passing the `backend` optio All of the APIs in `@parcel/watcher` support the following options, which are passed as an object as the last function argument. -- `ignore` - an array of paths to ignore. They can be either files or directories. No events will be emitted about these files or directories or their children. +- `ignore` - an array of paths or glob patterns to ignore. uses [`is-glob`](https://github.com/micromatch/is-glob) to distinguish paths from globs. glob patterns are parsed with [`micromatch`](https://github.com/micromatch/micromatch) (see [features](https://github.com/micromatch/micromatch#matching-features)). + - paths can be relative or absolute and can either be files or directories. No events will be emitted about these files or directories or their children. + - glob patterns match on relative paths from the root that is watched. No events will be emitted for matching paths. - `backend` - the name of an explicitly chosen backend to use. Allowed options are `"fs-events"`, `"watchman"`, `"inotify"`, `"windows"`, or `"brute-force"` (only for querying). If the specified backend is not available on the current platform, the default backend will be used instead. ## Who is using this? diff --git a/binding.gyp b/binding.gyp index 5a33d887..c7dd5524 100644 --- a/binding.gyp +++ b/binding.gyp @@ -3,7 +3,7 @@ { "target_name": "watcher", "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], - "sources": [ "src/binding.cc", "src/Watcher.cc", "src/Backend.cc", "src/DirTree.cc" ], + "sources": [ "src/binding.cc", "src/Watcher.cc", "src/Backend.cc", "src/DirTree.cc", "src/Glob.cc" ], "include_dirs" : [" path.resolve(dir, ignore)), - }); + const { ignore, ...rest } = opts; + + if (Array.isArray(ignore)) { + opts = { ...rest }; + + for (const value of ignore) { + if (isGlob(value)) { + if (!opts.ignoreGlobs) { + opts.ignoreGlobs = []; + } + + const regex = micromatch.makeRe(value, { + // We set `dot: true` to workaround an issue with the + // regular expression on Linux where the resulting + // negative lookahead `(?!(\\/|^)` was never matching + // in some cases. See also https://bit.ly/3UZlQDm + dot: true, + // C++ does not support lookbehind regex patterns, they + // were only added later to JavaScript engines + // (https://bit.ly/3V7S6UL) + lookbehinds: false + }); + opts.ignoreGlobs.push(regex.source); + } else { + if (!opts.ignorePaths) { + opts.ignorePaths = []; + } + + opts.ignorePaths.push(path.resolve(dir, value)); + } + } } return opts; diff --git a/index.js.flow b/index.js.flow index 6f80d5d4..d75da93d 100644 --- a/index.js.flow +++ b/index.js.flow @@ -1,5 +1,6 @@ // @flow declare type FilePath = string; +declare type GlobPattern = string; export type BackendType = | 'fs-events' @@ -9,7 +10,7 @@ export type BackendType = | 'brute-force'; export type EventType = 'create' | 'update' | 'delete'; export interface Options { - ignore?: Array, + ignore?: Array, backend?: BackendType } export type SubscribeCallback = ( diff --git a/package.json b/package.json index 15faf904..79fbbca8 100644 --- a/package.json +++ b/package.json @@ -48,15 +48,17 @@ ] }, "dependencies": { + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", "node-addon-api": "^3.2.1", "node-gyp-build": "^4.3.0" }, "devDependencies": { "fs-extra": "^10.0.0", "husky": "^7.0.2", + "lint-staged": "^11.1.2", "mocha": "^9.1.1", "prebuildify": "^4.2.1", - "lint-staged": "^11.1.2", "prettier": "^2.3.2" }, "binary": { diff --git a/src/Glob.cc b/src/Glob.cc new file mode 100644 index 00000000..c8747877 --- /dev/null +++ b/src/Glob.cc @@ -0,0 +1,15 @@ +#include "Glob.hh" +#include + +Glob::Glob(std::string raw) : Glob(raw, std::regex(raw)) + { } + +Glob::Glob(std::string raw, std::regex regex) + : mHash(std::hash()(raw)), + mRegex(regex), + mRaw(raw) + { } + +bool Glob::isIgnored(std::string relative_path) const { + return std::regex_match(relative_path, mRegex); +} diff --git a/src/Glob.hh b/src/Glob.hh new file mode 100644 index 00000000..1c491317 --- /dev/null +++ b/src/Glob.hh @@ -0,0 +1,33 @@ +#ifndef GLOB_H +#define GLOB_H + +#include +#include + +struct Glob { + std::size_t mHash; + std::regex mRegex; + std::string mRaw; + + Glob(std::string raw); + Glob(std::string raw, std::regex regex); + + bool operator==(const Glob &other) const { + return mHash == other.mHash; + } + + bool isIgnored(std::string relative_path) const; +}; + +namespace std +{ + template <> + struct hash + { + size_t operator()(const Glob& g) const { + return g.mHash; + } + }; +} + +#endif diff --git a/src/Watcher.cc b/src/Watcher.cc index a5b7ed6b..384c243d 100644 --- a/src/Watcher.cc +++ b/src/Watcher.cc @@ -17,8 +17,8 @@ struct WatcherCompare { static std::unordered_set, WatcherHash, WatcherCompare> sharedWatchers; -std::shared_ptr Watcher::getShared(std::string dir, std::unordered_set ignore) { - std::shared_ptr watcher = std::make_shared(dir, ignore); +std::shared_ptr Watcher::getShared(std::string dir, std::unordered_set ignorePaths, std::unordered_set ignoreGlobs) { + std::shared_ptr watcher = std::make_shared(dir, ignorePaths, ignoreGlobs); auto found = sharedWatchers.find(watcher); if (found != sharedWatchers.end()) { return *found; @@ -37,9 +37,10 @@ void removeShared(Watcher *watcher) { } } -Watcher::Watcher(std::string dir, std::unordered_set ignore) +Watcher::Watcher(std::string dir, std::unordered_set ignorePaths, std::unordered_set ignoreGlobs) : mDir(dir), - mIgnore(ignore), + mIgnorePaths(ignorePaths), + mIgnoreGlobs(ignoreGlobs), mWatched(false), mAsync(NULL), mCallingCallbacks(false) { @@ -199,12 +200,26 @@ void Watcher::onClose(uv_handle_t *handle) { } bool Watcher::isIgnored(std::string path) { - for (auto it = mIgnore.begin(); it != mIgnore.end(); it++) { + for (auto it = mIgnorePaths.begin(); it != mIgnorePaths.end(); it++) { auto dir = *it + DIR_SEP; if (*it == path || path.compare(0, dir.size(), dir) == 0) { return true; } } + auto basePath = mDir + DIR_SEP; + + if (path.rfind(basePath, 0) != 0) { + return false; + } + + auto relativePath = path.substr(basePath.size()); + + for (auto it = mIgnoreGlobs.begin(); it != mIgnoreGlobs.end(); it++) { + if (it->isIgnored(relativePath)) { + return true; + } + } + return false; } diff --git a/src/Watcher.hh b/src/Watcher.hh index d836911e..5aac4f59 100644 --- a/src/Watcher.hh +++ b/src/Watcher.hh @@ -6,6 +6,7 @@ #include #include #include +#include "Glob.hh" #include "Event.hh" #include "Debounce.hh" #include "DirTree.hh" @@ -15,16 +16,17 @@ using namespace Napi; struct Watcher { std::string mDir; - std::unordered_set mIgnore; + std::unordered_set mIgnorePaths; + std::unordered_set mIgnoreGlobs; EventList mEvents; void *state; bool mWatched; - Watcher(std::string dir, std::unordered_set ignore); + Watcher(std::string dir, std::unordered_set ignorePaths, std::unordered_set ignoreGlobs); ~Watcher(); bool operator==(const Watcher &other) const { - return mDir == other.mDir && mIgnore == other.mIgnore; + return mDir == other.mDir && mIgnorePaths == other.mIgnorePaths && mIgnoreGlobs == other.mIgnoreGlobs; } void wait(); @@ -35,7 +37,7 @@ struct Watcher { void unref(); bool isIgnored(std::string path); - static std::shared_ptr getShared(std::string dir, std::unordered_set ignore); + static std::shared_ptr getShared(std::string dir, std::unordered_set ignorePaths, std::unordered_set ignoreGlobs); private: std::mutex mMutex; diff --git a/src/binding.cc b/src/binding.cc index e2b54efe..70a01a1b 100644 --- a/src/binding.cc +++ b/src/binding.cc @@ -2,6 +2,7 @@ #include #include #include +#include "Glob.hh" #include "Event.hh" #include "Backend.hh" #include "Watcher.hh" @@ -9,23 +10,43 @@ using namespace Napi; -std::unordered_set getIgnore(Env env, Value opts) { - std::unordered_set ignore; +std::unordered_set getIgnorePaths(Env env, Value opts) { + std::unordered_set result; if (opts.IsObject()) { - Value v = opts.As().Get(String::New(env, "ignore")); + Value v = opts.As().Get(String::New(env, "ignorePaths")); if (v.IsArray()) { Array items = v.As(); for (size_t i = 0; i < items.Length(); i++) { Value item = items.Get(Number::New(env, i)); if (item.IsString()) { - ignore.insert(std::string(item.As().Utf8Value().c_str())); + result.insert(std::string(item.As().Utf8Value().c_str())); } } } } - return ignore; + return result; +} + +std::unordered_set getIgnoreGlobs(Env env, Value opts) { + std::unordered_set result; + + if (opts.IsObject()) { + Value v = opts.As().Get(String::New(env, "ignoreGlobs")); + if (v.IsArray()) { + Array items = v.As(); + for (size_t i = 0; i < items.Length(); i++) { + Value item = items.Get(Number::New(env, i)); + if (item.IsString()) { + auto key = item.As().Utf8Value(); + result.emplace(key, std::regex(key.c_str())); + } + } + } + } + + return result; } std::shared_ptr getBackend(Env env, Value opts) { @@ -45,7 +66,8 @@ class WriteSnapshotRunner : public PromiseRunner { snapshotPath(std::string(snap.As().Utf8Value().c_str())) { watcher = Watcher::getShared( std::string(dir.As().Utf8Value().c_str()), - getIgnore(env, opts) + getIgnorePaths(env, opts), + getIgnoreGlobs(env, opts) ); backend = getBackend(env, opts); @@ -72,7 +94,8 @@ class GetEventsSinceRunner : public PromiseRunner { snapshotPath(std::string(snap.As().Utf8Value().c_str())) { watcher = std::make_shared( std::string(dir.As().Utf8Value().c_str()), - getIgnore(env, opts) + getIgnorePaths(env, opts), + getIgnoreGlobs(env, opts) ); backend = getBackend(env, opts); @@ -137,7 +160,8 @@ class SubscribeRunner : public PromiseRunner { SubscribeRunner(Env env, Value dir, Value fn, Value opts) : PromiseRunner(env) { watcher = Watcher::getShared( std::string(dir.As().Utf8Value().c_str()), - getIgnore(env, opts) + getIgnorePaths(env, opts), + getIgnoreGlobs(env, opts) ); backend = getBackend(env, opts); @@ -160,7 +184,8 @@ class UnsubscribeRunner : public PromiseRunner { UnsubscribeRunner(Env env, Value dir, Value fn, Value opts) : PromiseRunner(env) { watcher = Watcher::getShared( std::string(dir.As().Utf8Value().c_str()), - getIgnore(env, opts) + getIgnorePaths(env, opts), + getIgnoreGlobs(env, opts) ); backend = getBackend(env, opts); diff --git a/src/linux/InotifyBackend.cc b/src/linux/InotifyBackend.cc index 1a281d16..d498ccaf 100644 --- a/src/linux/InotifyBackend.cc +++ b/src/linux/InotifyBackend.cc @@ -156,7 +156,7 @@ bool InotifyBackend::handleSubscription(struct inotify_event *event, std::shared path += "/" + std::string(event->name); } - if (watcher->mIgnore.count(path) > 0) { + if (watcher->isIgnored(path)) { return false; } diff --git a/src/macos/FSEventsBackend.cc b/src/macos/FSEventsBackend.cc index a3bbfe94..c584f3ce 100644 --- a/src/macos/FSEventsBackend.cc +++ b/src/macos/FSEventsBackend.cc @@ -214,8 +214,8 @@ void FSEventsBackend::startStream(Watcher &watcher, FSEventStreamEventId id) { kFSEventStreamCreateFlagFileEvents ); - CFMutableArrayRef exclusions = CFArrayCreateMutable(NULL, watcher.mIgnore.size(), NULL); - for (auto it = watcher.mIgnore.begin(); it != watcher.mIgnore.end(); it++) { + CFMutableArrayRef exclusions = CFArrayCreateMutable(NULL, watcher.mIgnorePaths.size(), NULL); + for (auto it = watcher.mIgnorePaths.begin(); it != watcher.mIgnorePaths.end(); it++) { CFStringRef path = CFStringCreateWithCString( NULL, it->c_str(), diff --git a/src/unix/fts.cc b/src/unix/fts.cc index 10e85958..6b411297 100644 --- a/src/unix/fts.cc +++ b/src/unix/fts.cc @@ -36,7 +36,7 @@ void BruteForceBackend::readTree(Watcher &watcher, std::shared_ptr tree throw WatcherError(strerror(ENOTDIR), &watcher); } - if (watcher.mIgnore.count(std::string(node->fts_path)) > 0) { + if (watcher.isIgnored(std::string(node->fts_path))) { fts_set(fts, node, FTS_SKIP); continue; } diff --git a/src/unix/legacy.cc b/src/unix/legacy.cc index e4108675..01911282 100644 --- a/src/unix/legacy.cc +++ b/src/unix/legacy.cc @@ -44,7 +44,7 @@ void iterateDir(Watcher &watcher, const std::shared_ptr tree, const ch std::string fullPath = dirname + "/" + ent->d_name; - if (watcher.mIgnore.count(fullPath) == 0) { + if (!watcher.isIgnored(fullPath)) { struct stat attrib; fstatat(new_fd, ent->d_name, &attrib, AT_SYMLINK_NOFOLLOW); bool isDir = ent->d_type == DT_DIR; diff --git a/src/watchman/WatchmanBackend.cc b/src/watchman/WatchmanBackend.cc index c4aa6099..cba4251b 100644 --- a/src/watchman/WatchmanBackend.cc +++ b/src/watchman/WatchmanBackend.cc @@ -292,12 +292,12 @@ void WatchmanBackend::subscribe(Watcher &watcher) { opts.emplace("fields", fields); opts.emplace("since", clock(watcher)); - if (watcher.mIgnore.size() > 0) { + if (watcher.mIgnorePaths.size() > 0) { BSER::Array ignore; BSER::Array anyOf; anyOf.push_back("anyof"); - for (auto it = watcher.mIgnore.begin(); it != watcher.mIgnore.end(); it++) { + for (auto it = watcher.mIgnorePaths.begin(); it != watcher.mIgnorePaths.end(); it++) { std::string pathStart = watcher.mDir + DIR_SEP; if (it->rfind(pathStart, 0) == 0) { auto relative = it->substr(pathStart.size()); diff --git a/src/windows/WindowsBackend.cc b/src/windows/WindowsBackend.cc index 2e54acbe..b5045495 100644 --- a/src/windows/WindowsBackend.cc +++ b/src/windows/WindowsBackend.cc @@ -37,7 +37,7 @@ void BruteForceBackend::readTree(Watcher &watcher, std::shared_ptr tree do { if (strcmp(ffd.cFileName, ".") != 0 && strcmp(ffd.cFileName, "..") != 0) { std::string fullPath = path + "\\" + ffd.cFileName; - if (watcher.mIgnore.count(fullPath) > 0) { + if (watcher.isIgnored(fullPath)) { continue; } diff --git a/test/watcher.js b/test/watcher.js index 4771973e..77371740 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -46,7 +46,7 @@ describe('watcher', () => { ...dir, `test${c++}${Math.random().toString(31).slice(2)}`, ); - let ignoreDir, ignoreFile, fileToRename, dirToRename, sub; + let ignoreDir, ignoreFile, ignoreGlobDir, fileToRename, dirToRename, sub; before(async () => { tmpDir = path.join( @@ -56,6 +56,12 @@ describe('watcher', () => { fs.mkdirpSync(tmpDir); ignoreDir = getFilename(); ignoreFile = getFilename(); + ignoreGlobDir = getFilename(); + const ignoreGlobDirName = path.basename(ignoreGlobDir); + await fs.mkdir(ignoreGlobDir); + await fs.mkdir(path.join(ignoreGlobDir, 'ignore')); + await fs.mkdir(path.join(ignoreGlobDir, 'erongi')); + await fs.mkdir(path.join(ignoreGlobDir, 'erongi', 'deep')); fileToRename = getFilename(); dirToRename = getFilename(); fs.writeFileSync(fileToRename, 'hi'); @@ -63,7 +69,7 @@ describe('watcher', () => { await new Promise((resolve) => setTimeout(resolve, 100)); sub = await watcher.subscribe(tmpDir, fn, { backend, - ignore: [ignoreDir, ignoreFile], + ignore: [ignoreDir, ignoreFile, `${ignoreGlobDirName}/*.ignore`, `${ignoreGlobDirName}/ignore/**`, `${ignoreGlobDirName}/[a-e]?on(g|l)i/**`] }); }); @@ -547,6 +553,20 @@ describe('watcher', () => { let res = await nextEvent(); assert.deepEqual(res, [{type: 'create', path: f1}]); }); + + it('should ignore globs', async () => { + fs.writeFile(path.join(ignoreGlobDir, 'test.txt'), 'hello'); + fs.writeFile(path.join(ignoreGlobDir, 'test.ignore'), 'hello'); + fs.writeFile(path.join(ignoreGlobDir, 'ignore', 'test.txt'), 'hello'); + fs.writeFile(path.join(ignoreGlobDir, 'ignore', 'test.ignore'), 'hello'); + fs.writeFile(path.join(ignoreGlobDir, 'erongi', 'test.txt'), 'hello'); + fs.writeFile(path.join(ignoreGlobDir, 'erongi', 'deep', 'test.txt'), 'hello'); + + let res = await nextEvent(); + assert.deepEqual(res, [ + {type: 'create', path: path.join(ignoreGlobDir, 'test.txt')}, + ]); + }); }); describe('multiple', () => { diff --git a/yarn.lock b/yarn.lock index e680bbc6..8b0c7325 100644 --- a/yarn.lock +++ b/yarn.lock @@ -133,7 +133,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.1, braces@~3.0.2: +braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -561,6 +561,13 @@ is-glob@^4.0.1, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -698,6 +705,14 @@ micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" +micromatch@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -889,6 +904,11 @@ picomatch@^2.2.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"