# Watcher Libraryrequire'listen'require'middleman-core/contracts'require'backports/2.0.0/enumerable/lazy'moduleMiddleman# The default source watcher implementation. Watches a directory on disk# and responds to events on changes.classSourceWatcherextendForwardableincludeContracts# References to parent `Sources` app and `globally_ignored?` check.def_delegators:@parent,:app,:globally_ignored?# Reference to the singleton loggerdef_delegator:app,:logger# The type this watcher is representingContractNone=>Symbolattr_reader:type# The directory that is being watchedContractNone=>Pathnameattr_reader:directory# Options for configuring the watcherContractNone=>Hashattr_reader:options# Construct a new SourceWatcher## @param [Middleman::Sources] parent The parent collection.# @param [Symbol] type The watcher type.# @param [String] directory The on-disk path to watch.# @param [Hash] options Configuration options.ContractIsA['Middleman::Sources'],Symbol,String,Hash=>Anydefinitialize(parent,type,directory,options={})@parent=parent@options=options@type=type@directory=Pathname(directory)@files={}@extensionless_files={}@validator=options.fetch(:validator,proc{true})@ignored=options.fetch(:ignored,proc{false})@disable_watcher=app.build?||@parent.options.fetch(:disable_watcher,false)@force_polling=@parent.options.fetch(:force_polling,false)@latency=@parent.options.fetch(:latency,nil)@listener=nil@on_change_callbacks=Set.new@waiting_for_existence=!@directory.exist?end# Change the path of the watcher (if config values upstream change).## @param [String] directory The new path.# @return [void]ContractString=>Anydefupdate_path(directory)@directory=Pathname(directory)stop_listener!if@listenerupdate([],@files.values)poll_once!listen!unless@disable_watcherend# Stop watching.## @return [void]ContractNone=>Anydefunwatchstop_listener!end# All the known files in this watcher.## @return [Array<Middleman::SourceFile>]ContractNone=>ArrayOf[IsA['Middleman::SourceFile']]deffiles@files.valuesend# Find a specific file in this watcher.## @param [String, Pathname] path The search path.# @param [Boolean] glob If the path contains wildcard characters.# @return [Middleman::SourceFile, nil]ContractOr[String,Pathname],Maybe[Bool]=>Maybe[IsA['Middleman::SourceFile']]deffind(path,glob=false)p=Pathname(path)returnnilifp.absolute?&&!p.to_s.start_with?(@directory.to_s)p=@directory+pifp.relative?ifglob@extensionless_files[p]else@files[p]endend# Check if a file simply exists in this watcher.## @param [String, Pathname] path The search path.# @return [Boolean]ContractOr[String,Pathname]=>Booldefexists?(path)!find(path).nil?end# Start the `listen` gem Listener.## @return [void]ContractNone=>Anydeflisten!returnif@disable_watcher||@listener||@waiting_for_existenceconfig={force_polling: @force_polling}config[:latency]=@latencyif@latency@listener=::Listen.to(@directory.to_s,config,&method(:on_listener_change))@listener.startend# Stop the listener.## @return [void]ContractNone=>Anydefstop_listener!returnunless@listener@listener.stop@listener=nilend# Manually trigger update events.## @return [void]ContractNone=>Anydefpoll_once!removed=@files.keysupdated=[]::Middleman::Util.all_files_under(@directory.to_s).eachdo|filepath|removed.delete(filepath)updated<<filepathendupdate(updated,removed)returnunless@waiting_for_existence&&@directory.exist?@waiting_for_existence=falselisten!end# Add callback to be run on file change## @param [Proc] matcher A Regexp to match the change path against# @return [Set<Proc>]ContractProc=>SetOf[Proc]defon_change(&block)@on_change_callbacks<<block@on_change_callbacksend# Work around this bug: http://bugs.ruby-lang.org/issues/4521# where Ruby will call to_s/inspect while printing exception# messages, which can take a long time (minutes at full CPU)# if the object is huge or has cyclic references, like this.defto_s"#<Middleman::SourceWatcher:0x#{object_id} type=#{@type.inspect} directory=#{@directory.inspect}>"endalias_method:inspect,:to_s# Ruby 2.0 calls inspect for NoMethodError instead of to_sprotected# The `listen` gem callback.## @param [Array] modified List of modified files.# @param [Array] added List of added files.# @param [Array] removed List of removed files.# @return [void]ContractArray,Array,Array=>Anydefon_listener_change(modified,added,removed)updated=(modified+added)returnifupdated.empty?&&removed.empty?update(updated.map{|s|Pathname(s)},removed.map{|s|Pathname(s)})end# Update our internal list of files on a change.## @param [String, Pathname] path The updated file path.# @return [void]ContractArrayOf[Pathname],ArrayOf[Pathname]=>Anydefupdate(updated_paths,removed_paths)valid_updates=updated_paths.lazy.map(&method(:path_to_source_file)).select(&method(:valid?)).to_a.eachdo|f|add_file_to_cache(f)logger.debug"== Change (#{f[:types].inspect}): #{f[:relative_path]}"endvalid_removes=removed_paths.lazy.select(&@files.method(:key?)).map(&@files.method(:[])).select(&method(:valid?)).to_a.eachdo|f|remove_file_from_cache(f)logger.debug"== Deletion (#{f[:types].inspect}): #{f[:relative_path]}"endrun_callbacks(@on_change_callbacks,valid_updates,valid_removes)unlessvalid_updates.empty?&&valid_removes.empty?enddefadd_file_to_cache(f)@files[f[:full_path]]=f@extensionless_files[strip_extensions(f[:full_path])]=fenddefremove_file_from_cache(f)@files.delete(f[:full_path])@extensionless_files.delete(strip_extensions(f[:full_path]))enddefstrip_extensions(p)while::Tilt[p.to_s]||p.extname==='.html'p=p.sub_ext('')endPathname(p.to_s+'.*')end# Check if this watcher should care about a file.## @param [Middleman::SourceFile] file The file.# @return [Boolean]ContractIsA['Middleman::SourceFile']=>Booldefvalid?(file)@validator.call(file)&&!globally_ignored?(file)&&!@ignored.call(file)end# Convert a path to a file resprentation.## @param [Pathname] path The path.# @return [Middleman::SourceFile]ContractPathname=>IsA['Middleman::SourceFile']defpath_to_source_file(path)types=Set.new([@type])::Middleman::SourceFile.new(path.relative_path_from(@directory),path,@directory,types)end# Notify callbacks for a file given an array of callbacks## @param [Pathname] path The file that was changed# @param [Symbol] callbacks_name The name of the callbacks method# @return [void]ContractSet,ArrayOf[IsA['Middleman::SourceFile']],ArrayOf[IsA['Middleman::SourceFile']]=>Anydefrun_callbacks(callbacks,updated_files,removed_files)callbacks.eachdo|callback|callback.call(updated_files,removed_files,self)endendendend