require 'set' require 'find' require 'digest/sha1' module Listen # The directory record stores information about # a directory and keeps track of changes to # the structure of its childs. # class DirectoryRecord attr_reader :directory, :paths, :sha1_checksums DEFAULT_IGNORED_DIRECTORIES = %w[.rbx .bundle .git .svn log tmp vendor] DEFAULT_IGNORED_EXTENSIONS = %w[.DS_Store] # Defines the used precision based on the type of mtime returned by the # system (whether its in milliseconds or just seconds) # HIGH_PRECISION_SUPPORTED = File.mtime(__FILE__).to_f.to_s[-2..-1] != '.0' # Data structure used to save meta data about a path # MetaData = Struct.new(:type, :mtime) # Class methods # class << self # Creates the ignoring patterns from the default ignored # directories and extensions. It memoizes the generated patterns # to avoid unnecessary computation. # def generate_default_ignoring_patterns @@default_ignoring_patterns ||= Array.new.tap do |default_patterns| # Add directories ignored_directories = DEFAULT_IGNORED_DIRECTORIES.map { |d| Regexp.escape(d) } default_patterns << %r{^(?:#{ignored_directories.join('|')})/} # Add extensions ignored_extensions = DEFAULT_IGNORED_EXTENSIONS.map { |e| Regexp.escape(e) } default_patterns << %r{(?:#{ignored_extensions.join('|')})$} end end end # Initializes a directory record. # # @option [String] directory the directory to keep track of # def initialize(directory) raise ArgumentError, "The path '#{directory}' is not a directory!" unless File.directory?(directory) @directory = directory @ignoring_patterns = Set.new @filtering_patterns = Set.new @sha1_checksums = Hash.new @ignoring_patterns.merge(DirectoryRecord.generate_default_ignoring_patterns) end # Returns the ignoring patterns in the record # # @return [Array] the ignoring patterns # def ignoring_patterns @ignoring_patterns.to_a end # Returns the filtering patterns used in the record to know # which paths should be stored. # # @return [Array] the filtering patterns # def filtering_patterns @filtering_patterns.to_a end # Adds ignoring patterns to the record. # # @example Ignore some paths # ignore %r{^ignored/path/}, /man/ # # @param [Regexp] regexp a pattern for ignoring paths # def ignore(*regexps) @ignoring_patterns.merge(regexps) end # Adds filtering patterns to the listener. # # @example Filter some files # ignore /\.txt$/, /.*\.zip/ # # @param [Regexp] regexp a pattern for filtering paths # def filter(*regexps) @filtering_patterns.merge(regexps) end # Returns whether a path should be ignored or not. # # @param [String] path the path to test. # # @return [Boolean] # def ignored?(path) path = relative_to_base(path) @ignoring_patterns.any? { |pattern| pattern =~ path } end # Returns whether a path should be filtered or not. # # @param [String] path the path to test. # # @return [Boolean] # def filtered?(path) # When no filtering patterns are set, ALL files are stored. return true if @filtering_patterns.empty? path = relative_to_base(path) @filtering_patterns.any? { |pattern| pattern =~ path } end # Finds the paths that should be stored and adds them # to the paths' hash. # def build @paths = Hash.new { |h, k| h[k] = Hash.new } important_paths { |path| insert_path(path) } end # Detects changes in the passed directories, updates # the record with the new changes and returns the changes # # @param [Array] directories the list of directories scan for changes # @param [Hash] options # @option options [Boolean] recursive scan all sub-directories recursively # @option options [Boolean] relative_paths whether or not to use relative paths for changes # # @return [Hash] the changes # def fetch_changes(directories, options = {}) @changes = { :modified => [], :added => [], :removed => [] } directories = directories.sort_by { |el| el.length }.reverse # diff sub-dir first directories.each do |directory| next unless directory[@directory] # Path is or inside directory detect_modifications_and_removals(directory, options) detect_additions(directory, options) end @changes end # Converts an absolute path to a path that's relative to the base directory. # # @param [String] path the path to convert # # @return [String] the relative path # def relative_to_base(path) return nil unless path[@directory] path.sub(%r{^#{@directory}#{File::SEPARATOR}?}, '') end private # Detects modifications and removals recursively in a directory. # # @note Modifications detection begins by checking the modification time (mtime) # of files and then by checking content changes (using SHA1-checksum) # when the mtime of files is not changed. # # @param [String] directory the path to analyze # @param [Hash] options # @option options [Boolean] recursive scan all sub-directories recursively # @option options [Boolean] relative_paths whether or not to use relative paths for changes # def detect_modifications_and_removals(directory, options = {}) @paths[directory].each do |basename, meta_data| path = File.join(directory, basename) case meta_data.type when 'Dir' if File.directory?(path) detect_modifications_and_removals(path, options) if options[:recursive] else detect_modifications_and_removals(path, { :recursive => true }.merge(options)) @paths[directory].delete(basename) @paths.delete("#{directory}/#{basename}") end when 'File' if File.exist?(path) new_mtime = mtime_of(path) # First check if we are in the same second (to update checksums) # before checking the time difference if (meta_data.mtime.to_i == new_mtime.to_i && content_modified?(path)) || meta_data.mtime < new_mtime # Update the meta data of the files meta_data.mtime = new_mtime @paths[directory][basename] = meta_data @changes[:modified] << (options[:relative_paths] ? relative_to_base(path) : path) end else @paths[directory].delete(basename) @sha1_checksums.delete(path) @changes[:removed] << (options[:relative_paths] ? relative_to_base(path) : path) end end end end # Detects additions in a directory. # # @param [String] directory the path to analyze # @param [Hash] options # @option options [Boolean] recursive scan all sub-directories recursively # @option options [Boolean] relative_paths whether or not to use relative paths for changes # def detect_additions(directory, options = {}) # Don't process removed directories return unless File.exist?(directory) Find.find(directory) do |path| next if path == @directory if File.directory?(path) # Add a trailing slash to directories when checking if a directory is # ignored to optimize finding them as Find.find doesn't. if ignored?(path + File::SEPARATOR) || (directory != path && (!options[:recursive] && existing_path?(path))) Find.prune # Don't look any further into this directory. else insert_path(path) end elsif !ignored?(path) && filtered?(path) && !existing_path?(path) if File.file?(path) @changes[:added] << (options[:relative_paths] ? relative_to_base(path) : path) end insert_path(path) end end end # Returns whether or not a file's content has been modified by # comparing the SHA1-checksum to a stored one. # # @param [String] path the file path # def content_modified?(path) sha1_checksum = Digest::SHA1.file(path).to_s return false if @sha1_checksums[path] == sha1_checksum had_no_checksum = @sha1_checksums[path].nil? @sha1_checksums[path] = sha1_checksum had_no_checksum ? false : true end # Traverses the base directory looking for paths that should # be stored; thus paths that are filters or not ignored. # # @yield [path] an important path # def important_paths Find.find(@directory) do |path| next if path == @directory if File.directory?(path) # Add a trailing slash to directories when checking if a directory is # ignored to optimize finding them as Find.find doesn't. if ignored?(path + File::SEPARATOR) Find.prune # Don't look any further into this directory. else yield(path) end elsif !ignored?(path) && filtered?(path) yield(path) end end end # Inserts a path with its type (Dir or File) in paths hash. # # @param [String] path the path to insert in @paths. # def insert_path(path) meta_data = MetaData.new meta_data.type = File.directory?(path) ? 'Dir' : 'File' meta_data.mtime = mtime_of(path) unless meta_data.type == 'Dir' # mtimes of dirs are not used yet @paths[File.dirname(path)][File.basename(path)] = meta_data end # Returns whether or not a path exists in the paths hash. # # @param [String] path the path to check # # @return [Boolean] # def existing_path?(path) @paths[File.dirname(path)][File.basename(path)] != nil end # Returns the modification time of a file based on the precision defined by the system # # @param [String] file the file for which the mtime must be returned # # @return [Fixnum, Float] the mtime of the file # def mtime_of(file) File.mtime(file).send(HIGH_PRECISION_SUPPORTED ? :to_f : :to_i) end end end