• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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