--- /dev/null
+var fs = require('fs')
+var glob = require('glob')
+var mm = require('minimatch')
+var q = require('q')
+
+var helper = require('./helper')
+var log = require('./logger').create('watcher')
+
+var createWinGlob = function (realGlob) {
+ return function (pattern, options, done) {
+ realGlob(pattern, options, function (err, results) {
+ done(err, results.map(helper.normalizeWinPath))
+ })
+ }
+}
+
+var findBucketByPath = function (buckets, path) {
+ for (var i = 0; i < buckets.length; i++) {
+ for (var j = 0; j < buckets[i].length; j++) {
+ if (buckets[i][j].originalPath === path) {
+ return [i, j]
+ }
+ }
+ }
+}
+
+if (process.platform === 'win32') {
+ glob = createWinGlob(glob)
+}
+
+var File = function (path, mtime) {
+ // used for serving (processed path, eg some/file.coffee -> some/file.coffee.js)
+ this.path = path
+
+ // original absolute path, id of the file
+ this.originalPath = path
+
+ // where the content is stored (processed)
+ this.contentPath = path
+
+ this.mtime = mtime
+ this.isUrl = false
+}
+
+var Url = function (path) {
+ this.path = path
+ this.isUrl = true
+}
+
+Url.prototype.toString = File.prototype.toString = function () {
+ return this.path
+}
+
+var GLOB_OPTS = {
+ // globDebug: true,
+ follow: true,
+ cwd: '/'
+}
+
+var byPath = function (a, b) {
+ if (a.path > b.path) {
+ return 1
+ }
+ if (a.path < b.path) {
+ return -1
+ }
+ return 0
+}
+
+// TODO(vojta): ignore changes (add/change/remove) when in the middle of refresh
+// TODO(vojta): do not glob patterns that are watched (both on init and refresh)
+var List = function (patterns, excludes, emitter, preprocess, batchInterval) {
+ var self = this
+ var pendingDeferred
+ var pendingTimeout
+
+ var errors = []
+
+ var addError = function (path) {
+ if (errors.indexOf(path) === -1) {
+ errors.push(path)
+ }
+ }
+
+ var removeError = function (path) {
+ var idx = errors.indexOf(path)
+
+ if (idx !== -1) {
+ errors.splice(idx, 1)
+ }
+ }
+
+ var resolveFiles = function (buckets) {
+ var uniqueMap = {}
+ var files = {
+ served: [],
+ included: []
+ }
+
+ buckets.forEach(function (bucket, idx) {
+ bucket.sort(byPath).forEach(function (file) {
+ if (!uniqueMap[file.path]) {
+ if (patterns[idx].served) {
+ files.served.push(file)
+ }
+
+ if (patterns[idx].included) {
+ files.included.push(file)
+ }
+
+ uniqueMap[file.path] = true
+ }
+ })
+ })
+
+ return files
+ }
+
+ var resolveDeferred = function (files) {
+ clearPendingTimeout()
+
+ if (!errors.length) {
+ pendingDeferred.resolve(files || resolveFiles(self.buckets))
+ } else {
+ pendingDeferred.reject(errors.slice())
+ }
+
+ pendingDeferred = pendingTimeout = null
+ }
+
+ var fireEventAndDefer = function () {
+ clearPendingTimeout()
+
+ if (!pendingDeferred) {
+ pendingDeferred = q.defer()
+ emitter.emit('file_list_modified', pendingDeferred.promise)
+ }
+
+ pendingTimeout = setTimeout(resolveDeferred, batchInterval)
+ }
+
+ var clearPendingTimeout = function () {
+ if (pendingTimeout) {
+ clearTimeout(pendingTimeout)
+ }
+ }
+
+ // re-glob all the patterns
+ this.refresh = function () {
+ // TODO(vojta): cancel refresh if another refresh starts
+ var buckets = self.buckets = new Array(patterns.length)
+
+ var complete = function () {
+ if (buckets !== self.buckets) {
+ return
+ }
+
+ var files = resolveFiles(buckets)
+
+ resolveDeferred(files)
+ log.debug('Resolved files:\n\t' + files.served.join('\n\t'))
+ }
+
+ // TODO(vojta): use some async helper library for this
+ var pending = 0
+ var finish = function () {
+ pending--
+
+ if (!pending) {
+ complete()
+ }
+ }
+
+ errors = []
+
+ if (!pendingDeferred) {
+ pendingDeferred = q.defer()
+ emitter.emit('file_list_modified', pendingDeferred.promise)
+ }
+
+ clearPendingTimeout()
+
+ patterns.forEach(function (patternObject, i) {
+ var pattern = patternObject.pattern
+
+ if (helper.isUrlAbsolute(pattern)) {
+ buckets[i] = [new Url(pattern)]
+ return
+ }
+
+ pending++
+ glob(pattern, GLOB_OPTS, function (err, resolvedFiles) {
+ if (err) log.warn(err)
+
+ var matchedAndNotIgnored = 0
+
+ buckets[i] = []
+
+ if (!resolvedFiles.length) {
+ log.warn('Pattern "%s" does not match any file.', pattern)
+ return finish()
+ }
+
+ // stat each file to get mtime and isDirectory
+ resolvedFiles.forEach(function (path) {
+ var matchExclude = function (excludePattern) {
+ return mm(path, excludePattern)
+ }
+
+ if (excludes.some(matchExclude)) {
+ log.debug('Excluded file "%s"', path)
+ return
+ }
+
+ pending++
+ matchedAndNotIgnored++
+ fs.stat(path, function (error, stat) {
+ if (error) {
+ log.debug('An error occured while reading "%s"', path)
+ finish()
+ } else {
+ if (!stat.isDirectory()) {
+ // TODO(vojta): reuse file objects
+ var file = new File(path, stat.mtime)
+
+ preprocess(file, function (err) {
+ buckets[i].push(file)
+
+ if (err) {
+ addError(path)
+ }
+
+ finish()
+ })
+ } else {
+ log.debug('Ignored directory "%s"', path)
+ finish()
+ }
+ }
+ })
+ })
+
+ if (!matchedAndNotIgnored) {
+ log.warn('All files matched by "%s" were excluded.', pattern)
+ }
+
+ finish()
+ })
+ })
+
+ if (!pending) {
+ process.nextTick(complete)
+ }
+
+ return pendingDeferred.promise
+ }
+
+ // set new patterns and excludes
+ // and re-glob
+ this.reload = function (newPatterns, newExcludes) {
+ patterns = newPatterns
+ excludes = newExcludes
+
+ return this.refresh()
+ }
+
+ /**
+ * Adds a new file into the list (called by watcher)
+ * - ignore excluded files
+ * - ignore files that are already in the list
+ * - get mtime (by stat)
+ * - fires "file_list_modified"
+ */
+ this.addFile = function (path, done) {
+ var buckets = this.buckets
+ var i, j
+
+ // sorry, this callback is just for easier testing
+ done = done || function () {}
+
+ // check excludes
+ for (i = 0; i < excludes.length; i++) {
+ if (mm(path, excludes[i])) {
+ log.debug('Add file "%s" ignored. Excluded by "%s".', path, excludes[i])
+ return done()
+ }
+ }
+
+ for (i = 0; i < patterns.length; i++) {
+ if (mm(path, patterns[i].pattern)) {
+ for (j = 0; j < buckets[i].length; j++) {
+ if (buckets[i][j].originalPath === path) {
+ log.debug('Add file "%s" ignored. Already in the list.', path)
+ return done()
+ }
+ }
+
+ break
+ }
+ }
+
+ if (i >= patterns.length) {
+ log.debug('Add file "%s" ignored. Does not match any pattern.', path)
+ return done()
+ }
+
+ var addedFile = new File(path)
+ buckets[i].push(addedFile)
+
+ clearPendingTimeout()
+
+ return fs.stat(path, function (err, stat) {
+ if (err) log.warn(err)
+
+ // in the case someone refresh() the list before stat callback
+ if (self.buckets === buckets) {
+ addedFile.mtime = stat.mtime
+
+ return preprocess(addedFile, function (err) {
+ // TODO(vojta): ignore if refresh/reload happens
+ log.info('Added file "%s".', path)
+
+ if (err) {
+ addError(path)
+ }
+
+ fireEventAndDefer()
+ done()
+ })
+ }
+
+ return done()
+ })
+ }
+
+ /**
+ * Update mtime of a file (called by watcher)
+ * - ignore if file is not in the list
+ * - ignore if mtime has not changed
+ * - fire "file_list_modified"
+ */
+ this.changeFile = function (path, done) {
+ var buckets = this.buckets
+
+ // sorry, this callback is just for easier testing
+ done = done || function () {}
+
+ var indices = findBucketByPath(buckets, path) || []
+
+ var i = indices[0]
+ var j = indices[1]
+
+ if (i === undefined || !buckets[i]) {
+ log.debug('Changed file "%s" ignored. Does not match any file in the list.', path)
+ return done()
+ }
+
+ var changedFile = buckets[i][j]
+ return fs.stat(path, function (err, stat) {
+ // https://github.com/paulmillr/chokidar/issues/11
+ if (err || !stat) {
+ return self.removeFile(path, done)
+ }
+
+ if (self.buckets === buckets && stat.mtime > changedFile.mtime) {
+ log.info('Changed file "%s".', path)
+ changedFile.mtime = stat.mtime
+ // TODO(vojta): THIS CAN MAKE FILES INCONSISTENT
+ // if batched change is resolved before preprocessing is finished, the file can be in
+ // inconsistent state, when the promise is resolved.
+ // Solutions:
+ // 1/ the preprocessor should not change the object in place, but create a copy that would
+ // be eventually merged into the original file, here in the callback, synchronously.
+ // 2/ delay the promise resolution - wait for any changeFile operations to finish
+ return preprocess(changedFile, function (err) {
+ if (err) {
+ addError(path)
+ } else {
+ removeError(path)
+ }
+
+ // TODO(vojta): ignore if refresh/reload happens
+ fireEventAndDefer()
+ done()
+ })
+ }
+
+ return done()
+ })
+ }
+
+ /**
+ * Remove a file from the list (called by watcher)
+ * - ignore if file is not in the list
+ * - fire "file_list_modified"
+ */
+ this.removeFile = function (path, done) {
+ var buckets = this.buckets
+
+ // sorry, this callback is just for easier testing
+ done = done || function () {}
+
+ for (var i = 0; i < buckets.length; i++) {
+ for (var j = 0; j < buckets[i].length; j++) {
+ if (buckets[i][j].originalPath === path) {
+ buckets[i].splice(j, 1)
+ log.info('Removed file "%s".', path)
+ removeError(path)
+ fireEventAndDefer()
+ return done()
+ }
+ }
+ }
+
+ log.debug('Removed file "%s" ignored. Does not match any file in the list.', path)
+ return done()
+ }
+}
+List.$inject = ['config.files', 'config.exclude', 'emitter', 'preprocess',
+ 'config.autoWatchBatchDelay']
+
+// PUBLIC
+exports.List = List
+exports.File = File
+exports.Url = Url