listen 0.3.3 → 0.4.0

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.
@@ -5,28 +5,49 @@ module Listen
5
5
  #
6
6
  class Darwin < Adapter
7
7
 
8
- # Initialize the Adapter. See {Listen::Adapter#initialize} for more info.
8
+ LAST_SEPARATOR_REGEX = /\/$/
9
+
10
+ # Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
9
11
  #
10
- def initialize(directory, options = {}, &callback)
12
+ def initialize(directories, options = {}, &callback)
11
13
  super
12
- init_worker
14
+ @worker = init_worker
13
15
  end
14
16
 
15
- # Start the adapter.
17
+ # Starts the adapter.
16
18
  #
17
- def start
18
- super
19
- @worker.run
19
+ # @param [Boolean] blocking whether or not to block the current thread after starting
20
+ #
21
+ def start(blocking = true)
22
+ @mutex.synchronize do
23
+ return if @stop == false
24
+ super
25
+ end
26
+
27
+ @worker_thread = Thread.new { @worker.run }
28
+ @poll_thread = Thread.new { poll_changed_dirs }
29
+
30
+ # The FSEvent worker needs sometime to startup. Turnstiles can't
31
+ # be used to wait for it as it runs in a loop.
32
+ # TODO: Find a better way to block until the worker starts.
33
+ sleep @latency
34
+ @poll_thread.join if blocking
20
35
  end
21
36
 
22
- # Stop the adapter.
37
+ # Stops the adapter.
23
38
  #
24
39
  def stop
25
- super
40
+ @mutex.synchronize do
41
+ return if @stop == true
42
+ super
43
+ end
44
+
26
45
  @worker.stop
46
+ Thread.kill(@worker_thread) if @worker_thread
47
+ @poll_thread.join
27
48
  end
28
49
 
29
- # Check if the adapter is usable on the current OS.
50
+ # Checks if the adapter is usable on the current OS.
30
51
  #
31
52
  # @return [Boolean] whether usable or not
32
53
  #
@@ -39,17 +60,21 @@ module Listen
39
60
  false
40
61
  end
41
62
 
42
- private
63
+ private
43
64
 
44
- # Initialiaze FSEvent worker and set watch callback block
65
+ # Initializes a FSEvent worker and adds a watcher for
66
+ # each directory passed to the adapter.
67
+ #
68
+ # @return [FSEvent] initialized worker
45
69
  #
46
70
  def init_worker
47
- @worker = FSEvent.new
48
- @worker.watch(@directory, :latency => @latency) do |changed_dirs|
49
- next if @paused
50
-
51
- changed_dirs.map! { |path| path.sub /\/$/, '' }
52
- @callback.call(changed_dirs, {})
71
+ FSEvent.new.tap do |worker|
72
+ worker.watch(@directories.dup, :latency => @latency) do |changes|
73
+ next if @paused
74
+ @mutex.synchronize do
75
+ changes.each { |path| @changed_dirs << path.sub(LAST_SEPARATOR_REGEX, '') }
76
+ end
77
+ end
53
78
  end
54
79
  end
55
80
 
@@ -1,5 +1,3 @@
1
- require 'set'
2
-
3
1
  module Listen
4
2
  module Adapters
5
3
 
@@ -14,27 +12,39 @@ module Listen
14
12
  #
15
13
  class Linux < Adapter
16
14
 
17
- # Initialize the Adapter. See {Listen::Adapter#initialize} for more info.
15
+ # Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
18
16
  #
19
- def initialize(directory, options = {}, &callback)
17
+ def initialize(directories, options = {}, &callback)
20
18
  super
21
- @changed_dirs = Set.new
22
- init_worker
19
+ @worker = init_worker
23
20
  end
24
21
 
25
- # Start the adapter.
22
+ # Starts the adapter.
26
23
  #
27
- def start
28
- super
29
- Thread.new { @worker.run }
30
- poll_changed_dirs
24
+ # @param [Boolean] blocking whether or not to block the current thread after starting
25
+ #
26
+ def start(blocking = true)
27
+ @mutex.synchronize do
28
+ return if @stop == false
29
+ super
30
+ end
31
+
32
+ @worker_thread = Thread.new { @worker.run }
33
+ @poll_thread = Thread.new { poll_changed_dirs }
34
+ @poll_thread.join if blocking
31
35
  end
32
36
 
33
- # Stop the adapter.
37
+ # Stops the adapter.
34
38
  #
35
39
  def stop
36
- super
40
+ @mutex.synchronize do
41
+ return if @stop == true
42
+ super
43
+ end
44
+
37
45
  @worker.stop
46
+ Thread.kill(@worker_thread) if @worker_thread
47
+ @poll_thread.join
38
48
  end
39
49
 
40
50
  # Check if the adapter is usable on the current OS.
@@ -52,30 +62,35 @@ module Listen
52
62
 
53
63
  private
54
64
 
55
- # Initialize INotify worker and set watch callback block.
65
+ # Initializes a INotify worker and adds a watcher for
66
+ # each directory passed to the adapter.
56
67
  #
57
- def init_worker
58
- @worker = INotify::Notifier.new
59
- @worker.watch(@directory, *EVENTS.map(&:to_sym)) do |event|
60
- next if @paused
61
-
62
- unless event.name == "" # Event on root directory
63
- @changed_dirs << File.dirname(event.absolute_name)
64
- end
65
- end
66
- end
67
-
68
- # Polling around @changed_dirs presence.
68
+ # @return [INotify::Notifier] initialized worker
69
69
  #
70
- def poll_changed_dirs
71
- until @stop
72
- sleep(@latency)
70
+ def init_worker
71
+ worker = INotify::Notifier.new
72
+ @directories.each do |directory|
73
+ worker.watch(directory, *EVENTS.map(&:to_sym)) do |event|
74
+ if @paused || (
75
+ # Event on root directory
76
+ event.name == ""
77
+ ) || (
78
+ # INotify reports changes to files inside directories as events
79
+ # on the directories themselves too.
80
+ #
81
+ # @see http://linux.die.net/man/7/inotify
82
+ event.flags.include?(:isdir) and event.flags & [:close, :modify] != []
83
+ )
84
+ # Skip all of these!
85
+ next
86
+ end
73
87
 
74
- next if @changed_dirs.empty?
75
- changed_dirs = @changed_dirs.to_a
76
- @changed_dirs.clear
77
- @callback.call(changed_dirs, {})
88
+ @mutex.synchronize do
89
+ @changed_dirs << File.dirname(event.absolute_name)
90
+ end
91
+ end
78
92
  end
93
+ worker
79
94
  end
80
95
 
81
96
  end
@@ -13,22 +13,34 @@ module Listen
13
13
 
14
14
  # Initialize the Adapter. See {Listen::Adapter#initialize} for more info.
15
15
  #
16
- def initialize(directory, options = {}, &callback)
16
+ def initialize(directories, options = {}, &callback)
17
17
  @latency ||= DEFAULT_POLLING_LATENCY
18
18
  super
19
19
  end
20
20
 
21
21
  # Start the adapter.
22
22
  #
23
- def start
24
- super
25
- poll
23
+ # @param [Boolean] blocking whether or not to block the current thread after starting
24
+ #
25
+ def start(blocking = true)
26
+ @mutex.synchronize do
27
+ return if @stop == false
28
+ super
29
+ end
30
+
31
+ @poll_thread = Thread.new { poll }
32
+ @poll_thread.join if blocking
26
33
  end
27
34
 
28
35
  # Stop the adapter.
29
36
  #
30
37
  def stop
31
- super
38
+ @mutex.synchronize do
39
+ return if @stop == true
40
+ super
41
+ end
42
+
43
+ @poll_thread.join
32
44
  end
33
45
 
34
46
  private
@@ -37,10 +49,11 @@ module Listen
37
49
  #
38
50
  def poll
39
51
  until @stop
40
- sleep 0.1 && next if @paused
52
+ sleep(0.1) && next if @paused
41
53
 
42
54
  start = Time.now.to_f
43
- @callback.call([@directory], :recursive => true)
55
+ @callback.call(@directories.dup, :recursive => true)
56
+ @turnstile.signal
44
57
  nap_time = @latency - (Time.now.to_f - start)
45
58
  sleep(nap_time) if nap_time > 0
46
59
  end
@@ -7,30 +7,42 @@ module Listen
7
7
  #
8
8
  class Windows < Adapter
9
9
 
10
- # Initialize the Adapter. See {Listen::Adapter#initialize} for more info.
10
+ # Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
11
11
  #
12
- def initialize(directory, options = {}, &callback)
12
+ def initialize(directories, options = {}, &callback)
13
13
  super
14
- @changed_dirs = Set.new
15
- init_worker
14
+ @worker = init_worker
16
15
  end
17
16
 
18
- # Start the adapter.
17
+ # Starts the adapter.
19
18
  #
20
- def start
21
- super
22
- Thread.new { @worker.run }
23
- poll_changed_dirs
19
+ # @param [Boolean] blocking whether or not to block the current thread after starting
20
+ #
21
+ def start(blocking = true)
22
+ @mutex.synchronize do
23
+ return if @stop == false
24
+ super
25
+ end
26
+
27
+ @worker_thread = Thread.new { @worker.run }
28
+ @poll_thread = Thread.new { poll_changed_dirs(true) }
29
+ @poll_thread.join if blocking
24
30
  end
25
31
 
26
- # Stop the adapter.
32
+ # Stops the adapter.
27
33
  #
28
34
  def stop
29
- super
35
+ @mutex.synchronize do
36
+ return if @stop == true
37
+ super
38
+ end
39
+
30
40
  @worker.stop
41
+ Thread.kill(@worker_thread) if @worker_thread
42
+ @poll_thread.join
31
43
  end
32
44
 
33
- # Check if the adapter is usable on the current OS.
45
+ # Checks if the adapter is usable on the current OS.
34
46
  #
35
47
  # @return [Boolean] whether usable or not
36
48
  #
@@ -45,28 +57,23 @@ module Listen
45
57
 
46
58
  private
47
59
 
48
- # Initialiaze FSEvent worker and set watch callback block
60
+ # Initializes a FChange worker and adds a watcher for
61
+ # each directory passed to the adapter.
49
62
  #
50
- def init_worker
51
- @worker = FChange::Notifier.new
52
- @worker.watch(@directory, :all_events, :recursive) do |event|
53
- next if @paused
54
-
55
- @changed_dirs << File.expand_path(event.watcher.path)
56
- end
57
- end
58
-
59
- # Polling around @changed_dirs presence.
63
+ # @return [FChange::Notifier] initialized worker
60
64
  #
61
- def poll_changed_dirs
62
- until @stop
63
- sleep(@latency)
64
-
65
- next if @changed_dirs.empty?
66
- changed_dirs = @changed_dirs.to_a
67
- @changed_dirs.clear
68
- @callback.call(changed_dirs, :recursive => true)
65
+ def init_worker
66
+ worker = FChange::Notifier.new
67
+ @directories.each do |directory|
68
+ watcher = worker.watch(directory, :all_events, :recursive) do |event|
69
+ next if @paused
70
+ @mutex.synchronize do
71
+ @changed_dirs << File.expand_path(event.watcher.path)
72
+ end
73
+ end
74
+ worker.add_watcher(watcher)
69
75
  end
76
+ worker
70
77
  end
71
78
 
72
79
  end
@@ -0,0 +1,254 @@
1
+ require 'set'
2
+ require 'find'
3
+ require 'pathname'
4
+ require 'digest/sha1'
5
+
6
+ module Listen
7
+
8
+ # The directory record stores information about
9
+ # a directory and keeps track of changes to
10
+ # the structure of its childs.
11
+ #
12
+ class DirectoryRecord
13
+ attr_reader :directory, :paths, :sha1_checksums
14
+
15
+ # Default paths' beginnings that doesn't get stored in the record
16
+ DEFAULT_IGNORED_PATHS = %w[.bundle .git .DS_Store log tmp vendor]
17
+
18
+ # Initializes a directory record.
19
+ #
20
+ # @option [String] directory the directory to keep track of
21
+ #
22
+ def initialize(directory)
23
+ @directory = directory
24
+ raise ArgumentError, "The path '#{directory}' is not a directory!" unless File.directory?(@directory)
25
+
26
+ @ignored_paths = Set.new(DEFAULT_IGNORED_PATHS)
27
+ @filters = Set.new
28
+ @sha1_checksums = Hash.new
29
+ end
30
+
31
+ # Returns the ignored paths in the record
32
+ #
33
+ # @return [Array<String>] the ignored paths
34
+ #
35
+ def ignored_paths
36
+ @ignored_paths.to_a
37
+ end
38
+
39
+ # Returns the filters used in the record to know
40
+ # which paths should be stored.
41
+ #
42
+ # @return [Array<String>] the used filters
43
+ #
44
+ def filters
45
+ @filters.to_a
46
+ end
47
+
48
+ # Adds ignored path to the record.
49
+ #
50
+ # @example Ignore some paths
51
+ # ignore ".git", ".svn"
52
+ #
53
+ # @param [String, Array<String>] paths a path or a list of paths to ignore
54
+ #
55
+ def ignore(*paths)
56
+ @ignored_paths.merge(paths)
57
+ end
58
+
59
+ # Adds file filters to the listener.
60
+ #
61
+ # @example Filter some files
62
+ # ignore /\.txt$/, /.*\.zip/
63
+ #
64
+ # @param [Array<Regexp>] regexps a list of regexps file filters
65
+ #
66
+ # @return [Listen::Listener] the listener itself
67
+ #
68
+ def filter(*regexps)
69
+ @filters.merge(regexps)
70
+ end
71
+
72
+ # Returns whether a path should be ignored or not.
73
+ #
74
+ # @param [String] path the path to test.
75
+ #
76
+ # @return [Boolean]
77
+ #
78
+ def ignored?(path)
79
+ @ignored_paths.any? { |ignored_path| path =~ /#{ignored_path}$/ }
80
+ end
81
+
82
+ # Returns whether a path should be filtered or not.
83
+ #
84
+ # @param [String] path the path to test.
85
+ #
86
+ # @return [Boolean]
87
+ #
88
+ def filtered?(path)
89
+ @filters.empty? || @filters.any? { |filter| path =~ filter }
90
+ end
91
+
92
+ # Finds the paths that should be stored and adds them
93
+ # to the paths' hash.
94
+ #
95
+ def build
96
+ @paths = Hash.new { |h, k| h[k] = Hash.new }
97
+ important_paths { |path| insert_path(path) }
98
+ @updated_at = Time.now.to_i
99
+ end
100
+
101
+ # Detects changes in the passed directories, updates
102
+ # the record with the new changes and returns the changes
103
+ #
104
+ # @param [Array] directories the list of directories scan for changes
105
+ # @param [Hash] options
106
+ # @option options [Boolean] recursive scan all sub-directories recursively
107
+ # @option options [Boolean] relative_paths whether or not to use relative paths for changes
108
+ #
109
+ # @return [Hash<Array>] the changes
110
+ #
111
+ def fetch_changes(directories, options = {})
112
+ @changes = { :modified => [], :added => [], :removed => [] }
113
+ directories = directories.sort_by { |el| el.length }.reverse # diff sub-dir first
114
+ directories.each do |directory|
115
+ next unless directory[@directory] # Path is or inside directory
116
+ detect_modifications_and_removals(directory, options)
117
+ detect_additions(directory, options)
118
+ end
119
+ @updated_at = Time.now.to_i
120
+ @changes
121
+ end
122
+
123
+ private
124
+
125
+ # Detects modifications and removals recursively in a directory.
126
+ #
127
+ # @note Modifications detection begins by checking the modification time (mtime)
128
+ # of files and then by checking content changes (using SHA1-checksum)
129
+ # when the mtime of files is not changed.
130
+ #
131
+ # @param [String] directory the path to analyze
132
+ # @param [Hash] options
133
+ # @option options [Boolean] recursive scan all sub-directories recursively
134
+ # @option options [Boolean] relative_paths whether or not to use relative paths for changes
135
+ #
136
+ def detect_modifications_and_removals(directory, options = {})
137
+ @paths[directory].each do |basename, type|
138
+ path = File.join(directory, basename)
139
+
140
+ case type
141
+ when 'Dir'
142
+ if File.directory?(path)
143
+ detect_modifications_and_removals(path, options) if options[:recursive]
144
+ else
145
+ detect_modifications_and_removals(path, { :recursive => true }.merge(options))
146
+ @paths[directory].delete(basename)
147
+ @paths.delete("#{directory}/#{basename}")
148
+ end
149
+ when 'File'
150
+ if File.exist?(path)
151
+ new_mtime = File.mtime(path).to_i
152
+ if @updated_at < new_mtime || (@updated_at == new_mtime && content_modified?(path))
153
+ @changes[:modified] << (options[:relative_paths] ? relative_to_base(path) : path)
154
+ end
155
+ else
156
+ @paths[directory].delete(basename)
157
+ @sha1_checksums.delete(path)
158
+ @changes[:removed] << (options[:relative_paths] ? relative_to_base(path) : path)
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ # Detects additions in a directory.
165
+ #
166
+ # @param [String] directory the path to analyze
167
+ # @param [Hash] options
168
+ # @option options [Boolean] recursive scan all sub-directories recursively
169
+ # @option options [Boolean] relative_paths whether or not to use relative paths for changes
170
+ #
171
+ def detect_additions(directory, options = {})
172
+ Find.find(directory) do |path|
173
+ next if path == @directory
174
+
175
+ if File.directory?(path)
176
+ if ignored?(path) || (directory != path && (!options[:recursive] && existing_path?(path)))
177
+ Find.prune # Don't look any further into this directory.
178
+ else
179
+ insert_path(path)
180
+ end
181
+ elsif !ignored?(path) && filtered?(path) && !existing_path?(path)
182
+ if File.file?(path)
183
+ @changes[:added] << (options[:relative_paths] ? relative_to_base(path) : path)
184
+ end
185
+ insert_path(path)
186
+ end
187
+ end
188
+ end
189
+
190
+ # Returns whether or not a file's content has been modified by
191
+ # comparing the SHA1-checksum to a stored one.
192
+ #
193
+ # @param [String] path the file path
194
+ #
195
+ def content_modified?(path)
196
+ sha1_checksum = Digest::SHA1.file(path).to_s
197
+ if @sha1_checksums[path] != sha1_checksum
198
+ @sha1_checksums[path] = sha1_checksum
199
+ true
200
+ else
201
+ false
202
+ end
203
+ end
204
+
205
+ # Traverses the base directory looking for paths that should
206
+ # be stored; thus paths that are filters or not ignored.
207
+ #
208
+ # @yield [path] an important path
209
+ #
210
+ def important_paths
211
+ Find.find(@directory) do |path|
212
+ next if path == @directory
213
+
214
+ if File.directory?(path)
215
+ if ignored?(path)
216
+ Find.prune # Don't look any further into this directory.
217
+ else
218
+ yield(path)
219
+ end
220
+ elsif !ignored?(path) && filtered?(path)
221
+ yield(path)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Inserts a path with its type (Dir or File) in paths hash.
227
+ #
228
+ # @param [String] path the path to insert in @paths.
229
+ #
230
+ def insert_path(path)
231
+ @paths[File.dirname(path)][File.basename(path)] = File.directory?(path) ? 'Dir' : 'File'
232
+ end
233
+
234
+ # Returns whether or not a path exists in the paths hash.
235
+ #
236
+ # @param [String] path the path to check
237
+ #
238
+ # @return [Boolean]
239
+ #
240
+ def existing_path?(path)
241
+ @paths[File.dirname(path)][File.basename(path)] != nil
242
+ end
243
+
244
+ # Converts an absolute path to a path that's relative to the base directory.
245
+ #
246
+ # @param [String] path the path to convert
247
+ #
248
+ # @return [String] the relative path
249
+ #
250
+ def relative_to_base(path)
251
+ path.sub(%r(^#{@directory}/?/), '')
252
+ end
253
+ end
254
+ end