listen 0.4.7 → 0.5.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.
@@ -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