require 'hamster' require 'middleman-core/contracts' module Middleman # The standard "record" that contains information about a file on disk. SourceFile = Struct.new :relative_path, :full_path, :directory, :types # Sources handle multiple on-disk collections of files which make up # a Middleman project. They are separated by `type` which can then be # queried. For example, the `source` type represents all content that # the sitemap uses to build a project. The `data` type represents YAML # data. The `locales` type represents localization YAML, and so on. class Sources extend Forwardable include Contracts # Types which could cause output to change. OUTPUT_TYPES = [:source, :locales, :data].freeze # Types which require a reload to eval ruby CODE_TYPES = [:reload].freeze Matcher = Or[Regexp, RespondTo[:call]] # A reference to the current app. Contract IsA['Middleman::Application'] attr_reader :app # Duck-typed definition of a valid source watcher HANDLER = RespondTo[:on_change] # Config Contract Hash attr_reader :options # Reference to the global logger. def_delegator :@app, :logger # Built-in types # :source, :data, :locales, :reload # Create a new collection of sources. # # @param [Middleman::Application] app The parent app. # @param [Hash] options Global options. # @param [Array] watchers Default watchers. Contract IsA['Middleman::Application'], Maybe[Hash], Maybe[Array] => Any def initialize(app, options={}, watchers=[]) @app = app @watchers = watchers @sorted_watchers = @watchers.dup.freeze @options = options # Set of procs wanting to be notified of changes @on_change_callbacks = ::Hamster::Vector.empty # Global ignores @ignores = ::Hamster::Hash.empty # Whether we're "running", which means we're in a stable # watch state after all initialization and config. @running = false @update_count = 0 @last_update_count = -1 # When the app is about to shut down, stop our watchers. @app.before_shutdown(&method(:stop!)) end # Add a proc to ignore paths with either a regex or block. # # @param [Symbol] name A name for the ignore. # @param [Symbol] type The type of content to apply the ignore to. # @param [Regexp] regex Ignore by path regex. # @param [Proc] block Ignore by block evaluation. # @return [void] Contract Symbol, Symbol, Or[Regexp, Proc] => Any def ignore(name, type, regex=nil, &block) @ignores = @ignores.put(name, type: type, validator: (block_given? ? block : regex)) bump_count find_new_files! if @running end # Whether this path is ignored. # # @param [Middleman::SourceFile] file The file to check. # @return [Boolean] Contract SourceFile => Bool def globally_ignored?(file) @ignores.values.any? do |descriptor| ((descriptor[:type] == :all) || file[:types].include?(descriptor[:type])) && matches?(descriptor[:validator], file) end end # Connect a new watcher. Can either be a type with options, which will # create a `SourceWatcher` or you can pass in an instantiated class which # responds to #changed and #deleted # # @param [Symbol, #changed, #deleted] type_or_handler The handler. # @param [Hash] options The watcher options. # @return [#changed, #deleted] Contract Or[Symbol, HANDLER], Maybe[Hash] => HANDLER def watch(type_or_handler, options={}) handler = if type_or_handler.is_a? Symbol SourceWatcher.new(self, type_or_handler, options.delete(:path), options) else type_or_handler end @watchers << handler # The index trick is used so that the sort is stable - watchers with the same priority # will always be ordered in the same order as they were registered. n = 0 @sorted_watchers = @watchers.sort_by do |w| priority = w.options.fetch(:priority, 50) n += 1 [priority, n] end.reverse.freeze handler.on_change(&method(:did_change)) if @running handler.poll_once! handler.listen! end handler end # A list of registered watchers Contract ArrayOf[HANDLER] def watchers @sorted_watchers end # Disconnect a specific watcher. # # @param [SourceWatcher] watcher The watcher to remove. # @return [void] Contract RespondTo[:on_change] => Any def unwatch(watcher) @watchers.delete(watcher) watcher.unwatch bump_count end # Filter the collection of watchers by a type. # # @param [Symbol] type The watcher type. # @return [Middleman::Sources] Contract Symbol => ::Middleman::Sources def by_type(type) self.class.new @app, @options, watchers.select { |d| d.type == type } end # Get all files for this collection of watchers. # # @return [Array] Contract ArrayOf[SourceFile] def files watchers.flat_map(&:files).uniq { |f| f[:relative_path] } end # Find a file given a type and path. # # @param [Symbol,Array] types A list of file "type". # @param [String] path The file path. # @param [Boolean] glob If the path contains wildcard or glob characters. # @return [Middleman::SourceFile, nil] Contract Or[Symbol, ArrayOf[Symbol], SetOf[Symbol]], String, Maybe[Bool] => Maybe[SourceFile] def find(types, path, glob=false) watchers .lazy .select { |d| Array(types).include?(d.type) } .map { |d| d.find(path, glob) } .reject(&:nil?) .first end # Check if a file for a given type exists. # # @param [Symbol,Array] types The list of file "type". # @param [String] path The file path relative to it's source root. # @return [Boolean] Contract Or[Symbol, ArrayOf[Symbol], SetOf[Symbol]], String => Bool def exists?(types, path) watchers .lazy .select { |d| Array(types).include?(d.type) } .any? { |d| d.exists?(path) } end # Check if a file for a given type exists. # # @param [Symbol,Array] types The list of file "type". # @param [String] path The file path relative to it's source root. # @return [Boolean] Contract Or[Symbol, ArrayOf[Symbol], SetOf[Symbol]], String => Maybe[HANDLER] def watcher_for_path(types, path) watchers .select { |d| Array(types).include?(d.type) } .find { |d| d.exists?(path) } end # Manually poll all watchers for new content. # # @return [void] Contract ArrayOf[Pathname] def find_new_files! return [] unless @update_count != @last_update_count @last_update_count = @update_count watchers.reduce([]) { |sum, w| sum + w.poll_once! } end # Start up all listeners. # # @return [void] Contract Any def start! watchers.each(&:listen!) @running = true end # Stop the watchers. # # @return [void] Contract Any def stop! watchers.each(&:stop_listener!) @running = false end # A callback requires a type and the proc to execute. CallbackDescriptor = Struct.new :type, :proc # Add callback to be run on file change or deletion # # @param [Symbol,Array] types The change types to register the callback. # @return [void] Contract Or[Symbol, ArrayOf[Symbol], SetOf[Symbol]], Proc => Any def on_change(types, &block) Array(types).each do |type| @on_change_callbacks = @on_change_callbacks.push(CallbackDescriptor.new(type, block)) end end # Backwards compatible change handler. # # @param [nil,Regexp] matcher A Regexp to match the change path against Contract Maybe[Matcher] => Any def changed(matcher=nil, &_block) on_change OUTPUT_TYPES do |updated, _removed| updated .select { |f| matcher.nil? ? true : matches?(matcher, f) } .each { |f| yield f[:relative_path] } end end # Backwards compatible delete handler. # # @param [nil,Regexp] matcher A Regexp to match the change path against Contract Maybe[Matcher] => Any def deleted(matcher=nil, &_block) on_change OUTPUT_TYPES do |_updated, removed| removed .select { |f| matcher.nil? ? true : matches?(matcher, f) } .each { |f| yield f[:relative_path] } end end # Backwards compatible ignored check. # # @param [Pathname,String] path The path to check. Contract Or[Pathname, String] => Bool def ignored?(path) descriptor = find(OUTPUT_TYPES, path) !descriptor || globally_ignored?(descriptor) end protected # Whether a validator matches a file. # # @param [Regexp, #call] validator The match validator. # @param [Middleman::SourceFile] file The file to check. # @return [Boolean] Contract Matcher, SourceFile => Bool def matches?(validator, file) path = file[:relative_path] if validator.is_a? Regexp !!validator.match(path.to_s) else !!validator.call(path, @app) end end # Increment the internal counter for changes. # # @return [void] Contract Any def bump_count @update_count += 1 end # Notify callbacks that a file changed # # @param [Middleman::SourceFile] file The file that changed # @return [void] Contract ArrayOf[SourceFile], ArrayOf[SourceFile], HANDLER => Any def did_change(updated_files, removed_files, watcher) valid_updated = updated_files.select do |file| watcher_for_path(file[:types], file[:relative_path].to_s) == watcher end valid_removed = removed_files.select do |file| watcher_for_path(file[:types], file[:relative_path].to_s).nil? end return if valid_updated.empty? && valid_removed.empty? bump_count run_callbacks(@on_change_callbacks, valid_updated, valid_removed) end # Notify callbacks for a file given a set of callbacks # # @param [Set] callback_descriptors The registered callbacks. # @param [Array] files The files that were changed. # @return [void] Contract VectorOf[CallbackDescriptor], ArrayOf[SourceFile], ArrayOf[SourceFile] => Any def run_callbacks(callback_descriptors, updated_files, removed_files) callback_descriptors.each do |callback| if callback[:type] == :all callback[:proc].call(updated_files, removed_files) else valid_updated = updated_files.select { |f| f[:types].include?(callback[:type]) } valid_removed = removed_files.select { |f| f[:types].include?(callback[:type]) } callback[:proc].call(valid_updated, valid_removed) unless valid_updated.empty? && valid_removed.empty? end end end end end # And, require the actual default implementation for a watcher. require 'middleman-core/sources/source_watcher'