module ActiveSupport # \FileUpdateChecker specifies the API used by Rails to watch files # and control reloading. The API depends on four methods: # # * +initialize+ which expects two parameters and one block as # described below; # # * +updated?+ which returns a boolean if there were updates in # the filesystem or not; # # * +execute+ which executes the given block on initialization # and updates the latest watched files and timestamp; # # * +execute_if_updated+ which just executes the block if it was updated; # # After initialization, a call to +execute_if_updated+ must execute # the block only if there was really a change in the filesystem. # # == Examples # # This class is used by Rails to reload the I18n framework whenever # they are changed upon a new request. # # i18n_reloader = ActiveSupport::FileUpdateChecker.new(paths) do # I18n.reload! # end # # ActionDispatch::Reloader.to_prepare do # i18n_reloader.execute_if_updated # end # class FileUpdateChecker # It accepts two parameters on initialization. The first is an array # of files and the second is an optional hash of directories. The hash must # have directories as keys and the value is an array of extensions to be # watched under that directory. # # This method must also receive a block that will be called once a path # changes. The array of files and list of directories cannot be changed # after FileUpdateChecker has been initialized. def initialize(files, dirs={}, &block) @files = files.freeze @glob = compile_glob(dirs) @block = block @watched = nil @updated_at = nil @last_watched = watched @last_update_at = updated_at(@last_watched) end # Check if any of the entries were updated. If so, the watched and/or # updated_at values are cached until the block is executed via +execute+ # or +execute_if_updated+ def updated? current_watched = watched if @last_watched.size != current_watched.size @watched = current_watched true else current_updated_at = updated_at(current_watched) if @last_update_at < current_updated_at @watched = current_watched @updated_at = current_updated_at true else false end end end # Executes the given block and updates the latest watched files and timestamp. def execute @last_watched = watched @last_update_at = updated_at(@last_watched) @block.call ensure @watched = nil @updated_at = nil end # Execute the block given if updated. def execute_if_updated if updated? execute true else false end end private def watched @watched || begin all = @files.select { |f| File.exists?(f) } all.concat(Dir[@glob]) if @glob all end end def updated_at(paths) @updated_at || max_mtime(paths) || Time.at(0) end # This method returns the maximum mtime of the files in +paths+, or +nil+ # if the array is empty. # # Files with a mtime in the future are ignored. Such abnormal situation # can happen for example if the user changes the clock by hand. It is # healthy to consider this edge case because with mtimes in the future # reloading is not triggered. def max_mtime(paths) time_now = Time.now paths.map {|path| File.mtime(path)}.reject {|mtime| time_now < mtime}.max end def compile_glob(hash) hash.freeze # Freeze so changes aren't accidently pushed return if hash.empty? globs = hash.map do |key, value| "#{escape(key)}/**/*#{compile_ext(value)}" end "{#{globs.join(",")}}" end def escape(key) key.gsub(',','\,') end def compile_ext(array) array = Array(array) return if array.empty? ".{#{array.join(",")}}" end end end