listen 0.4.7 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,6 +4,10 @@ module Listen
4
4
  # Adapter implementation for Mac OS X `FSEvents`.
5
5
  #
6
6
  class Darwin < Adapter
7
+ extend DependencyManager
8
+
9
+ # Declare the adapter's dependencies
10
+ dependency 'rb-fsevent', '~> 0.9.1'
7
11
 
8
12
  LAST_SEPARATOR_REGEX = /\/$/
9
13
 
@@ -25,13 +29,14 @@ module Listen
25
29
  end
26
30
 
27
31
  @worker_thread = Thread.new { @worker.run }
28
- @poll_thread = Thread.new { poll_changed_dirs }
29
32
 
30
33
  # The FSEvent worker needs sometime to startup. Turnstiles can't
31
34
  # be used to wait for it as it runs in a loop.
32
35
  # TODO: Find a better way to block until the worker starts.
33
- sleep @latency
34
- @poll_thread.join if blocking
36
+ sleep 0.1
37
+
38
+ @poll_thread = Thread.new { poll_changed_dirs } if @report_changes
39
+ @worker_thread.join if blocking
35
40
  end
36
41
 
37
42
  # Stops the adapter.
@@ -43,8 +48,8 @@ module Listen
43
48
  end
44
49
 
45
50
  @worker.stop
46
- Thread.kill(@worker_thread) if @worker_thread
47
- @poll_thread.join
51
+ @worker_thread.join if @worker_thread
52
+ @poll_thread.join if @poll_thread
48
53
  end
49
54
 
50
55
  # Checks if the adapter is usable on the current OS.
@@ -53,11 +58,7 @@ module Listen
53
58
  #
54
59
  def self.usable?
55
60
  return false unless RbConfig::CONFIG['target_os'] =~ /darwin(1.+)?$/i
56
-
57
- require 'rb-fsevent'
58
- true
59
- rescue LoadError
60
- false
61
+ super
61
62
  end
62
63
 
63
64
  private
@@ -4,6 +4,10 @@ module Listen
4
4
  # Listener implementation for Linux `inotify`.
5
5
  #
6
6
  class Linux < Adapter
7
+ extend DependencyManager
8
+
9
+ # Declare the adapter's dependencies
10
+ dependency 'rb-inotify', '~> 0.8.8'
7
11
 
8
12
  # Watched inotify events
9
13
  #
@@ -41,8 +45,9 @@ module Listen
41
45
  end
42
46
 
43
47
  @worker_thread = Thread.new { @worker.run }
44
- @poll_thread = Thread.new { poll_changed_dirs }
45
- @poll_thread.join if blocking
48
+ @poll_thread = Thread.new { poll_changed_dirs } if @report_changes
49
+
50
+ @worker_thread.join if blocking
46
51
  end
47
52
 
48
53
  # Stops the adapter.
@@ -55,20 +60,16 @@ module Listen
55
60
 
56
61
  @worker.stop
57
62
  Thread.kill(@worker_thread) if @worker_thread
58
- @poll_thread.join
63
+ @poll_thread.join if @poll_thread
59
64
  end
60
65
 
61
- # Check if the adapter is usable on the current OS.
66
+ # Checks if the adapter is usable on the current OS.
62
67
  #
63
68
  # @return [Boolean] whether usable or not
64
69
  #
65
70
  def self.usable?
66
71
  return false unless RbConfig::CONFIG['target_os'] =~ /linux/i
67
-
68
- require 'rb-inotify'
69
- true
70
- rescue LoadError
71
- false
72
+ super
72
73
  end
73
74
 
74
75
  private
@@ -79,29 +80,31 @@ module Listen
79
80
  # @return [INotify::Notifier] initialized worker
80
81
  #
81
82
  def init_worker
82
- worker = INotify::Notifier.new
83
- @directories.each do |directory|
84
- worker.watch(directory, *EVENTS.map(&:to_sym)) do |event|
85
- if @paused || (
86
- # Event on root directory
87
- event.name == ""
88
- ) || (
89
- # INotify reports changes to files inside directories as events
90
- # on the directories themselves too.
91
- #
92
- # @see http://linux.die.net/man/7/inotify
93
- event.flags.include?(:isdir) and event.flags & [:close, :modify] != []
94
- )
95
- # Skip all of these!
96
- next
97
- end
98
-
99
- @mutex.synchronize do
100
- @changed_dirs << File.dirname(event.absolute_name)
101
- end
83
+ callback = lambda do |event|
84
+ if @paused || (
85
+ # Event on root directory
86
+ event.name == ""
87
+ ) || (
88
+ # INotify reports changes to files inside directories as events
89
+ # on the directories themselves too.
90
+ #
91
+ # @see http://linux.die.net/man/7/inotify
92
+ event.flags.include?(:isdir) and event.flags & [:close, :modify] != []
93
+ )
94
+ # Skip all of these!
95
+ next
96
+ end
97
+
98
+ @mutex.synchronize do
99
+ @changed_dirs << File.dirname(event.absolute_name)
100
+ end
101
+ end
102
+
103
+ INotify::Notifier.new.tap do |worker|
104
+ @directories.each do |directory|
105
+ worker.watch(directory, *EVENTS.map(&:to_sym), &callback)
102
106
  end
103
107
  end
104
- worker
105
108
  end
106
109
 
107
110
  end
@@ -10,6 +10,7 @@ module Listen
10
10
  # file IO that the other implementations.
11
11
  #
12
12
  class Polling < Adapter
13
+ extend DependencyManager
13
14
 
14
15
  # Initialize the Adapter. See {Listen::Adapter#initialize} for more info.
15
16
  #
@@ -3,9 +3,13 @@ require 'set'
3
3
  module Listen
4
4
  module Adapters
5
5
 
6
- # Adapter implementation for Windows `fchange`.
6
+ # Adapter implementation for Windows `wdm`.
7
7
  #
8
8
  class Windows < Adapter
9
+ extend DependencyManager
10
+
11
+ # Declare the adapter's dependencies
12
+ dependency 'wdm', '~> 0.0.3'
9
13
 
10
14
  # Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
11
15
  #
@@ -24,9 +28,15 @@ module Listen
24
28
  super
25
29
  end
26
30
 
27
- @worker_thread = Thread.new { @worker.run }
28
- @poll_thread = Thread.new { poll_changed_dirs(true) }
29
- @poll_thread.join if blocking
31
+ @worker_thread = Thread.new { @worker.run! }
32
+
33
+ # Wait for the worker to start. This is needed to avoid a deadlock
34
+ # when stopping immediately after starting.
35
+ sleep 0.1
36
+
37
+ @poll_thread = Thread.new { poll_changed_dirs } if @report_changes
38
+
39
+ @worker_thread.join if blocking
30
40
  end
31
41
 
32
42
  # Stops the adapter.
@@ -38,8 +48,8 @@ module Listen
38
48
  end
39
49
 
40
50
  @worker.stop
41
- Thread.kill(@worker_thread) if @worker_thread
42
- @poll_thread.join
51
+ @worker_thread.join if @worker_thread
52
+ @poll_thread.join if @poll_thread
43
53
  end
44
54
 
45
55
  # Checks if the adapter is usable on the current OS.
@@ -48,31 +58,27 @@ module Listen
48
58
  #
49
59
  def self.usable?
50
60
  return false unless RbConfig::CONFIG['target_os'] =~ /mswin|mingw/i
51
-
52
- require 'rb-fchange'
53
- true
54
- rescue LoadError
55
- false
61
+ super
56
62
  end
57
63
 
58
64
  private
59
65
 
60
- # Initializes a FChange worker and adds a watcher for
66
+ # Initializes a WDM monitor and adds a watcher for
61
67
  # each directory passed to the adapter.
62
68
  #
63
- # @return [FChange::Notifier] initialized worker
69
+ # @return [WDM::Monitor] initialized worker
64
70
  #
65
71
  def init_worker
66
- FChange::Notifier.new.tap do |worker|
67
- @directories.each do |directory|
68
- 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
72
+ callback = Proc.new do |change|
73
+ next if @paused
74
+ @mutex.synchronize do
75
+ @changed_dirs << File.dirname(change.path)
74
76
  end
75
77
  end
78
+
79
+ WDM::Monitor.new.tap do |worker|
80
+ @directories.each { |d| worker.watch_recursively(d, &callback) }
81
+ end
76
82
  end
77
83
 
78
84
  end
@@ -0,0 +1,126 @@
1
+ require 'set'
2
+
3
+ module Listen
4
+
5
+ # The dependency-manager offers a simple DSL which allows
6
+ # classes to declare their gem dependencies and load them when
7
+ # needed.
8
+ # It raises a user-friendly exception when the dependencies
9
+ # can't be loaded which has the install command in the message.
10
+ #
11
+ module DependencyManager
12
+
13
+ GEM_LOAD_MESSAGE = <<-EOS.gsub(/^ {6}/, '')
14
+ Missing dependency '%s' (version '%s')!
15
+ EOS
16
+
17
+ GEM_INSTALL_COMMAND = <<-EOS.gsub(/^ {6}/, '')
18
+ Please run the following to satisfy the dependency:
19
+ gem install --version '%s' %s
20
+ EOS
21
+
22
+ BUNDLER_DECLARE_GEM = <<-EOS.gsub(/^ {6}/, '')
23
+ Please add the following to your Gemfile to satisfy the dependency:
24
+ gem '%s', '%s'
25
+ EOS
26
+
27
+ Dependency = Struct.new(:name, :version)
28
+
29
+ # The error raised when a dependency can't be loaded.
30
+ class Error < StandardError; end
31
+
32
+ # A list of all loaded dependencies in the dependency manager.
33
+ @_loaded_dependencies = Set.new
34
+
35
+ # class methods
36
+ class << self
37
+
38
+ # Initializes the extended class.
39
+ #
40
+ # @param [Class] the class for which some dependencies must be managed
41
+ #
42
+ def extended(base)
43
+ base.class_eval do
44
+ @_dependencies = Set.new
45
+ end
46
+ end
47
+
48
+ # Adds a loaded dependency to a list so that it doesn't have
49
+ # to be loaded again by another classes.
50
+ #
51
+ # @param [Dependency] dependency
52
+ #
53
+ def add_loaded(dependency)
54
+ @_loaded_dependencies << dependency
55
+ end
56
+
57
+ # Returns whether the dependency is alread loaded or not.
58
+ #
59
+ # @param [Dependency] dependency
60
+ # @return [Boolean]
61
+ #
62
+ def already_loaded?(dependency)
63
+ @_loaded_dependencies.include?(dependency)
64
+ end
65
+
66
+ # Clears the list of loaded dependencies.
67
+ #
68
+ def clear_loaded
69
+ @_loaded_dependencies.clear
70
+ end
71
+ end
72
+
73
+ # Registers a new dependency.
74
+ #
75
+ # @param [String] name the name of the gem
76
+ # @param [String] version the version of the gem
77
+ #
78
+ def dependency(name, version)
79
+ @_dependencies << Dependency.new(name, version)
80
+ end
81
+
82
+ # Loads the registered dependencies.
83
+ #
84
+ # @raise DependencyManager::Error if the dependency can't be loaded.
85
+ #
86
+ def load_depenencies
87
+ @_dependencies.each do |dependency|
88
+ begin
89
+ next if DependencyManager.already_loaded?(dependency)
90
+ gem(dependency.name, dependency.version)
91
+ require(dependency.name)
92
+ DependencyManager.add_loaded(dependency)
93
+ @_dependencies.delete(dependency)
94
+ rescue Gem::LoadError
95
+ args = [dependency.name, dependency.version]
96
+ command = if running_under_bundler?
97
+ BUNDLER_DECLARE_GEM % args
98
+ else
99
+ GEM_INSTALL_COMMAND % args.reverse
100
+ end
101
+ message = GEM_LOAD_MESSAGE % args
102
+
103
+ raise Error.new(message + command)
104
+ end
105
+ end
106
+ end
107
+
108
+ # Returns whether all the dependencies has been loaded or not.
109
+ #
110
+ # @return [Boolean]
111
+ #
112
+ def dependencies_loaded?
113
+ @_dependencies.empty?
114
+ end
115
+
116
+ private
117
+
118
+ # Returns whether we are running under bundler or not
119
+ #
120
+ # @return [Boolean]
121
+ #
122
+ def running_under_bundler?
123
+ !!(File.exists?('Gemfile') && ENV['BUNDLE_GEMFILE'])
124
+ end
125
+ end
126
+ end
@@ -1,318 +1,318 @@
1
- require 'set'
2
- require 'find'
3
- require 'digest/sha1'
4
-
5
- module Listen
6
-
7
- # The directory record stores information about
8
- # a directory and keeps track of changes to
9
- # the structure of its childs.
10
- #
11
- class DirectoryRecord
12
- attr_reader :directory, :paths, :sha1_checksums
13
-
14
- DEFAULT_IGNORED_DIRECTORIES = %w[.rbx .bundle .git .svn log tmp vendor]
15
-
16
- DEFAULT_IGNORED_EXTENSIONS = %w[.DS_Store]
17
-
18
- # Defines the used precision based on the type of mtime returned by the
19
- # system (whether its in milliseconds or just seconds)
20
- #
21
- HIGH_PRECISION_SUPPORTED = File.mtime(__FILE__).to_f.to_s[-2..-1] != '.0'
22
-
23
- # Data structure used to save meta data about a path
24
- #
25
- MetaData = Struct.new(:type, :mtime)
26
-
27
- # Class methods
28
- #
29
- class << self
30
-
31
- # Creates the ignoring patterns from the default ignored
32
- # directories and extensions. It memoizes the generated patterns
33
- # to avoid unnecessary computation.
34
- #
35
- def generate_default_ignoring_patterns
36
- @@default_ignoring_patterns ||= Array.new.tap do |default_patterns|
37
- # Add directories
38
- ignored_directories = DEFAULT_IGNORED_DIRECTORIES.map { |d| Regexp.escape(d) }
39
- default_patterns << %r{^(?:#{ignored_directories.join('|')})/}
40
-
41
- # Add extensions
42
- ignored_extensions = DEFAULT_IGNORED_EXTENSIONS.map { |e| Regexp.escape(e) }
43
- default_patterns << %r{(?:#{ignored_extensions.join('|')})$}
44
- end
45
- end
46
- end
47
-
48
- # Initializes a directory record.
49
- #
50
- # @option [String] directory the directory to keep track of
51
- #
52
- def initialize(directory)
53
- raise ArgumentError, "The path '#{directory}' is not a directory!" unless File.directory?(directory)
54
-
55
- @directory = directory
56
- @ignoring_patterns = Set.new
57
- @filtering_patterns = Set.new
58
- @sha1_checksums = Hash.new
59
-
60
- @ignoring_patterns.merge(DirectoryRecord.generate_default_ignoring_patterns)
61
- end
62
-
63
- # Returns the ignoring patterns in the record
64
- #
65
- # @return [Array<Regexp>] the ignoring patterns
66
- #
67
- def ignoring_patterns
68
- @ignoring_patterns.to_a
69
- end
70
-
71
- # Returns the filtering patterns used in the record to know
72
- # which paths should be stored.
73
- #
74
- # @return [Array<Regexp>] the filtering patterns
75
- #
76
- def filtering_patterns
77
- @filtering_patterns.to_a
78
- end
79
-
80
- # Adds ignoring patterns to the record.
81
- #
82
- # @example Ignore some paths
83
- # ignore %r{^ignored/path/}, /man/
84
- #
85
- # @param [Regexp] regexp a pattern for ignoring paths
86
- #
87
- def ignore(*regexps)
88
- @ignoring_patterns.merge(regexps)
89
- end
90
-
91
- # Adds filtering patterns to the listener.
92
- #
93
- # @example Filter some files
94
- # ignore /\.txt$/, /.*\.zip/
95
- #
96
- # @param [Regexp] regexp a pattern for filtering paths
97
- #
98
- def filter(*regexps)
99
- @filtering_patterns.merge(regexps)
100
- end
101
-
102
- # Returns whether a path should be ignored or not.
103
- #
104
- # @param [String] path the path to test.
105
- #
106
- # @return [Boolean]
107
- #
108
- def ignored?(path)
109
- path = relative_to_base(path)
110
- @ignoring_patterns.any? { |pattern| pattern =~ path }
111
- end
112
-
113
- # Returns whether a path should be filtered or not.
114
- #
115
- # @param [String] path the path to test.
116
- #
117
- # @return [Boolean]
118
- #
119
- def filtered?(path)
120
- # When no filtering patterns are set, ALL files are stored.
121
- return true if @filtering_patterns.empty?
122
-
123
- path = relative_to_base(path)
124
- @filtering_patterns.any? { |pattern| pattern =~ path }
125
- end
126
-
127
- # Finds the paths that should be stored and adds them
128
- # to the paths' hash.
129
- #
130
- def build
131
- @paths = Hash.new { |h, k| h[k] = Hash.new }
132
- important_paths { |path| insert_path(path) }
133
- end
134
-
135
- # Detects changes in the passed directories, updates
136
- # the record with the new changes and returns the changes
137
- #
138
- # @param [Array] directories the list of directories scan for changes
139
- # @param [Hash] options
140
- # @option options [Boolean] recursive scan all sub-directories recursively
141
- # @option options [Boolean] relative_paths whether or not to use relative paths for changes
142
- #
143
- # @return [Hash<Array>] the changes
144
- #
145
- def fetch_changes(directories, options = {})
146
- @changes = { :modified => [], :added => [], :removed => [] }
147
- directories = directories.sort_by { |el| el.length }.reverse # diff sub-dir first
148
-
149
- directories.each do |directory|
150
- next unless directory[@directory] # Path is or inside directory
151
- detect_modifications_and_removals(directory, options)
152
- detect_additions(directory, options)
153
- end
154
-
155
- @changes
156
- end
157
-
158
- # Converts an absolute path to a path that's relative to the base directory.
159
- #
160
- # @param [String] path the path to convert
161
- #
162
- # @return [String] the relative path
163
- #
164
- def relative_to_base(path)
165
- return nil unless path[@directory]
166
- path.sub(%r{^#{Regexp.quote(@directory)}#{File::SEPARATOR}?}, '')
167
- end
168
-
169
- private
170
-
171
- # Detects modifications and removals recursively in a directory.
172
- #
173
- # @note Modifications detection begins by checking the modification time (mtime)
174
- # of files and then by checking content changes (using SHA1-checksum)
175
- # when the mtime of files is not changed.
176
- #
177
- # @param [String] directory the path to analyze
178
- # @param [Hash] options
179
- # @option options [Boolean] recursive scan all sub-directories recursively
180
- # @option options [Boolean] relative_paths whether or not to use relative paths for changes
181
- #
182
- def detect_modifications_and_removals(directory, options = {})
183
- @paths[directory].each do |basename, meta_data|
184
- path = File.join(directory, basename)
185
-
186
- case meta_data.type
187
- when 'Dir'
188
- if File.directory?(path)
189
- detect_modifications_and_removals(path, options) if options[:recursive]
190
- else
191
- detect_modifications_and_removals(path, { :recursive => true }.merge(options))
192
- @paths[directory].delete(basename)
193
- @paths.delete("#{directory}/#{basename}")
194
- end
195
- when 'File'
196
- if File.exist?(path)
197
- new_mtime = mtime_of(path)
198
-
199
- # First check if we are in the same second (to update checksums)
200
- # before checking the time difference
201
- if (meta_data.mtime.to_i == new_mtime.to_i && content_modified?(path)) || meta_data.mtime < new_mtime
202
- # Update the meta data of the files
203
- meta_data.mtime = new_mtime
204
- @paths[directory][basename] = meta_data
205
-
206
- @changes[:modified] << (options[:relative_paths] ? relative_to_base(path) : path)
207
- end
208
- else
209
- @paths[directory].delete(basename)
210
- @sha1_checksums.delete(path)
211
- @changes[:removed] << (options[:relative_paths] ? relative_to_base(path) : path)
212
- end
213
- end
214
- end
215
- end
216
-
217
- # Detects additions in a directory.
218
- #
219
- # @param [String] directory the path to analyze
220
- # @param [Hash] options
221
- # @option options [Boolean] recursive scan all sub-directories recursively
222
- # @option options [Boolean] relative_paths whether or not to use relative paths for changes
223
- #
224
- def detect_additions(directory, options = {})
225
- # Don't process removed directories
226
- return unless File.exist?(directory)
227
-
228
- Find.find(directory) do |path|
229
- next if path == @directory
230
-
231
- if File.directory?(path)
232
- # Add a trailing slash to directories when checking if a directory is
233
- # ignored to optimize finding them as Find.find doesn't.
234
- if ignored?(path + File::SEPARATOR) || (directory != path && (!options[:recursive] && existing_path?(path)))
235
- Find.prune # Don't look any further into this directory.
236
- else
237
- insert_path(path)
238
- end
239
- elsif !ignored?(path) && filtered?(path) && !existing_path?(path)
240
- if File.file?(path)
241
- @changes[:added] << (options[:relative_paths] ? relative_to_base(path) : path)
242
- insert_path(path)
243
- end
244
- end
245
- end
246
- end
247
-
248
- # Returns whether or not a file's content has been modified by
249
- # comparing the SHA1-checksum to a stored one.
250
- #
251
- # @param [String] path the file path
252
- #
253
- def content_modified?(path)
254
- sha1_checksum = Digest::SHA1.file(path).to_s
255
- return false if @sha1_checksums[path] == sha1_checksum
256
- @sha1_checksums.key?(path)
257
- rescue Errno::EACCES, Errno::ENOENT
258
- false
259
- ensure
260
- @sha1_checksums[path] = sha1_checksum if sha1_checksum
261
- end
262
-
263
- # Traverses the base directory looking for paths that should
264
- # be stored; thus paths that are filters or not ignored.
265
- #
266
- # @yield [path] an important path
267
- #
268
- def important_paths
269
- Find.find(@directory) do |path|
270
- next if path == @directory
271
-
272
- if File.directory?(path)
273
- # Add a trailing slash to directories when checking if a directory is
274
- # ignored to optimize finding them as Find.find doesn't.
275
- if ignored?(path + File::SEPARATOR)
276
- Find.prune # Don't look any further into this directory.
277
- else
278
- yield(path)
279
- end
280
- elsif !ignored?(path) && filtered?(path)
281
- yield(path)
282
- end
283
- end
284
- end
285
-
286
- # Inserts a path with its type (Dir or File) in paths hash.
287
- #
288
- # @param [String] path the path to insert in @paths.
289
- #
290
- def insert_path(path)
291
- meta_data = MetaData.new
292
- meta_data.type = File.directory?(path) ? 'Dir' : 'File'
293
- meta_data.mtime = mtime_of(path) unless meta_data.type == 'Dir' # mtimes of dirs are not used yet
294
- @paths[File.dirname(path)][File.basename(path)] = meta_data
295
- rescue Errno::ENOENT
296
- end
297
-
298
- # Returns whether or not a path exists in the paths hash.
299
- #
300
- # @param [String] path the path to check
301
- #
302
- # @return [Boolean]
303
- #
304
- def existing_path?(path)
305
- @paths[File.dirname(path)][File.basename(path)] != nil
306
- end
307
-
308
- # Returns the modification time of a file based on the precision defined by the system
309
- #
310
- # @param [String] file the file for which the mtime must be returned
311
- #
312
- # @return [Fixnum, Float] the mtime of the file
313
- #
314
- def mtime_of(file)
315
- File.lstat(file).mtime.send(HIGH_PRECISION_SUPPORTED ? :to_f : :to_i)
316
- end
317
- end
318
- end
1
+ require 'set'
2
+ require 'find'
3
+ require 'digest/sha1'
4
+
5
+ module Listen
6
+
7
+ # The directory record stores information about
8
+ # a directory and keeps track of changes to
9
+ # the structure of its childs.
10
+ #
11
+ class DirectoryRecord
12
+ attr_reader :directory, :paths, :sha1_checksums
13
+
14
+ DEFAULT_IGNORED_DIRECTORIES = %w[.rbx .bundle .git .svn log tmp vendor]
15
+
16
+ DEFAULT_IGNORED_EXTENSIONS = %w[.DS_Store]
17
+
18
+ # Defines the used precision based on the type of mtime returned by the
19
+ # system (whether its in milliseconds or just seconds)
20
+ #
21
+ HIGH_PRECISION_SUPPORTED = File.mtime(__FILE__).to_f.to_s[-2..-1] != '.0'
22
+
23
+ # Data structure used to save meta data about a path
24
+ #
25
+ MetaData = Struct.new(:type, :mtime)
26
+
27
+ # Class methods
28
+ #
29
+ class << self
30
+
31
+ # Creates the ignoring patterns from the default ignored
32
+ # directories and extensions. It memoizes the generated patterns
33
+ # to avoid unnecessary computation.
34
+ #
35
+ def generate_default_ignoring_patterns
36
+ @@default_ignoring_patterns ||= Array.new.tap do |default_patterns|
37
+ # Add directories
38
+ ignored_directories = DEFAULT_IGNORED_DIRECTORIES.map { |d| Regexp.escape(d) }
39
+ default_patterns << %r{^(?:#{ignored_directories.join('|')})/}
40
+
41
+ # Add extensions
42
+ ignored_extensions = DEFAULT_IGNORED_EXTENSIONS.map { |e| Regexp.escape(e) }
43
+ default_patterns << %r{(?:#{ignored_extensions.join('|')})$}
44
+ end
45
+ end
46
+ end
47
+
48
+ # Initializes a directory record.
49
+ #
50
+ # @option [String] directory the directory to keep track of
51
+ #
52
+ def initialize(directory)
53
+ raise ArgumentError, "The path '#{directory}' is not a directory!" unless File.directory?(directory)
54
+
55
+ @directory = directory
56
+ @ignoring_patterns = Set.new
57
+ @filtering_patterns = Set.new
58
+ @sha1_checksums = Hash.new
59
+
60
+ @ignoring_patterns.merge(DirectoryRecord.generate_default_ignoring_patterns)
61
+ end
62
+
63
+ # Returns the ignoring patterns in the record
64
+ #
65
+ # @return [Array<Regexp>] the ignoring patterns
66
+ #
67
+ def ignoring_patterns
68
+ @ignoring_patterns.to_a
69
+ end
70
+
71
+ # Returns the filtering patterns used in the record to know
72
+ # which paths should be stored.
73
+ #
74
+ # @return [Array<Regexp>] the filtering patterns
75
+ #
76
+ def filtering_patterns
77
+ @filtering_patterns.to_a
78
+ end
79
+
80
+ # Adds ignoring patterns to the record.
81
+ #
82
+ # @example Ignore some paths
83
+ # ignore %r{^ignored/path/}, /man/
84
+ #
85
+ # @param [Regexp] regexp a pattern for ignoring paths
86
+ #
87
+ def ignore(*regexps)
88
+ @ignoring_patterns.merge(regexps)
89
+ end
90
+
91
+ # Adds filtering patterns to the listener.
92
+ #
93
+ # @example Filter some files
94
+ # ignore /\.txt$/, /.*\.zip/
95
+ #
96
+ # @param [Regexp] regexp a pattern for filtering paths
97
+ #
98
+ def filter(*regexps)
99
+ @filtering_patterns.merge(regexps)
100
+ end
101
+
102
+ # Returns whether a path should be ignored or not.
103
+ #
104
+ # @param [String] path the path to test.
105
+ #
106
+ # @return [Boolean]
107
+ #
108
+ def ignored?(path)
109
+ path = relative_to_base(path)
110
+ @ignoring_patterns.any? { |pattern| pattern =~ path }
111
+ end
112
+
113
+ # Returns whether a path should be filtered or not.
114
+ #
115
+ # @param [String] path the path to test.
116
+ #
117
+ # @return [Boolean]
118
+ #
119
+ def filtered?(path)
120
+ # When no filtering patterns are set, ALL files are stored.
121
+ return true if @filtering_patterns.empty?
122
+
123
+ path = relative_to_base(path)
124
+ @filtering_patterns.any? { |pattern| pattern =~ path }
125
+ end
126
+
127
+ # Finds the paths that should be stored and adds them
128
+ # to the paths' hash.
129
+ #
130
+ def build
131
+ @paths = Hash.new { |h, k| h[k] = Hash.new }
132
+ important_paths { |path| insert_path(path) }
133
+ end
134
+
135
+ # Detects changes in the passed directories, updates
136
+ # the record with the new changes and returns the changes
137
+ #
138
+ # @param [Array] directories the list of directories scan for changes
139
+ # @param [Hash] options
140
+ # @option options [Boolean] recursive scan all sub-directories recursively
141
+ # @option options [Boolean] relative_paths whether or not to use relative paths for changes
142
+ #
143
+ # @return [Hash<Array>] the changes
144
+ #
145
+ def fetch_changes(directories, options = {})
146
+ @changes = { :modified => [], :added => [], :removed => [] }
147
+ directories = directories.sort_by { |el| el.length }.reverse # diff sub-dir first
148
+
149
+ directories.each do |directory|
150
+ next unless directory[@directory] # Path is or inside directory
151
+ detect_modifications_and_removals(directory, options)
152
+ detect_additions(directory, options)
153
+ end
154
+
155
+ @changes
156
+ end
157
+
158
+ # Converts an absolute path to a path that's relative to the base directory.
159
+ #
160
+ # @param [String] path the path to convert
161
+ #
162
+ # @return [String] the relative path
163
+ #
164
+ def relative_to_base(path)
165
+ return nil unless path[@directory]
166
+ path.sub(%r{^#{Regexp.quote(@directory)}#{File::SEPARATOR}?}, '')
167
+ end
168
+
169
+ private
170
+
171
+ # Detects modifications and removals recursively in a directory.
172
+ #
173
+ # @note Modifications detection begins by checking the modification time (mtime)
174
+ # of files and then by checking content changes (using SHA1-checksum)
175
+ # when the mtime of files is not changed.
176
+ #
177
+ # @param [String] directory the path to analyze
178
+ # @param [Hash] options
179
+ # @option options [Boolean] recursive scan all sub-directories recursively
180
+ # @option options [Boolean] relative_paths whether or not to use relative paths for changes
181
+ #
182
+ def detect_modifications_and_removals(directory, options = {})
183
+ @paths[directory].each do |basename, meta_data|
184
+ path = File.join(directory, basename)
185
+
186
+ case meta_data.type
187
+ when 'Dir'
188
+ if File.directory?(path)
189
+ detect_modifications_and_removals(path, options) if options[:recursive]
190
+ else
191
+ detect_modifications_and_removals(path, { :recursive => true }.merge(options))
192
+ @paths[directory].delete(basename)
193
+ @paths.delete("#{directory}/#{basename}")
194
+ end
195
+ when 'File'
196
+ if File.exist?(path)
197
+ new_mtime = mtime_of(path)
198
+
199
+ # First check if we are in the same second (to update checksums)
200
+ # before checking the time difference
201
+ if (meta_data.mtime.to_i == new_mtime.to_i && content_modified?(path)) || meta_data.mtime < new_mtime
202
+ # Update the meta data of the files
203
+ meta_data.mtime = new_mtime
204
+ @paths[directory][basename] = meta_data
205
+
206
+ @changes[:modified] << (options[:relative_paths] ? relative_to_base(path) : path)
207
+ end
208
+ else
209
+ @paths[directory].delete(basename)
210
+ @sha1_checksums.delete(path)
211
+ @changes[:removed] << (options[:relative_paths] ? relative_to_base(path) : path)
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ # Detects additions in a directory.
218
+ #
219
+ # @param [String] directory the path to analyze
220
+ # @param [Hash] options
221
+ # @option options [Boolean] recursive scan all sub-directories recursively
222
+ # @option options [Boolean] relative_paths whether or not to use relative paths for changes
223
+ #
224
+ def detect_additions(directory, options = {})
225
+ # Don't process removed directories
226
+ return unless File.exist?(directory)
227
+
228
+ Find.find(directory) do |path|
229
+ next if path == @directory
230
+
231
+ if File.directory?(path)
232
+ # Add a trailing slash to directories when checking if a directory is
233
+ # ignored to optimize finding them as Find.find doesn't.
234
+ if ignored?(path + File::SEPARATOR) || (directory != path && (!options[:recursive] && existing_path?(path)))
235
+ Find.prune # Don't look any further into this directory.
236
+ else
237
+ insert_path(path)
238
+ end
239
+ elsif !ignored?(path) && filtered?(path) && !existing_path?(path)
240
+ if File.file?(path)
241
+ @changes[:added] << (options[:relative_paths] ? relative_to_base(path) : path)
242
+ insert_path(path)
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ # Returns whether or not a file's content has been modified by
249
+ # comparing the SHA1-checksum to a stored one.
250
+ #
251
+ # @param [String] path the file path
252
+ #
253
+ def content_modified?(path)
254
+ sha1_checksum = Digest::SHA1.file(path).to_s
255
+ return false if @sha1_checksums[path] == sha1_checksum
256
+ @sha1_checksums.key?(path)
257
+ rescue Errno::EACCES, Errno::ENOENT
258
+ false
259
+ ensure
260
+ @sha1_checksums[path] = sha1_checksum if sha1_checksum
261
+ end
262
+
263
+ # Traverses the base directory looking for paths that should
264
+ # be stored; thus paths that are filters or not ignored.
265
+ #
266
+ # @yield [path] an important path
267
+ #
268
+ def important_paths
269
+ Find.find(@directory) do |path|
270
+ next if path == @directory
271
+
272
+ if File.directory?(path)
273
+ # Add a trailing slash to directories when checking if a directory is
274
+ # ignored to optimize finding them as Find.find doesn't.
275
+ if ignored?(path + File::SEPARATOR)
276
+ Find.prune # Don't look any further into this directory.
277
+ else
278
+ yield(path)
279
+ end
280
+ elsif !ignored?(path) && filtered?(path)
281
+ yield(path)
282
+ end
283
+ end
284
+ end
285
+
286
+ # Inserts a path with its type (Dir or File) in paths hash.
287
+ #
288
+ # @param [String] path the path to insert in @paths.
289
+ #
290
+ def insert_path(path)
291
+ meta_data = MetaData.new
292
+ meta_data.type = File.directory?(path) ? 'Dir' : 'File'
293
+ meta_data.mtime = mtime_of(path) unless meta_data.type == 'Dir' # mtimes of dirs are not used yet
294
+ @paths[File.dirname(path)][File.basename(path)] = meta_data
295
+ rescue Errno::ENOENT
296
+ end
297
+
298
+ # Returns whether or not a path exists in the paths hash.
299
+ #
300
+ # @param [String] path the path to check
301
+ #
302
+ # @return [Boolean]
303
+ #
304
+ def existing_path?(path)
305
+ @paths[File.dirname(path)][File.basename(path)] != nil
306
+ end
307
+
308
+ # Returns the modification time of a file based on the precision defined by the system
309
+ #
310
+ # @param [String] file the file for which the mtime must be returned
311
+ #
312
+ # @return [Fixnum, Float] the mtime of the file
313
+ #
314
+ def mtime_of(file)
315
+ File.lstat(file).mtime.send(HIGH_PRECISION_SUPPORTED ? :to_f : :to_i)
316
+ end
317
+ end
318
+ end