UNPKG

glomp

Version:

Lightweight, clearly-defined alternative to file glob strings

512 lines (511 loc) 20.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Glomp = void 0; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const os_1 = __importDefault(require("os")); const parallel_park_1 = require("parallel-park"); function resolvePath(somePath, rootDir) { let resolvedPath = somePath; if (!path_1.default.isAbsolute(somePath)) { resolvedPath = path_1.default.resolve(rootDir, somePath); } return resolvedPath.replace(/\/$/, ""); } function clone(someGlomp) { const newGlomp = new Glomp(); newGlomp.rules = someGlomp.rules.slice(); return newGlomp; } /** * An object that traverses the filesystem looking for files that * match a user-defined set of rules. Use the methods on the `Glomp` * instance to add filtering rules, then run `findMatches` or * `findMatchesSync` to search the filesystem for files that match * those filtering rules. */ class Glomp { constructor() { this.rules = []; } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that only files within the specified directory should be * included in the results from `findMatches` or `findMatchesSync`. * * If `someDir` isn't an absolute path, it will be resolved into * an absolute path by using the Glomp's rootDir property as * the directory to resolve from. If this isn't desired, pass * in an absolute path instead. */ withinDir(someDir) { return this.customRule((info) => { const dir = resolvePath(someDir, info.rootDir); if (info.isDir) { return (dir.startsWith(info.absolutePath) || info.absolutePath.startsWith(dir)); } else { return info.absolutePath.startsWith(dir); } }, `withinDir(${JSON.stringify(someDir)})`); } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that files within the specified directory should _NOT_ be * included in the results from `findMatches` or `findMatchesSync`. * * If `someDir` isn't an absolute path, it will be resolved into * an absolute path by using the Glomp's rootDir property as * the directory to resolve from. If this isn't desired, pass * in an absolute path instead. */ excludeDir(someDir) { return this.customRule((info) => { const dir = resolvePath(someDir, info.rootDir); return !info.absolutePath.startsWith(dir); }, `excludeDir(${JSON.stringify(someDir)})`); } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that only files with the specified filetype extension should * be included in the results from `findMatches` or `findMatchesSync`. */ withExtension(extension) { let resolvedExtension = extension; if (!extension.startsWith(".")) { resolvedExtension = "." + resolvedExtension; } return this.customRule((info) => { if (info.isDir) return true; return info.absolutePath.endsWith(extension); }, `withExtension(${JSON.stringify(extension)})`); } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that files with the specified filetype extension should _NOT_ * be included in the results from `findMatches` or `findMatchesSync`. */ excludeExtension(extension) { return this.and(glomp.withExtension(extension).inverse()); } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that only files which are **immediate children** of the * specified directory should be included in the results from `findMatches` * or `findMatchesSync`. * * This means that files that exist in subdirectories of the specified * directory will _NOT_ be included. If you want to include all files * *including* those in subdirectories, use `withinDir` instead. * * If `someDir` isn't an absolute path, it will be resolved into * an absolute path by using the Glomp's rootDir property as * the directory to resolve from. If this isn't desired, pass * in an absolute path instead. */ immediateChildrenOfDir(someDir) { return this.customRule((info) => { const dir = resolvePath(someDir, info.rootDir); if (info.isDir) { if (dir.startsWith(info.absolutePath)) { // Need to descend past here to find its children return true; } else { return false; } } return path_1.default.dirname(info.absolutePath) === dir; }, `immediateChildrenOfDir(${JSON.stringify(someDir)})`); } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that files which are **immediate children** of the specified * directory should _NOT_ be included in the results from `findMatches` or * `findMatchesSync`. However, files that exist in subdirectories of the * specified directory _will_ be included. If you want to exclude all of * the contents of a directory, including files in subdirectories of that * directory, use `excludeDir` instead. * * If `someDir` isn't an absolute path, it will be resolved into * an absolute path by using the Glomp's rootDir property as * the directory to resolve from. If this isn't desired, pass * in an absolute path instead. */ excludeImmediateChildrenOfDir(someDir) { return this.customRule((info) => { if (info.isDir) return true; const dir = resolvePath(someDir, info.rootDir); return path_1.default.dirname(info.absolutePath) !== dir; }, `excludeImmediateChildrenOfDir(${JSON.stringify(someDir)})`); } /** * Return a new Glomp with all the rules of this Glomp plus a new custom * rule that determines whether paths should be included in the results from * `findMatches` or `findMatchesSync`. * * Rules are functions with this signature: * ```ts * function myRule(info: { * absolutePath: string, * isDir: boolean, * rootDir: string * }): boolean; * ``` * * The rule function will be called repeatedly with absolute paths referring * to files and folders on disk, and should return either true or false. * * When `info.isDir` is false, that means that Glomp is asking you: * * "`info.absolutePath` refers to a file. Should this file be included in the * results from `findMatches` or `findMatchesSync`?"" * * - If the answer is no, return `false`, indicating that this file should * _NOT_ be included in the output. * * - If the answer is yes, return `true`, indicating that this file _should_ * be included in the output. * * When `info.isDir` is true, that means that Glomp is asking you: * * "`info.absolutePath` refers to a directory. Could there be any files in this * directory that should be included in the results from `findMatches` or * `findMatchesSync`?" * * - If the answer is yes, return `true`, indicating that the contents of * the directory should be searched through. * * - If you know that that folder won't have any files you want, then * return `false`, indicating that there is no need to search through the * contents of that firectory. * * `info.rootDir` contains the absolute path to the root directory passed to * `findMatches` or `findMatchesSync`. * * Internally, all the rule-defining methods on a Glomp use the same * rule mechanism as `customRule`. */ customRule(rule, name) { const newGlomp = clone(this); Object.defineProperty(rule, "name", { value: name || rule.name || "<anonymous>", }); newGlomp.rules.push(rule); return newGlomp; } /** * Create a new Glomp by combining the rules in this Glomp with the rules in * another Glomp. * * The new Glomp will only match files that satisfy the rules in both Glomps. * * @param other The other Glomp to combine with. */ and(other) { const newGlomp = clone(this); newGlomp.rules.push(...other.rules); return newGlomp; } /** * Create a new Glomp by combining the rules in this Glomp with the rules in * another Glomp. * * The new Glomp will only match files that satisfy the rules in this Glomp * and DO NOT satisfy the rules in the other Glomp. * * This is the same as .and(other.inverse()), but using it makes your code * easier to read, because the "inverse" part would otherwise be at the * bottom of a long chain. * * @param other The other Glomp to combine with. */ andNot(other) { return this.and(other.inverse()); } /** * Create a new Glomp by combining the rules in this Glomp with the rules in * another Glomp. * * The new Glomp will match files that satisfy the rules in either this Glomp or the other one. * * @param other The other Glomp to combine with. */ or(other) { const newGlomp = new Glomp(); return newGlomp.customRule((info) => { const selfIsHappy = this.rules.every((rule) => rule(info)); if (selfIsHappy) return true; const otherIsHappy = other.rules.every((rule) => rule(info)); return selfIsHappy || otherIsHappy; }, `or(\n ${this.rules.map((rule) => rule.name).join(", ")},\n ${other.rules.map((rule) => rule.name).join(", ")}\n)`); } /** * Create a new Glomp by inverting all the rules in this glomp. * * The new Glomp will exclude files that this Glomp matches. * * @param other The other Glomp to combine with. */ inverse() { const newGlomp = clone(this); newGlomp.rules = this.rules.map((rule) => { const invertedRule = (info) => { // We still want to be able to traverse into directories properly, // so only invert the rule when we're talking about a file. // // TODO: this means inverted rules traverse down unnecessarily in some cases. if (info.isDir) return true; return !rule(info); }; Object.defineProperty(invertedRule, "name", { value: "inversion of " + rule.name, }); return invertedRule; }); return newGlomp; } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that the entire absolute path to a file must match the provided * regular expression. */ withAbsolutePathMatchingRegExp(regexp) { return this.customRule((info) => { if (info.isDir) return true; return regexp.test(info.absolutePath); }, `withAbsolutePathMatchingRegExp(${regexp.toString()})`); } /** * Return a new Glomp with all the rules of this Glomp plus a new rule * specifying that filenames must match the provided regular expression. */ withNameMatchingRegExp(regexp) { return this.customRule((info) => { if (info.isDir) return true; return regexp.test(path_1.default.basename(info.absolutePath)); }, `withNameMatchingRegExp(${regexp.toString()})`); } /** * Asynchronously scan through the specified folder, finding files * that match the rules that have been defined on this Glomp instance. * * To define rules, use any of these methods: * - `withinDir` * - `excludeDir` * - `withExtension` * - `excludeExtension` * - `immediateChildrenOfDir` * - `excludeImmediateChildrenOfDir` * - `withAbsolutePathMatchingRegExp` * - `withNameMatchingRegExp` * - `customRule` * * After the relevant directories have all been searched, the Promise * returned from this function will resolve to an Array of absolute path * strings to any located files that match your rules. * * Note that ONLY PATHS TO FILES WILL BE RETURNED, _NOT_ PATHS TO * FOLDERS! This is an intentional design decision of `glomp`. * * --- * * In order to scan the filesystem quickly, multiple Promises will run * concurrently to scan the filesystem. If you'd like to, you can customize * how many Promises run in parallel by passing an object with a * `concurrency` property on it, set to a number. * * A higher concurrency number will attempt to perform more fs operations at * once, while a lower one will stretch the fs operations out over time, * limiting CPU and disk usage at the expense of clock run-time. * * The concurrency value defaults to the number of CPUs your system has * minus one. */ async findMatches(rootDir, { concurrency = os_1.default.cpus().length - 1, } = {}) { if (!path_1.default.isAbsolute(rootDir)) { throw new Error("findMatches requires an absolute path, not a relative one."); } const searchPaths = [rootDir]; const matches = []; await (0, parallel_park_1.runJobs)(searchPaths, async (dir) => { let dirStats; try { dirStats = await fs_1.default.promises.stat(dir); } catch (err) { // Ignore filesystem errors and keep going return; } const isDir = dirStats.isDirectory(); if (!isDir) { throw new Error("Expected path to be a directory, but it wasn't: " + dir); } let children; try { children = await fs_1.default.promises.readdir(dir); } catch (err) { // Ignore filesystem errors and keep going return; } for (const child of children) { const pathToChild = path_1.default.join(dir, child); let childStats; try { childStats = await fs_1.default.promises.stat(pathToChild); } catch (err) { // Ignore filesystem errors and keep going continue; } if (childStats.isDirectory()) { let shouldTraverse = true; for (const rule of this.rules) { const info = { absolutePath: pathToChild, isDir: true, rootDir, }; shouldTraverse = rule(info); if (this.trace != null) { this.trace(`${rule.name} with ${JSON.stringify(info, null, 2)} -> ${shouldTraverse}`); } if (!shouldTraverse) break; } if (shouldTraverse) { searchPaths.push(pathToChild); } } else { let doesMatch = true; for (const rule of this.rules) { const info = { absolutePath: pathToChild, isDir: false, rootDir, }; doesMatch = rule(info); if (this.trace != null) { this.trace(`${rule.name} with ${JSON.stringify(info, null, 2)} -> ${doesMatch}`); } if (!doesMatch) break; } if (doesMatch) { matches.push(pathToChild); } } } }, { concurrency }); return matches; } /** * Synchronously scans through the specified folder, finding files * that match the rules that have been defined on this Glomp instance. * * To define rules, use any of these methods: * - `withinDir` * - `excludeDir` * - `withExtension` * - `excludeExtension` * - `immediateChildrenOfDir` * - `excludeImmediateChildrenOfDir` * - `customRule` * * After the relevant directories have all been searched, this function will * return an Array of absolute path strings to any located files that match * your rules. * * Note that ONLY PATHS TO FILES WILL BE RETURNED, _NOT_ PATHS TO * FOLDERS! This is an intentional design decision of `glomp`. */ findMatchesSync(rootDir) { if (!path_1.default.isAbsolute(rootDir)) { throw new Error("findMatchesSync requires an absolute path, not a relative one."); } const searchPaths = [rootDir]; const matches = []; const searchPathsIterable = searchPaths[Symbol.iterator](); for (const dir of searchPathsIterable) { let dirStats; try { dirStats = fs_1.default.statSync(dir); } catch (err) { // Ignore filesystem errors and keep going continue; } const isDir = dirStats.isDirectory(); if (!isDir) { throw new Error("Expected path to be a directory, but it wasn't: " + dir); } let children; try { children = fs_1.default.readdirSync(dir); } catch (err) { // Ignore filesystem errors and keep going continue; } for (const child of children) { const pathToChild = path_1.default.join(dir, child); let childStats; try { childStats = fs_1.default.statSync(pathToChild); } catch (err) { // Ignore filesystem errors and keep going continue; } if (childStats.isDirectory()) { let shouldTraverse = true; for (const rule of this.rules) { shouldTraverse = rule({ absolutePath: pathToChild, isDir: true, rootDir, }); if (!shouldTraverse) break; } if (shouldTraverse) { searchPaths.push(pathToChild); } } else { let doesMatch = true; for (const rule of this.rules) { doesMatch = rule({ absolutePath: pathToChild, isDir: false, rootDir, }); if (!doesMatch) break; } if (doesMatch) { matches.push(pathToChild); } } } } return matches; } } exports.Glomp = Glomp; /** * The default Glomp instance, from which all other Glomps can be created. */ const glomp = new Glomp(); exports.default = glomp;