Jump To …

src/tasks/watch/

watcher.coffee


          
fs = require 'fs'
os = require 'os'
rl = require 'readline'
path = require 'path'
Q = require 'q'
Neat = require '../../neat'
Watch = require './watch'
n = Neat.require 'notifications'
{parallel} = Neat.require 'async'
{compile} = require 'coffee-script'
{
  warn, error, puts, yellow,
  green, red, cyan, inverse
} = Neat.require 'utils/logs'
{asyncErrorTrap} = Neat.require 'utils/commands'

existsSync = fs.existsSync or path.existsSync

class Watcher
  constructor: ->
    switch os.platform()
      when 'darwin'
        @notifier = new n.Notifier new n.plugins.Growly
      when 'linux'
        @notifier = new n.Notifier new n.plugins.NotifySend

    @notifier.notify success: true, title: 'Watchfile', message: 'loaded'

  init: =>
    data = {}
    @watches = {}
    promise = @loadWatchignore()
    .then (ignoreList) =>
      @ignoreList = data.ignoreList = ignoreList
    .then(@indexPaths)
    .then (paths) =>
      {@watchedPaths, @ignoredPaths} = paths
      data = data.merge paths
    .then(@loadWatchfile)
    .then(@evaluateWatchfile)
    .then(@registerWatchers)
    .then ->
      puts green 'Watcher initialized'
      puts yellow "#{data.watchedPaths.length} files watched"
    .then(@initializePlugins)
    .then(@startCLI)
    .then ->
      return data
    .fail (err) ->
      error red err.message
      puts err.stack.join '\n'

    @promise ||= promise

    process.on 'SIGINT', @sigintListener
    process.stdin.on 'keypress', @keypressListener

    promise

  dispose: =>
    promise = Q.fcall =>
      watch.close() for k,watch of @watches
      @watches = null
      @ignoreList = null
      @watchedPaths = null
      @ignoredPaths = null
      @cli.close()
      @cli.removeListener 'line', @lineListener
      @cli.removeListener 'SIGINT', @lineListener
      process.removeListener 'SIGINT', @sigintListener
      process.stdin.removeListener 'keypress', @keypressListener
    promise = promise.then(-> plugin.dispose()) for k,plugin of @plugins
    promise.then => @plugins = null

  isIgnored: (file) ->
    @ignoreList.some (i) -> ///#{Neat.root}/#{i}///.test file

  watcher: (path) =>
    lastTime = 0

    changesSpacedEnough = (time) ->
      result = time - lastTime >= 1000
      lastTime = time
      result

    (action) =>
      time = new Date()
      return unless changesSpacedEnough(time.getTime())

      @pathChanged path, action

  pathChanged: (path, action) ->
    promise = @promise.then =>
      @cliPaused = true
      puts cyan "\r#{inverse " #{action.toUpperCase()}D "} #{path}"
    switch path
      when Neat.resolve('Watchfile'), Neat.resolve('.watchignore')
        promise = promise.then(@dispose).then(@init)
      else
        @plugins.each (name, plugin) =>
          if plugin.match path
            p = plugin.pathChanged path, action
            promise = promise.then =>
              @activePlugin = plugin
              puts cyan "#{inverse " #{name.toUpperCase()} "} #{path}"
            promise = promise.then p

    @promise = promise.then =>
      @cliPaused = false
      @cli.prompt()

  runAll: ->
    promise = @promise.then =>
      @cliPaused = true
      puts cyan "\r#{inverse ' WATCH '} Run all"

    @plugins.each (name, plugin) =>
      promise = promise.then plugin.runAll

    @promise = promise.then =>
      @cliPaused = false
      @cli.prompt()

  enqueue: (promise) ->
    @promise = @promise.then promise

  loadWatchfile: =>
    defer = Q.defer()
    fs.readFile Neat.resolve('Watchfile'), (err, file) ->
      return defer.reject err if err?
      defer.resolve file.toString()

    defer.promise

  initializePlugins: =>
    Q.all(plugin.init(this) for k, plugin of @plugins)

  loadWatchignore: =>
    defer = Q.defer()
    fs.readFile Neat.resolve('.watchignore'), (err, file) ->
      return defer.reject err if err?
      defer.resolve file.toString().split('\n').select (s) -> s.length > 0

    defer.promise

  indexPaths: =>
    defer = Q.defer()
    watchedPaths = []
    ignoredPaths = []
    search = (root) => (cb) =>
      return ignoredPaths.push(root) and cb?() if @isIgnored root

      watchedPaths.push root
      fs.lstat root, (err, stats) ->
        if stats.isDirectory()
          fs.readdir root, (err, paths) ->
            parallel (search(path.resolve root, p) for p in paths), cb
        else
          cb?()

    search(Neat.root) ->
      defer.resolve {watchedPaths, ignoredPaths}
    defer.promise

  watchDirectory: (directory, watcher) =>
    return unless existsSync directory
    @watches[directory] = fs.watch directory, (action) =>
      @enqueue Q.fcall =>
        files = try
          fs.readdirSync directory
        catch err
          []

        for file in files
          file = path.resolve directory, file

          unless file of @watches or @isIgnored file

FIXME In some cases, when exiting, an exception is raised here due to an ENOENT error. Currently the exception is silently handled.

            try
              stats = fs.lstatSync file
              if stats.isDirectory()
                @watchDirectory file, watcher
              else
                w = watcher(file)
                w 'create', file
                @rewatch file, w

              @watchedPaths.push file

      , 0

  rewatch: (file, watcher) =>
    if @watches[file]?
      @watches[file].close()

    @watches[file] = fs.watch file, (action) =>
      exist = existsSync file
      action = 'delete' unless exist

      watcher action, file
      if exist
        @rewatch file, watcher
      else
        @watchedPaths

  registerWatchers: =>
    @watchedPaths.forEach (path) =>
      stats = fs.lstatSync path
      if stats.isDirectory()
        @watchDirectory path, @watcher
      else
        @rewatch path, @watcher(path)

  evaluateWatchfile: (watchfile) =>
    @plugins = {}
    currentWatcher = null
    currentGroup = null

    plugins = Neat.require 'watchers'

    watcher = (name, options, block) =>
      [options, block] = [block, options] if typeof options is 'function'
      options ||= {}
      if name of plugins
        @plugins[name] ?= new plugins[name] options, this
        currentWatcher = name
        block.call()
      else
        warn yellow "Unregistered plugin #{name}"

    group = (name, block) =>
      currentGroup = name
      block.call()

    watch = (pattern, options, block) =>
      [options, block] = [block, options] if typeof options is 'function'
      options ||= {}

      re = ///#{Neat.root}/#{pattern}///
      @plugins[currentWatcher].watch new Watch re, options, block

    eval compile watchfile, bare: true

  startCLI: =>
    @cli = rl.createInterface
      input: process.stdin
      output: process.stdout

completer: -> console.log arguments

    @cli.setPrompt 'neat: '
    @cli.on 'line', @lineListener
    @cli.on 'SIGINT', @sigintListener
    @cli.prompt()

  toString: -> "[object Watcher]"

  sigintListener: =>
    if @activePlugin?.isPending()
      puts yellow "\n#{@activePlugin} interrupted"
      @activePlugin.kill('SIGINT')
    else
      process.exit(1)

  keypressListener: (s, key) =>
    if key? and key.ctrl and key.name is 'l'
      process.stdout.write '\u001B[2J\u001B[0;0f'
      @cli.prompt() unless @cliPaused

  lineListener: (line) =>
    unless @cliPaused
      switch line
        when '', 'a', 'all' then @runAll()
        when 'q', 'quit', 'e', 'exit' then process.exit(1)
        when 'h', 'help'
          console.log """
          #{cyan '↩, a, all'}: Run all plugins.
          #{cyan 'h, help'}: Print this message.
          #{cyan 'q, quit, e, exit'}: Kill cake watch.
          """
          @cli.prompt()
        else
          puts red "Unknown command '#{line}'"
          @cli.prompt()

module.exports = Watcher