index.js 8.6 KB

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