blob: 30df947514dae2bb73bcb6fd0e3db3e046e0818a [file] [log] [blame]
Leo Repp58b9f112021-11-22 11:57:47 +01001'use strict';
2
3const fs = require('fs');
4const { Readable } = require('stream');
5const sysPath = require('path');
6const { promisify } = require('util');
7const picomatch = require('picomatch');
8
9const readdir = promisify(fs.readdir);
10const stat = promisify(fs.stat);
11const lstat = promisify(fs.lstat);
12const 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
23const BANG = '!';
24const NORMAL_FLOW_ERRORS = new Set(['ENOENT', 'EPERM', 'EACCES', 'ELOOP']);
25const FILE_TYPE = 'files';
26const DIR_TYPE = 'directories';
27const FILE_DIR_TYPE = 'files_directories';
28const EVERYTHING_TYPE = 'all';
29const ALL_TYPES = [FILE_TYPE, DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE];
30
31const isNormalFlowError = error => NORMAL_FLOW_ERRORS.has(error.code);
32
33const 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
65class 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 */
252const 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
268const 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
278readdirp.promise = readdirpPromise;
279readdirp.ReaddirpStream = ReaddirpStream;
280readdirp.default = readdirp;
281
282module.exports = readdirp;