311 lines
8.6 KiB
JavaScript
311 lines
8.6 KiB
JavaScript
|
'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)
|
||
|
// set to true if this.path is a symlink, whether follow is true or not
|
||
|
this.isSymbolicLink = opts.isSymbolicLink
|
||
|
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
|
||
|
this.exact = opts.exact
|
||
|
}
|
||
|
|
||
|
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 !== '..' &&
|
||
|
this.ignoreFiles.indexOf(e) !== -1
|
||
|
}
|
||
|
|
||
|
onReaddir (entries) {
|
||
|
this.entries = entries
|
||
|
if (entries.length === 0) {
|
||
|
if (this.includeEmpty) {
|
||
|
this.result.add(this.path.slice(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(rule => {
|
||
|
return new Minimatch(rule.trim(), 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, isSymbolicLink }, then) {
|
||
|
const abs = this.path + '/' + entry
|
||
|
if (!st.isDirectory()) {
|
||
|
if (file) {
|
||
|
this.result.add(abs.slice(this.root.length + 1))
|
||
|
}
|
||
|
then()
|
||
|
} else {
|
||
|
// is a directory
|
||
|
if (dir) {
|
||
|
this.walker(entry, { isSymbolicLink, exact: file || this.filterEntry(entry + '/') }, then)
|
||
|
} else {
|
||
|
then()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
stat ({ entry, file, dir }, then) {
|
||
|
const abs = this.path + '/' + entry
|
||
|
fs.lstat(abs, (lstatErr, lstatResult) => {
|
||
|
if (lstatErr) {
|
||
|
this.emit('error', lstatErr)
|
||
|
} else {
|
||
|
const isSymbolicLink = lstatResult.isSymbolicLink()
|
||
|
if (this.follow && isSymbolicLink) {
|
||
|
fs.stat(abs, (statErr, statResult) => {
|
||
|
if (statErr) {
|
||
|
this.emit('error', statErr)
|
||
|
} else {
|
||
|
this.onstat({ st: statResult, entry, file, dir, isSymbolicLink }, then)
|
||
|
}
|
||
|
})
|
||
|
} else {
|
||
|
this.onstat({ st: lstatResult, entry, file, dir, isSymbolicLink }, then)
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
walkerOpt (entry, opts) {
|
||
|
return {
|
||
|
path: this.path + '/' + entry,
|
||
|
parent: this,
|
||
|
ignoreFiles: this.ignoreFiles,
|
||
|
follow: this.follow,
|
||
|
includeEmpty: this.includeEmpty,
|
||
|
...opts,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
walker (entry, opts, then) {
|
||
|
new Walker(this.walkerOpt(entry, opts)).on('done', then).start()
|
||
|
}
|
||
|
|
||
|
filterEntry (entry, partial, entryBasename) {
|
||
|
let included = true
|
||
|
|
||
|
// this = /a/b/c
|
||
|
// entry = d
|
||
|
// parent /a/b sees c/d
|
||
|
if (this.parent && this.parent.filterEntry) {
|
||
|
const parentEntry = this.basename + '/' + entry
|
||
|
const parentBasename = entryBasename || entry
|
||
|
included = this.parent.filterEntry(parentEntry, partial, parentBasename)
|
||
|
if (!included && !this.exact) {
|
||
|
return false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
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) {
|
||
|
const isRelativeRule = entryBasename && rule.globParts.some(part =>
|
||
|
part.length <= (part.slice(-1)[0] ? 1 : 2)
|
||
|
)
|
||
|
|
||
|
// first, match against /foo/bar
|
||
|
// then, against foo/bar
|
||
|
// then, in the case of partials, match with a /
|
||
|
// then, if also the rule is relative, match against basename
|
||
|
const match = rule.match('/' + entry) ||
|
||
|
rule.match(entry) ||
|
||
|
!!partial && (
|
||
|
rule.match('/' + entry + '/') ||
|
||
|
rule.match(entry + '/') ||
|
||
|
rule.negate && (
|
||
|
rule.match('/' + entry, true) ||
|
||
|
rule.match(entry, true)) ||
|
||
|
isRelativeRule && (
|
||
|
rule.match('/' + entryBasename + '/') ||
|
||
|
rule.match(entryBasename + '/') ||
|
||
|
rule.negate && (
|
||
|
rule.match('/' + entryBasename, true) ||
|
||
|
rule.match(entryBasename, true))))
|
||
|
|
||
|
if (match) {
|
||
|
included = rule.negate
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
})
|
||
|
|
||
|
return included
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class WalkerSync extends Walker {
|
||
|
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
|
||
|
let st = fs.lstatSync(abs)
|
||
|
const isSymbolicLink = st.isSymbolicLink()
|
||
|
if (this.follow && isSymbolicLink) {
|
||
|
st = fs.statSync(abs)
|
||
|
}
|
||
|
|
||
|
// console.error('STAT SYNC', {st, entry, file, dir, isSymbolicLink, then})
|
||
|
this.onstat({ st, entry, file, dir, isSymbolicLink }, then)
|
||
|
}
|
||
|
|
||
|
walker (entry, opts, then) {
|
||
|
new WalkerSync(this.walkerOpt(entry, opts)).start()
|
||
|
then()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const walk = (opts, callback) => {
|
||
|
const p = new Promise((resolve, reject) => {
|
||
|
new Walker(opts).on('done', resolve).on('error', reject).start()
|
||
|
})
|
||
|
return callback ? p.then(res => callback(null, res), callback) : p
|
||
|
}
|
||
|
|
||
|
const walkSync = opts => new WalkerSync(opts).start().result
|
||
|
|
||
|
module.exports = walk
|
||
|
walk.sync = walkSync
|
||
|
walk.Walker = Walker
|
||
|
walk.WalkerSync = WalkerSync
|