filehound.js

import _ from 'lodash';
import Promise from 'bluebird';
import path from 'path';
import File from 'file-js';

import {
  negate,
  compose
} from './functions';

import {
  reducePaths
} from './files';

import {
  fromFirst,
  copy,
  from
} from './arrays';

import {
  isDate,
  isNumber
} from 'unit-compare';

import {
  EventEmitter
} from 'events';

function isDefined(value) {
  return value !== undefined;
}

function flatten(a, b) {
  return a.concat(b);
}

function isRegExpMatch(pattern) {
  return (file) => {
    return new RegExp(pattern).test(file.getName());
  };
}

function cleanExtension(ext) {
  if (_.startsWith(ext, '.')) {
    return ext.slice(1);
  }
  return ext;
}

/** @class */
class FileHound extends EventEmitter {
  constructor() {
    super();
    this._filters = [];
    this._searchPaths = [];
    this._searchPaths.push(process.cwd());
    this._ignoreHiddenDirectories = false;
    this._isMatch = _.noop;
    this._sync = false;
    this._directoriesOnly = false;
    this._includeStats = false;
  }

  /**
   * Static factory method to create an instance of FileHound
   *
   * @static
   * @memberOf FileHound
   * @method
   * create
   * @return FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   */
  static create() {
    return new FileHound();
  }

  /**
   * Returns all matches from one of more FileHound instances
   *
   * @static
   * @memberOf FileHound
   * @method
   * any
   * @return a promise containing all matches. If the Promise fulfils,
   * the fulfilment value is an array of all matching files.
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.any(fh1, fh2);
   */
  static any() {
    const args = from(arguments);
    return Promise.all(args).reduce(flatten, []);
  }

  /**
   * Filters by modifiction time
   *
   * @memberOf FileHound
   * @method
   * modified
   * @param {string} dateExpression - date expression
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .modified("< 2 days")
   *   .find()
   *   .each(console.log);
   */
  modified(pattern) {
    this.addFilter((file) => {
      const modified = file.lastModifiedSync();
      return isDate(modified).assert(pattern);
    });
    return this;
  }

  /**
   * Filters by file access time
   *
   * @memberOf FileHound
   * @method
   * accessed
   * @param {string} dateExpression - date expression
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .accessed("< 10 minutes")
   *   .find()
   *   .each(console.log);
   */
  accessed(pattern) {
    this.addFilter((file) => {
      const accessed = file.lastAccessedSync();
      return isDate(accessed).assert(pattern);
    });
    return this;
  }

  /**
   * Filters change time
   *
   * @memberOf FileHound
   * @instance
   * @method
   * changed
   * @param {string} dateExpression - date expression
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .changed("< 10 minutes")
   *   .find()
   *   .each(console.log);
   */
  changed(pattern) {
    this.addFilter((file) => {
      const changed = file.lastChangedSync();
      return isDate(changed).assert(pattern);
    });
    return this;
  }

  /**
   *
   * @memberOf FileHound
   * @instance
   * @method
   * addFilter
   * @param {function} function - custom filter function
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .addFilter(customFilter)
   *   .find()
   *   .each(console.log);
   */
  addFilter(filter) {
    this._filters.push(filter);
    return this;
  }

  /**
   * Defines the search paths
   *
   * @memberOf FileHound
   * @instance
   * @method
   * paths
   * @param {array} path - array of paths
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .paths("/tmp", "/etc") // or ["/tmp", "/etc"]
   *   .find()
   *   .each(console.log);
   */
  paths() {
    this._searchPaths = _.uniq(from(arguments)).map(path.normalize);
    return this;
  }

  /**
   * Define the search path
   *
   * @memberOf FileHound
   * @instance
   * @method
   * path
   * @param {string} path - path
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .path("/tmp")
   *   .find()
   *   .each(console.log);
   */
  path() {
    return this.paths(fromFirst(arguments));
  }

  /**
   * Ignores files or sub-directories matching pattern
   *
   * @memberOf FileHound
   * @instance
   * @method
   * discard
   * @param {string|array} regex - regex or array of regex
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .discard("*.tmp*")
   *   .find()
   *   .each(console.log);
   */
  discard() {
    const patterns = from(arguments);
    patterns.forEach((pattern) => {
      this.addFilter(negate(isRegExpMatch(pattern)));
    });
    return this;
  }

  /**
   * Filter on file extension
   *
   * @memberOf FileHound
   * @instance
   * @method
   * ext
   * @param {string|array} extensions - extension or an array of extensions
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * let filehound = FileHound.create();
   * filehound
   *   .ext(".json")
   *   .find()
   *   .each(console.log);
   *
   * // array of extensions to filter by
   * filehound = FileHound.create();
   * filehound
   *   .ext([".json", ".txt"])
   *   .find()
   *   .each(console.log);
   *
   * // supports var args
   * filehound = FileHound.create();
   * filehound
   *   .ext(".json", ".txt")
   *   .find()
   *   .each(console.log);
   */
  ext() {
    const extensions = from(arguments).map(cleanExtension);

    this.addFilter((file) => {
      return _.includes(extensions, file.getPathExtension());
    });
    return this;
  }

  /**
   * Filter by file size
   *
   * @memberOf FileHound
   * @instance
   * @method
   * size
   * @param {string} sizeExpression - a size expression
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .size("<10kb")
   *   .find()
   *   .each(console.log);
   */
  size(sizeExpression) {
    this.addFilter((file) => {
      const size = file.sizeSync();
      return isNumber(size).assert(sizeExpression);
    });
    return this;
  }

  /**
   * Filter by zero length files
   *
   * @memberOf FileHound
   * @instance
   * @method
   * isEmpty
   * @param {string} path - path
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .size("<10kb")
   *   .find()
   *   .each(console.log);
   */
  isEmpty() {
    this.size(0);
    return this;
  }

  /**
   * Filter by a file glob
   *
   * @memberOf FileHound
   * @instance
   * @method
   * glob
   * @param {array} glob - array of globs
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .glob(['*tmp*']) // .glob('*tmp*') || .glob('*tmp1*','*tmp2*')
   *   .find()
   *   .each(console.log); // array of files names all containing 'tmp'
   */
  glob() {
    return this.match(from(arguments));
  }

  /**
   * Filter by a file glob
   *
   * @memberOf FileHound
   * @instance
   * @method
   * match
   * @param {array} glob - array of globs
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .match(['*tmp*']) // .match('*tmp*')
   *   .find()
   *   .each(console.log); // array of files names all containing 'tmp'
   */
  match(globPatterns) {
    if (_.isArray(globPatterns)) {
      this.addFilter((file) => {
        const isMatch = globPatterns.filter((globPattern) => file.isMatch(globPattern))[0];
        return isMatch ? true : false;
      });
    } else {
      this.addFilter((file) => {
        return file.isMatch(globPatterns);
      });
    }
    return this;
  }

  /**
   * Negates filters
   *
   * @memberOf FileHound
   * @instance
   * @method
   * not
   * @param {string} glob - file glob
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .not()
   *   .glob("*tmp*")
   *   .find()
   *   .each(console.log); // array of files names NOT containing 'tmp'
   */
  not() {
    this.negateFilters = true;
    return this;
  }

  /**
   * Filter to ignore hidden files
   *
   * @memberOf FileHound
   * @instance
   * @method
   * ignoreHiddenFiles
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .ignoreHiddenFiles()
   *   .find()
   *   .each(console.log); // array of files names that are not hidden files
   */
  ignoreHiddenFiles() {
    this.addFilter((file) => {
      return !file.isHiddenSync();
    });
    return this;
  }

  /**
   * Ignore hidden directories
   *
   * @memberOf FileHound
   * @instance
   * @method
   * ignoreHiddenDirectories
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .ignoreHiddenDirectories()
   *   .find()
   *   .each(console.log); // array of files names that are not hidden directories
   */
  ignoreHiddenDirectories() {
    this._ignoreHiddenDirectories = true;
    return this;
  }

  /**
   * Include file stats 
   *
   * @memberOf FileHound
   * @instance
   * @method
   * includeFileStats
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .includeFileStats()
   *   .find()
   *   .each(console.log); // array of file objects containing `path` and `stats` properties
   */
  includeFileStats() {
    this._includeStats = true;
    return this;
  }

  /**
   * Find sub-directories
   *
   * @memberOf FileHound
   * @instance
   * @method
   * directory
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .directory()
   *   .find()
   *   .each(console.log); // array of matching sub-directories
   */
  directory() {
    this._directoriesOnly = true;
    return this;
  }

  /**
   * Find sockets
   *
   * @memberOf FileHound
   * @instance
   * @method
   * socket
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .socket()
   *   .find()
   *   .each(console.log); // array of matching sockets
   */
  socket() {
    this.addFilter((file) => {
      return file.isSocket();
    });
    return this;
  }

  /**
   * Specify the directory search depth. If set to zero, recursive searching
   * will be disabled
   *
   * @memberOf FileHound
   * @instance
   * @method
   * depth
   * @return a FileHound instance
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .depth(0)
   *   .find()
   *   .each(console.log); // array of files names only in the current directory
   */
  depth(depth) {
    this.maxDepth = depth;
    return this;
  }

  /**
   * Asynchronously executes a file search.
   *
   * @memberOf FileHound
   * @instance
   * @method
   * find
   * @param {function} function - Optionally accepts a callback function
   * @return Returns a Promise of all matches. If the Promise fulfils,
   * the fulfilment value is an array of all matching files
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * filehound
   *   .find()
   *   .each(console.log);
   *
   * // using a callback
   * filehound
   *   .find((err, files) => {
   *      if (err) return console.error(err);
   *
   *      console.log(files);
   *   });
   */
  find(cb) {
    this._initFilters();

    const searchAsync = this._searchAsync.bind(this);
    const searches = Promise.map(this.getSearchPaths(), searchAsync);

    return Promise
      .all(searches)
      .reduce(flatten)
      .map(this.formatResult.bind(this))
      .catch((e) => {
        this.emit('error', e);
        throw e;
      })
      .finally(() => {
        this.emit('end');
      })
      .asCallback(cb);
  }

  /**
   * Synchronously executes a file search.
   *
   * @memberOf FileHound
   * @instance
   * @method
   * findSync
   * @return Returns an array of all matching files
   * @example
   * import FileHound from 'filehound';
   *
   * const filehound = FileHound.create();
   * const files = filehound.findSync();
   * console.log(files);
   *
   */
  findSync() {
    this._initFilters();

    const searchSync = this._searchSync.bind(this);

    return this.getSearchPaths()
      .map(searchSync)
      .reduce(flatten)
      .map(this.formatResult.bind(this));
  }

  _atMaxDepth(root, dir) {
    const depth = dir.getDepthSync() - root.getDepthSync();
    return isDefined(this.maxDepth) && depth > this.maxDepth;
  }

  _shouldFilterDirectory(root, dir) {
    return this._atMaxDepth(root, dir) ||
      (this._ignoreHiddenDirectories && dir.isHiddenSync());
  }

  _newMatcher() {
    const isMatch = compose(this._filters);
    if (this.negateFilters) {
      return negate(isMatch);
    }
    return isMatch;
  }

  _initFilters() {
    this._isMatch = this._newMatcher();
  }

  _searchSync(dir) {
    this._sync = true;
    const root = File.create(dir);
    const trackedPaths = [];
    const files = this._search(root, root, trackedPaths);
    return this._directoriesOnly ? trackedPaths.filter(this._isMatch) : files;
  }

  _searchAsync(dir) {
    const root = File.create(dir);
    const trackedPaths = [];
    const pending = this._search(root, root, trackedPaths);

    return pending
      .then((files) => {
        if (this._directoriesOnly) return trackedPaths.filter(this._isMatch);

        files.forEach((file) => {
          this.emit('match', file.getName());
        });
        return files;
      });
  }

  _search(root, path, trackedPaths) {
    if (this._shouldFilterDirectory(root, path)) return [];

    const getFiles = this._sync ? path.getFilesSync.bind(path) : path.getFiles.bind(path);
    return getFiles()
      .map((file) => {
        let isDir = false;
        try {
          isDir = file.isDirectorySync();
          // eslint-disable-next-line no-empty
        } catch (e) { }

        if (isDir) {
          if (!this._shouldFilterDirectory(root, file)) trackedPaths.push(file);
          return this._search(root, file, trackedPaths);
        }
        return file;
      })
      .reduce(flatten, [])
      .filter(this._isMatch);
  }

  formatResult(file) {
    if (this._includeStats) {
      return {
        path: file.getName(),
        stats: file._getStatsSync()
      };
    }
    return file.getName();
  }

  getSearchPaths() {
    const paths = isDefined(this.maxDepth) ? this._searchPaths : reducePaths(this._searchPaths);

    return copy(paths);
  }
}

export default FileHound;