You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

270 lines
6.9 KiB

'use strict'
const fs = require('fs')
const path = require('path')
const EE = require('events').EventEmitter
const Minimatch = require('minimatch').Minimatch
class Walker extends EE {
constructor (opts) {
opts = opts || {}
super(opts)
this.path = opts.path || process.cwd()
this.basename = path.basename(this.path)
this.ignoreFiles = opts.ignoreFiles || [ '.ignore' ]
this.ignoreRules = {}
this.parent = opts.parent || null
this.includeEmpty = !!opts.includeEmpty
this.root = this.parent ? this.parent.root : this.path
this.follow = !!opts.follow
this.result = this.parent ? this.parent.result : new Set()
this.entries = null
this.sawError = false
}
sort (a, b) {
return a.localeCompare(b, 'en')
}
emit (ev, data) {
let ret = false
if (!(this.sawError && ev === 'error')) {
if (ev === 'error')
this.sawError = true
else if (ev === 'done' && !this.parent) {
data = Array.from(data)
.map(e => /^@/.test(e) ? `./${e}` : e).sort(this.sort)
this.result = data
}
if (ev === 'error' && this.parent)
ret = this.parent.emit('error', data)
else
ret = super.emit(ev, data)
}
return ret
}
start () {
fs.readdir(this.path, (er, entries) =>
er ? this.emit('error', er) : this.onReaddir(entries))
return this
}
isIgnoreFile (e) {
return e !== "." &&
e !== ".." &&
-1 !== this.ignoreFiles.indexOf(e)
}
onReaddir (entries) {
this.entries = entries
if (entries.length === 0) {
if (this.includeEmpty)
this.result.add(this.path.substr(this.root.length + 1))
this.emit('done', this.result)
} else {
const hasIg = this.entries.some(e =>
this.isIgnoreFile(e))
if (hasIg)
this.addIgnoreFiles()
else
this.filterEntries()
}
}
addIgnoreFiles () {
const newIg = this.entries
.filter(e => this.isIgnoreFile(e))
let igCount = newIg.length
const then = _ => {
if (--igCount === 0)
this.filterEntries()
}
newIg.forEach(e => this.addIgnoreFile(e, then))
}
addIgnoreFile (file, then) {
const ig = path.resolve(this.path, file)
fs.readFile(ig, 'utf8', (er, data) =>
er ? this.emit('error', er) : this.onReadIgnoreFile(file, data, then))
}
onReadIgnoreFile (file, data, then) {
const mmopt = {
matchBase: true,
dot: true,
flipNegate: true,
nocase: true
}
const rules = data.split(/\r?\n/)
.filter(line => !/^#|^$/.test(line.trim()))
.map(r => new Minimatch(r, mmopt))
this.ignoreRules[file] = rules
then()
}
filterEntries () {
// at this point we either have ignore rules, or just inheriting
// this exclusion is at the point where we know the list of
// entries in the dir, but don't know what they are. since
// some of them *might* be directories, we have to run the
// match in dir-mode as well, so that we'll pick up partials
// of files that will be included later. Anything included
// at this point will be checked again later once we know
// what it is.
const filtered = this.entries.map(entry => {
// at this point, we don't know if it's a dir or not.
const passFile = this.filterEntry(entry)
const passDir = this.filterEntry(entry, true)
return (passFile || passDir) ? [entry, passFile, passDir] : false
}).filter(e => e)
// now we stat them all
// if it's a dir, and passes as a dir, then recurse
// if it's not a dir, but passes as a file, add to set
let entryCount = filtered.length
if (entryCount === 0) {
this.emit('done', this.result)
} else {
const then = _ => {
if (-- entryCount === 0)
this.emit('done', this.result)
}
filtered.forEach(filt => {
const entry = filt[0]
const file = filt[1]
const dir = filt[2]
this.stat(entry, file, dir, then)
})
}
}
onstat (st, entry, file, dir, then) {
const abs = this.path + '/' + entry
if (!st.isDirectory()) {
if (file)
this.result.add(abs.substr(this.root.length + 1))
then()
} else {
// is a directory
if (dir)
this.walker(entry, then)
else
then()
}
}
stat (entry, file, dir, then) {
const abs = this.path + '/' + entry
fs[this.follow ? 'stat' : 'lstat'](abs, (er, st) => {
if (er)
this.emit('error', er)
else
this.onstat(st, entry, file, dir, then)
})
}
walkerOpt (entry) {
return {
path: this.path + '/' + entry,
parent: this,
ignoreFiles: this.ignoreFiles,
follow: this.follow,
includeEmpty: this.includeEmpty
}
}
walker (entry, then) {
new Walker(this.walkerOpt(entry)).on('done', then).start()
}
filterEntry (entry, partial) {
let included = true
// this = /a/b/c
// entry = d
// parent /a/b sees c/d
if (this.parent && this.parent.filterEntry) {
var pt = this.basename + "/" + entry
included = this.parent.filterEntry(pt, partial)
}
this.ignoreFiles.forEach(f => {
if (this.ignoreRules[f]) {
this.ignoreRules[f].forEach(rule => {
// negation means inclusion
// so if it's negated, and already included, no need to check
// likewise if it's neither negated nor included
if (rule.negate !== included) {
// first, match against /foo/bar
// then, against foo/bar
// then, in the case of partials, match with a /
const match = rule.match('/' + entry) ||
rule.match(entry) ||
(!!partial && (
rule.match('/' + entry + '/') ||
rule.match(entry + '/'))) ||
(!!partial && rule.negate && (
rule.match('/' + entry, true) ||
rule.match(entry, true)))
if (match)
included = rule.negate
}
})
}
})
return included
}
}
class WalkerSync extends Walker {
constructor (opt) {
super(opt)
}
start () {
this.onReaddir(fs.readdirSync(this.path))
return this
}
addIgnoreFile (file, then) {
const ig = path.resolve(this.path, file)
this.onReadIgnoreFile(file, fs.readFileSync(ig, 'utf8'), then)
}
stat (entry, file, dir, then) {
const abs = this.path + '/' + entry
const st = fs[this.follow ? 'statSync' : 'lstatSync'](abs)
this.onstat(st, entry, file, dir, then)
}
walker (entry, then) {
new WalkerSync(this.walkerOpt(entry)).start()
then()
}
}
const walk = (options, callback) => {
const p = new Promise((resolve, reject) => {
new Walker(options).on('done', resolve).on('error', reject).start()
})
return callback ? p.then(res => callback(null, res), callback) : p
}
const walkSync = options => {
return new WalkerSync(options).start().result
}
module.exports = walk
walk.sync = walkSync
walk.Walker = Walker
walk.WalkerSync = WalkerSync