blob: c5f8447a3a901e10370b6604be5056ad44997c66 [file] [log] [blame]
'use strict';
const fs = require('fs');
const { Readable } = require('stream');
const sysPath = require('path');
const picomatch = require('picomatch');
const { promisify } = require('util');
const [readdir, stat, lstat] = [promisify(fs.readdir), promisify(fs.stat), promisify(fs.lstat)];
const supportsDirent = 'Dirent' in fs;
/**
* @typedef {Object} EntryInfo
* @property {String} path
* @property {String} fullPath
* @property {fs.Stats=} stats
* @property {fs.Dirent=} dirent
* @property {String} basename
*/
const isWindows = process.platform === 'win32';
const supportsBigint = typeof BigInt === 'function';
const BANG = '!';
const NORMAL_FLOW_ERRORS = new Set(['ENOENT', 'EPERM', 'EACCES', 'ELOOP']);
const STAT_OPTIONS_SUPPORT_LENGTH = 3;
const FILE_TYPE = 'files';
const DIR_TYPE = 'directories';
const FILE_DIR_TYPE = 'files_directories';
const EVERYTHING_TYPE = 'all';
const FILE_TYPES = new Set([FILE_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE]);
const DIR_TYPES = new Set([DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE]);
const ALL_TYPES = [FILE_TYPE, DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE];
const isNormalFlowError = errorCode => NORMAL_FLOW_ERRORS.has(errorCode);
const checkBasename = f => f(entry.basename);
const normalizeFilter = filter => {
if (filter === undefined) return;
if (typeof filter === 'function') return filter;
if (typeof filter === 'string') {
const glob = picomatch(filter.trim());
return entry => glob(entry.basename);
}
if (Array.isArray(filter)) {
const positive = [];
const negative = [];
for (const item of filter) {
const trimmed = item.trim();
if (trimmed.charAt(0) === BANG) {
negative.push(picomatch(trimmed.slice(1)));
} else {
positive.push(picomatch(trimmed));
}
}
if (negative.length > 0) {
if (positive.length > 0) {
return entry =>
positive.some(f => f(entry.basename)) && !negative.some(f => f(entry.basename));
} else {
return entry => !negative.some(f => f(entry.basename));
}
} else {
return entry => positive.some(f => f(entry.basename));
}
}
};
class ExploringDir {
constructor(path, depth) {
this.path = path;
this.depth = depth;
}
}
class ReaddirpStream extends Readable {
static get defaultOptions() {
return {
root: '.',
fileFilter: path => true,
directoryFilter: path => true,
type: 'files',
lstat: false,
depth: 2147483648,
alwaysStat: false
};
}
constructor(options = {}) {
super({ objectMode: true, highWaterMark: 1, autoDestroy: true });
const opts = Object.assign({}, ReaddirpStream.defaultOptions, options);
const { root } = opts;
this._fileFilter = normalizeFilter(opts.fileFilter);
this._directoryFilter = normalizeFilter(opts.directoryFilter);
this._statMethod = opts.lstat ? lstat : stat;
this._statOpts = { bigint: isWindows };
this._maxDepth = opts.depth;
this._entryType = opts.type;
this._root = sysPath.resolve(root);
this._isDirent = !opts.alwaysStat && supportsDirent;
this._statsProp = this._isDirent ? 'dirent' : 'stats';
this._readdir_options = { encoding: 'utf8', withFileTypes: this._isDirent };
// Launch stream with one parent, the root dir.
/** @type Array<[string, number]> */
this.parents = [new ExploringDir(root, 0)];
this.filesToRead = 0;
}
async _read() {
do {
// If the stream was destroyed, we must not proceed.
if (this.destroyed) return;
const parent = this.parents.pop();
if (!parent) {
// ...we have files to process; but not directories.
// hence, parent is undefined; and we cannot execute fs.readdir().
// The files are being processed anywhere.
break;
}
await this._exploreDirectory(parent);
} while (!this.isPaused() && !this._isQueueEmpty());
this._endStreamIfQueueIsEmpty();
}
async _exploreDirectory(parent) {
/** @type Array<fs.Dirent|string> */
let files = [];
// To prevent race conditions, we increase counter while awaiting readdir.
this.filesToRead++;
try {
files = await readdir(parent.path, this._readdir_options);
} catch (error) {
if (isNormalFlowError(error.code)) {
this._handleError(error);
} else {
this._handleFatalError(error);
}
}
this.filesToRead--;
// If the stream was destroyed, after readdir is completed
if (this.destroyed) return;
this.filesToRead += files.length;
const entries = await Promise.all(files.map(dirent => this._formatEntry(dirent, parent)));
if (this.destroyed) return;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
this.filesToRead--;
if (!entry) {
continue;
}
if (this._isDirAndMatchesFilter(entry)) {
this._pushNewParentIfLessThanMaxDepth(entry.fullPath, parent.depth + 1);
this._emitPushIfUserWantsDir(entry);
} else if (this._isFileAndMatchesFilter(entry)) {
this._emitPushIfUserWantsFile(entry);
}
}
}
_isStatOptionsSupported() {
return this._statMethod.length === STAT_OPTIONS_SUPPORT_LENGTH;
}
_stat(fullPath) {
if (isWindows && this._isStatOptionsSupported()) {
return this._statMethod(fullPath, this._statOpts);
} else {
return this._statMethod(fullPath);
}
}
async _formatEntry(dirent, parent) {
const basename = this._isDirent ? dirent.name : dirent;
const fullPath = sysPath.resolve(sysPath.join(parent.path, basename));
let stats;
if (this._isDirent) {
stats = dirent;
} else {
try {
stats = await this._stat(fullPath);
} catch (error) {
if (isNormalFlowError(error.code)) {
this._handleError(error);
} else {
this._handleFatalError(error);
}
return;
}
}
const path = sysPath.relative(this._root, fullPath);
/** @type {EntryInfo} */
const entry = { path, fullPath, basename, [this._statsProp]: stats };
return entry;
}
_isQueueEmpty() {
return this.parents.length === 0 && this.filesToRead === 0 && this.readable;
}
_endStreamIfQueueIsEmpty() {
if (this._isQueueEmpty()) {
this.push(null);
}
}
_pushNewParentIfLessThanMaxDepth(parentPath, depth) {
if (depth <= this._maxDepth) {
this.parents.push(new ExploringDir(parentPath, depth));
return true;
} else {
return false;
}
}
_isDirAndMatchesFilter(entry) {
return entry[this._statsProp].isDirectory() && this._directoryFilter(entry);
}
_isFileAndMatchesFilter(entry) {
const stats = entry[this._statsProp];
const isFileType =
(this._entryType === EVERYTHING_TYPE && !stats.isDirectory()) ||
(stats.isFile() || stats.isSymbolicLink());
return isFileType && this._fileFilter(entry);
}
_emitPushIfUserWantsDir(entry) {
if (DIR_TYPES.has(this._entryType)) {
// TODO: Understand why this happens.
const fn = () => {
this.push(entry);
};
if (this._isDirent) setImmediate(fn);
else fn();
}
}
_emitPushIfUserWantsFile(entry) {
if (FILE_TYPES.has(this._entryType)) {
this.push(entry);
}
}
_handleError(error) {
if (!this.destroyed) {
this.emit('warn', error);
}
}
_handleFatalError(error) {
this.destroy(error);
}
}
/**
* @typedef {Object} ReaddirpArguments
* @property {Function=} fileFilter
* @property {Function=} directoryFilter
* @property {String=} type
* @property {Number=} depth
* @property {String=} root
* @property {Boolean=} lstat
* @property {Boolean=} bigint
*/
/**
* Main function which ends up calling readdirRec and reads all files and directories in given root recursively.
* @param {String} root Root directory
* @param {ReaddirpArguments=} options Options to specify root (start directory), filters and recursion depth
*/
const readdirp = (root, options = {}) => {
let type = options['entryType'] || options.type;
if (type === 'both') type = FILE_DIR_TYPE; // backwards-compatibility
if (type) options.type = type;
if (root == null || typeof root === 'undefined') {
throw new Error('readdirp: root argument is required. Usage: readdirp(root, options)');
} else if (typeof root !== 'string') {
throw new Error(`readdirp: root argument must be a string. Usage: readdirp(root, options)`);
} else if (type && !ALL_TYPES.includes(type)) {
throw new Error(`readdirp: Invalid type passed. Use one of ${ALL_TYPES.join(', ')}`);
}
options.root = root;
return new ReaddirpStream(options);
};
const readdirpPromise = (root, options = {}) => {
return new Promise((resolve, reject) => {
const files = [];
readdirp(root, options)
.on('data', entry => files.push(entry))
.on('end', () => resolve(files))
.on('error', error => reject(error));
});
};
readdirp.promise = readdirpPromise;
readdirp.ReaddirpStream = ReaddirpStream;
readdirp.default = readdirp;
module.exports = readdirp;