3 const Readable = require('stream').Readable;
4 const EventEmitter = require('events').EventEmitter;
5 const path = require('path');
6 const normalizeOptions = require('./normalize-options');
7 const stat = require('./stat');
8 const call = require('./call');
11 * Asynchronously reads the contents of a directory and streams the results
12 * via a {@link stream.Readable}.
14 class DirectoryReader {
16 * @param {string} dir - The absolute or relative directory path to read
17 * @param {object} [options] - User-specified options, if any (see {@link normalizeOptions})
18 * @param {object} internalOptions - Internal options that aren't part of the public API
21 constructor (dir, options, internalOptions) {
22 this.options = options = normalizeOptions(options, internalOptions);
24 // Indicates whether we should keep reading
25 // This is set false if stream.Readable.push() returns false.
26 this.shouldRead = true;
28 // The directories to read
29 // (initialized with the top-level directory)
32 basePath: options.basePath,
33 posixBasePath: options.posixBasePath,
37 // The number of directories that are currently being processed
40 // The data that has been read, but not yet emitted
43 this.stream = new Readable({ objectMode: true });
44 this.stream._read = () => {
45 // Start (or resume) reading
46 this.shouldRead = true;
48 // If we have data in the buffer, then send the next chunk
49 if (this.buffer.length > 0) {
50 this.pushFromBuffer();
53 // If we have directories queued, then start processing the next one
54 if (this.queue.length > 0) {
55 if (this.options.facade.sync) {
56 while (this.queue.length > 0) {
57 this.readNextDirectory();
61 this.readNextDirectory();
70 * Reads the next directory in the queue
72 readNextDirectory () {
73 let facade = this.options.facade;
74 let dir = this.queue.shift();
77 // Read the directory listing
78 call.safe(facade.fs.readdir, dir.path, (err, items) => {
80 // fs.readdir threw an error
81 this.emit('error', err);
82 return this.finishedReadingDirectory();
86 // Process each item in the directory (simultaneously, if async)
89 this.processItem.bind(this, dir),
90 this.finishedReadingDirectory.bind(this, dir)
94 // facade.forEach threw an error
95 // (probably because fs.readdir returned an invalid result)
96 this.emit('error', err2);
97 this.finishedReadingDirectory();
103 * This method is called after all items in a directory have been processed.
105 * NOTE: This does not necessarily mean that the reader is finished, since there may still
106 * be other directories queued or pending.
108 finishedReadingDirectory () {
111 if (this.shouldRead) {
112 // If we have directories queued, then start processing the next one
113 if (this.queue.length > 0 && this.options.facade.async) {
114 this.readNextDirectory();
122 * Determines whether the reader has finished processing all items in all directories.
123 * If so, then the "end" event is fired (via {@Readable#push})
126 if (this.buffer.length === 0 && // The stuff we've already read
127 this.pending === 0 && // The stuff we're currently reading
128 this.queue.length === 0) { // The stuff we haven't read yet
129 // There's no more stuff!
130 this.stream.push(null);
135 * Processes a single item in a directory.
137 * If the item is a directory, and `option.deep` is enabled, then the item will be added
138 * to the directory queue.
140 * If the item meets the filter criteria, then it will be emitted to the reader's stream.
142 * @param {object} dir - A directory object from the queue
143 * @param {string} item - The name of the item (name only, no path)
144 * @param {function} done - A callback function that is called after the item has been processed
146 processItem (dir, item, done) {
147 let stream = this.stream;
148 let options = this.options;
150 let itemPath = dir.basePath + item;
151 let posixPath = dir.posixBasePath + item;
152 let fullPath = path.join(dir.path, item);
154 // If `options.deep` is a number, and we've already recursed to the max depth,
155 // then there's no need to check fs.Stats to know if it's a directory.
156 // If `options.deep` is a function, then we'll need fs.Stats
157 let maxDepthReached = dir.depth >= options.recurseDepth;
159 // Do we need to call `fs.stat`?
161 !maxDepthReached || // we need the fs.Stats to know if it's a directory
162 options.stats || // the user wants fs.Stats objects returned
163 options.recurseFn || // we need fs.Stats for the recurse function
164 options.filterFn || // we need fs.Stats for the filter function
165 EventEmitter.listenerCount(stream, 'file') || // we need the fs.Stats to know if it's a file
166 EventEmitter.listenerCount(stream, 'directory') || // we need the fs.Stats to know if it's a directory
167 EventEmitter.listenerCount(stream, 'symlink'); // we need the fs.Stats to know if it's a symlink
169 // If we don't need stats, then exit early
171 if (this.filter(itemPath, posixPath)) {
172 this.pushOrBuffer({ data: itemPath });
177 // Get the fs.Stats object for this path
178 stat(options.facade.fs, fullPath, (err, stats) => {
180 // fs.stat threw an error
181 this.emit('error', err);
186 // Add the item's path to the fs.Stats object
187 // The base of this path, and its separators are determined by the options
188 // (i.e. options.basePath and options.sep)
189 stats.path = itemPath;
191 // Add depth of the path to the fs.Stats object for use this in the filter function
192 stats.depth = dir.depth;
194 if (this.shouldRecurse(stats, posixPath, maxDepthReached)) {
195 // Add this subdirectory to the queue
198 basePath: itemPath + options.sep,
199 posixBasePath: posixPath + '/',
200 depth: dir.depth + 1,
204 // Determine whether this item matches the filter criteria
205 if (this.filter(stats, posixPath)) {
207 data: options.stats ? stats : itemPath,
208 file: stats.isFile(),
209 directory: stats.isDirectory(),
210 symlink: stats.isSymbolicLink(),
217 // An error occurred while processing the item
218 // (probably during a user-specified function, such as options.deep, options.filter, etc.)
219 this.emit('error', err2);
226 * Pushes the given chunk of data to the stream, or adds it to the buffer,
227 * depending on the state of the stream.
229 * @param {object} chunk
231 pushOrBuffer (chunk) {
232 // Add the chunk to the buffer
233 this.buffer.push(chunk);
235 // If we're still reading, then immediately emit the next chunk in the buffer
236 // (which may or may not be the chunk that we just added)
237 if (this.shouldRead) {
238 this.pushFromBuffer();
243 * Immediately pushes the next chunk in the buffer to the reader's stream.
244 * The "data" event will always be fired (via {@link Readable#push}).
245 * In addition, the "file", "directory", and/or "symlink" events may be fired,
246 * depending on the type of properties of the chunk.
249 let stream = this.stream;
250 let chunk = this.buffer.shift();
254 this.shouldRead = stream.push(chunk.data);
257 this.emit('error', err);
260 // Also emit specific events, based on the type of chunk
261 chunk.file && this.emit('file', chunk.data);
262 chunk.symlink && this.emit('symlink', chunk.data);
263 chunk.directory && this.emit('directory', chunk.data);
267 * Determines whether the given directory meets the user-specified recursion criteria.
268 * If the user didn't specify recursion criteria, then this function will default to true.
270 * @param {fs.Stats} stats - The directory's {@link fs.Stats} object
271 * @param {string} posixPath - The item's POSIX path (used for glob matching)
272 * @param {boolean} maxDepthReached - Whether we've already crawled the user-specified depth
275 shouldRecurse (stats, posixPath, maxDepthReached) {
276 let options = this.options;
278 if (maxDepthReached) {
279 // We've already crawled to the maximum depth. So no more recursion.
282 else if (!stats.isDirectory()) {
283 // It's not a directory. So don't try to crawl it.
286 else if (options.recurseGlob) {
287 // Glob patterns are always tested against the POSIX path, even on Windows
288 // https://github.com/isaacs/node-glob#windows
289 return options.recurseGlob.test(posixPath);
291 else if (options.recurseRegExp) {
292 // Regular expressions are tested against the normal path
293 // (based on the OS or options.sep)
294 return options.recurseRegExp.test(stats.path);
296 else if (options.recurseFn) {
298 // Run the user-specified recursion criteria
299 return options.recurseFn.call(null, stats);
302 // An error occurred in the user's code.
303 // In Sync and Async modes, this will return an error.
304 // In Streaming mode, we emit an "error" event, but continue processing
305 this.emit('error', err);
309 // No recursion function was specified, and we're within the maximum depth.
310 // So crawl this directory.
316 * Determines whether the given item meets the user-specified filter criteria.
317 * If the user didn't specify a filter, then this function will always return true.
319 * @param {string|fs.Stats} value - Either the item's path, or the item's {@link fs.Stats} object
320 * @param {string} posixPath - The item's POSIX path (used for glob matching)
323 filter (value, posixPath) {
324 let options = this.options;
326 if (options.filterGlob) {
327 // Glob patterns are always tested against the POSIX path, even on Windows
328 // https://github.com/isaacs/node-glob#windows
329 return options.filterGlob.test(posixPath);
331 else if (options.filterRegExp) {
332 // Regular expressions are tested against the normal path
333 // (based on the OS or options.sep)
334 return options.filterRegExp.test(value.path || value);
336 else if (options.filterFn) {
338 // Run the user-specified filter function
339 return options.filterFn.call(null, value);
342 // An error occurred in the user's code.
343 // In Sync and Async modes, this will return an error.
344 // In Streaming mode, we emit an "error" event, but continue processing
345 this.emit('error', err);
349 // No filter was specified, so match everything
355 * Emits an event. If one of the event listeners throws an error,
356 * then an "error" event is emitted.
358 * @param {string} eventName
361 emit (eventName, data) {
362 let stream = this.stream;
365 stream.emit(eventName, data);
368 if (eventName === 'error') {
369 // Don't recursively emit "error" events.
370 // If the first one fails, then just throw
374 stream.emit('error', err);
380 module.exports = DirectoryReader;