/**
* @module watcher
*/
import * as path from "node:path";
import chokidar from "chokidar";
import picomatch from "picomatch";
import {isString, isArray, checkType} from "./utils.js";
/**
* @description File watcher with dependency handling.
*/
class Watcher {
/**
* @param {Logger} logger
* @param {Object} rawFileDependencies File depenency tree.
* @return {Watcher}
*/
constructor(logger, rawFileDependencies) {
this.logger = logger;
this._ = new Map();
this.fileDependencies = null;
// We cache compiled globs here
// so we don't need to recompile them again and again.
this.dependencyMatchers = null;
this.updateFileDependencies(rawFileDependencies);
this.queue = [];
this.handling = false;
}
/**
* @private
* @description Parse and reverse dependency tree for faster look up.
* @param {Object} rawFileDependencies File depenency tree.
* @return {Map} Reversed file dependency tree.
*/
reverseFileDependencies(rawFileDependencies) {
// Let's reverse dependency tree for fast look up.
const fileDependencies = new Map();
for (const srcDir in rawFileDependencies) {
fileDependencies.set(srcDir, new Map());
for (const k in rawFileDependencies[srcDir]) {
for (const v of rawFileDependencies[srcDir][k]) {
if (!fileDependencies.get(srcDir).has(v)) {
fileDependencies.get(srcDir).set(v, new Set());
}
fileDependencies.get(srcDir).get(v).add(k);
}
}
}
return fileDependencies;
}
/**
* @private
* @description Compile globs in dependency tree.
* @return {Map} Key is glob pattern string, value is compiled function.
*/
compileDependencyMatchers() {
const dependencyMatchers = new Map();
for (const srcDir of this.fileDependencies.keys()) {
for (const dep of this.fileDependencies.get(srcDir).keys()) {
if (!dependencyMatchers.has(dep)) {
dependencyMatchers.set(dep, picomatch(dep));
}
}
}
return dependencyMatchers;
}
/**
* @description Update file dependencies.
* @param {Object} rawFileDependencies File depenency tree.
*/
updateFileDependencies(rawFileDependencies) {
this.fileDependencies = this.reverseFileDependencies(rawFileDependencies);
this.dependencyMatchers = this.compileDependencyMatchers();
}
/**
* @callback watchCallback
* @description On each time a file added/changed/removed, itself will be
* emitted, and after that, an array of dependency files are also emitted as
* changed, this keeps related files get latest dependency.
* @param {String} srcDir
* @param {Object} srcPaths
* @param {String[]} [srcPaths.added] Added files.
* @param {String[]} [srcPaths.changed] Changed files.
* @param {String[]} [srcPaths.removed] Removed files.
*/
/**
* @description Register handler for dirs.
* @param {(String|String[])} dirs Dirs to watch.
* @param {watchCallback} fn
* @param {Object} [opts] Optional watch parameters. Please notice that
* if you register a dir twice with different opts, the latter will replace
* the former.
* @param {Boolean} [opts.recursive=true] Whether watch files in subdirs.
* @param {Function} [opts.filter] Custom file filter.
*/
register(dirs, fn, opts = {}) {
if (dirs == null) {
return;
}
if (fn == null) {
return;
}
checkType(dirs, "dirs", "Array", "String");
checkType(fn, "fn", "Function");
if (!isArray(dirs) && isString(dirs)) {
dirs = [dirs];
}
if (opts["recursive"] !== false) {
opts["recursive"] = true;
}
// Globs must not contain windows spearators.
if (opts["filter"] == null && opts["customGlob"] != null) {
this.logger.warn(`Hikaru suggests you to use \`${
this.logger.yellow("opts.filter")
}\` instead of \`${
this.logger.yellow("opts.customGlob")
}\` because it's deprecated!`);
opts["filter"] = picomatch(opts["customGlob"]);
}
if (opts["filter"] == null && !opts["recursive"]) {
opts["filter"] = picomatch("./*");
}
for (const srcDir of dirs) {
let handler;
if (this._.has(srcDir)) {
handler = this._.get(srcDir);
if (handler["watcher"] != null) {
handler["watcher"].close();
}
} else {
handler = {"watcher": null, "fns": new Set()};
this._.set(srcDir, handler);
}
handler["fns"].add(fn);
const absdir = path.resolve(srcDir);
// See <https://github.com/paulmillr/chokidar/issues/464>.
const watcherOpts = {"cwd": absdir, "ignoreInitial": true};
if (opts["filter"] != null) {
watcherOpts["ignored"] = (filepath, stats) => {
const relpath = path.relative(absdir, filepath);
// See <https://github.com/paulmillr/chokidar/issues/1350#issuecomment-2350100627>.
//
// You must not ignore dirs, otherwise it won't watch files in those
// dirs, and yes, this also applys to `.`, strange design.
return stats && !stats.isDirectory() && !opts["filter"](relpath);
};
}
const watcher = chokidar.watch(".", watcherOpts);
handler["watcher"] = watcher;
for (const event of ["add", "change", "unlink"]) {
watcher.on(event, (srcPath) => {
this.logger.debug(
`Hikaru is handling event \`${
this.logger.blue(event)
}\` from \`${
this.logger.cyan(path.join(srcDir, srcPath))
}\`...`
);
const i = this.queue.findIndex((p) => {
return p["srcDir"] === srcDir && p["srcPath"] === srcPath;
});
if (i !== -1) {
// We have a pending event for this file, just replace it.
this.queue[i]["type"] = event;
} else {
this.queue.push({event, srcDir, srcPath});
}
setImmediate(this.handleEvents.bind(this));
});
}
}
}
/**
* @private
* @description Look up dependency tree recursively to collect all affected
* files.
* @return {Set}
*/
getDependencies(srcDir, srcPath, checkedPaths = new Set()) {
const res = new Set();
if (this.fileDependencies.has(srcDir)) {
for (const dep of this.fileDependencies.get(srcDir).keys()) {
// Check exact match first to save time. All deps are compiled so it is
// safe to get matchers without checking.
if (srcPath === dep || this.dependencyMatchers.get(dep)(srcPath)) {
checkedPaths.add(srcPath);
const subset = this.fileDependencies.get(srcDir).get(dep);
for (const e of subset) {
res.add(e);
}
}
}
}
for (const p of res) {
// Break circular dependencies here.
if (checkedPaths.has(p)) {
continue;
}
// I'm not good at handling recursive functions,
// but this time it's different! I did a good job!
const subres = this.getDependencies(srcDir, p, checkedPaths);
for (const e of subres) {
res.add(e);
}
}
return res;
}
/**
* @private
*/
handleEvents() {
if (this.queue.length === 0 || this.handling) {
return;
}
this.handling = true;
// Don't merge different events' files here, some maybe conflict,
// e.g. A is changed and then removed.
let e;
while ((e = this.queue.shift()) != null) {
if (!this._.has(e["srcDir"])) {
continue;
}
const srcPaths = {"added": [], "changed": [], "removed": []};
if (e["event"] === "add") {
srcPaths["added"].push(e["srcPath"]);
} else if (e["event"] === "unlink") {
srcPaths["removed"].push(e["srcPath"]);
} else {
srcPaths["changed"].push(e["srcPath"]);
}
// Call changed on all dependencies.
const set = this.getDependencies(e["srcDir"], e["srcPath"]);
// Break potential circular dependencies.
set.delete(e["srcPath"]);
srcPaths["changed"].push(...set);
for (const fn of this._.get(e["srcDir"])["fns"]) {
fn(e["srcDir"], srcPaths);
}
}
this.handling = false;
}
/**
* @description Unregister dirs.
* @param {(String|String[])} dirs Dirs to stop watching.
*/
unregister(dirs) {
if (dirs == null) {
return;
}
checkType(dirs, "dirs", "Array", "String");
if (!isArray(dirs) && isString(dirs)) {
dirs = [dirs];
}
for (const srcDir of dirs) {
if (!this._.has(srcDir)) {
continue;
}
if (this._.get(srcDir)["watcher"] != null) {
this._.get(srcDir)["watcher"].close();
}
this._.delete(srcDir);
}
}
/**
* @description Unregister all dirs.
*/
close() {
this.unregister([...this._.keys()]);
}
}
export default Watcher;