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
@@ -1,203 +1,136 @@
1
- require 'pathname'
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
5
+ require 'listen/version'
6
+
7
+ require 'listen/backend'
8
+
9
+ require 'listen/silencer'
10
+ require 'listen/silencer/controller'
11
+
12
+ require 'listen/queue_optimizer'
13
+
14
+ require 'listen/fsm'
15
+
16
+ require 'listen/event/loop'
17
+ require 'listen/event/queue'
18
+ require 'listen/event/config'
19
+
20
+ require 'listen/listener/config'
2
21
 
3
22
  module Listen
4
23
  class Listener
5
- attr_reader :directory, :directory_record, :adapter
24
+ include Listen::FSM
6
25
 
7
- # The default value for using relative paths in the callback.
8
- DEFAULT_TO_RELATIVE_PATHS = false
9
-
10
- # Initializes the directory listener.
26
+ # Initializes the directories listener.
11
27
  #
12
- # @param [String] directory the directory to listen to
13
- # @param [Hash] options the listen options
14
- # @option options [Regexp] ignore a pattern for ignoring paths
15
- # @option options [Regexp] filter a pattern for filtering paths
16
- # @option options [Float] latency the delay between checking for changes in seconds
17
- # @option options [Boolean] relative_paths whether or not to use relative-paths in the callback
18
- # @option options [Boolean] force_polling whether to force the polling adapter or not
19
- # @option options [String, Boolean] polling_fallback_message to change polling fallback message or remove it
28
+ # @param [String] directory the directories to listen to
29
+ # @param [Hash] options the listen options (see Listen::Listener::Options)
20
30
  #
21
31
  # @yield [modified, added, removed] the changed files
22
32
  # @yieldparam [Array<String>] modified the list of modified files
23
33
  # @yieldparam [Array<String>] added the list of added files
24
34
  # @yieldparam [Array<String>] removed the list of removed files
25
35
  #
26
- def initialize(directory, options = {}, &block)
27
- @block = block
28
- @directory = Pathname.new(directory).realpath.to_s
29
- @directory_record = DirectoryRecord.new(@directory)
30
- @use_relative_paths = DEFAULT_TO_RELATIVE_PATHS
36
+ # rubocop:disable Metrics/MethodLength
37
+ def initialize(*dirs, &block)
38
+ options = dirs.last.is_a?(Hash) ? dirs.pop : {}
31
39
 
32
- @use_relative_paths = options.delete(:relative_paths) if options[:relative_paths]
33
- @directory_record.ignore(*options.delete(:ignore)) if options[:ignore]
34
- @directory_record.filter(*options.delete(:filter)) if options[:filter]
40
+ @config = Config.new(options)
35
41
 
36
- @adapter_options = options
37
- end
42
+ eq_config = Event::Queue::Config.new(@config.relative?)
43
+ queue = Event::Queue.new(eq_config)
38
44
 
39
- # Starts the listener by initializing the adapter and building
40
- # the directory record concurrently, then it starts the adapter to watch
41
- # for changes.
42
- #
43
- # @param [Boolean] blocking whether or not to block the current thread after starting
44
- #
45
- def start(blocking = true)
46
- t = Thread.new { @directory_record.build }
47
- @adapter = initialize_adapter
48
- t.join
49
- @adapter.start(blocking)
50
- end
45
+ silencer = Silencer.new
46
+ rules = @config.silencer_rules
47
+ @silencer_controller = Silencer::Controller.new(silencer, rules)
51
48
 
52
- # Stops the listener.
53
- #
54
- def stop
55
- @adapter.stop
49
+ @backend = Backend.new(dirs, queue, silencer, @config)
50
+
51
+ optimizer_config = QueueOptimizer::Config.new(@backend, silencer)
52
+
53
+ pconfig = Event::Config.new(
54
+ self,
55
+ queue,
56
+ QueueOptimizer.new(optimizer_config),
57
+ @backend.min_delay_between_events,
58
+ &block)
59
+
60
+ @processor = Event::Loop.new(pconfig)
61
+
62
+ initialize_fsm
56
63
  end
64
+ # rubocop:enable Metrics/MethodLength
57
65
 
58
- # Pauses the listener.
59
- #
60
- # @return [Listen::Listener] the listener
61
- #
62
- def pause
63
- @adapter.paused = true
64
- self
66
+ start_state :initializing
67
+
68
+ state :initializing, to: [:backend_started, :stopped]
69
+
70
+ state :backend_started, to: [:processing_events, :stopped] do
71
+ @backend.start
65
72
  end
66
73
 
67
- # Unpauses the listener.
68
- #
69
- # @return [Listen::Listener] the listener
70
- #
71
- def unpause
72
- @directory_record.build
73
- @adapter.paused = false
74
- self
74
+ state :processing_events, to: [:paused, :stopped] do
75
+ @processor.start
75
76
  end
76
77
 
77
- # Returns whether the listener is paused or not.
78
- #
79
- # @return [Boolean] adapter paused status
80
- #
81
- def paused?
82
- !!@adapter && @adapter.paused == true
78
+ state :paused, to: [:processing_events, :stopped] do
79
+ @processor.pause
83
80
  end
84
81
 
85
- # Adds ignoring patterns to the listener.
86
- #
87
- # @param (see Listen::DirectoryRecord#ignore)
88
- #
89
- # @return [Listen::Listener] the listener
90
- #
91
- def ignore(*regexps)
92
- @directory_record.ignore(*regexps)
93
- self
82
+ state :stopped, to: [:backend_started] do
83
+ @backend.stop # halt events ASAP
84
+ @processor.stop
94
85
  end
95
86
 
96
- # Adds filtering patterns to the listener.
97
- #
98
- # @param (see Listen::DirectoryRecord#filter)
99
- #
100
- # @return [Listen::Listener] the listener
101
- #
102
- def filter(*regexps)
103
- @directory_record.filter(*regexps)
104
- self
87
+ # Starts processing events and starts adapters
88
+ # or resumes invoking callbacks if paused
89
+ def start
90
+ case state
91
+ when :initializing
92
+ transition :backend_started
93
+ transition :processing_events
94
+ when :paused
95
+ transition :processing_events
96
+ else
97
+ raise ArgumentError, "cannot start from state #{state.inspect}"
98
+ end
105
99
  end
106
100
 
107
- # Sets the latency for the adapter. This is a helper method
108
- # to simplify changing the latency directly from the listener.
109
- #
110
- # @example Wait 0.5 seconds each time before checking changes
111
- # latency 0.5
112
- #
113
- # @param [Float] seconds the amount of delay, in seconds
114
- #
115
- # @return [Listen::Listener] the listener
116
- #
117
- def latency(seconds)
118
- @adapter_options[:latency] = seconds
119
- self
101
+ # Stops both listening for events and processing them
102
+ def stop
103
+ transition :stopped
120
104
  end
121
105
 
122
- # Sets whether the use of the polling adapter
123
- # should be forced or not.
124
- #
125
- # @example Forcing the use of the polling adapter
126
- # force_polling true
127
- #
128
- # @param [Boolean] value whether to force the polling adapter or not
129
- #
130
- # @return [Listen::Listener] the listener
131
- #
132
- def force_polling(value)
133
- @adapter_options[:force_polling] = value
134
- self
106
+ # Stops invoking callbacks (messages pile up)
107
+ def pause
108
+ transition :paused
135
109
  end
136
110
 
137
- # Sets whether the paths in the callback should be
138
- # relative or absolute.
139
- #
140
- # @example Enabling relative paths in the callback
141
- # relative_paths true
142
- #
143
- # @param [Boolean] value whether to enable relative paths in the callback or not
144
- #
145
- # @return [Listen::Listener] the listener
146
- #
147
- def relative_paths(value)
148
- @use_relative_paths = value
149
- self
111
+ # processing means callbacks are called
112
+ def processing?
113
+ state == :processing_events
150
114
  end
151
115
 
152
- # Defines a custom polling fallback message of disable it.
153
- #
154
- # @example Disabling the polling fallback message
155
- # polling_fallback_message false
156
- #
157
- # @param [String, Boolean] value to change polling fallback message or remove it
158
- #
159
- # @return [Listen::Listener] the listener
160
- #
161
- def polling_fallback_message(value)
162
- @adapter_options[:polling_fallback_message] = value
163
- self
116
+ def paused?
117
+ state == :paused
164
118
  end
165
119
 
166
- # Sets the callback that gets called on changes.
167
- #
168
- # @example Assign a callback to be called on changes
169
- # callback = lambda { |modified, added, removed| ... }
170
- # change &callback
171
- #
172
- # @param [Proc] block the callback proc
173
- #
174
- # @return [Listen::Listener] the listener
175
- #
176
- def change(&block) # modified, added, removed
177
- @block = block
178
- self
120
+ def stopped?
121
+ state == :stopped
179
122
  end
180
123
 
181
- # Runs the callback passing it the changes if there are any.
182
- #
183
- # @param (see Listen::DirectoryRecord#fetch_changes)
184
- #
185
- def on_change(directories, options = {})
186
- changes = @directory_record.fetch_changes(directories, options.merge(
187
- :relative_paths => @use_relative_paths
188
- ))
189
- unless changes.values.all? { |paths| paths.empty? }
190
- @block.call(changes[:modified],changes[:added],changes[:removed])
191
- end
124
+ def ignore(regexps)
125
+ @silencer_controller.append_ignores(regexps)
192
126
  end
193
127
 
194
- private
128
+ def ignore!(regexps)
129
+ @silencer_controller.replace_with_bang_ignores(regexps)
130
+ end
195
131
 
196
- # Initializes an adapter passing it the callback and adapters' options.
197
- #
198
- def initialize_adapter
199
- callback = lambda { |changed_dirs, options| self.on_change(changed_dirs, options) }
200
- Adapter.select_and_initialize(@directory, @adapter_options, &callback)
132
+ def only(regexps)
133
+ @silencer_controller.replace_with_only(regexps)
201
134
  end
202
135
  end
203
136
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ @logger = nil
5
+
6
+ # Listen.logger will always be present.
7
+ # If you don't want logging, set Listen.logger = ::Logger.new('/dev/null', level: ::Logger::UNKNOWN)
8
+
9
+ class << self
10
+ attr_writer :logger
11
+
12
+ def logger
13
+ @logger ||= default_logger
14
+ end
15
+
16
+ private
17
+
18
+ def default_logger
19
+ level =
20
+ case ENV['LISTEN_GEM_DEBUGGING'].to_s
21
+ when /debug|2/i
22
+ ::Logger::DEBUG
23
+ when /info|true|yes|1/i
24
+ ::Logger::INFO
25
+ when /warn/i
26
+ ::Logger::WARN
27
+ when /fatal/i
28
+ ::Logger::FATAL
29
+ else
30
+ ::Logger::ERROR
31
+ end
32
+
33
+ ::Logger.new(STDERR, level: level)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ module MonotonicTime
5
+ class << self
6
+ if defined?(Process::CLOCK_MONOTONIC)
7
+
8
+ def now
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ end
11
+
12
+ elsif defined?(Process::CLOCK_MONOTONIC_RAW)
13
+
14
+ def now
15
+ Process.clock_gettime(Process::CLOCK_MONOTONIC_RAW)
16
+ end
17
+
18
+ else
19
+
20
+ def now
21
+ Time.now.to_f
22
+ end
23
+
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ class Options
5
+ def initialize(opts, defaults)
6
+ @options = {}
7
+ given_options = opts.dup
8
+ defaults.each_key do |key|
9
+ @options[key] = given_options.delete(key) || defaults[key]
10
+ end
11
+
12
+ given_options.empty? or raise ArgumentError, "Unknown options: #{given_options.inspect}"
13
+ end
14
+
15
+ # rubocop:disable Lint/MissingSuper
16
+ def respond_to_missing?(name, *_)
17
+ @options.has_key?(name)
18
+ end
19
+
20
+ def method_missing(name, *_)
21
+ respond_to_missing?(name) or raise NameError, "Bad option: #{name.inspect} (valid:#{@options.keys.inspect})"
22
+ @options[name]
23
+ end
24
+ # rubocop:enable Lint/MissingSuper
25
+ end
26
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ class QueueOptimizer
5
+ class Config
6
+ def initialize(adapter_class, silencer)
7
+ @adapter_class = adapter_class
8
+ @silencer = silencer
9
+ end
10
+
11
+ def exist?(path)
12
+ Pathname(path).exist?
13
+ end
14
+
15
+ def silenced?(path, type)
16
+ @silencer.silenced?(path, type)
17
+ end
18
+
19
+ def debug(*args, &block)
20
+ Listen.logger.debug(*args, &block)
21
+ end
22
+ end
23
+
24
+ def smoosh_changes(changes)
25
+ # TODO: adapter could be nil at this point (shutdown)
26
+ cookies = changes.group_by do |_, _, _, _, options|
27
+ (options || {})[:cookie]
28
+ end
29
+ _squash_changes(_reinterpret_related_changes(cookies))
30
+ end
31
+
32
+ def initialize(config)
33
+ @config = config
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :config
39
+
40
+ # groups changes into the expected structure expected by
41
+ # clients
42
+ def _squash_changes(changes)
43
+ # We combine here for backward compatibility
44
+ # Newer clients should receive dir and path separately
45
+ changes = changes.map { |change, dir, path| [change, dir + path] }
46
+
47
+ actions = changes.group_by(&:last).map do |path, action_list|
48
+ [_logical_action_for(path, action_list.map(&:first)), path.to_s]
49
+ end
50
+
51
+ config.debug("listen: raw changes: #{actions.inspect}")
52
+
53
+ { modified: [], added: [], removed: [] }.tap do |squashed|
54
+ actions.each do |type, path|
55
+ squashed[type] << path unless type.nil?
56
+ end
57
+ config.debug("listen: final changes: #{squashed.inspect}")
58
+ end
59
+ end
60
+
61
+ def _logical_action_for(path, actions)
62
+ actions << :added if actions.delete(:moved_to)
63
+ actions << :removed if actions.delete(:moved_from)
64
+
65
+ modified = actions.find { |x| x == :modified }
66
+ _calculate_add_remove_difference(actions, path, modified)
67
+ end
68
+
69
+ def _calculate_add_remove_difference(actions, path, default_if_exists)
70
+ added = actions.count { |x| x == :added }
71
+ removed = actions.count { |x| x == :removed }
72
+ diff = added - removed
73
+
74
+ # TODO: avoid checking if path exists and instead assume the events are
75
+ # in order (if last is :removed, it doesn't exist, etc.)
76
+ if config.exist?(path)
77
+ if diff > 0
78
+ :added
79
+ elsif diff.zero? && added > 0
80
+ :modified
81
+ else
82
+ default_if_exists
83
+ end
84
+ else
85
+ diff < 0 ? :removed : nil
86
+ end
87
+ end
88
+
89
+ # remove extraneous rb-inotify events, keeping them only if it's a possible
90
+ # editor rename() call (e.g. Kate and Sublime)
91
+ def _reinterpret_related_changes(cookies)
92
+ table = { moved_to: :added, moved_from: :removed }
93
+ cookies.flat_map do |_, changes|
94
+ if (editor_modified = editor_modified?(changes))
95
+ [[:modified, *editor_modified]]
96
+ else
97
+ not_silenced = changes.reject do |type, _, _, path, _|
98
+ config.silenced?(Pathname(path), type)
99
+ end
100
+ not_silenced.map do |_, change, dir, path, _|
101
+ [table.fetch(change, change), dir, path]
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def editor_modified?(changes)
108
+ return unless changes.size == 2
109
+
110
+ from_type = from = nil
111
+ to_type = to_dir = to = nil
112
+
113
+ changes.each do |data|
114
+ case data[1]
115
+ when :moved_from
116
+ from_type, _from_change, _, from, = data
117
+ when :moved_to
118
+ to_type, _to_change, to_dir, to, = data
119
+ end
120
+ end
121
+
122
+ # Expect an ignored moved_from and non-ignored moved_to
123
+ # to qualify as an "editor modify"
124
+ if from && to && config.silenced?(Pathname(from), from_type) && !config.silenced?(Pathname(to), to_type)
125
+ [to_dir, to]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ # @private api
5
+ class Record
6
+ # Represents a directory entry (dir or file)
7
+ class Entry
8
+ # file: "/home/me/watched_dir", "app/models", "foo.rb"
9
+ # dir, "/home/me/watched_dir", "."
10
+ def initialize(root, relative, name = nil)
11
+ @root = root
12
+ @relative = relative
13
+ @name = name
14
+ end
15
+
16
+ attr_reader :root, :relative, :name
17
+
18
+ def children
19
+ child_relative = _join
20
+ (_entries(sys_path) - %w[. ..]).map do |name|
21
+ Entry.new(@root, child_relative, name)
22
+ end
23
+ end
24
+
25
+ def meta
26
+ lstat = ::File.lstat(sys_path)
27
+ { mtime: lstat.mtime.to_f, mode: lstat.mode, size: lstat.size }
28
+ end
29
+
30
+ # record hash is e.g.
31
+ # if @record["/home/me/watched_dir"]["project/app/models"]["foo.rb"]
32
+ # if @record["/home/me/watched_dir"]["project/app"]["models"]
33
+ # record_dir_key is "project/app/models"
34
+ def record_dir_key
35
+ ::File.join(*[@relative, @name].compact)
36
+ end
37
+
38
+ def sys_path
39
+ # Use full path in case someone uses chdir
40
+ ::File.join(*[@root, @relative, @name].compact)
41
+ end
42
+
43
+ def real_path
44
+ @real_path ||= ::File.realpath(sys_path)
45
+ end
46
+
47
+ private
48
+
49
+ def _join
50
+ args = [@relative, @name].compact
51
+ args.empty? ? nil : ::File.join(*args)
52
+ end
53
+
54
+ def _entries(dir)
55
+ return Dir.entries(dir) unless RUBY_ENGINE == 'jruby'
56
+
57
+ # JRuby inconsistency workaround, see:
58
+ # https://github.com/jruby/jruby/issues/3840
59
+ exists = ::File.exist?(dir)
60
+ directory = ::File.directory?(dir)
61
+ return Dir.entries(dir) unless exists && !directory
62
+ raise Errno::ENOTDIR, dir
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'listen/error'
5
+
6
+ module Listen
7
+ # @private api
8
+ class Record
9
+ class SymlinkDetector
10
+ README_URL = 'https://github.com/guard/listen/blob/master/README.md'
11
+
12
+ SYMLINK_LOOP_ERROR = <<-EOS
13
+ ** ERROR: directory is already being watched! **
14
+
15
+ Directory: %s
16
+
17
+ is already being watched through: %s
18
+
19
+ MORE INFO: #{README_URL}
20
+ EOS
21
+
22
+ Error = ::Listen::Error # for backward compatibility
23
+
24
+ def initialize
25
+ @real_dirs = Set.new
26
+ end
27
+
28
+ def verify_unwatched!(entry)
29
+ real_path = entry.real_path
30
+ @real_dirs.add?(real_path) or _fail(entry.sys_path, real_path)
31
+ end
32
+
33
+ private
34
+
35
+ def _fail(symlinked, real_path)
36
+ warn(format(SYMLINK_LOOP_ERROR, symlinked, real_path))
37
+ raise ::Listen::Error::SymlinkLoop, 'Failed due to looped symlinks'
38
+ end
39
+ end
40
+ end
41
+ end