| Leo Repp | 58b9f11 | 2021-11-22 11:57:47 +0100 | [diff] [blame^] | 1 | 'use strict'; |
| 2 | |
| 3 | const fs = require('fs'); |
| 4 | const { Readable } = require('stream'); |
| 5 | const sysPath = require('path'); |
| 6 | const { promisify } = require('util'); |
| 7 | const picomatch = require('picomatch'); |
| 8 | |
| 9 | const readdir = promisify(fs.readdir); |
| 10 | const stat = promisify(fs.stat); |
| 11 | const lstat = promisify(fs.lstat); |
| 12 | const realpath = promisify(fs.realpath); |
| 13 | |
| 14 | /** |
| 15 | * @typedef {Object} EntryInfo |
| 16 | * @property {String} path |
| 17 | * @property {String} fullPath |
| 18 | * @property {fs.Stats=} stats |
| 19 | * @property {fs.Dirent=} dirent |
| 20 | * @property {String} basename |
| 21 | */ |
| 22 | |
| 23 | const BANG = '!'; |
| 24 | const NORMAL_FLOW_ERRORS = new Set(['ENOENT', 'EPERM', 'EACCES', 'ELOOP']); |
| 25 | const FILE_TYPE = 'files'; |
| 26 | const DIR_TYPE = 'directories'; |
| 27 | const FILE_DIR_TYPE = 'files_directories'; |
| 28 | const EVERYTHING_TYPE = 'all'; |
| 29 | const ALL_TYPES = [FILE_TYPE, DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE]; |
| 30 | |
| 31 | const isNormalFlowError = error => NORMAL_FLOW_ERRORS.has(error.code); |
| 32 | |
| 33 | const normalizeFilter = filter => { |
| 34 | if (filter === undefined) return; |
| 35 | if (typeof filter === 'function') return filter; |
| 36 | |
| 37 | if (typeof filter === 'string') { |
| 38 | const glob = picomatch(filter.trim()); |
| 39 | return entry => glob(entry.basename); |
| 40 | } |
| 41 | |
| 42 | if (Array.isArray(filter)) { |
| 43 | const positive = []; |
| 44 | const negative = []; |
| 45 | for (const item of filter) { |
| 46 | const trimmed = item.trim(); |
| 47 | if (trimmed.charAt(0) === BANG) { |
| 48 | negative.push(picomatch(trimmed.slice(1))); |
| 49 | } else { |
| 50 | positive.push(picomatch(trimmed)); |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | if (negative.length > 0) { |
| 55 | if (positive.length > 0) { |
| 56 | return entry => |
| 57 | positive.some(f => f(entry.basename)) && !negative.some(f => f(entry.basename)); |
| 58 | } |
| 59 | return entry => !negative.some(f => f(entry.basename)); |
| 60 | } |
| 61 | return entry => positive.some(f => f(entry.basename)); |
| 62 | } |
| 63 | }; |
| 64 | |
| 65 | class ReaddirpStream extends Readable { |
| 66 | static get defaultOptions() { |
| 67 | return { |
| 68 | root: '.', |
| 69 | /* eslint-disable no-unused-vars */ |
| 70 | fileFilter: (path) => true, |
| 71 | directoryFilter: (path) => true, |
| 72 | /* eslint-enable no-unused-vars */ |
| 73 | type: FILE_TYPE, |
| 74 | lstat: false, |
| 75 | depth: 2147483648, |
| 76 | alwaysStat: false |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | constructor(options = {}) { |
| 81 | super({ |
| 82 | objectMode: true, |
| 83 | autoDestroy: true, |
| 84 | highWaterMark: options.highWaterMark || 4096 |
| 85 | }); |
| 86 | const opts = { ...ReaddirpStream.defaultOptions, ...options }; |
| 87 | const { root, type } = opts; |
| 88 | |
| 89 | this._fileFilter = normalizeFilter(opts.fileFilter); |
| 90 | this._directoryFilter = normalizeFilter(opts.directoryFilter); |
| 91 | |
| 92 | const statMethod = opts.lstat ? lstat : stat; |
| 93 | // Use bigint stats if it's windows and stat() supports options (node 10+). |
| 94 | if (process.platform === 'win32' && stat.length === 3) { |
| 95 | this._stat = path => statMethod(path, { bigint: true }); |
| 96 | } else { |
| 97 | this._stat = statMethod; |
| 98 | } |
| 99 | |
| 100 | this._maxDepth = opts.depth; |
| 101 | this._wantsDir = [DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE].includes(type); |
| 102 | this._wantsFile = [FILE_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE].includes(type); |
| 103 | this._wantsEverything = type === EVERYTHING_TYPE; |
| 104 | this._root = sysPath.resolve(root); |
| 105 | this._isDirent = ('Dirent' in fs) && !opts.alwaysStat; |
| 106 | this._statsProp = this._isDirent ? 'dirent' : 'stats'; |
| 107 | this._rdOptions = { encoding: 'utf8', withFileTypes: this._isDirent }; |
| 108 | |
| 109 | // Launch stream with one parent, the root dir. |
| 110 | this.parents = [this._exploreDir(root, 1)]; |
| 111 | this.reading = false; |
| 112 | this.parent = undefined; |
| 113 | } |
| 114 | |
| 115 | async _read(batch) { |
| 116 | if (this.reading) return; |
| 117 | this.reading = true; |
| 118 | |
| 119 | try { |
| 120 | while (!this.destroyed && batch > 0) { |
| 121 | const { path, depth, files = [] } = this.parent || {}; |
| 122 | |
| 123 | if (files.length > 0) { |
| 124 | const slice = files.splice(0, batch).map(dirent => this._formatEntry(dirent, path)); |
| 125 | for (const entry of await Promise.all(slice)) { |
| 126 | if (this.destroyed) return; |
| 127 | |
| 128 | const entryType = await this._getEntryType(entry); |
| 129 | if (entryType === 'directory' && this._directoryFilter(entry)) { |
| 130 | if (depth <= this._maxDepth) { |
| 131 | this.parents.push(this._exploreDir(entry.fullPath, depth + 1)); |
| 132 | } |
| 133 | |
| 134 | if (this._wantsDir) { |
| 135 | this.push(entry); |
| 136 | batch--; |
| 137 | } |
| 138 | } else if ((entryType === 'file' || this._includeAsFile(entry)) && this._fileFilter(entry)) { |
| 139 | if (this._wantsFile) { |
| 140 | this.push(entry); |
| 141 | batch--; |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | } else { |
| 146 | const parent = this.parents.pop(); |
| 147 | if (!parent) { |
| 148 | this.push(null); |
| 149 | break; |
| 150 | } |
| 151 | this.parent = await parent; |
| 152 | if (this.destroyed) return; |
| 153 | } |
| 154 | } |
| 155 | } catch (error) { |
| 156 | this.destroy(error); |
| 157 | } finally { |
| 158 | this.reading = false; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | async _exploreDir(path, depth) { |
| 163 | let files; |
| 164 | try { |
| 165 | files = await readdir(path, this._rdOptions); |
| 166 | } catch (error) { |
| 167 | this._onError(error); |
| 168 | } |
| 169 | return {files, depth, path}; |
| 170 | } |
| 171 | |
| 172 | async _formatEntry(dirent, path) { |
| 173 | let entry; |
| 174 | try { |
| 175 | const basename = this._isDirent ? dirent.name : dirent; |
| 176 | const fullPath = sysPath.resolve(sysPath.join(path, basename)); |
| 177 | entry = {path: sysPath.relative(this._root, fullPath), fullPath, basename}; |
| 178 | entry[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath); |
| 179 | } catch (err) { |
| 180 | this._onError(err); |
| 181 | } |
| 182 | return entry; |
| 183 | } |
| 184 | |
| 185 | _onError(err) { |
| 186 | if (isNormalFlowError(err) && !this.destroyed) { |
| 187 | this.emit('warn', err); |
| 188 | } else { |
| 189 | this.destroy(err); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | async _getEntryType(entry) { |
| 194 | // entry may be undefined, because a warning or an error were emitted |
| 195 | // and the statsProp is undefined |
| 196 | const stats = entry && entry[this._statsProp]; |
| 197 | if (!stats) { |
| 198 | return; |
| 199 | } |
| 200 | if (stats.isFile()) { |
| 201 | return 'file'; |
| 202 | } |
| 203 | if (stats.isDirectory()) { |
| 204 | return 'directory'; |
| 205 | } |
| 206 | if (stats && stats.isSymbolicLink()) { |
| 207 | const full = entry.fullPath; |
| 208 | try { |
| 209 | const entryRealPath = await realpath(full); |
| 210 | const entryRealPathStats = await lstat(entryRealPath); |
| 211 | if (entryRealPathStats.isFile()) { |
| 212 | return 'file'; |
| 213 | } |
| 214 | if (entryRealPathStats.isDirectory()) { |
| 215 | const len = entryRealPath.length; |
| 216 | if (full.startsWith(entryRealPath) && full.substr(len, 1) === sysPath.sep) { |
| 217 | return this._onError(new Error( |
| 218 | `Circular symlink detected: "${full}" points to "${entryRealPath}"` |
| 219 | )); |
| 220 | } |
| 221 | return 'directory'; |
| 222 | } |
| 223 | } catch (error) { |
| 224 | this._onError(error); |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | _includeAsFile(entry) { |
| 230 | const stats = entry && entry[this._statsProp]; |
| 231 | |
| 232 | return stats && this._wantsEverything && !stats.isDirectory(); |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * @typedef {Object} ReaddirpArguments |
| 238 | * @property {Function=} fileFilter |
| 239 | * @property {Function=} directoryFilter |
| 240 | * @property {String=} type |
| 241 | * @property {Number=} depth |
| 242 | * @property {String=} root |
| 243 | * @property {Boolean=} lstat |
| 244 | * @property {Boolean=} bigint |
| 245 | */ |
| 246 | |
| 247 | /** |
| 248 | * Main function which ends up calling readdirRec and reads all files and directories in given root recursively. |
| 249 | * @param {String} root Root directory |
| 250 | * @param {ReaddirpArguments=} options Options to specify root (start directory), filters and recursion depth |
| 251 | */ |
| 252 | const readdirp = (root, options = {}) => { |
| 253 | let type = options.entryType || options.type; |
| 254 | if (type === 'both') type = FILE_DIR_TYPE; // backwards-compatibility |
| 255 | if (type) options.type = type; |
| 256 | if (!root) { |
| 257 | throw new Error('readdirp: root argument is required. Usage: readdirp(root, options)'); |
| 258 | } else if (typeof root !== 'string') { |
| 259 | throw new TypeError('readdirp: root argument must be a string. Usage: readdirp(root, options)'); |
| 260 | } else if (type && !ALL_TYPES.includes(type)) { |
| 261 | throw new Error(`readdirp: Invalid type passed. Use one of ${ALL_TYPES.join(', ')}`); |
| 262 | } |
| 263 | |
| 264 | options.root = root; |
| 265 | return new ReaddirpStream(options); |
| 266 | }; |
| 267 | |
| 268 | const readdirpPromise = (root, options = {}) => { |
| 269 | return new Promise((resolve, reject) => { |
| 270 | const files = []; |
| 271 | readdirp(root, options) |
| 272 | .on('data', entry => files.push(entry)) |
| 273 | .on('end', () => resolve(files)) |
| 274 | .on('error', error => reject(error)); |
| 275 | }); |
| 276 | }; |
| 277 | |
| 278 | readdirp.promise = readdirpPromise; |
| 279 | readdirp.ReaddirpStream = ReaddirpStream; |
| 280 | readdirp.default = readdirp; |
| 281 | |
| 282 | module.exports = readdirp; |