listen 0.5.3 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1 -186
  3. data/CONTRIBUTING.md +45 -0
  4. data/{LICENSE → LICENSE.txt} +3 -1
  5. data/README.md +332 -181
  6. data/bin/listen +11 -0
  7. data/lib/listen/adapter/base.rb +129 -0
  8. data/lib/listen/adapter/bsd.rb +107 -0
  9. data/lib/listen/adapter/config.rb +25 -0
  10. data/lib/listen/adapter/darwin.rb +77 -0
  11. data/lib/listen/adapter/linux.rb +108 -0
  12. data/lib/listen/adapter/polling.rb +40 -0
  13. data/lib/listen/adapter/windows.rb +96 -0
  14. data/lib/listen/adapter.rb +32 -201
  15. data/lib/listen/backend.rb +40 -0
  16. data/lib/listen/change.rb +69 -0
  17. data/lib/listen/cli.rb +65 -0
  18. data/lib/listen/directory.rb +93 -0
  19. data/lib/listen/error.rb +11 -0
  20. data/lib/listen/event/config.rb +40 -0
  21. data/lib/listen/event/loop.rb +94 -0
  22. data/lib/listen/event/processor.rb +126 -0
  23. data/lib/listen/event/queue.rb +54 -0
  24. data/lib/listen/file.rb +95 -0
  25. data/lib/listen/fsm.rb +133 -0
  26. data/lib/listen/listener/config.rb +41 -0
  27. data/lib/listen/listener.rb +93 -160
  28. data/lib/listen/logger.rb +36 -0
  29. data/lib/listen/monotonic_time.rb +27 -0
  30. data/lib/listen/options.rb +26 -0
  31. data/lib/listen/queue_optimizer.rb +129 -0
  32. data/lib/listen/record/entry.rb +66 -0
  33. data/lib/listen/record/symlink_detector.rb +41 -0
  34. data/lib/listen/record.rb +123 -0
  35. data/lib/listen/silencer/controller.rb +50 -0
  36. data/lib/listen/silencer.rb +106 -0
  37. data/lib/listen/thread.rb +54 -0
  38. data/lib/listen/version.rb +3 -1
  39. data/lib/listen.rb +40 -32
  40. metadata +87 -38
  41. data/lib/listen/adapters/darwin.rb +0 -85
  42. data/lib/listen/adapters/linux.rb +0 -113
  43. data/lib/listen/adapters/polling.rb +0 -67
  44. data/lib/listen/adapters/windows.rb +0 -87
  45. data/lib/listen/dependency_manager.rb +0 -126
  46. data/lib/listen/directory_record.rb +0 -344
  47. data/lib/listen/multi_listener.rb +0 -121
  48. data/lib/listen/turnstile.rb +0 -28
data/bin/listen ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'listen'
5
+ require 'listen/cli'
6
+
7
+ if !defined?(JRUBY_VERSION) && Signal.list.keys.include?('INT')
8
+ Signal.trap('INT') { Thread.new { Listen.stop } }
9
+ end
10
+
11
+ Listen::CLI.start
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen/options'
4
+ require 'listen/record'
5
+ require 'listen/change'
6
+ require 'listen/thread'
7
+
8
+ module Listen
9
+ module Adapter
10
+ class Base
11
+ attr_reader :options, :config
12
+
13
+ # TODO: only used by tests
14
+ DEFAULTS = {}.freeze
15
+
16
+ def initialize(config)
17
+ @started = false
18
+ @config = config
19
+
20
+ @configured = nil
21
+
22
+ fail 'No directories to watch!' if config.directories.empty?
23
+
24
+ defaults = self.class.const_get('DEFAULTS')
25
+ @options = Listen::Options.new(config.adapter_options, defaults)
26
+ rescue
27
+ _log_exception 'adapter config failed: %s:%s called from: %s', caller
28
+ raise
29
+ end
30
+
31
+ # TODO: it's a separate method as a temporary workaround for tests
32
+ # rubocop:disable Metrics/MethodLength
33
+ def configure
34
+ if @configured
35
+ Listen.logger.warn('Adapter already configured!')
36
+ return
37
+ end
38
+
39
+ @configured = true
40
+
41
+ @callbacks ||= {}
42
+ config.directories.each do |dir|
43
+ callback = @callbacks[dir] || lambda do |event|
44
+ _process_event(dir, event)
45
+ end
46
+ @callbacks[dir] = callback
47
+ _configure(dir, &callback)
48
+ end
49
+
50
+ @snapshots ||= {}
51
+ # TODO: separate config per directory (some day maybe)
52
+ change_config = Change::Config.new(config.queue, config.silencer)
53
+ config.directories.each do |dir|
54
+ record = Record.new(dir, config.silencer)
55
+ snapshot = Change.new(change_config, record)
56
+ @snapshots[dir] = snapshot
57
+ end
58
+ end
59
+ # rubocop:enable Metrics/MethodLength
60
+
61
+ def started?
62
+ @started
63
+ end
64
+
65
+ def start
66
+ configure
67
+
68
+ if started?
69
+ Listen.logger.warn('Adapter already started!')
70
+ return
71
+ end
72
+
73
+ @started = true
74
+
75
+ @run_thread = Listen::Thread.new("run_thread") do
76
+ @snapshots.each_value do |snapshot|
77
+ _timed('Record.build()') { snapshot.record.build }
78
+ end
79
+ _run
80
+ end
81
+ end
82
+
83
+ def stop
84
+ _stop
85
+ config.queue.close # this causes queue.pop to return `nil` to the front-end
86
+ end
87
+
88
+ private
89
+
90
+ def _stop
91
+ @run_thread&.kill
92
+ @run_thread = nil
93
+ end
94
+
95
+ def _timed(title)
96
+ start = MonotonicTime.now
97
+ yield
98
+ diff = MonotonicTime.now - start
99
+ Listen.logger.info format('%s: %.05f seconds', title, diff)
100
+ rescue
101
+ Listen.logger.warn "#{title} crashed: #{$ERROR_INFO.inspect}"
102
+ raise
103
+ end
104
+
105
+ # TODO: allow backend adapters to pass specific invalidation objects
106
+ # e.g. Darwin -> DirRescan, INotify -> MoveScan, etc.
107
+ def _queue_change(type, dir, rel_path, options)
108
+ @snapshots[dir].invalidate(type, rel_path, options)
109
+ end
110
+
111
+ def _log_exception(msg, caller_stack)
112
+ formatted = format(
113
+ msg,
114
+ $ERROR_INFO,
115
+ $ERROR_POSITION * "\n",
116
+ caller_stack * "\n"
117
+ )
118
+
119
+ Listen.logger.error(formatted)
120
+ end
121
+
122
+ class << self
123
+ def usable?
124
+ const_get('OS_REGEXP') =~ RbConfig::CONFIG['target_os']
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Listener implementation for BSD's `kqueue`.
4
+ # @see http://www.freebsd.org/cgi/man.cgi?query=kqueue
5
+ # @see https://github.com/mat813/rb-kqueue/blob/master/lib/rb-kqueue/queue.rb
6
+ #
7
+ module Listen
8
+ module Adapter
9
+ class BSD < Base
10
+ OS_REGEXP = /bsd|dragonfly/i.freeze
11
+
12
+ DEFAULTS = {
13
+ events: [
14
+ :delete,
15
+ :write,
16
+ :extend,
17
+ :attrib,
18
+ :rename
19
+ # :link, :revoke
20
+ ]
21
+ }.freeze
22
+
23
+ BUNDLER_DECLARE_GEM = <<-EOS.gsub(/^ {6}/, '')
24
+ Please add the following to your Gemfile to avoid polling for changes:
25
+ require 'rbconfig'
26
+ if RbConfig::CONFIG['target_os'] =~ /#{OS_REGEXP}/
27
+ gem 'rb-kqueue', '>= 0.2'
28
+ end
29
+ EOS
30
+
31
+ def self.usable?
32
+ return false unless super
33
+ require 'rb-kqueue'
34
+ require 'find'
35
+ true
36
+ rescue LoadError
37
+ Kernel.warn BUNDLER_DECLARE_GEM
38
+ false
39
+ end
40
+
41
+ private
42
+
43
+ def _configure(directory, &callback)
44
+ @worker ||= KQueue::Queue.new
45
+ @callback = callback
46
+ # use Record to make a snapshot of dir, so we
47
+ # can detect new files
48
+ _find(directory.to_s) { |path| _watch_file(path, @worker) }
49
+ end
50
+
51
+ def _run
52
+ @worker.run
53
+ end
54
+
55
+ def _process_event(dir, event)
56
+ full_path = _event_path(event)
57
+ if full_path.directory?
58
+ # Force dir content tracking to kick in, or we won't have
59
+ # names of added files
60
+ _queue_change(:dir, dir, '.', recursive: true)
61
+ elsif full_path.exist?
62
+ path = full_path.relative_path_from(dir)
63
+ _queue_change(:file, dir, path.to_s, change: _change(event.flags))
64
+ end
65
+
66
+ # If it is a directory, and it has a write flag, it means a
67
+ # file has been added so find out which and deal with it.
68
+ # No need to check for removed files, kqueue will forget them
69
+ # when the vfs does.
70
+ _watch_for_new_file(event) if full_path.directory?
71
+ end
72
+
73
+ def _change(event_flags)
74
+ { modified: [:attrib, :extend],
75
+ added: [:write],
76
+ removed: [:rename, :delete] }.each do |change, flags|
77
+ return change unless (flags & event_flags).empty?
78
+ end
79
+ nil
80
+ end
81
+
82
+ def _event_path(event)
83
+ Pathname.new(event.watcher.path)
84
+ end
85
+
86
+ def _watch_for_new_file(event)
87
+ queue = event.watcher.queue
88
+ _find(_event_path(event).to_s) do |file_path|
89
+ unless queue.watchers.find { |_, v| v.path == file_path.to_s }
90
+ _watch_file(file_path, queue)
91
+ end
92
+ end
93
+ end
94
+
95
+ def _watch_file(path, queue)
96
+ queue.watch_file(path, *options.events, &@callback)
97
+ rescue Errno::ENOENT => e
98
+ Listen.logger.warn "kqueue: watch file failed: #{e.message}"
99
+ end
100
+
101
+ # Quick rubocop workaround
102
+ def _find(*paths, &block)
103
+ Find.send(:find, *paths, &block)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Listen
6
+ module Adapter
7
+ class Config
8
+ attr_reader :directories, :silencer, :queue, :adapter_options
9
+
10
+ def initialize(directories, queue, silencer, adapter_options)
11
+ # Default to current directory if no directories are supplied
12
+ directories = [Dir.pwd] if directories.to_a.empty?
13
+
14
+ # TODO: fix (flatten, array, compact?)
15
+ @directories = directories.map do |directory|
16
+ Pathname.new(directory.to_s).realpath
17
+ end
18
+
19
+ @silencer = silencer
20
+ @queue = queue
21
+ @adapter_options = adapter_options
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen/thread'
4
+
5
+ module Listen
6
+ module Adapter
7
+ # Adapter implementation for Mac OS X `FSEvents`.
8
+ #
9
+ class Darwin < Base
10
+ OS_REGEXP = /darwin(?<major_version>(1|2)\d+)/i.freeze
11
+
12
+ # The default delay between checking for changes.
13
+ DEFAULTS = { latency: 0.1 }.freeze
14
+
15
+ INCOMPATIBLE_GEM_VERSION = <<-EOS.gsub(/^ {8}/, '')
16
+ rb-fsevent > 0.9.4 no longer supports OS X 10.6 through 10.8.
17
+
18
+ Please add the following to your Gemfile to avoid polling for changes:
19
+ require 'rbconfig'
20
+ if RbConfig::CONFIG['target_os'] =~ /darwin(1[0-3])/i
21
+ gem 'rb-fsevent', '<= 0.9.4'
22
+ end
23
+ EOS
24
+
25
+ def self.usable?
26
+ version = RbConfig::CONFIG['target_os'][OS_REGEXP, :major_version]
27
+ return false unless version
28
+ return true if version.to_i >= 13 # darwin13 is OS X 10.9
29
+
30
+ require 'rb-fsevent'
31
+ fsevent_version = Gem::Version.new(FSEvent::VERSION)
32
+ return true if fsevent_version <= Gem::Version.new('0.9.4')
33
+ Kernel.warn INCOMPATIBLE_GEM_VERSION
34
+ false
35
+ end
36
+
37
+ private
38
+
39
+ def _configure(dir, &callback)
40
+ @callbacks[dir] = callback
41
+ end
42
+
43
+ def _run
44
+ require 'rb-fsevent'
45
+ worker = FSEvent.new
46
+ dirs_to_watch = @callbacks.keys.map(&:to_s)
47
+ Listen.logger.info { "fsevent: watching: #{dirs_to_watch.inspect}" }
48
+ worker.watch(dirs_to_watch, { latency: options.latency }, &method(:_process_changes))
49
+ @worker_thread = Listen::Thread.new("worker_thread") { worker.run }
50
+ end
51
+
52
+ def _process_changes(dirs)
53
+ dirs.each do |dir|
54
+ dir = Pathname.new(dir.sub(%r{/$}, ''))
55
+
56
+ @callbacks.each do |watched_dir, callback|
57
+ if watched_dir.eql?(dir) || Listen::Directory.ascendant_of?(watched_dir, dir)
58
+ callback.call(dir)
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ def _process_event(dir, path)
65
+ Listen.logger.debug { "fsevent: processing path: #{path.inspect}" }
66
+ # TODO: does this preserve symlinks?
67
+ rel_path = path.relative_path_from(dir).to_s
68
+ _queue_change(:dir, dir, rel_path, recursive: true)
69
+ end
70
+
71
+ def _stop
72
+ @worker_thread&.kill
73
+ super
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ module Adapter
5
+ # @see https://github.com/nex3/rb-inotify
6
+ class Linux < Base
7
+ OS_REGEXP = /linux/i.freeze
8
+
9
+ DEFAULTS = {
10
+ events: [
11
+ :recursive,
12
+ :attrib,
13
+ :create,
14
+ :modify,
15
+ :delete,
16
+ :move,
17
+ :close_write
18
+ ],
19
+ wait_for_delay: 0.1
20
+ }.freeze
21
+
22
+ private
23
+
24
+ README_URL = 'https://github.com/guard/listen'\
25
+ '/blob/master/README.md#increasing-the-amount-of-inotify-watchers'
26
+
27
+ def _configure(directory, &callback)
28
+ require 'rb-inotify'
29
+ @worker ||= ::INotify::Notifier.new
30
+ @worker.watch(directory.to_s, *options.events, &callback)
31
+ rescue Errno::ENOSPC
32
+ raise ::Listen::Error::INotifyMaxWatchesExceeded, <<~EOS
33
+ Unable to monitor directories for changes because iNotify max watches exceeded. See #{README_URL} .
34
+ EOS
35
+ end
36
+
37
+ def _run
38
+ @worker.run
39
+ end
40
+
41
+ # rubocop:disable Metrics/MethodLength
42
+ def _process_event(dir, event)
43
+ # NOTE: avoid using event.absolute_name since new API
44
+ # will need to have a custom recursion implemented
45
+ # to properly match events to configured directories
46
+ path = Pathname.new(event.watcher.path) + event.name
47
+ rel_path = path.relative_path_from(dir).to_s
48
+
49
+ Listen.logger.debug { "inotify: #{rel_path} (#{event.flags.inspect})" }
50
+
51
+ if /1|true/ =~ ENV['LISTEN_GEM_SIMULATE_FSEVENT']
52
+ if (event.flags & [:moved_to, :moved_from]) || _dir_event?(event)
53
+ rel_path = path.dirname.relative_path_from(dir).to_s
54
+ end
55
+ _queue_change(:dir, dir, rel_path, {})
56
+ return
57
+ end
58
+
59
+ return if _skip_event?(event)
60
+
61
+ cookie_params = event.cookie.zero? ? {} : { cookie: event.cookie }
62
+
63
+ # Note: don't pass options to force rescanning the directory, so we can
64
+ # detect moving/deleting a whole tree
65
+ if _dir_event?(event)
66
+ _queue_change(:dir, dir, rel_path, cookie_params)
67
+ return
68
+ end
69
+
70
+ params = cookie_params.merge(change: _change(event.flags))
71
+
72
+ _queue_change(:file, dir, rel_path, params)
73
+ end
74
+ # rubocop:enable Metrics/MethodLength
75
+
76
+ def _skip_event?(event)
77
+ # Event on root directory
78
+ return true if event.name == ''
79
+ # INotify reports changes to files inside directories as events
80
+ # on the directories themselves too.
81
+ #
82
+ # @see http://linux.die.net/man/7/inotify
83
+ _dir_event?(event) && (event.flags & [:close, :modify]).any?
84
+ end
85
+
86
+ def _change(event_flags)
87
+ { modified: [:attrib, :close_write],
88
+ moved_to: [:moved_to],
89
+ moved_from: [:moved_from],
90
+ added: [:create],
91
+ removed: [:delete] }.each do |change, flags|
92
+ return change unless (flags & event_flags).empty?
93
+ end
94
+ nil
95
+ end
96
+
97
+ def _dir_event?(event)
98
+ event.flags.include?(:isdir)
99
+ end
100
+
101
+ def _stop
102
+ @worker&.close
103
+
104
+ super
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ module Adapter
5
+ # Polling Adapter that works cross-platform and
6
+ # has no dependencies. This is the adapter that
7
+ # uses the most CPU processing power and has higher
8
+ # file IO than the other implementations.
9
+ #
10
+ class Polling < Base
11
+ OS_REGEXP = //.freeze # match every OS
12
+
13
+ DEFAULTS = { latency: 1.0, wait_for_delay: 0.05 }.freeze
14
+
15
+ private
16
+
17
+ def _configure(_, &callback)
18
+ @polling_callbacks ||= []
19
+ @polling_callbacks << callback
20
+ end
21
+
22
+ def _run
23
+ loop do
24
+ start = MonotonicTime.now
25
+ @polling_callbacks.each do |callback|
26
+ callback.call(nil)
27
+ if (nap_time = options.latency - (MonotonicTime.now - start)) > 0
28
+ # TODO: warn if nap_time is negative (polling too slow)
29
+ sleep(nap_time)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def _process_event(dir, _)
36
+ _queue_change(:dir, dir, '.', recursive: true)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ module Adapter
5
+ # Adapter implementation for Windows `wdm`.
6
+ #
7
+ class Windows < Base
8
+ OS_REGEXP = /mswin|mingw|cygwin/i.freeze
9
+
10
+ BUNDLER_DECLARE_GEM = <<-EOS.gsub(/^ {6}/, '')
11
+ Please add the following to your Gemfile to avoid polling for changes:
12
+ gem 'wdm', '>= 0.1.0' if Gem.win_platform?
13
+ EOS
14
+
15
+ def self.usable?
16
+ return false unless super
17
+ require 'wdm'
18
+ true
19
+ rescue LoadError
20
+ Listen.logger.debug format('wdm - load failed: %s:%s', $ERROR_INFO,
21
+ $ERROR_POSITION * "\n")
22
+
23
+ Kernel.warn BUNDLER_DECLARE_GEM
24
+ false
25
+ end
26
+
27
+ private
28
+
29
+ def _configure(dir)
30
+ require 'wdm'
31
+ Listen.logger.debug 'wdm - starting...'
32
+ @worker ||= WDM::Monitor.new
33
+ @worker.watch_recursively(dir.to_s, :files) do |change|
34
+ yield([:file, change])
35
+ end
36
+
37
+ @worker.watch_recursively(dir.to_s, :directories) do |change|
38
+ yield([:dir, change])
39
+ end
40
+
41
+ @worker.watch_recursively(dir.to_s, :attributes, :last_write) do |change|
42
+ yield([:attr, change])
43
+ end
44
+ end
45
+
46
+ def _run
47
+ @worker.run!
48
+ end
49
+
50
+ # rubocop:disable Metrics/MethodLength
51
+ def _process_event(dir, event)
52
+ Listen.logger.debug "wdm - callback: #{event.inspect}"
53
+
54
+ type, change = event
55
+
56
+ full_path = Pathname(change.path)
57
+
58
+ rel_path = full_path.relative_path_from(dir).to_s
59
+
60
+ options = { change: _change(change.type) }
61
+
62
+ case type
63
+ when :file
64
+ _queue_change(:file, dir, rel_path, options)
65
+ when :attr
66
+ unless full_path.directory?
67
+ _queue_change(:file, dir, rel_path, options)
68
+ end
69
+ when :dir
70
+ case change.type
71
+ when :removed
72
+ # TODO: check if watched dir?
73
+ _queue_change(:dir, dir, Pathname(rel_path).dirname.to_s, {})
74
+ when :added
75
+ _queue_change(:dir, dir, rel_path, {})
76
+ # do nothing - changed directory means either:
77
+ # - removed subdirs (handled above)
78
+ # - added subdirs (handled above)
79
+ # - removed files (handled by _file_callback)
80
+ # - added files (handled by _file_callback)
81
+ # so what's left?
82
+ end
83
+ end
84
+ end
85
+ # rubocop:enable Metrics/MethodLength
86
+
87
+ def _change(type)
88
+ { modified: [:modified, :attrib], # TODO: is attrib really passed?
89
+ added: [:added, :renamed_new_file],
90
+ removed: [:removed, :renamed_old_file] }.find do |change, types|
91
+ types.include?(type) and break change
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end