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