1'use strict' 2 3const fs = require('fs') 4const path = require('path') 5const EE = require('events').EventEmitter 6const Minimatch = require('minimatch').Minimatch 7 8class Walker extends EE { 9 constructor (opts) { 10 opts = opts || {} 11 super(opts) 12 this.path = opts.path || process.cwd() 13 this.basename = path.basename(this.path) 14 this.ignoreFiles = opts.ignoreFiles || [ '.ignore' ] 15 this.ignoreRules = {} 16 this.parent = opts.parent || null 17 this.includeEmpty = !!opts.includeEmpty 18 this.root = this.parent ? this.parent.root : this.path 19 this.follow = !!opts.follow 20 this.result = this.parent ? this.parent.result : new Set() 21 this.entries = null 22 this.sawError = false 23 } 24 25 sort (a, b) { 26 return a.localeCompare(b) 27 } 28 29 emit (ev, data) { 30 let ret = false 31 if (!(this.sawError && ev === 'error')) { 32 if (ev === 'error') 33 this.sawError = true 34 else if (ev === 'done' && !this.parent) { 35 data = Array.from(data) 36 .map(e => /^@/.test(e) ? `./${e}` : e).sort(this.sort) 37 this.result = data 38 } 39 40 if (ev === 'error' && this.parent) 41 ret = this.parent.emit('error', data) 42 else 43 ret = super.emit(ev, data) 44 } 45 return ret 46 } 47 48 start () { 49 fs.readdir(this.path, (er, entries) => 50 er ? this.emit('error', er) : this.onReaddir(entries)) 51 return this 52 } 53 54 isIgnoreFile (e) { 55 return e !== "." && 56 e !== ".." && 57 -1 !== this.ignoreFiles.indexOf(e) 58 } 59 60 onReaddir (entries) { 61 this.entries = entries 62 if (entries.length === 0) { 63 if (this.includeEmpty) 64 this.result.add(this.path.substr(this.root.length + 1)) 65 this.emit('done', this.result) 66 } else { 67 const hasIg = this.entries.some(e => 68 this.isIgnoreFile(e)) 69 70 if (hasIg) 71 this.addIgnoreFiles() 72 else 73 this.filterEntries() 74 } 75 } 76 77 addIgnoreFiles () { 78 const newIg = this.entries 79 .filter(e => this.isIgnoreFile(e)) 80 81 let igCount = newIg.length 82 const then = _ => { 83 if (--igCount === 0) 84 this.filterEntries() 85 } 86 87 newIg.forEach(e => this.addIgnoreFile(e, then)) 88 } 89 90 addIgnoreFile (file, then) { 91 const ig = path.resolve(this.path, file) 92 fs.readFile(ig, 'utf8', (er, data) => 93 er ? this.emit('error', er) : this.onReadIgnoreFile(file, data, then)) 94 } 95 96 onReadIgnoreFile (file, data, then) { 97 const mmopt = { 98 matchBase: true, 99 dot: true, 100 flipNegate: true, 101 nocase: true 102 } 103 const rules = data.split(/\r?\n/) 104 .filter(line => !/^#|^$/.test(line.trim())) 105 .map(r => new Minimatch(r, mmopt)) 106 107 this.ignoreRules[file] = rules 108 109 then() 110 } 111 112 filterEntries () { 113 // at this point we either have ignore rules, or just inheriting 114 // this exclusion is at the point where we know the list of 115 // entries in the dir, but don't know what they are. since 116 // some of them *might* be directories, we have to run the 117 // match in dir-mode as well, so that we'll pick up partials 118 // of files that will be included later. Anything included 119 // at this point will be checked again later once we know 120 // what it is. 121 const filtered = this.entries.map(entry => { 122 // at this point, we don't know if it's a dir or not. 123 const passFile = this.filterEntry(entry) 124 const passDir = this.filterEntry(entry, true) 125 return (passFile || passDir) ? [entry, passFile, passDir] : false 126 }).filter(e => e) 127 128 // now we stat them all 129 // if it's a dir, and passes as a dir, then recurse 130 // if it's not a dir, but passes as a file, add to set 131 let entryCount = filtered.length 132 if (entryCount === 0) { 133 this.emit('done', this.result) 134 } else { 135 const then = _ => { 136 if (-- entryCount === 0) 137 this.emit('done', this.result) 138 } 139 filtered.forEach(filt => { 140 const entry = filt[0] 141 const file = filt[1] 142 const dir = filt[2] 143 this.stat(entry, file, dir, then) 144 }) 145 } 146 } 147 148 onstat (st, entry, file, dir, then) { 149 const abs = this.path + '/' + entry 150 if (!st.isDirectory()) { 151 if (file) 152 this.result.add(abs.substr(this.root.length + 1)) 153 then() 154 } else { 155 // is a directory 156 if (dir) 157 this.walker(entry, then) 158 else 159 then() 160 } 161 } 162 163 stat (entry, file, dir, then) { 164 const abs = this.path + '/' + entry 165 fs[this.follow ? 'stat' : 'lstat'](abs, (er, st) => { 166 if (er) 167 this.emit('error', er) 168 else 169 this.onstat(st, entry, file, dir, then) 170 }) 171 } 172 173 walkerOpt (entry) { 174 return { 175 path: this.path + '/' + entry, 176 parent: this, 177 ignoreFiles: this.ignoreFiles, 178 follow: this.follow, 179 includeEmpty: this.includeEmpty 180 } 181 } 182 183 walker (entry, then) { 184 new Walker(this.walkerOpt(entry)).on('done', then).start() 185 } 186 187 filterEntry (entry, partial) { 188 let included = true 189 190 // this = /a/b/c 191 // entry = d 192 // parent /a/b sees c/d 193 if (this.parent && this.parent.filterEntry) { 194 var pt = this.basename + "/" + entry 195 included = this.parent.filterEntry(pt, partial) 196 } 197 198 this.ignoreFiles.forEach(f => { 199 if (this.ignoreRules[f]) { 200 this.ignoreRules[f].forEach(rule => { 201 // negation means inclusion 202 // so if it's negated, and already included, no need to check 203 // likewise if it's neither negated nor included 204 if (rule.negate !== included) { 205 // first, match against /foo/bar 206 // then, against foo/bar 207 // then, in the case of partials, match with a / 208 const match = rule.match('/' + entry) || 209 rule.match(entry) || 210 (!!partial && ( 211 rule.match('/' + entry + '/') || 212 rule.match(entry + '/'))) || 213 (!!partial && rule.negate && ( 214 rule.match('/' + entry, true) || 215 rule.match(entry, true))) 216 217 if (match) 218 included = rule.negate 219 } 220 }) 221 } 222 }) 223 224 return included 225 } 226} 227 228class WalkerSync extends Walker { 229 constructor (opt) { 230 super(opt) 231 } 232 233 start () { 234 this.onReaddir(fs.readdirSync(this.path)) 235 return this 236 } 237 238 addIgnoreFile (file, then) { 239 const ig = path.resolve(this.path, file) 240 this.onReadIgnoreFile(file, fs.readFileSync(ig, 'utf8'), then) 241 } 242 243 stat (entry, file, dir, then) { 244 const abs = this.path + '/' + entry 245 const st = fs[this.follow ? 'statSync' : 'lstatSync'](abs) 246 this.onstat(st, entry, file, dir, then) 247 } 248 249 walker (entry, then) { 250 new WalkerSync(this.walkerOpt(entry)).start() 251 then() 252 } 253} 254 255const walk = (options, callback) => { 256 const p = new Promise((resolve, reject) => { 257 new Walker(options).on('done', resolve).on('error', reject).start() 258 }) 259 return callback ? p.then(res => callback(null, res), callback) : p 260} 261 262const walkSync = options => { 263 return new WalkerSync(options).start().result 264} 265 266module.exports = walk 267walk.sync = walkSync 268walk.Walker = Walker 269walk.WalkerSync = WalkerSync 270