├── .hound.yml ├── .rspec ├── lib ├── listen │ ├── version.rb │ ├── options.rb │ ├── adapter │ │ ├── config.rb │ │ ├── polling.rb │ │ ├── windows.rb │ │ ├── darwin.rb │ │ ├── bsd.rb │ │ ├── linux.rb │ │ └── base.rb │ ├── logger.rb │ ├── internals │ │ └── thread_pool.rb │ ├── record │ │ ├── symlink_detector.rb │ │ └── entry.rb │ ├── backend.rb │ ├── event │ │ ├── config.rb │ │ ├── queue.rb │ │ ├── loop.rb │ │ └── processor.rb │ ├── listener │ │ └── config.rb │ ├── silencer │ │ └── controller.rb │ ├── adapter.rb │ ├── cli.rb │ ├── change.rb │ ├── silencer.rb │ ├── file.rb │ ├── directory.rb │ ├── record.rb │ ├── listener.rb │ ├── fsm.rb │ └── queue_optimizer.rb └── listen.rb ├── CHANGELOG.md ├── .yardopts ├── bin └── listen ├── spec ├── lib │ ├── listen │ │ ├── adapter │ │ │ ├── bsd_spec.rb │ │ │ ├── windows_spec.rb │ │ │ ├── polling_spec.rb │ │ │ ├── base_spec.rb │ │ │ ├── config_spec.rb │ │ │ ├── linux_spec.rb │ │ │ └── darwin_spec.rb │ │ ├── listener │ │ │ └── config_spec.rb │ │ ├── event │ │ │ ├── config_spec.rb │ │ │ ├── queue_spec.rb │ │ │ ├── loop_spec.rb │ │ │ └── processor_spec.rb │ │ ├── backend_spec.rb │ │ ├── adapter_spec.rb │ │ ├── silencer_spec.rb │ │ ├── silencer │ │ │ └── controller_spec.rb │ │ ├── cli_spec.rb │ │ ├── change_spec.rb │ │ ├── queue_optimizer_spec.rb │ │ ├── file_spec.rb │ │ ├── directory_spec.rb │ │ ├── listener_spec.rb │ │ └── record_spec.rb │ └── listen_spec.rb ├── support │ ├── platform_helper.rb │ ├── fixtures_helper.rb │ └── acceptance_helper.rb ├── spec_helper.rb └── acceptance │ └── listen_spec.rb ├── .gitignore ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── listen.gemspec ├── LICENSE.txt ├── Guardfile ├── CONTRIBUTING.md ├── Rakefile └── vendor └── hound └── config └── style_guides └── ruby.yml /.hound.yml: -------------------------------------------------------------------------------- 1 | ruby: 2 | enabled: true 3 | config_file: .rubocop.yml 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/listen/version.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | VERSION = '3.2.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Moved to [GitHub releases](https://github.com/guard/listen/releases) page. 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'Listen Documentation' 2 | --readme README.md 3 | --markup markdown 4 | --markup-provider redcarpet 5 | --private 6 | --protected 7 | --output-dir ./doc 8 | lib/**/*.rb 9 | - 10 | CHANGELOG.md 11 | LICENSE 12 | -------------------------------------------------------------------------------- /bin/listen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'listen' 4 | require 'listen/cli' 5 | 6 | unless defined?(JRUBY_VERSION) 7 | if Signal.list.keys.include?('INT') 8 | Signal.trap('INT') { Thread.new { Listen.stop } } 9 | end 10 | end 11 | 12 | Listen::CLI.start 13 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/bsd_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Adapter::BSD do 2 | describe 'class' do 3 | subject { described_class } 4 | 5 | if bsd? 6 | it { should be_usable } 7 | else 8 | it { should_not be_usable } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/windows_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Adapter::Windows do 2 | describe 'class' do 3 | subject { described_class } 4 | 5 | if windows? 6 | it { should be_usable } 7 | else 8 | it { should_not be_usable } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/platform_helper.rb: -------------------------------------------------------------------------------- 1 | def darwin? 2 | RbConfig::CONFIG['target_os'] =~ /darwin/i 3 | end 4 | 5 | def linux? 6 | RbConfig::CONFIG['target_os'] =~ /linux/i 7 | end 8 | 9 | def bsd? 10 | RbConfig::CONFIG['target_os'] =~ /bsd|dragonfly/i 11 | end 12 | 13 | def windows? 14 | RbConfig::CONFIG['target_os'] =~ /mswin|mingw|cygwin/i 15 | end 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | doc/* 3 | *.gem 4 | *.rbc 5 | .*.swp 6 | *.bak 7 | bundle 8 | .bundle 9 | .yardoc 10 | .rbx 11 | .rvmrc 12 | .vagrant 13 | Gemfile.lock 14 | spec/.fixtures 15 | coverage 16 | .ruby-version 17 | example* 18 | test.txt 19 | 20 | ## MAC OS 21 | .DS_Store 22 | .Trashes 23 | .com.apple.timemachine.supported 24 | .fseventsd 25 | Desktop DB 26 | Desktop DF 27 | 28 | .idea 29 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - vendor/hound/config/style_guides/ruby.yml 3 | 4 | # Rails cops 5 | AllCops: 6 | RunRailsCops: false 7 | 8 | # Files you want to exclude 9 | AllCops: 10 | TargetRubyVersion: 2.2 11 | Exclude: 12 | - db/schema.rb 13 | - Gemfile 14 | - Guardfile 15 | - Rakefile 16 | 17 | # TODO: put your overrides here: 18 | Style/StringLiterals: 19 | EnforcedStyle: single_quotes 20 | Enabled: true 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | 4 | matrix: 5 | include: 6 | - rvm: 2.3 7 | - rvm: 2.4 8 | - rvm: 2.5 9 | - rvm: 2.6 10 | - rvm: 2.5 11 | os: osx 12 | env: 13 | # TODO: 0.8 is enough on Linux, but 2 seems needed for Travis/OSX 14 | - LISTEN_TESTS_DEFAULT_LAG=2 15 | - rvm: jruby 16 | - rvm: truffleruby 17 | - rvm: jruby-head 18 | - rvm: ruby-head 19 | - rvm: rbx-3 20 | allow_failures: 21 | - rvm: truffleruby 22 | - rvm: jruby 23 | - rvm: ruby-head 24 | - rvm: jruby-head 25 | - rvm: rbx-3 26 | fast_finish: true 27 | -------------------------------------------------------------------------------- /spec/lib/listen_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen do 2 | let(:listener) { instance_double(Listen::Listener, stop: nil) } 3 | 4 | after do 5 | Listen.stop 6 | end 7 | 8 | describe '.to' do 9 | it 'initalizes listener' do 10 | expect(Listen::Listener).to receive(:new).with('/path') { listener } 11 | described_class.to('/path') 12 | end 13 | end 14 | 15 | describe '.stop' do 16 | it 'stops all listeners' do 17 | allow(Listen::Listener).to receive(:new).with('/path') { listener } 18 | expect(listener).to receive(:stop) 19 | described_class.to('/path') 20 | Listen.stop 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/listen/options.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | class Options 3 | def initialize(opts, defaults) 4 | @options = {} 5 | given_options = opts.dup 6 | defaults.keys.each do |key| 7 | @options[key] = given_options.delete(key) || defaults[key] 8 | end 9 | 10 | return if given_options.empty? 11 | 12 | msg = "Unknown options: #{given_options.inspect}" 13 | Listen::Logger.warn msg 14 | fail msg 15 | end 16 | 17 | def method_missing(name, *_) 18 | return @options[name] if @options.key?(name) 19 | msg = "Bad option: #{name.inspect} (valid:#{@options.keys.inspect})" 20 | fail NameError, msg 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/listen/adapter/config.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | module Listen 4 | module Adapter 5 | class Config 6 | attr_reader :directories 7 | attr_reader :silencer 8 | attr_reader :queue 9 | attr_reader :adapter_options 10 | 11 | def initialize(directories, queue, silencer, adapter_options) 12 | # Default to current directory if no directories are supplied 13 | directories = [Dir.pwd] if directories.to_a.empty? 14 | 15 | # TODO: fix (flatten, array, compact?) 16 | @directories = directories.map do |directory| 17 | Pathname.new(directory.to_s).realpath 18 | end 19 | 20 | @silencer = silencer 21 | @queue = queue 22 | @adapter_options = adapter_options 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/listen/logger.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | def self.logger 3 | @logger ||= nil 4 | end 5 | 6 | def self.logger=(logger) 7 | @logger = logger 8 | end 9 | 10 | def self.setup_default_logger_if_unset 11 | self.logger ||= ::Logger.new(STDERR).tap do |logger| 12 | debugging = ENV['LISTEN_GEM_DEBUGGING'] 13 | logger.level = 14 | case debugging.to_s 15 | when /2/ 16 | ::Logger::DEBUG 17 | when /true|yes|1/i 18 | ::Logger::INFO 19 | else 20 | ::Logger::ERROR 21 | end 22 | end 23 | end 24 | 25 | class Logger 26 | [:fatal, :error, :warn, :info, :debug].each do |meth| 27 | define_singleton_method(meth) do |*args, &block| 28 | Listen.logger.public_send(meth, *args, &block) if Listen.logger 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/listen/listener/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/listener/config' 2 | RSpec.describe Listen::Listener::Config do 3 | describe 'options' do 4 | context 'custom options' do 5 | subject do 6 | described_class.new( 7 | latency: 1.234, 8 | wait_for_delay: 0.85, 9 | force_polling: true, 10 | relative: true) 11 | end 12 | 13 | it 'extracts adapter options' do 14 | klass = Class.new do 15 | DEFAULTS = { latency: 5.4321 }.freeze 16 | end 17 | expected = { latency: 1.234 } 18 | expect(subject.adapter_instance_options(klass)).to eq(expected) 19 | end 20 | 21 | it 'extract adapter selecting options' do 22 | expected = { force_polling: true, polling_fallback_message: nil } 23 | expect(subject.adapter_select_options).to eq(expected) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/listen/event/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/event/config' 2 | 3 | RSpec.describe Listen::Event::Config do 4 | let(:listener) { instance_double(Listen::Listener) } 5 | let(:event_queue) { instance_double(Listen::Event::Queue) } 6 | let(:queue_optimizer) { instance_double(Listen::QueueOptimizer) } 7 | let(:wait_for_delay) { 1.234 } 8 | 9 | context 'with a given block' do 10 | let(:myblock) { instance_double(Proc) } 11 | 12 | subject do 13 | described_class.new( 14 | listener, 15 | event_queue, 16 | queue_optimizer, 17 | wait_for_delay) do |*args| 18 | myblock.call(*args) 19 | end 20 | end 21 | 22 | it 'calls the block' do 23 | expect(myblock).to receive(:call).with(:foo, :bar) 24 | subject.call(:foo, :bar) 25 | end 26 | 27 | it 'is callable' do 28 | expect(subject).to be_callable 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/support/fixtures_helper.rb: -------------------------------------------------------------------------------- 1 | require 'tmpdir' 2 | 3 | include FileUtils 4 | 5 | # Prepares temporary fixture-directories and 6 | # cleans them afterwards. 7 | # 8 | # @param [Fixnum] number_of_directories the number of fixture-directories to 9 | # make 10 | # 11 | # @yield [path1, path2, ...] the empty fixture-directories 12 | # @yieldparam [String] path the path to a fixture directory 13 | # 14 | def fixtures(number_of_directories = 1) 15 | current_pwd = Dir.pwd 16 | paths = 1.upto(number_of_directories).map { mk_fixture_tmp_dir } 17 | 18 | FileUtils.cd(paths.first) if number_of_directories == 1 19 | 20 | yield(*paths) 21 | ensure 22 | FileUtils.cd current_pwd 23 | paths.map { |p| FileUtils.rm_rf(p) if File.exist?(p) } 24 | end 25 | 26 | def mk_fixture_tmp_dir 27 | timestamp = Time.now.to_f.to_s.sub('.', '') + rand(9999).to_s 28 | path = Pathname.pwd.join('spec', '.fixtures', timestamp).expand_path 29 | path.tap(&:mkpath) 30 | end 31 | -------------------------------------------------------------------------------- /lib/listen/internals/thread_pool.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | # @private api 3 | module Internals 4 | module ThreadPool 5 | def self.add(&block) 6 | Thread.new { block.call }.tap do |th| 7 | (@threads ||= Queue.new) << th 8 | end 9 | end 10 | 11 | def self.stop 12 | return unless @threads ||= nil 13 | return if @threads.empty? # return to avoid using possibly stubbed Queue 14 | 15 | killed = Queue.new 16 | # You can't kill a read on a descriptor in JRuby, so let's just 17 | # ignore running threads (listen rb-inotify waiting for disk activity 18 | # before closing) pray threads die faster than they are created... 19 | limit = RUBY_ENGINE == 'jruby' ? [1] : [] 20 | 21 | killed << @threads.pop.kill until @threads.empty? 22 | until killed.empty? 23 | th = killed.pop 24 | th.join(*limit) unless th[:listen_blocking_read_thread] 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/listen/record/symlink_detector.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Listen 4 | # @private api 5 | class Record 6 | class SymlinkDetector 7 | WIKI = 'https://github.com/guard/listen/wiki/Duplicate-directory-errors'.freeze 8 | 9 | SYMLINK_LOOP_ERROR = <<-EOS.freeze 10 | ** ERROR: directory is already being watched! ** 11 | 12 | Directory: %s 13 | 14 | is already being watched through: %s 15 | 16 | MORE INFO: #{WIKI} 17 | EOS 18 | 19 | class Error < RuntimeError 20 | end 21 | 22 | def initialize 23 | @real_dirs = Set.new 24 | end 25 | 26 | def verify_unwatched!(entry) 27 | real_path = entry.real_path 28 | @real_dirs.add?(real_path) || _fail(entry.sys_path, real_path) 29 | end 30 | 31 | private 32 | 33 | def _fail(symlinked, real_path) 34 | STDERR.puts format(SYMLINK_LOOP_ERROR, symlinked, real_path) 35 | fail Error, 'Failed due to looped symlinks' 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Create this file to use pristine/installed version of Listen for development 4 | use_installed = "./use_installed_guard" 5 | if File.exist?(use_installed) 6 | STDERR.puts "WARNING: using installed version of Listen for development" \ 7 | " (remove #{use_installed} file to use local version)" 8 | else 9 | gemspec development_group: :gem_build_tools 10 | end 11 | 12 | require 'rbconfig' 13 | 14 | case RbConfig::CONFIG['target_os'] 15 | when /mswin|mingw|cygwin/i 16 | gem 'wdm', '>= 0.1.0' 17 | when /bsd|dragonfly/i 18 | gem 'rb-kqueue', '>= 0.2' 19 | end 20 | 21 | group :test do 22 | gem 'rake' 23 | gem 'rspec', '~> 3.3' 24 | gem 'coveralls' 25 | end 26 | 27 | group :development do 28 | gem 'yard', require: false 29 | gem 'guard-rspec', require: false 30 | gem 'rubocop', '~> 0.49.0' # TODO: should match Gemfile HoundCi 31 | gem 'guard-rubocop' 32 | gem 'pry-rescue' 33 | gem 'pry-stack_explorer', platforms: [:mri, :rbx] 34 | gem 'gems', require: false 35 | gem 'netrc', require: false 36 | gem 'octokit', require: false 37 | end 38 | -------------------------------------------------------------------------------- /listen.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'listen/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'listen' 8 | s.version = Listen::VERSION 9 | s.license = 'MIT' 10 | s.author = 'Thibaud Guillaume-Gentil' 11 | s.email = 'thibaud@thibaud.gg' 12 | s.homepage = 'https://github.com/guard/listen' 13 | s.summary = 'Listen to file modifications' 14 | s.description = 'The Listen gem listens to file modifications and '\ 15 | 'notifies you about the changes. Works everywhere!' 16 | 17 | s.files = `git ls-files -z`.split("\x0").select do |f| 18 | %r{^(?:bin|lib)\/} =~ f 19 | end + %w(CHANGELOG.md CONTRIBUTING.md LICENSE.txt README.md) 20 | 21 | s.test_files = [] 22 | s.executable = 'listen' 23 | s.require_path = 'lib' 24 | 25 | s.required_ruby_version = ['~> 2.2', '>= 2.2.7'] 26 | 27 | s.add_dependency 'rb-fsevent', '~> 0.10', '>= 0.10.3' 28 | s.add_dependency 'rb-inotify', '~> 0.9', '>= 0.9.10' 29 | 30 | s.add_development_dependency 'bundler' 31 | end 32 | -------------------------------------------------------------------------------- /lib/listen/adapter/polling.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | module Adapter 3 | # Polling Adapter that works cross-platform and 4 | # has no dependencies. This is the adapter that 5 | # uses the most CPU processing power and has higher 6 | # file IO than the other implementations. 7 | # 8 | class Polling < Base 9 | OS_REGEXP = // # match every OS 10 | 11 | DEFAULTS = { latency: 1.0, wait_for_delay: 0.05 }.freeze 12 | 13 | private 14 | 15 | def _configure(_, &callback) 16 | @polling_callbacks ||= [] 17 | @polling_callbacks << callback 18 | end 19 | 20 | def _run 21 | loop do 22 | start = Time.now.to_f 23 | @polling_callbacks.each do |callback| 24 | callback.call(nil) 25 | nap_time = options.latency - (Time.now.to_f - start) 26 | # TODO: warn if nap_time is negative (polling too slow) 27 | sleep(nap_time) if nap_time > 0 28 | end 29 | end 30 | end 31 | 32 | def _process_event(dir, _) 33 | _queue_change(:dir, dir, '.', recursive: true) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Thibaud Guillaume-Gentil 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | ignore(%r{spec/\.fixtures/}) 2 | 3 | group :specs, halt_on_fail: true do 4 | guard :rspec, cmd: 'bundle exec rspec -t ~acceptance', failed_mode: :keep, all_after_pass: true do 5 | watch(%r{^spec/lib/.+_spec\.rb$}) 6 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 7 | watch(%r{^spec/support/*}) { 'spec' } 8 | watch('spec/spec_helper.rb') { 'spec' } 9 | end 10 | 11 | guard :rubocop, all_on_start: false, cli: '--rails' do 12 | watch(%r{.+\.rb$}) { |m| m[0] } 13 | watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) } 14 | watch(%r{(?:.+/)?\.rubocop_todo\.yml$}) { |m| File.dirname(m[0]) } 15 | end 16 | 17 | # TODO: guard rspec should have a configurable file for this to work 18 | # TODO: also split up Rakefile 19 | guard :rspec, cmd: 'bundle exec rspec -t acceptance', failed_mode: :keep, all_after_pass: true do 20 | watch(%r{^spec/lib/.+_spec\.rb$}) 21 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 22 | watch(%r{^spec/support/*}) { 'spec' } 23 | watch('spec/spec_helper.rb') { 'spec' } 24 | watch(%r{^spec/acceptance/.+_spec\.rb$}) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/listen/backend.rb: -------------------------------------------------------------------------------- 1 | require 'listen/adapter' 2 | require 'listen/adapter/base' 3 | require 'listen/adapter/config' 4 | 5 | require 'forwardable' 6 | 7 | # This class just aggregates configuration object to avoid Listener specs 8 | # from exploding with huge test setup blocks 9 | module Listen 10 | class Backend 11 | extend Forwardable 12 | 13 | def initialize(directories, queue, silencer, config) 14 | adapter_select_opts = config.adapter_select_options 15 | 16 | adapter_class = Adapter.select(adapter_select_opts) 17 | 18 | # Use default from adapter if possible 19 | @min_delay_between_events = config.min_delay_between_events 20 | @min_delay_between_events ||= adapter_class::DEFAULTS[:wait_for_delay] 21 | @min_delay_between_events ||= 0.1 22 | 23 | adapter_opts = config.adapter_instance_options(adapter_class) 24 | 25 | aconfig = Adapter::Config.new(directories, queue, silencer, adapter_opts) 26 | @adapter = adapter_class.new(aconfig) 27 | end 28 | 29 | delegate start: :adapter 30 | delegate stop: :adapter 31 | 32 | attr_reader :min_delay_between_events 33 | 34 | private 35 | 36 | attr_reader :adapter 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/listen/event/config.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | module Event 3 | class Config 4 | def initialize( 5 | listener, 6 | event_queue, 7 | queue_optimizer, 8 | wait_for_delay, 9 | &block) 10 | 11 | @listener = listener 12 | @event_queue = event_queue 13 | @queue_optimizer = queue_optimizer 14 | @min_delay_between_events = wait_for_delay 15 | @block = block 16 | end 17 | 18 | def sleep(*args) 19 | Kernel.sleep(*args) 20 | end 21 | 22 | def call(*args) 23 | @block.call(*args) if @block 24 | end 25 | 26 | def timestamp 27 | Time.now.to_f 28 | end 29 | 30 | attr_reader :event_queue 31 | 32 | def callable? 33 | @block 34 | end 35 | 36 | def optimize_changes(changes) 37 | @queue_optimizer.smoosh_changes(changes) 38 | end 39 | 40 | attr_reader :min_delay_between_events 41 | 42 | def stopped? 43 | listener.state == :stopped 44 | end 45 | 46 | def paused? 47 | listener.state == :paused 48 | end 49 | 50 | private 51 | 52 | attr_reader :listener 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/listen/listener/config.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | class Listener 3 | class Config 4 | DEFAULTS = { 5 | # Listener options 6 | debug: false, # TODO: is this broken? 7 | wait_for_delay: nil, # NOTE: should be provided by adapter if possible 8 | relative: false, 9 | 10 | # Backend selecting options 11 | force_polling: false, 12 | polling_fallback_message: nil 13 | }.freeze 14 | 15 | def initialize(opts) 16 | @options = DEFAULTS.merge(opts) 17 | @relative = @options[:relative] 18 | @min_delay_between_events = @options[:wait_for_delay] 19 | @silencer_rules = @options # silencer will extract what it needs 20 | end 21 | 22 | def relative? 23 | @relative 24 | end 25 | 26 | attr_reader :min_delay_between_events 27 | 28 | attr_reader :silencer_rules 29 | 30 | def adapter_instance_options(klass) 31 | valid_keys = klass.const_get('DEFAULTS').keys 32 | Hash[@options.select { |key, _| valid_keys.include?(key) }] 33 | end 34 | 35 | def adapter_select_options 36 | valid_keys = %w(force_polling polling_fallback_message).map(&:to_sym) 37 | Hash[@options.select { |key, _| valid_keys.include?(key) }] 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/listen/silencer/controller.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | class Silencer 3 | class Controller 4 | def initialize(silencer, default_options) 5 | @silencer = silencer 6 | 7 | opts = default_options 8 | 9 | @prev_silencer_options = {} 10 | rules = [:only, :ignore, :ignore!].map do |option| 11 | [option, opts[option]] if opts.key? option 12 | end 13 | 14 | _reconfigure_silencer(Hash[rules.compact]) 15 | end 16 | 17 | def append_ignores(*regexps) 18 | prev_ignores = Array(@prev_silencer_options[:ignore]) 19 | _reconfigure_silencer(ignore: [prev_ignores + regexps]) 20 | end 21 | 22 | def replace_with_bang_ignores(regexps) 23 | _reconfigure_silencer(ignore!: regexps) 24 | end 25 | 26 | def replace_with_only(regexps) 27 | _reconfigure_silencer(only: regexps) 28 | end 29 | 30 | private 31 | 32 | def _reconfigure_silencer(extra_options) 33 | opts = extra_options.dup 34 | opts = opts.map do |key, value| 35 | [key, Array(value).flatten.compact] 36 | end 37 | opts = Hash[opts] 38 | 39 | if opts.key?(:ignore) && opts[:ignore].empty? 40 | opts.delete(:ignore) 41 | end 42 | 43 | @prev_silencer_options = opts 44 | @silencer.configure(@prev_silencer_options.dup.freeze) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # TODO: reduce requires everwhere and be more strict about it 2 | require 'listen' 3 | 4 | Listen.logger.level = Logger::WARN unless ENV['LISTEN_GEM_DEBUGGING'] 5 | 6 | require 'listen/internals/thread_pool' 7 | 8 | def ci? 9 | ENV['CI'] 10 | end 11 | 12 | if ci? 13 | require 'coveralls' 14 | Coveralls.wear! 15 | end 16 | 17 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 18 | 19 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 20 | RSpec.configure do |config| 21 | config.order = :random 22 | config.filter_run focus: true 23 | config.run_all_when_everything_filtered = true 24 | # config.fail_fast = !ci? 25 | config.expect_with :rspec do |c| 26 | c.syntax = :expect 27 | end 28 | 29 | config.mock_with :rspec do |mocks| 30 | mocks.verify_doubled_constant_names = true 31 | mocks.verify_partial_doubles = true 32 | end 33 | 34 | config.disable_monkey_patching! 35 | end 36 | 37 | module SpecHelpers 38 | def fake_path(str, options = {}) 39 | instance_double(Pathname, str, { to_s: str }.merge(options)) 40 | end 41 | end 42 | 43 | RSpec.configure do |config| 44 | config.include SpecHelpers 45 | end 46 | 47 | Thread.abort_on_exception = true 48 | 49 | RSpec.configuration.before(:each) do 50 | Listen::Internals::ThreadPool.stop 51 | end 52 | 53 | RSpec.configuration.after(:each) do 54 | Listen::Internals::ThreadPool.stop 55 | end 56 | -------------------------------------------------------------------------------- /lib/listen/event/queue.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | 3 | require 'forwardable' 4 | 5 | module Listen 6 | module Event 7 | class Queue 8 | extend Forwardable 9 | 10 | class Config 11 | def initialize(relative) 12 | @relative = relative 13 | end 14 | 15 | def relative? 16 | @relative 17 | end 18 | end 19 | 20 | def initialize(config, &block) 21 | @event_queue = ::Queue.new 22 | @block = block 23 | @config = config 24 | end 25 | 26 | def <<(args) 27 | type, change, dir, path, options = *args 28 | fail "Invalid type: #{type.inspect}" unless [:dir, :file].include? type 29 | fail "Invalid change: #{change.inspect}" unless change.is_a?(Symbol) 30 | fail "Invalid path: #{path.inspect}" unless path.is_a?(String) 31 | 32 | dir = _safe_relative_from_cwd(dir) 33 | event_queue.public_send(:<<, [type, change, dir, path, options]) 34 | 35 | block.call(args) if block 36 | end 37 | 38 | delegate empty?: :event_queue 39 | delegate pop: :event_queue 40 | 41 | private 42 | 43 | attr_reader :event_queue 44 | attr_reader :block 45 | attr_reader :config 46 | 47 | def _safe_relative_from_cwd(dir) 48 | return dir unless config.relative? 49 | dir.relative_path_from(Pathname.pwd) 50 | rescue ArgumentError 51 | dir 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/listen/adapter.rb: -------------------------------------------------------------------------------- 1 | require 'listen/adapter/base' 2 | require 'listen/adapter/bsd' 3 | require 'listen/adapter/darwin' 4 | require 'listen/adapter/linux' 5 | require 'listen/adapter/polling' 6 | require 'listen/adapter/windows' 7 | 8 | module Listen 9 | module Adapter 10 | OPTIMIZED_ADAPTERS = [Darwin, Linux, BSD, Windows].freeze 11 | POLLING_FALLBACK_MESSAGE = 'Listen will be polling for changes.'\ 12 | 'Learn more at https://github.com/guard/listen#listen-adapters.'.freeze 13 | 14 | class << self 15 | def select(options = {}) 16 | _log :debug, 'Adapter: considering polling ...' 17 | return Polling if options[:force_polling] 18 | _log :debug, 'Adapter: considering optimized backend...' 19 | return _usable_adapter_class if _usable_adapter_class 20 | _log :debug, 'Adapter: falling back to polling...' 21 | _warn_polling_fallback(options) 22 | Polling 23 | rescue 24 | _log :warn, format('Adapter: failed: %s:%s', $ERROR_POSITION.inspect, 25 | $ERROR_POSITION * "\n") 26 | raise 27 | end 28 | 29 | private 30 | 31 | def _usable_adapter_class 32 | OPTIMIZED_ADAPTERS.detect(&:usable?) 33 | end 34 | 35 | def _warn_polling_fallback(options) 36 | msg = options.fetch(:polling_fallback_message, POLLING_FALLBACK_MESSAGE) 37 | Kernel.warn "[Listen warning]:\n #{msg}" if msg 38 | end 39 | 40 | def _log(type, message) 41 | Listen::Logger.send(type, message) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/listen.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'listen/logger' 3 | require 'listen/listener' 4 | 5 | require 'listen/internals/thread_pool' 6 | 7 | # Always set up logging by default first time file is required 8 | # 9 | # NOTE: If you need to clear the logger completely, do so *after* 10 | # requiring this file. If you need to set a custom logger, 11 | # require the listen/logger file and set the logger before requiring 12 | # this file. 13 | Listen.setup_default_logger_if_unset 14 | 15 | # Won't print anything by default because of level - unless you've set 16 | # LISTEN_GEM_DEBUGGING or provided your own logger with a high enough level 17 | Listen::Logger.info "Listen loglevel set to: #{Listen.logger.level}" 18 | Listen::Logger.info "Listen version: #{Listen::VERSION}" 19 | 20 | module Listen 21 | class << self 22 | # Listens to file system modifications on a either single directory or 23 | # multiple directories. 24 | # 25 | # @param (see Listen::Listener#new) 26 | # 27 | # @yield [modified, added, removed] the changed files 28 | # @yieldparam [Array] modified the list of modified files 29 | # @yieldparam [Array] added the list of added files 30 | # @yieldparam [Array] removed the list of removed files 31 | # 32 | # @return [Listen::Listener] the listener 33 | # 34 | def to(*args, &block) 35 | @listeners ||= [] 36 | Listener.new(*args, &block).tap do |listener| 37 | @listeners << listener 38 | end 39 | end 40 | 41 | # This is used by the `listen` binary to handle Ctrl-C 42 | # 43 | def stop 44 | Internals::ThreadPool.stop 45 | @listeners ||= [] 46 | 47 | # TODO: should use a mutex for this 48 | @listeners.each(&:stop) 49 | @listeners = nil 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contribute to Listen 2 | =================== 3 | 4 | File an issue 5 | ------------- 6 | 7 | If you haven't already, first see [TROUBLESHOOTING](https://github.com/guard/listen/wiki/Troubleshooting) for known issues, solutions and workarounds. 8 | 9 | You can report bugs and feature requests to [GitHub Issues](https://github.com/guard/listen/issues). 10 | 11 | **Please don't ask question in the issue tracker**, instead ask them in our 12 | [Google group](http://groups.google.com/group/guard-dev) or on `#guard` (irc.freenode.net). 13 | 14 | Try to figure out where the issue belongs to: Is it an issue with Listen itself or with Guard? 15 | 16 | 17 | **It's most likely that your bug gets resolved faster if you provide as much information as possible!** 18 | 19 | The MOST useful information is debugging output from Listen (`LISTEN_GEM_DEBUGGING=1`) - see [TROUBLESHOOTING](https://github.com/guard/listen/wiki/Troubleshooting) for details. 20 | 21 | 22 | Development 23 | ----------- 24 | 25 | * Documentation hosted at [RubyDoc](http://rubydoc.info/github/guard/listen/master/frames). 26 | * Source hosted at [GitHub](https://github.com/guard/listen). 27 | 28 | Pull requests are very welcome! Please try to follow these simple rules if applicable: 29 | 30 | * Please create a topic branch for every separate change you make. 31 | * Make sure your patches are well tested. All specs run with `rake spec` must pass. 32 | * Update the [Yard](http://yardoc.org/) documentation. 33 | * Update the [README](https://github.com/guard/listen/blob/master/README.md). 34 | * Update the [CHANGELOG](https://github.com/guard/listen/blob/master/CHANGELOG.md) for noteworthy changes. 35 | * Please **do not change** the version number. 36 | 37 | For questions please join us in our [Google group](http://groups.google.com/group/guard-dev) or on 38 | `#guard` (irc.freenode.net). 39 | -------------------------------------------------------------------------------- /lib/listen/cli.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | require 'listen' 3 | require 'logger' 4 | 5 | module Listen 6 | class CLI < Thor 7 | default_task :start 8 | 9 | desc 'start', 'Starts Listen' 10 | 11 | class_option :verbose, 12 | type: :boolean, 13 | default: false, 14 | aliases: '-v', 15 | banner: 'Verbose' 16 | 17 | class_option :directory, 18 | type: :array, 19 | default: '.', 20 | aliases: '-d', 21 | banner: 'The directory to listen to' 22 | 23 | class_option :relative, 24 | type: :boolean, 25 | default: false, 26 | aliases: '-r', 27 | banner: 'Convert paths relative to current directory' 28 | 29 | def start 30 | Listen::Forwarder.new(options).start 31 | end 32 | end 33 | 34 | class Forwarder 35 | attr_reader :logger 36 | def initialize(options) 37 | @options = options 38 | @logger = ::Logger.new(STDOUT) 39 | @logger.level = ::Logger::INFO 40 | @logger.formatter = proc { |_, _, _, msg| "#{msg}\n" } 41 | end 42 | 43 | def start 44 | logger.info 'Starting listen...' 45 | directory = @options[:directory] 46 | relative = @options[:relative] 47 | callback = proc do |modified, added, removed| 48 | if @options[:verbose] 49 | logger.info "+ #{added}" unless added.empty? 50 | logger.info "- #{removed}" unless removed.empty? 51 | logger.info "> #{modified}" unless modified.empty? 52 | end 53 | end 54 | 55 | listener = Listen.to( 56 | directory, 57 | relative: relative, 58 | &callback) 59 | 60 | listener.start 61 | 62 | sleep 0.5 while listener.processing? 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/listen/record/entry.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | # @private api 3 | class Record 4 | # Represents a directory entry (dir or file) 5 | class Entry 6 | # file: "/home/me/watched_dir", "app/models", "foo.rb" 7 | # dir, "/home/me/watched_dir", "." 8 | def initialize(root, relative, name = nil) 9 | @root = root 10 | @relative = relative 11 | @name = name 12 | end 13 | 14 | attr_reader :root, :relative, :name 15 | 16 | def children 17 | child_relative = _join 18 | (_entries(sys_path) - %w(. ..)).map do |name| 19 | Entry.new(@root, child_relative, name) 20 | end 21 | end 22 | 23 | def meta 24 | lstat = ::File.lstat(sys_path) 25 | { mtime: lstat.mtime.to_f, mode: lstat.mode } 26 | end 27 | 28 | # record hash is e.g. 29 | # if @record["/home/me/watched_dir"]["project/app/models"]["foo.rb"] 30 | # if @record["/home/me/watched_dir"]["project/app"]["models"] 31 | # record_dir_key is "project/app/models" 32 | def record_dir_key 33 | ::File.join(*[@relative, @name].compact) 34 | end 35 | 36 | def sys_path 37 | # Use full path in case someone uses chdir 38 | ::File.join(*[@root, @relative, @name].compact) 39 | end 40 | 41 | def real_path 42 | @real_path ||= ::File.realpath(sys_path) 43 | end 44 | 45 | private 46 | 47 | def _join 48 | args = [@relative, @name].compact 49 | args.empty? ? nil : ::File.join(*args) 50 | end 51 | 52 | def _entries(dir) 53 | return Dir.entries(dir) unless RUBY_ENGINE == 'jruby' 54 | 55 | # JRuby inconsistency workaround, see: 56 | # https://github.com/jruby/jruby/issues/3840 57 | exists = ::File.exist?(dir) 58 | directory = ::File.directory?(dir) 59 | return Dir.entries(dir) unless exists && !directory 60 | raise Errno::ENOTDIR, dir 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/listen/change.rb: -------------------------------------------------------------------------------- 1 | require 'listen/file' 2 | require 'listen/directory' 3 | 4 | module Listen 5 | # TODO: rename to Snapshot 6 | class Change 7 | # TODO: test this class for coverage 8 | class Config 9 | def initialize(queue, silencer) 10 | @queue = queue 11 | @silencer = silencer 12 | end 13 | 14 | def silenced?(path, type) 15 | @silencer.silenced?(Pathname(path), type) 16 | end 17 | 18 | def queue(*args) 19 | @queue << args 20 | end 21 | end 22 | 23 | attr_reader :record 24 | 25 | def initialize(config, record) 26 | @config = config 27 | @record = record 28 | end 29 | 30 | # Invalidate some part of the snapshot/record (dir, file, subtree, etc.) 31 | def invalidate(type, rel_path, options) 32 | watched_dir = Pathname.new(record.root) 33 | 34 | change = options[:change] 35 | cookie = options[:cookie] 36 | 37 | if !cookie && config.silenced?(rel_path, type) 38 | Listen::Logger.debug { "(silenced): #{rel_path.inspect}" } 39 | return 40 | end 41 | 42 | path = watched_dir + rel_path 43 | 44 | Listen::Logger.debug do 45 | log_details = options[:silence] && 'recording' || change || 'unknown' 46 | "#{log_details}: #{type}:#{path} (#{options.inspect})" 47 | end 48 | 49 | if change 50 | options = cookie ? { cookie: cookie } : {} 51 | config.queue(type, change, watched_dir, rel_path, options) 52 | elsif type == :dir 53 | # NOTE: POSSIBLE RECURSION 54 | # TODO: fix - use a queue instead 55 | Directory.scan(self, rel_path, options) 56 | else 57 | change = File.change(record, rel_path) 58 | return if !change || options[:silence] 59 | config.queue(:file, change, watched_dir, rel_path) 60 | end 61 | rescue RuntimeError => ex 62 | msg = format( 63 | '%s#%s crashed %s:%s', 64 | self.class, 65 | __method__, 66 | exinspect, 67 | ex.backtrace * "\n") 68 | Listen::Logger.error(msg) 69 | raise 70 | end 71 | 72 | private 73 | 74 | attr_reader :config 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/polling_spec.rb: -------------------------------------------------------------------------------- 1 | include Listen 2 | 3 | RSpec.describe Adapter::Polling do 4 | describe 'class' do 5 | subject { described_class } 6 | it { should be_usable } 7 | end 8 | 9 | subject do 10 | described_class.new(config) 11 | end 12 | 13 | let(:dir1) do 14 | instance_double(Pathname, 'dir1', to_s: '/foo/dir1', cleanpath: real_dir1) 15 | end 16 | 17 | # just so cleanpath works in above double 18 | let(:real_dir1) { instance_double(Pathname, 'dir1', to_s: '/foo/dir1') } 19 | 20 | let(:config) { instance_double(Listen::Adapter::Config) } 21 | let(:directories) { [dir1] } 22 | let(:options) { {} } 23 | let(:queue) { instance_double(Queue) } 24 | let(:silencer) { instance_double(Listen::Silencer) } 25 | let(:snapshot) { instance_double(Listen::Change) } 26 | 27 | let(:record) { instance_double(Listen::Record) } 28 | 29 | context 'with a valid configuration' do 30 | before do 31 | allow(config).to receive(:directories).and_return(directories) 32 | allow(config).to receive(:adapter_options).and_return(options) 33 | allow(config).to receive(:queue).and_return(queue) 34 | allow(config).to receive(:silencer).and_return(silencer) 35 | 36 | allow(Listen::Record).to receive(:new).with(dir1).and_return(record) 37 | 38 | allow(Listen::Change).to receive(:new).with(config, record). 39 | and_return(snapshot) 40 | allow(Listen::Change::Config).to receive(:new).with(queue, silencer). 41 | and_return(config) 42 | end 43 | 44 | describe '#start' do 45 | before do 46 | allow(snapshot).to receive(:record).and_return(record) 47 | allow(record).to receive(:build) 48 | end 49 | 50 | it 'notifies change on every listener directories path' do 51 | expect(snapshot).to receive(:invalidate). 52 | with(:dir, '.', recursive: true) 53 | 54 | t = Thread.new { subject.start } 55 | sleep 0.25 56 | t.kill 57 | t.join 58 | end 59 | end 60 | 61 | describe '#_latency' do 62 | subject do 63 | adapter = described_class.new(config) 64 | adapter.options.latency 65 | end 66 | 67 | context 'with no overriding option' do 68 | it { should eq 1.0 } 69 | end 70 | 71 | context 'with custom latency overriding' do 72 | let(:options) { { latency: 1234 } } 73 | it { should eq 1234 } 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/lib/listen/backend_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/backend' 2 | 3 | RSpec.describe Listen::Backend do 4 | let(:dir1) { instance_double(Pathname, 'dir1', to_s: '/foo/dir1') } 5 | 6 | let(:silencer) { instance_double(Listen::Silencer) } 7 | let(:queue) { instance_double(Queue) } 8 | 9 | let(:select_options) do 10 | { force_polling: false, polling_fallback_message: 'foo' } 11 | end 12 | 13 | let(:adapter_options) { { latency: 1234 } } 14 | let(:options) { select_options.merge(adapter_options) } 15 | 16 | let(:adapter_config_class) { class_double('Listen::Adapter::Config') } 17 | let(:adapter_config) { instance_double('Listen::Adapter::Config') } 18 | 19 | let(:config) { instance_double(Listen::Listener::Config) } 20 | 21 | subject { described_class.new([dir1], queue, silencer, config) } 22 | 23 | # Use Polling since it has a valid :latency option 24 | let(:adapter_defaults) { { latency: 5.4321 } } 25 | let(:adapter_class) { Listen::Adapter::Polling } 26 | let(:adapter) { instance_double('Listen::Adapter::Polling') } 27 | 28 | let(:config_min_delay_between_events) { 0.1234 } 29 | 30 | before do 31 | stub_const('Listen::Adapter::Config', adapter_config_class) 32 | 33 | allow(adapter_config_class).to receive(:new). 34 | with([dir1], queue, silencer, adapter_options). 35 | and_return(adapter_config) 36 | 37 | allow(Listen::Adapter).to receive(:select). 38 | with(select_options).and_return(adapter_class) 39 | 40 | allow(adapter_class).to receive(:new). 41 | with(adapter_config).and_return(adapter) 42 | 43 | allow(Listen::Adapter::Polling).to receive(:new).with(adapter_config). 44 | and_return(adapter) 45 | 46 | allow(config).to receive(:adapter_select_options). 47 | and_return(select_options) 48 | 49 | allow(config).to receive(:adapter_instance_options). 50 | and_return(adapter_options) 51 | 52 | allow(config).to receive(:min_delay_between_events). 53 | and_return(config_min_delay_between_events) 54 | end 55 | 56 | describe '#initialize' do 57 | context 'with config' do 58 | it 'sets up an adapter class' do 59 | expect(adapter_class).to receive(:new). 60 | with(adapter_config).and_return(adapter) 61 | 62 | subject 63 | end 64 | end 65 | end 66 | 67 | describe '#start' do 68 | it 'starts the adapter' do 69 | expect(adapter).to receive(:start) 70 | subject.start 71 | end 72 | end 73 | 74 | describe '#stop' do 75 | it 'stops the adapter' do 76 | expect(adapter).to receive(:stop) 77 | subject.stop 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/listen/silencer.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | class Silencer 3 | # The default list of directories that get ignored. 4 | DEFAULT_IGNORED_DIRECTORIES = %r{^(?: 5 | \.git 6 | | \.svn 7 | | \.hg 8 | | \.rbx 9 | | \.bundle 10 | | bundle 11 | | vendor/bundle 12 | | log 13 | | tmp 14 | |vendor/ruby 15 | )(/|$)}x 16 | 17 | # The default list of files that get ignored. 18 | DEFAULT_IGNORED_EXTENSIONS = %r{(?: 19 | # Kate's tmp\/swp files 20 | \..*\d+\.new 21 | | \.kate-swp 22 | 23 | # Gedit tmp files 24 | | \.goutputstream-.{6} 25 | 26 | # Intellij files 27 | | ___jb_bak___ 28 | | ___jb_old___ 29 | 30 | # Vim swap files and write test 31 | | \.sw[px] 32 | | \.swpx 33 | | ^4913 34 | 35 | # Sed temporary files - but without actual words, like 'sedatives' 36 | | (?:^ 37 | sed 38 | 39 | (?: 40 | [a-zA-Z0-9]{0}[A-Z]{1}[a-zA-Z0-9]{5} | 41 | [a-zA-Z0-9]{1}[A-Z]{1}[a-zA-Z0-9]{4} | 42 | [a-zA-Z0-9]{2}[A-Z]{1}[a-zA-Z0-9]{3} | 43 | [a-zA-Z0-9]{3}[A-Z]{1}[a-zA-Z0-9]{2} | 44 | [a-zA-Z0-9]{4}[A-Z]{1}[a-zA-Z0-9]{1} | 45 | [a-zA-Z0-9]{5}[A-Z]{1}[a-zA-Z0-9]{0} 46 | ) 47 | ) 48 | 49 | # other files 50 | | \.DS_Store 51 | | \.tmp 52 | | ~ 53 | )$}x 54 | 55 | attr_accessor :only_patterns, :ignore_patterns 56 | 57 | def initialize 58 | configure({}) 59 | end 60 | 61 | def configure(options) 62 | @only_patterns = options[:only] ? Array(options[:only]) : nil 63 | @ignore_patterns = _init_ignores(options[:ignore], options[:ignore!]) 64 | end 65 | 66 | # Note: relative_path is temporarily expected to be a relative Pathname to 67 | # make refactoring easier (ideally, it would take a string) 68 | 69 | # TODO: switch type and path places - and verify 70 | def silenced?(relative_path, type) 71 | path = relative_path.to_s 72 | 73 | if only_patterns && type == :file 74 | return true unless only_patterns.any? { |pattern| path =~ pattern } 75 | end 76 | 77 | ignore_patterns.any? { |pattern| path =~ pattern } 78 | end 79 | 80 | private 81 | 82 | attr_reader :options 83 | 84 | def _init_ignores(ignores, overrides) 85 | patterns = [] 86 | unless overrides 87 | patterns << DEFAULT_IGNORED_DIRECTORIES 88 | patterns << DEFAULT_IGNORED_EXTENSIONS 89 | end 90 | 91 | patterns << ignores 92 | patterns << overrides 93 | 94 | patterns.compact.flatten 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Adapter do 2 | let(:listener) { instance_double(Listen::Listener, options: {}) } 3 | before do 4 | allow(Listen::Adapter::BSD).to receive(:usable?) { false } 5 | allow(Listen::Adapter::Darwin).to receive(:usable?) { false } 6 | allow(Listen::Adapter::Linux).to receive(:usable?) { false } 7 | allow(Listen::Adapter::Windows).to receive(:usable?) { false } 8 | end 9 | 10 | describe '.select' do 11 | it 'returns Polling adapter if forced' do 12 | klass = Listen::Adapter.select(force_polling: true) 13 | expect(klass).to eq Listen::Adapter::Polling 14 | end 15 | 16 | it 'returns BSD adapter when usable' do 17 | allow(Listen::Adapter::BSD).to receive(:usable?) { true } 18 | klass = Listen::Adapter.select 19 | expect(klass).to eq Listen::Adapter::BSD 20 | end 21 | 22 | it 'returns Darwin adapter when usable' do 23 | allow(Listen::Adapter::Darwin).to receive(:usable?) { true } 24 | klass = Listen::Adapter.select 25 | expect(klass).to eq Listen::Adapter::Darwin 26 | end 27 | 28 | it 'returns Linux adapter when usable' do 29 | allow(Listen::Adapter::Linux).to receive(:usable?) { true } 30 | klass = Listen::Adapter.select 31 | expect(klass).to eq Listen::Adapter::Linux 32 | end 33 | 34 | it 'returns Windows adapter when usable' do 35 | allow(Listen::Adapter::Windows).to receive(:usable?) { true } 36 | klass = Listen::Adapter.select 37 | expect(klass).to eq Listen::Adapter::Windows 38 | end 39 | 40 | context 'no usable adapters' do 41 | before { allow(Kernel).to receive(:warn) } 42 | 43 | it 'returns Polling adapter' do 44 | klass = Listen::Adapter.select(force_polling: true) 45 | expect(klass).to eq Listen::Adapter::Polling 46 | end 47 | 48 | it 'warns polling fallback with default message' do 49 | msg = described_class::POLLING_FALLBACK_MESSAGE 50 | expect(Kernel).to receive(:warn).with("[Listen warning]:\n #{msg}") 51 | Listen::Adapter.select 52 | end 53 | 54 | it "doesn't warn if polling_fallback_message is false" do 55 | expect(Kernel).to_not receive(:warn) 56 | Listen::Adapter.select(polling_fallback_message: false) 57 | end 58 | 59 | it 'warns polling fallback with custom message if set' do 60 | expected_msg = "[Listen warning]:\n custom fallback message" 61 | expect(Kernel).to receive(:warn).with(expected_msg) 62 | msg = 'custom fallback message' 63 | Listen::Adapter.select(polling_fallback_message: msg) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/base_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Adapter::Base do 2 | class FakeAdapter < described_class 3 | def initialize(config) 4 | @my_callbacks = {} 5 | super 6 | end 7 | 8 | def _run 9 | fail NotImplementedError 10 | end 11 | 12 | def _configure(dir, &callback) 13 | @my_callbacks[dir.to_s] = callback 14 | end 15 | 16 | def fake_event(event) 17 | dir = event[:dir] 18 | @my_callbacks[dir].call(event) 19 | end 20 | 21 | def _process_event(dir, event) 22 | _queue_change(:file, dir, event[:file], cookie: event[:cookie]) 23 | end 24 | end 25 | 26 | let(:dir1) { instance_double(Pathname, 'dir1', to_s: '/foo/dir1') } 27 | 28 | let(:config) { instance_double(Listen::Adapter::Config) } 29 | let(:queue) { instance_double(Queue) } 30 | let(:silencer) { instance_double(Listen::Silencer) } 31 | let(:adapter_options) { {} } 32 | 33 | let(:snapshot) { instance_double(Listen::Change) } 34 | let(:record) { instance_double(Listen::Record) } 35 | 36 | subject { FakeAdapter.new(config) } 37 | 38 | before do 39 | allow(config).to receive(:directories).and_return([dir1]) 40 | allow(config).to receive(:queue).and_return(queue) 41 | allow(config).to receive(:silencer).and_return(silencer) 42 | allow(config).to receive(:adapter_options).and_return(adapter_options) 43 | 44 | allow(Listen::Internals::ThreadPool). 45 | to receive(:add) { |&block| block.call } 46 | 47 | # Stuff that happens in configure() 48 | allow(Listen::Record).to receive(:new).with(dir1).and_return(record) 49 | 50 | allow(Listen::Change::Config).to receive(:new).with(queue, silencer). 51 | and_return(config) 52 | 53 | allow(Listen::Change).to receive(:new).with(config, record). 54 | and_return(snapshot) 55 | end 56 | 57 | describe '#start' do 58 | before do 59 | allow(subject).to receive(:_run) 60 | 61 | allow(snapshot).to receive(:record).and_return(record) 62 | allow(record).to receive(:build) 63 | end 64 | 65 | it 'builds record' do 66 | expect(record).to receive(:build) 67 | subject.start 68 | end 69 | 70 | it 'runs the adapter' do 71 | expect(subject).to receive(:_run) 72 | subject.start 73 | end 74 | end 75 | 76 | describe 'handling events' do 77 | before do 78 | allow(subject).to receive(:_run) 79 | 80 | allow(snapshot).to receive(:record).and_return(record) 81 | allow(record).to receive(:build) 82 | end 83 | 84 | context 'when an event occurs' do 85 | it 'passes invalidates the snapshot based on the event' do 86 | subject.start 87 | 88 | expect(snapshot).to receive(:invalidate).with(:file, 'bar', cookie: 3) 89 | 90 | event = { dir: '/foo/dir1', file: 'bar', type: :moved, cookie: 3 } 91 | subject.fake_event(event) 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/listen/file.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | 3 | module Listen 4 | class File 5 | def self.change(record, rel_path) 6 | path = Pathname.new(record.root) + rel_path 7 | lstat = path.lstat 8 | 9 | data = { mtime: lstat.mtime.to_f, mode: lstat.mode } 10 | 11 | record_data = record.file_data(rel_path) 12 | 13 | if record_data.empty? 14 | record.update_file(rel_path, data) 15 | return :added 16 | end 17 | 18 | if data[:mode] != record_data[:mode] 19 | record.update_file(rel_path, data) 20 | return :modified 21 | end 22 | 23 | if data[:mtime] != record_data[:mtime] 24 | record.update_file(rel_path, data) 25 | return :modified 26 | end 27 | 28 | return if /1|true/ =~ ENV['LISTEN_GEM_DISABLE_HASHING'] 29 | return unless inaccurate_mac_time?(lstat) 30 | 31 | # Check if change happened within 1 second (maybe it's even 32 | # too much, e.g. 0.3-0.5 could be sufficient). 33 | # 34 | # With rb-fsevent, there's a (configurable) latency between 35 | # when file was changed and when the event was triggered. 36 | # 37 | # If a file is saved at ???14.998, by the time the event is 38 | # actually received by Listen, the time could already be e.g. 39 | # ???15.7. 40 | # 41 | # And since Darwin adapter uses directory scanning, the file 42 | # mtime may be the same (e.g. file was changed at ???14.001, 43 | # then at ???14.998, but the fstat time would be ???14.0 in 44 | # both cases). 45 | # 46 | # If change happend at ???14.999997, the mtime is 14.0, so for 47 | # an mtime=???14.0 we assume it could even be almost ???15.0 48 | # 49 | # So if Time.now.to_f is ???15.999998 and stat reports mtime 50 | # at ???14.0, then event was due to that file'd change when: 51 | # 52 | # ???15.999997 - ???14.999998 < 1.0s 53 | # 54 | # So the "2" is "1 + 1" (1s to cover rb-fsevent latency + 55 | # 1s maximum difference between real mtime and that recorded 56 | # in the file system) 57 | # 58 | return if data[:mtime].to_i + 2 <= Time.now.to_f 59 | 60 | md5 = Digest::MD5.file(path).digest 61 | record.update_file(rel_path, data.merge(md5: md5)) 62 | :modified if record_data[:md5] && md5 != record_data[:md5] 63 | rescue SystemCallError 64 | record.unset_path(rel_path) 65 | :removed 66 | rescue 67 | Listen::Logger.debug "lstat failed for: #{rel_path} (#{$ERROR_INFO})" 68 | raise 69 | end 70 | 71 | def self.inaccurate_mac_time?(stat) 72 | # 'mac' means Modified/Accessed/Created 73 | 74 | # Since precision depends on mounted FS (e.g. you can have a FAT partiion 75 | # mounted on Linux), check for fields with a remainder to detect this 76 | 77 | [stat.mtime, stat.ctime, stat.atime].map(&:usec).all?(&:zero?) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/listen/event/loop.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | 3 | require 'timeout' 4 | require 'listen/event/processor' 5 | 6 | module Listen 7 | module Event 8 | class Loop 9 | class Error < RuntimeError 10 | class NotStarted < Error 11 | end 12 | end 13 | 14 | def initialize(config) 15 | @config = config 16 | @wait_thread = nil 17 | @state = :paused 18 | @reasons = ::Queue.new 19 | end 20 | 21 | def wakeup_on_event 22 | return if stopped? 23 | return unless processing? 24 | return unless wait_thread.alive? 25 | _wakeup(:event) 26 | end 27 | 28 | def paused? 29 | wait_thread && state == :paused 30 | end 31 | 32 | def processing? 33 | return false if stopped? 34 | return false if paused? 35 | state == :processing 36 | end 37 | 38 | def setup 39 | # TODO: use a Fiber instead? 40 | q = ::Queue.new 41 | @wait_thread = Internals::ThreadPool.add do 42 | _wait_for_changes(q, config) 43 | end 44 | 45 | Listen::Logger.debug('Waiting for processing to start...') 46 | Timeout.timeout(5) { q.pop } 47 | end 48 | 49 | def resume 50 | fail Error::NotStarted if stopped? 51 | return unless wait_thread 52 | _wakeup(:resume) 53 | end 54 | 55 | def pause 56 | # TODO: works? 57 | # fail NotImplementedError 58 | end 59 | 60 | def teardown 61 | return unless wait_thread 62 | if wait_thread.alive? 63 | _wakeup(:teardown) 64 | wait_thread.join 65 | end 66 | @wait_thread = nil 67 | end 68 | 69 | def stopped? 70 | !wait_thread 71 | end 72 | 73 | private 74 | 75 | attr_reader :config 76 | attr_reader :wait_thread 77 | 78 | attr_accessor :state 79 | 80 | def _wait_for_changes(ready_queue, config) 81 | processor = Event::Processor.new(config, @reasons) 82 | 83 | _wait_until_resumed(ready_queue) 84 | processor.loop_for(config.min_delay_between_events) 85 | rescue StandardError => ex 86 | _nice_error(ex) 87 | end 88 | 89 | def _sleep(*args) 90 | Kernel.sleep(*args) 91 | end 92 | 93 | def _wait_until_resumed(ready_queue) 94 | self.state = :paused 95 | ready_queue << :ready 96 | sleep 97 | self.state = :processing 98 | end 99 | 100 | def _nice_error(ex) 101 | indent = "\n -- " 102 | msg = format( 103 | 'exception while processing events: %s Backtrace:%s%s', 104 | ex, 105 | indent, 106 | ex.backtrace * indent 107 | ) 108 | Listen::Logger.error(msg) 109 | end 110 | 111 | def _wakeup(reason) 112 | @reasons << reason 113 | wait_thread.wakeup 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/listen/directory.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Listen 4 | # TODO: refactor (turn it into a normal object, cache the stat, etc) 5 | class Directory 6 | def self.scan(snapshot, rel_path, options) 7 | record = snapshot.record 8 | dir = Pathname.new(record.root) 9 | previous = record.dir_entries(rel_path) 10 | 11 | record.add_dir(rel_path) 12 | 13 | # TODO: use children(with_directory: false) 14 | path = dir + rel_path 15 | current = Set.new(_children(path)) 16 | 17 | Listen::Logger.debug do 18 | format('%s: %s(%s): %s -> %s', 19 | (options[:silence] ? 'Recording' : 'Scanning'), 20 | rel_path, options.inspect, previous.inspect, current.inspect) 21 | end 22 | 23 | begin 24 | current.each do |full_path| 25 | type = ::File.lstat(full_path.to_s).directory? ? :dir : :file 26 | item_rel_path = full_path.relative_path_from(dir).to_s 27 | _change(snapshot, type, item_rel_path, options) 28 | end 29 | rescue Errno::ENOENT 30 | # The directory changed meanwhile, so rescan it 31 | current = Set.new(_children(path)) 32 | retry 33 | end 34 | 35 | # TODO: this is not tested properly 36 | previous = previous.reject { |entry, _| current.include? path + entry } 37 | 38 | _async_changes(snapshot, Pathname.new(rel_path), previous, options) 39 | 40 | rescue Errno::ENOENT, Errno::EHOSTDOWN 41 | record.unset_path(rel_path) 42 | _async_changes(snapshot, Pathname.new(rel_path), previous, options) 43 | 44 | rescue Errno::ENOTDIR 45 | # TODO: path not tested 46 | record.unset_path(rel_path) 47 | _async_changes(snapshot, path, previous, options) 48 | _change(snapshot, :file, rel_path, options) 49 | rescue 50 | Listen::Logger.warn do 51 | format('scan DIED: %s:%s', $ERROR_INFO, $ERROR_POSITION * "\n") 52 | end 53 | raise 54 | end 55 | 56 | def self._async_changes(snapshot, path, previous, options) 57 | fail "Not a Pathname: #{path.inspect}" unless path.respond_to?(:children) 58 | previous.each do |entry, data| 59 | # TODO: this is a hack with insufficient testing 60 | type = data.key?(:mtime) ? :file : :dir 61 | rel_path_s = (path + entry).to_s 62 | _change(snapshot, type, rel_path_s, options) 63 | end 64 | end 65 | 66 | def self._change(snapshot, type, path, options) 67 | return snapshot.invalidate(type, path, options) if type == :dir 68 | 69 | # Minor param cleanup for tests 70 | # TODO: use a dedicated Event class 71 | opts = options.dup 72 | opts.delete(:recursive) 73 | snapshot.invalidate(type, path, opts) 74 | end 75 | 76 | def self._children(path) 77 | return path.children unless RUBY_ENGINE == 'jruby' 78 | 79 | # JRuby inconsistency workaround, see: 80 | # https://github.com/jruby/jruby/issues/3840 81 | exists = path.exist? 82 | directory = path.directory? 83 | return path.children unless exists && !directory 84 | raise Errno::ENOTDIR, path.to_s 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/listen/adapter/windows.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | module Adapter 3 | # Adapter implementation for Windows `wdm`. 4 | # 5 | class Windows < Base 6 | OS_REGEXP = /mswin|mingw|cygwin/i 7 | 8 | BUNDLER_DECLARE_GEM = <<-EOS.gsub(/^ {6}/, '') 9 | Please add the following to your Gemfile to avoid polling for changes: 10 | gem 'wdm', '>= 0.1.0' if Gem.win_platform? 11 | EOS 12 | 13 | def self.usable? 14 | return false unless super 15 | require 'wdm' 16 | true 17 | rescue LoadError 18 | _log :debug, format('wdm - load failed: %s:%s', $ERROR_INFO, 19 | $ERROR_POSITION * "\n") 20 | 21 | Kernel.warn BUNDLER_DECLARE_GEM 22 | false 23 | end 24 | 25 | private 26 | 27 | def _configure(dir) 28 | require 'wdm' 29 | _log :debug, 'wdm - starting...' 30 | @worker ||= WDM::Monitor.new 31 | @worker.watch_recursively(dir.to_s, :files) do |change| 32 | yield([:file, change]) 33 | end 34 | 35 | @worker.watch_recursively(dir.to_s, :directories) do |change| 36 | yield([:dir, change]) 37 | end 38 | 39 | events = [:attributes, :last_write] 40 | @worker.watch_recursively(dir.to_s, *events) do |change| 41 | yield([:attr, change]) 42 | end 43 | end 44 | 45 | def _run 46 | @worker.run! 47 | end 48 | 49 | def _process_event(dir, event) 50 | _log :debug, "wdm - callback: #{event.inspect}" 51 | 52 | type, change = event 53 | 54 | full_path = Pathname(change.path) 55 | 56 | rel_path = full_path.relative_path_from(dir).to_s 57 | 58 | options = { change: _change(change.type) } 59 | 60 | case type 61 | when :file 62 | _queue_change(:file, dir, rel_path, options) 63 | when :attr 64 | unless full_path.directory? 65 | _queue_change(:file, dir, rel_path, options) 66 | end 67 | when :dir 68 | if change.type == :removed 69 | # TODO: check if watched dir? 70 | _queue_change(:dir, dir, Pathname(rel_path).dirname.to_s, {}) 71 | elsif change.type == :added 72 | _queue_change(:dir, dir, rel_path, {}) 73 | # do nothing - changed directory means either: 74 | # - removed subdirs (handled above) 75 | # - added subdirs (handled above) 76 | # - removed files (handled by _file_callback) 77 | # - added files (handled by _file_callback) 78 | # so what's left? 79 | end 80 | end 81 | rescue 82 | details = event.inspect 83 | _log :error, format('wdm - callback (%s): %s:%s', details, $ERROR_INFO, 84 | $ERROR_POSITION * "\n") 85 | raise 86 | end 87 | 88 | def _change(type) 89 | { modified: [:modified, :attrib], # TODO: is attrib really passed? 90 | added: [:added, :renamed_new_file], 91 | removed: [:removed, :renamed_old_file] }.each do |change, types| 92 | return change if types.include?(type) 93 | end 94 | nil 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/lib/listen/silencer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :accept do |type, path| 2 | match { |actual| !actual.silenced?(Pathname(path), type) } 3 | end 4 | 5 | RSpec.describe Listen::Silencer do 6 | let(:options) { {} } 7 | before { subject.configure(options) } 8 | 9 | describe '#silenced?' do 10 | it { should accept(:file, Pathname('some_dir') + 'some_file.rb') } 11 | 12 | context 'with default ignore' do 13 | hidden_ignored = %w(.git .svn .hg .rbx .bundle) 14 | other_ignored = %w(bundle vendor/bundle log tmp vendor/ruby) 15 | (hidden_ignored + other_ignored).each do |dir| 16 | it { should_not accept(:dir, dir) } 17 | it { should accept(:dir, "#{dir}foo") } 18 | it { should accept(:dir, "foo#{dir}") } 19 | end 20 | 21 | ignored = %w(.DS_Store foo.tmp foo~) 22 | 23 | # Gedit swap files 24 | ignored += %w(.goutputstream-S3FBGX) 25 | 26 | # Kate editor swap files 27 | ignored += %w(foo.rbo54321.new foo.rbB22583.new foo.rb.kate-swp) 28 | 29 | # Intellij swap files 30 | ignored += %w(foo.rb___jb_bak___ foo.rb___jb_old___) 31 | 32 | # Vim swap files 33 | ignored += %w(foo.swp foo.swx foo.swpx 4913) 34 | 35 | # sed temp files 36 | ignored += %w(sedq7eVAR sed86w1kB) 37 | 38 | ignored.each do |path| 39 | it { should_not accept(:file, path) } 40 | end 41 | 42 | %w( 43 | foo.tmpl file.new file54321.new a.swf 14913 49131 44 | 45 | sed_ABCDE 46 | sedabcdefg 47 | .sedq7eVAR 48 | foo.sedq7eVAR 49 | sedatives 50 | sediments 51 | sedan2014 52 | 53 | ).each do |path| 54 | it { should accept(:file, path) } 55 | end 56 | end 57 | 58 | context 'when ignoring *.pid' do 59 | let(:options) { { ignore: /\.pid$/ } } 60 | it { should_not accept(:file, 'foo.pid') } 61 | end 62 | 63 | context 'when ignoring foo/bar* and *.pid' do 64 | let(:options) { { ignore: [%r{^foo\/bar}, /\.pid$/] } } 65 | it { should_not accept(:file, 'foo/bar/baz') } 66 | it { should_not accept(:file, 'foo.pid') } 67 | end 68 | 69 | context 'when ignoring only *.pid' do 70 | let(:options) { { ignore!: /\.pid$/ } } 71 | it { should_not accept(:file, 'foo.pid') } 72 | it { should accept(:file, '.git') } 73 | end 74 | 75 | context 'when accepting only *foo*' do 76 | let(:options) { { only: /foo/ } } 77 | it { should accept(:file, 'foo') } 78 | it { should_not accept(:file, 'bar') } 79 | end 80 | 81 | context 'when accepting only foo/* and *.txt' do 82 | let(:options) { { only: [%r{^foo\/}, /\.txt$/] } } 83 | it { should accept(:file, 'foo/bar.rb') } 84 | it { should accept(:file, 'bar.txt') } 85 | it { should_not accept(:file, 'bar/baz.rb') } 86 | it { should_not accept(:file, 'bar.rb') } 87 | end 88 | 89 | context 'when accepting only *.pid' do 90 | context 'when ignoring bar*' do 91 | let(:options) { { only: /\.pid$/, ignore: /^bar/ } } 92 | it { should_not accept(:file, 'foo.rb') } 93 | it { should_not accept(:file, 'bar.pid') } 94 | it { should accept(:file, 'foo.pid') } 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/listen/adapter/darwin.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'listen/internals/thread_pool' 3 | 4 | module Listen 5 | module Adapter 6 | # Adapter implementation for Mac OS X `FSEvents`. 7 | # 8 | class Darwin < Base 9 | OS_REGEXP = /darwin(?1\d+)/i 10 | 11 | # The default delay between checking for changes. 12 | DEFAULTS = { latency: 0.1 }.freeze 13 | 14 | INCOMPATIBLE_GEM_VERSION = <<-EOS.gsub(/^ {8}/, '') 15 | rb-fsevent > 0.9.4 no longer supports OS X 10.6 through 10.8. 16 | 17 | Please add the following to your Gemfile to avoid polling for changes: 18 | require 'rbconfig' 19 | if RbConfig::CONFIG['target_os'] =~ /darwin(1[0-3])/i 20 | gem 'rb-fsevent', '<= 0.9.4' 21 | end 22 | EOS 23 | 24 | def self.usable? 25 | version = RbConfig::CONFIG['target_os'][OS_REGEXP, :major_version] 26 | return false unless version 27 | return true if version.to_i >= 13 # darwin13 is OS X 10.9 28 | 29 | require 'rb-fsevent' 30 | fsevent_version = Gem::Version.new(FSEvent::VERSION) 31 | return true if fsevent_version <= Gem::Version.new('0.9.4') 32 | Kernel.warn INCOMPATIBLE_GEM_VERSION 33 | false 34 | end 35 | 36 | private 37 | 38 | # NOTE: each directory gets a DIFFERENT callback! 39 | def _configure(dir, &callback) 40 | require 'rb-fsevent' 41 | opts = { latency: options.latency } 42 | 43 | @workers ||= ::Queue.new 44 | @workers << FSEvent.new.tap do |worker| 45 | _log :debug, "fsevent: watching: #{dir.to_s.inspect}" 46 | worker.watch(dir.to_s, opts, &callback) 47 | end 48 | end 49 | 50 | def _run 51 | first = @workers.pop 52 | 53 | # NOTE: _run is called within a thread, so run every other 54 | # worker in it's own thread 55 | _run_workers_in_background(_to_array(@workers)) 56 | _run_worker(first) 57 | end 58 | 59 | def _process_event(dir, event) 60 | _log :debug, "fsevent: processing event: #{event.inspect}" 61 | event.each do |path| 62 | new_path = Pathname.new(path.sub(%r{\/$}, '')) 63 | _log :debug, "fsevent: #{new_path}" 64 | # TODO: does this preserve symlinks? 65 | rel_path = new_path.relative_path_from(dir).to_s 66 | _queue_change(:dir, dir, rel_path, recursive: true) 67 | end 68 | end 69 | 70 | def _run_worker(worker) 71 | _log :debug, "fsevent: running worker: #{worker.inspect}" 72 | worker.run 73 | rescue 74 | format_string = 'fsevent: running worker failed: %s:%s called from: %s' 75 | _log_exception format_string, caller 76 | end 77 | 78 | def _run_workers_in_background(workers) 79 | workers.each do |worker| 80 | # NOTE: while passing local variables to the block below is not 81 | # thread safe, using 'worker' from the enumerator above is ok 82 | Listen::Internals::ThreadPool.add { _run_worker(worker) } 83 | end 84 | end 85 | 86 | def _to_array(queue) 87 | workers = [] 88 | workers << queue.pop until queue.empty? 89 | workers 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/listen/silencer/controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/silencer/controller' 2 | 3 | RSpec.describe Listen::Silencer::Controller do 4 | let(:silencer) { instance_double(Listen::Silencer) } 5 | 6 | describe 'append_ignores' do 7 | context 'with no previous :ignore rules' do 8 | subject do 9 | described_class.new(silencer, {}) 10 | end 11 | 12 | before do 13 | allow(silencer).to receive(:configure).with({}) 14 | end 15 | 16 | context 'when providing a nil' do 17 | it 'sets the given :ignore rules as empty array' do 18 | subject 19 | allow(silencer).to receive(:configure).with(ignore: []) 20 | subject.append_ignores(nil) 21 | end 22 | end 23 | 24 | context 'when providing a single regexp as argument' do 25 | it 'sets the given :ignore rules as array' do 26 | subject 27 | allow(silencer).to receive(:configure).with(ignore: [/foo/]) 28 | subject.append_ignores(/foo/) 29 | end 30 | end 31 | 32 | context 'when providing multiple arguments' do 33 | it 'sets the given :ignore rules as a flat array' do 34 | subject 35 | allow(silencer).to receive(:configure).with(ignore: [/foo/, /bar/]) 36 | subject.append_ignores(/foo/, /bar/) 37 | end 38 | end 39 | 40 | context 'when providing as array' do 41 | it 'sets the given :ignore rules' do 42 | subject 43 | allow(silencer).to receive(:configure).with(ignore: [/foo/, /bar/]) 44 | subject.append_ignores([/foo/, /bar/]) 45 | end 46 | end 47 | end 48 | 49 | context 'with previous :ignore rules' do 50 | subject do 51 | described_class.new(silencer, ignore: [/foo/, /bar/]) 52 | end 53 | 54 | before do 55 | allow(silencer).to receive(:configure).with(ignore: [/foo/, /bar/]) 56 | end 57 | 58 | context 'when providing a nil' do 59 | # TODO: should this invocation maybe reset the rules? 60 | it 'reconfigures with existing :ignore rules' do 61 | subject 62 | allow(silencer).to receive(:configure).with(ignore: [/foo/, /bar/]) 63 | subject.append_ignores(nil) 64 | end 65 | end 66 | 67 | context 'when providing a single regexp as argument' do 68 | it 'appends the given :ignore rules as array' do 69 | subject 70 | expected = { ignore: [/foo/, /bar/, /baz/] } 71 | allow(silencer).to receive(:configure).with(expected) 72 | subject.append_ignores(/baz/) 73 | end 74 | end 75 | 76 | context 'when providing multiple arguments' do 77 | it 'appends the given :ignore rules as a flat array' do 78 | subject 79 | expected = { ignore: [/foo/, /bar/, /baz/, /bak/] } 80 | allow(silencer).to receive(:configure).with(expected) 81 | subject.append_ignores(/baz/, /bak/) 82 | end 83 | end 84 | 85 | context 'when providing as array' do 86 | it 'appends the given :ignore rules' do 87 | subject 88 | expected = { ignore: [/foo/, /bar/, /baz/, /bak/] } 89 | allow(silencer).to receive(:configure).with(expected) 90 | subject.append_ignores([/baz/, /bak/]) 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/listen/adapter/bsd.rb: -------------------------------------------------------------------------------- 1 | # Listener implementation for BSD's `kqueue`. 2 | # @see http://www.freebsd.org/cgi/man.cgi?query=kqueue 3 | # @see https://github.com/mat813/rb-kqueue/blob/master/lib/rb-kqueue/queue.rb 4 | # 5 | module Listen 6 | module Adapter 7 | class BSD < Base 8 | OS_REGEXP = /bsd|dragonfly/i 9 | 10 | DEFAULTS = { 11 | events: [ 12 | :delete, 13 | :write, 14 | :extend, 15 | :attrib, 16 | :rename 17 | # :link, :revoke 18 | ] 19 | }.freeze 20 | 21 | BUNDLER_DECLARE_GEM = <<-EOS.gsub(/^ {6}/, '') 22 | Please add the following to your Gemfile to avoid polling for changes: 23 | require 'rbconfig' 24 | if RbConfig::CONFIG['target_os'] =~ /#{OS_REGEXP}/ 25 | gem 'rb-kqueue', '>= 0.2' 26 | end 27 | EOS 28 | 29 | def self.usable? 30 | return false unless super 31 | require 'rb-kqueue' 32 | require 'find' 33 | true 34 | rescue LoadError 35 | Kernel.warn BUNDLER_DECLARE_GEM 36 | false 37 | end 38 | 39 | private 40 | 41 | def _configure(directory, &callback) 42 | @worker ||= KQueue::Queue.new 43 | @callback = callback 44 | # use Record to make a snapshot of dir, so we 45 | # can detect new files 46 | _find(directory.to_s) { |path| _watch_file(path, @worker) } 47 | end 48 | 49 | def _run 50 | @worker.run 51 | end 52 | 53 | def _process_event(dir, event) 54 | full_path = _event_path(event) 55 | if full_path.directory? 56 | # Force dir content tracking to kick in, or we won't have 57 | # names of added files 58 | _queue_change(:dir, dir, '.', recursive: true) 59 | elsif full_path.exist? 60 | path = full_path.relative_path_from(dir) 61 | _queue_change(:file, dir, path.to_s, change: _change(event.flags)) 62 | end 63 | 64 | # If it is a directory, and it has a write flag, it means a 65 | # file has been added so find out which and deal with it. 66 | # No need to check for removed files, kqueue will forget them 67 | # when the vfs does. 68 | _watch_for_new_file(event) if full_path.directory? 69 | end 70 | 71 | def _change(event_flags) 72 | { modified: [:attrib, :extend], 73 | added: [:write], 74 | removed: [:rename, :delete] 75 | }.each do |change, flags| 76 | return change unless (flags & event_flags).empty? 77 | end 78 | nil 79 | end 80 | 81 | def _event_path(event) 82 | Pathname.new(event.watcher.path) 83 | end 84 | 85 | def _watch_for_new_file(event) 86 | queue = event.watcher.queue 87 | _find(_event_path(event).to_s) do |file_path| 88 | unless queue.watchers.detect { |_, v| v.path == file_path.to_s } 89 | _watch_file(file_path, queue) 90 | end 91 | end 92 | end 93 | 94 | def _watch_file(path, queue) 95 | queue.watch_file(path, *options.events, &@callback) 96 | rescue Errno::ENOENT => e 97 | _log :warn, "kqueue: watch file failed: #{e.message}" 98 | end 99 | 100 | # Quick rubocop workaround 101 | def _find(*paths, &block) 102 | Find.send(:find, *paths, &block) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/lib/listen/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/cli' 2 | 3 | RSpec.describe Listen::CLI do 4 | let(:options) { {} } 5 | let(:forwarder) { instance_double(Listen::Forwarder) } 6 | 7 | before do 8 | allow(forwarder).to receive(:start) 9 | end 10 | 11 | describe 'directories option' do 12 | context 'not specified' do 13 | let(:options) { %w[] } 14 | it 'is set to local directory' do 15 | expect(Listen::Forwarder).to receive(:new) do |options| 16 | expect(options[:directory]).to eq('.') 17 | forwarder 18 | end 19 | described_class.start(options) 20 | end 21 | end 22 | 23 | context 'with a single directory' do 24 | let(:options) { %w[-d app] } 25 | it 'is set to app' do 26 | expect(Listen::Forwarder).to receive(:new) do |options| 27 | expect(options[:directory]).to eq(['app']) 28 | forwarder 29 | end 30 | described_class.start(options) 31 | end 32 | end 33 | 34 | context 'with a multiple directories' do 35 | let(:options) { %w[-d app spec] } 36 | it 'is set to an array of the directories' do 37 | expect(Listen::Forwarder).to receive(:new) do |options| 38 | expect(options[:directory]).to eq(%w(app spec)) 39 | forwarder 40 | end 41 | described_class.start(options) 42 | end 43 | end 44 | end 45 | 46 | describe 'relative option' do 47 | context 'without relative option' do 48 | let(:options) { %w[] } 49 | it 'is set to false' do 50 | expect(Listen::Forwarder).to receive(:new) do |options| 51 | expect(options[:relative]).to be(false) 52 | forwarder 53 | end 54 | described_class.start(options) 55 | end 56 | end 57 | 58 | context 'when -r' do 59 | let(:options) { %w[-r] } 60 | 61 | it 'is set to true' do 62 | expect(Listen::Forwarder).to receive(:new) do |options| 63 | expect(options[:relative]).to be(true) 64 | forwarder 65 | end 66 | described_class.start(options) 67 | end 68 | end 69 | 70 | context 'when --relative' do 71 | let(:options) { %w[--relative] } 72 | 73 | it 'supports -r option' do 74 | expect(Listen::Forwarder).to receive(:new) do |options| 75 | expect(options[:relative]).to be(true) 76 | forwarder 77 | end 78 | described_class.start(options) 79 | end 80 | 81 | it 'supports --relative option' do 82 | expect(Listen::Forwarder).to receive(:new) do |options| 83 | expect(options[:relative]).to be(true) 84 | forwarder 85 | end 86 | described_class.start(options) 87 | end 88 | end 89 | end 90 | end 91 | 92 | RSpec.describe Listen::Forwarder do 93 | let(:logger) { instance_double(Logger) } 94 | let(:listener) { instance_double(Listen::Listener) } 95 | 96 | before do 97 | allow(Logger).to receive(:new).and_return(logger) 98 | allow(logger).to receive(:level=) 99 | allow(logger).to receive(:formatter=) 100 | allow(logger).to receive(:info) 101 | 102 | allow(listener).to receive(:start) 103 | allow(listener).to receive(:processing?).and_return false 104 | end 105 | 106 | it 'passes relative option to Listen' do 107 | value = double('value') 108 | expect(Listen).to receive(:to). 109 | with(nil, hash_including(relative: value)). 110 | and_return(listener) 111 | 112 | described_class.new(relative: value).start 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/listen/adapter/linux.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | module Adapter 3 | # @see https://github.com/nex3/rb-inotify 4 | class Linux < Base 5 | OS_REGEXP = /linux/i 6 | 7 | DEFAULTS = { 8 | events: [ 9 | :recursive, 10 | :attrib, 11 | :create, 12 | :delete, 13 | :move, 14 | :close_write 15 | ], 16 | wait_for_delay: 0.1 17 | }.freeze 18 | 19 | private 20 | 21 | WIKI_URL = 'https://github.com/guard/listen'\ 22 | '/wiki/Increasing-the-amount-of-inotify-watchers'.freeze 23 | 24 | INOTIFY_LIMIT_MESSAGE = <<-EOS.gsub(/^\s*/, '') 25 | FATAL: Listen error: unable to monitor directories for changes. 26 | Visit #{WIKI_URL} for info on how to fix this. 27 | EOS 28 | 29 | def _configure(directory, &callback) 30 | require 'rb-inotify' 31 | @worker ||= ::INotify::Notifier.new 32 | @worker.watch(directory.to_s, *options.events, &callback) 33 | rescue Errno::ENOSPC 34 | abort(INOTIFY_LIMIT_MESSAGE) 35 | end 36 | 37 | def _run 38 | Thread.current[:listen_blocking_read_thread] = true 39 | @worker.run 40 | Thread.current[:listen_blocking_read_thread] = false 41 | end 42 | 43 | def _process_event(dir, event) 44 | # NOTE: avoid using event.absolute_name since new API 45 | # will need to have a custom recursion implemented 46 | # to properly match events to configured directories 47 | path = Pathname.new(event.watcher.path) + event.name 48 | rel_path = path.relative_path_from(dir).to_s 49 | 50 | _log(:debug) { "inotify: #{rel_path} (#{event.flags.inspect})" } 51 | 52 | if /1|true/ =~ ENV['LISTEN_GEM_SIMULATE_FSEVENT'] 53 | if (event.flags & [:moved_to, :moved_from]) || _dir_event?(event) 54 | rel_path = path.dirname.relative_path_from(dir).to_s 55 | end 56 | _queue_change(:dir, dir, rel_path, {}) 57 | return 58 | end 59 | 60 | return if _skip_event?(event) 61 | 62 | cookie_params = event.cookie.zero? ? {} : { cookie: event.cookie } 63 | 64 | # Note: don't pass options to force rescanning the directory, so we can 65 | # detect moving/deleting a whole tree 66 | if _dir_event?(event) 67 | _queue_change(:dir, dir, rel_path, cookie_params) 68 | return 69 | end 70 | 71 | params = cookie_params.merge(change: _change(event.flags)) 72 | 73 | _queue_change(:file, dir, rel_path, params) 74 | end 75 | 76 | def _skip_event?(event) 77 | # Event on root directory 78 | return true if event.name == '' 79 | # INotify reports changes to files inside directories as events 80 | # on the directories themselves too. 81 | # 82 | # @see http://linux.die.net/man/7/inotify 83 | _dir_event?(event) && (event.flags & [:close, :modify]).any? 84 | end 85 | 86 | def _change(event_flags) 87 | { modified: [:attrib, :close_write], 88 | moved_to: [:moved_to], 89 | moved_from: [:moved_from], 90 | added: [:create], 91 | removed: [:delete] }.each do |change, flags| 92 | return change unless (flags & event_flags).empty? 93 | end 94 | nil 95 | end 96 | 97 | def _dir_event?(event) 98 | event.flags.include?(:isdir) 99 | end 100 | 101 | def _stop 102 | @worker && @worker.close 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/listen/event/processor.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | module Event 3 | class Processor 4 | def initialize(config, reasons) 5 | @config = config 6 | @reasons = reasons 7 | _reset_no_unprocessed_events 8 | end 9 | 10 | # TODO: implement this properly instead of checking the state at arbitrary 11 | # points in time 12 | def loop_for(latency) 13 | @latency = latency 14 | 15 | loop do 16 | _wait_until_events 17 | _wait_until_events_calm_down 18 | _wait_until_no_longer_paused 19 | _process_changes 20 | end 21 | rescue Stopped 22 | Listen::Logger.debug('Processing stopped') 23 | end 24 | 25 | private 26 | 27 | class Stopped < RuntimeError 28 | end 29 | 30 | def _wait_until_events_calm_down 31 | loop do 32 | now = _timestamp 33 | 34 | # Assure there's at least latency between callbacks to allow 35 | # for accumulating changes 36 | diff = _deadline - now 37 | break if diff <= 0 38 | 39 | # give events a bit of time to accumulate so they can be 40 | # compressed/optimized 41 | _sleep(:waiting_until_latency, diff) 42 | end 43 | end 44 | 45 | def _wait_until_no_longer_paused 46 | # TODO: may not be a good idea? 47 | _sleep(:waiting_for_unpause) while config.paused? 48 | end 49 | 50 | def _check_stopped 51 | return unless config.stopped? 52 | 53 | _flush_wakeup_reasons 54 | raise Stopped 55 | end 56 | 57 | def _sleep(_local_reason, *args) 58 | _check_stopped 59 | sleep_duration = config.sleep(*args) 60 | _check_stopped 61 | 62 | _flush_wakeup_reasons do |reason| 63 | next unless reason == :event 64 | _remember_time_of_first_unprocessed_event unless config.paused? 65 | end 66 | 67 | sleep_duration 68 | end 69 | 70 | def _remember_time_of_first_unprocessed_event 71 | @first_unprocessed_event_time ||= _timestamp 72 | end 73 | 74 | def _reset_no_unprocessed_events 75 | @first_unprocessed_event_time = nil 76 | end 77 | 78 | def _deadline 79 | @first_unprocessed_event_time + @latency 80 | end 81 | 82 | def _wait_until_events 83 | # TODO: long sleep may not be a good idea? 84 | _sleep(:waiting_for_events) while config.event_queue.empty? 85 | @first_unprocessed_event_time ||= _timestamp 86 | end 87 | 88 | def _flush_wakeup_reasons 89 | reasons = @reasons 90 | until reasons.empty? 91 | reason = reasons.pop 92 | yield reason if block_given? 93 | end 94 | end 95 | 96 | def _timestamp 97 | config.timestamp 98 | end 99 | 100 | # for easier testing without sleep loop 101 | def _process_changes 102 | _reset_no_unprocessed_events 103 | 104 | changes = [] 105 | changes << config.event_queue.pop until config.event_queue.empty? 106 | 107 | callable = config.callable? 108 | return unless callable 109 | 110 | hash = config.optimize_changes(changes) 111 | result = [hash[:modified], hash[:added], hash[:removed]] 112 | return if result.all?(&:empty?) 113 | 114 | block_start = _timestamp 115 | config.call(*result) 116 | Listen::Logger.debug "Callback took #{_timestamp - block_start} sec" 117 | end 118 | 119 | attr_reader :config 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/lib/listen/change_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Change do 2 | let(:config) { instance_double(Listen::Change::Config) } 3 | let(:dir) { instance_double(Pathname) } 4 | let(:record) { instance_double(Listen::Record, root: '/dir') } 5 | subject { Listen::Change.new(config, record) } 6 | 7 | let(:full_file_path) { instance_double(Pathname, to_s: '/dir/file.rb') } 8 | let(:full_dir_path) { instance_double(Pathname, to_s: '/dir') } 9 | 10 | before do 11 | allow(dir).to receive(:+).with('file.rb') { full_file_path } 12 | allow(dir).to receive(:+).with('dir1') { full_dir_path } 13 | end 14 | 15 | describe '#change' do 16 | before do 17 | allow(config).to receive(:silenced?).and_return(false) 18 | end 19 | 20 | context 'with build options' do 21 | it 'calls still_building! on record' do 22 | allow(config).to receive(:queue) 23 | allow(Listen::File).to receive(:change) 24 | subject.invalidate(:file, 'file.rb', build: true) 25 | end 26 | end 27 | 28 | context 'file' do 29 | context 'with known change' do 30 | it 'notifies change directly to listener' do 31 | expect(config).to receive(:queue). 32 | with(:file, :modified, Pathname.new('/dir'), 'file.rb', {}) 33 | 34 | subject.invalidate(:file, 'file.rb', change: :modified) 35 | end 36 | 37 | it "doesn't notify to listener if path is silenced" do 38 | expect(config).to receive(:silenced?).and_return(true) 39 | expect(config).to_not receive(:queue) 40 | subject.invalidate(:file, 'file.rb', change: :modified) 41 | end 42 | end 43 | 44 | context 'with unknown change' do 45 | it 'calls Listen::File#change' do 46 | expect(Listen::File).to receive(:change).with(record, 'file.rb') 47 | subject.invalidate(:file, 'file.rb', {}) 48 | end 49 | 50 | it "doesn't call Listen::File#change if path is silenced" do 51 | expect(config).to receive(:silenced?). 52 | with('file.rb', :file).and_return(true) 53 | 54 | expect(Listen::File).to_not receive(:change) 55 | subject.invalidate(:file, 'file.rb', {}) 56 | end 57 | 58 | context 'that returns a change' do 59 | before { allow(Listen::File).to receive(:change) { :modified } } 60 | 61 | context 'listener listen' do 62 | it 'notifies change to listener' do 63 | expect(config).to receive(:queue). 64 | with(:file, :modified, Pathname.new('/dir'), 'file.rb') 65 | 66 | subject.invalidate(:file, 'file.rb', {}) 67 | end 68 | 69 | context 'silence option' do 70 | it 'notifies change to listener' do 71 | expect(config).to_not receive(:queue) 72 | subject.invalidate(:file, 'file.rb', silence: true) 73 | end 74 | end 75 | end 76 | end 77 | 78 | context 'that returns no change' do 79 | before { allow(Listen::File).to receive(:change) { nil } } 80 | 81 | it "doesn't notifies no change" do 82 | expect(config).to_not receive(:queue) 83 | subject.invalidate(:file, 'file.rb', {}) 84 | end 85 | end 86 | end 87 | end 88 | 89 | context 'directory' do 90 | let(:dir_options) { { recursive: true } } 91 | 92 | it 'calls Listen::Directory#new' do 93 | expect(Listen::Directory).to receive(:scan). 94 | with(subject, 'dir1', dir_options) 95 | 96 | subject.invalidate(:dir, 'dir1', dir_options) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/listen/record.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'listen/record/entry' 3 | require 'listen/record/symlink_detector' 4 | 5 | module Listen 6 | class Record 7 | # TODO: one Record object per watched directory? 8 | # TODO: deprecate 9 | 10 | attr_reader :root 11 | def initialize(directory) 12 | @tree = _auto_hash 13 | @root = directory.to_s 14 | end 15 | 16 | def add_dir(rel_path) 17 | return if [nil, '', '.'].include? rel_path 18 | @tree[rel_path] ||= {} 19 | end 20 | 21 | def update_file(rel_path, data) 22 | dirname, basename = Pathname(rel_path).split.map(&:to_s) 23 | _fast_update_file(dirname, basename, data) 24 | end 25 | 26 | def unset_path(rel_path) 27 | dirname, basename = Pathname(rel_path).split.map(&:to_s) 28 | _fast_unset_path(dirname, basename) 29 | end 30 | 31 | def file_data(rel_path) 32 | dirname, basename = Pathname(rel_path).split.map(&:to_s) 33 | if [nil, '', '.'].include? dirname 34 | tree[basename] ||= {} 35 | tree[basename].dup 36 | else 37 | tree[dirname] ||= {} 38 | tree[dirname][basename] ||= {} 39 | tree[dirname][basename].dup 40 | end 41 | end 42 | 43 | def dir_entries(rel_path) 44 | subtree = 45 | if [nil, '', '.'].include? rel_path.to_s 46 | tree 47 | else 48 | tree[rel_path.to_s] ||= _auto_hash 49 | tree[rel_path.to_s] 50 | end 51 | 52 | result = {} 53 | subtree.each do |key, values| 54 | # only get data for file entries 55 | result[key] = values.key?(:mtime) ? values : {} 56 | end 57 | result 58 | end 59 | 60 | def build 61 | @tree = _auto_hash 62 | # TODO: test with a file name given 63 | # TODO: test other permissions 64 | # TODO: test with mixed encoding 65 | symlink_detector = SymlinkDetector.new 66 | remaining = ::Queue.new 67 | remaining << Entry.new(root, nil, nil) 68 | _fast_build_dir(remaining, symlink_detector) until remaining.empty? 69 | end 70 | 71 | private 72 | 73 | def _auto_hash 74 | Hash.new { |h, k| h[k] = Hash.new } 75 | end 76 | 77 | attr_reader :tree 78 | 79 | def _fast_update_file(dirname, basename, data) 80 | if [nil, '', '.'].include? dirname 81 | tree[basename] = (tree[basename] || {}).merge(data) 82 | else 83 | tree[dirname] ||= {} 84 | tree[dirname][basename] = (tree[dirname][basename] || {}).merge(data) 85 | end 86 | end 87 | 88 | def _fast_unset_path(dirname, basename) 89 | # this may need to be reworked to properly remove 90 | # entries from a tree, without adding non-existing dirs to the record 91 | if [nil, '', '.'].include? dirname 92 | return unless tree.key?(basename) 93 | tree.delete(basename) 94 | else 95 | return unless tree.key?(dirname) 96 | tree[dirname].delete(basename) 97 | end 98 | end 99 | 100 | def _fast_build_dir(remaining, symlink_detector) 101 | entry = remaining.pop 102 | children = entry.children # NOTE: children() implicitly tests if dir 103 | symlink_detector.verify_unwatched!(entry) 104 | children.each { |child| remaining << child } 105 | add_dir(entry.record_dir_key) 106 | rescue Errno::ENOTDIR 107 | _fast_try_file(entry) 108 | rescue SystemCallError, SymlinkDetector::Error 109 | _fast_unset_path(entry.relative, entry.name) 110 | end 111 | 112 | def _fast_try_file(entry) 113 | _fast_update_file(entry.relative, entry.name, entry.meta) 114 | rescue SystemCallError 115 | _fast_unset_path(entry.relative, entry.name) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/lib/listen/queue_optimizer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::QueueOptimizer do 2 | let(:config) { instance_double(Listen::QueueOptimizer::Config) } 3 | subject { described_class.new(config) } 4 | 5 | # watched dir 6 | let(:dir) { fake_path('dir') } 7 | 8 | # files 9 | let(:foo) { fake_path('foo') } 10 | let(:bar) { fake_path('bar') } 11 | let(:ignored) { fake_path('ignored') } 12 | 13 | before do 14 | allow(config).to receive(:debug) 15 | 16 | allow(dir).to receive(:+).with('foo') { foo } 17 | allow(dir).to receive(:+).with('bar') { bar } 18 | allow(dir).to receive(:+).with('ignored') { ignored } 19 | 20 | allow(config).to receive(:silenced?). 21 | with(Pathname('ignored'), :file) { true } 22 | 23 | allow(config).to receive(:silenced?). 24 | with(Pathname('foo'), :file) { false } 25 | 26 | allow(config).to receive(:silenced?). 27 | with(Pathname('bar'), :file) { false } 28 | 29 | allow(config).to receive(:exist?).with(foo).and_return(true) 30 | allow(config).to receive(:exist?).with(bar).and_return(true) 31 | allow(config).to receive(:exist?).with(ignored).and_return(true) 32 | end 33 | 34 | describe 'smoosh_changes' do 35 | subject { described_class.new(config).smoosh_changes(changes) } 36 | 37 | context 'with rename from temp file' do 38 | let(:changes) do 39 | [ 40 | [:file, :modified, dir, 'foo'], 41 | [:file, :removed, dir, 'foo'], 42 | [:file, :added, dir, 'foo'], 43 | [:file, :modified, dir, 'foo'] 44 | ] 45 | end 46 | it { is_expected.to eq(modified: ['foo'], added: [], removed: []) } 47 | end 48 | 49 | context 'with a deteted temp file' do 50 | before { allow(config).to receive(:exist?).with(foo).and_return(false) } 51 | 52 | let(:changes) do 53 | [ 54 | [:file, :added, dir, 'foo'], 55 | [:file, :modified, dir, 'foo'], 56 | [:file, :removed, dir, 'foo'], 57 | [:file, :modified, dir, 'foo'] 58 | ] 59 | end 60 | it { is_expected.to eq(modified: [], added: [], removed: []) } 61 | end 62 | 63 | # e.g. "mv foo x && mv x foo" is like "touch foo" 64 | context 'when double move' do 65 | let(:changes) do 66 | [ 67 | [:file, :removed, dir, 'foo'], 68 | [:file, :added, dir, 'foo'] 69 | ] 70 | end 71 | it { is_expected.to eq(modified: ['foo'], added: [], removed: []) } 72 | end 73 | 74 | context 'with cookie' do 75 | context 'when single moved' do 76 | let(:changes) { [[:file, :moved_to, dir, 'foo', cookie: 4321]] } 77 | it { is_expected.to eq(modified: [], added: ['foo'], removed: []) } 78 | end 79 | 80 | context 'when related moved_to' do 81 | let(:changes) do 82 | [ 83 | [:file, :moved_from, dir, 'foo', cookie: 4321], 84 | [:file, :moved_to, dir, 'bar', cookie: 4321] 85 | ] 86 | end 87 | it { is_expected.to eq(modified: [], added: ['bar'], removed: []) } 88 | end 89 | 90 | # Scenario with workaround for editors using rename() 91 | context 'when related moved_to with ignored moved_from' do 92 | let(:changes) do 93 | [ 94 | [:file, :moved_from, dir, 'ignored', cookie: 4321], 95 | [:file, :moved_to, dir, 'foo', cookie: 4321] 96 | ] 97 | end 98 | it { is_expected.to eq(modified: ['foo'], added: [], removed: []) } 99 | end 100 | end 101 | 102 | context 'with no cookie' do 103 | context 'with ignored file' do 104 | let(:changes) { [[:file, :modified, dir, 'ignored']] } 105 | it { is_expected.to eq(modified: [], added: [], removed: []) } 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/listen/listener.rb: -------------------------------------------------------------------------------- 1 | require 'English' 2 | 3 | require 'listen/version' 4 | 5 | require 'listen/backend' 6 | 7 | require 'listen/silencer' 8 | require 'listen/silencer/controller' 9 | 10 | require 'listen/queue_optimizer' 11 | 12 | require 'listen/fsm' 13 | 14 | require 'listen/event/loop' 15 | require 'listen/event/queue' 16 | require 'listen/event/config' 17 | 18 | require 'listen/listener/config' 19 | 20 | module Listen 21 | class Listener 22 | # TODO: move the state machine's methods private 23 | include Listen::FSM 24 | 25 | # Initializes the directories listener. 26 | # 27 | # @param [String] directory the directories to listen to 28 | # @param [Hash] options the listen options (see Listen::Listener::Options) 29 | # 30 | # @yield [modified, added, removed] the changed files 31 | # @yieldparam [Array] modified the list of modified files 32 | # @yieldparam [Array] added the list of added files 33 | # @yieldparam [Array] removed the list of removed files 34 | # 35 | def initialize(*dirs, &block) 36 | options = dirs.last.is_a?(Hash) ? dirs.pop : {} 37 | 38 | @config = Config.new(options) 39 | 40 | eq_config = Event::Queue::Config.new(@config.relative?) 41 | queue = Event::Queue.new(eq_config) { @processor.wakeup_on_event } 42 | 43 | silencer = Silencer.new 44 | rules = @config.silencer_rules 45 | @silencer_controller = Silencer::Controller.new(silencer, rules) 46 | 47 | @backend = Backend.new(dirs, queue, silencer, @config) 48 | 49 | optimizer_config = QueueOptimizer::Config.new(@backend, silencer) 50 | 51 | pconfig = Event::Config.new( 52 | self, 53 | queue, 54 | QueueOptimizer.new(optimizer_config), 55 | @backend.min_delay_between_events, 56 | &block) 57 | 58 | @processor = Event::Loop.new(pconfig) 59 | 60 | super() # FSM 61 | end 62 | 63 | default_state :initializing 64 | 65 | state :initializing, to: [:backend_started, :stopped] 66 | 67 | state :backend_started, to: [:frontend_ready, :stopped] do 68 | backend.start 69 | end 70 | 71 | state :frontend_ready, to: [:processing_events, :stopped] do 72 | processor.setup 73 | end 74 | 75 | state :processing_events, to: [:paused, :stopped] do 76 | processor.resume 77 | end 78 | 79 | state :paused, to: [:processing_events, :stopped] do 80 | processor.pause 81 | end 82 | 83 | state :stopped, to: [:backend_started] do 84 | backend.stop # should be before processor.teardown to halt events ASAP 85 | processor.teardown 86 | end 87 | 88 | # Starts processing events and starts adapters 89 | # or resumes invoking callbacks if paused 90 | def start 91 | transition :backend_started if state == :initializing 92 | transition :frontend_ready if state == :backend_started 93 | transition :processing_events if state == :frontend_ready 94 | transition :processing_events if state == :paused 95 | end 96 | 97 | # Stops both listening for events and processing them 98 | def stop 99 | transition :stopped 100 | end 101 | 102 | # Stops invoking callbacks (messages pile up) 103 | def pause 104 | transition :paused 105 | end 106 | 107 | # processing means callbacks are called 108 | def processing? 109 | state == :processing_events 110 | end 111 | 112 | def paused? 113 | state == :paused 114 | end 115 | 116 | def ignore(regexps) 117 | @silencer_controller.append_ignores(regexps) 118 | end 119 | 120 | def ignore!(regexps) 121 | @silencer_controller.replace_with_bang_ignores(regexps) 122 | end 123 | 124 | def only(regexps) 125 | @silencer_controller.replace_with_only(regexps) 126 | end 127 | 128 | private 129 | 130 | attr_reader :processor 131 | attr_reader :backend 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/adapter/config' 2 | 3 | RSpec.describe Listen::Adapter::Config do 4 | let(:directories) { [path1, path2] } 5 | let(:queue) { instance_double(Queue) } 6 | let(:silencer) { instance_double(Listen::Silencer) } 7 | 8 | # NOTE: defaults are handled later in Listen::Options 9 | let(:adapter_options) { { latency: 1.234 } } 10 | 11 | subject do 12 | described_class.new(directories, queue, silencer, adapter_options) 13 | end 14 | 15 | # Here's what may be passed to initializer 16 | let(:path1) { fake_path('/real/path1', realpath: real_path1) } 17 | let(:path2) { fake_path('/real/path2', realpath: real_path2) } 18 | 19 | let(:current_path) do 20 | fake_path('/real/current_path', realpath: real_current_path) 21 | end 22 | 23 | let(:symlinked_dir1) { fake_path('symlinked_dir1', realpath: real_path1) } 24 | let(:symlinked_dir2) { fake_path('symlinked_dir1', realpath: real_path2) } 25 | 26 | # Here's what expected to be returned (just so that realpath() calls return 27 | # something useful) 28 | let(:real_path1) { fake_path('/real/path1') } 29 | let(:real_path2) { fake_path('/real/path2') } 30 | let(:real_current_path) { fake_path('/real/current_path') } 31 | 32 | before do 33 | allow(Pathname).to receive(:new) do |*args| 34 | fail "unstubbed Pathname.new(#{args.map(&:inspect) * ','})" 35 | end 36 | 37 | allow(Pathname).to receive(:new).with('/real/path1').and_return(path1) 38 | allow(Pathname).to receive(:new).with('/real/path2').and_return(path2) 39 | 40 | allow(Pathname).to receive(:new).with(path1).and_return(path1) 41 | allow(Pathname).to receive(:new).with(path2).and_return(path2) 42 | 43 | allow(Pathname).to receive(:new).with('symlinked_dir1'). 44 | and_return(symlinked_dir1) 45 | 46 | allow(Pathname).to receive(:new).with('symlinked_dir2'). 47 | and_return(symlinked_dir2) 48 | 49 | allow(Dir).to receive(:pwd).and_return('/real/current_path') 50 | 51 | allow(Pathname).to receive(:new). 52 | with('/real/current_path').and_return(current_path) 53 | end 54 | 55 | describe '#initialize' do 56 | context 'with directories as array' do 57 | context 'with strings for directories' do 58 | context 'when already resolved' do 59 | let(:directories) { ['/real/path1', '/real/path2'] } 60 | it 'returns array of pathnames' do 61 | expect(subject.directories).to eq([real_path1, real_path2]) 62 | end 63 | end 64 | 65 | context 'when not resolved' do 66 | let(:directories) { %w(symlinked_dir1 symlinked_dir2) } 67 | it 'returns array of resolved pathnames' do 68 | expect(subject.directories).to eq([real_path1, real_path2]) 69 | end 70 | end 71 | end 72 | 73 | context 'with Pathnames for directories' do 74 | let(:directories) { [path1, path2] } 75 | it 'returns array of pathnames' do 76 | expect(subject.directories).to eq([real_path1, real_path2]) 77 | end 78 | end 79 | end 80 | 81 | context 'with directories as messy array' do 82 | pending 'implement me' 83 | end 84 | 85 | context 'with no directories' do 86 | let(:directories) {} 87 | it 'returns the current path in array' do 88 | expect(subject.directories).to eq([real_current_path]) 89 | end 90 | end 91 | end 92 | 93 | describe '#adapter_options' do 94 | it 'provides a set of adapter_specific options' do 95 | expect(subject.adapter_options).to eq(latency: 1.234) 96 | end 97 | end 98 | 99 | describe '#queue' do 100 | it 'provides a direct queue for filesystem events' do 101 | expect(subject.queue).to eq(queue) 102 | end 103 | end 104 | 105 | describe '#silencer' do 106 | it 'provides a silencer object' do 107 | expect(subject.silencer).to eq(silencer) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/listen/fsm.rb: -------------------------------------------------------------------------------- 1 | # Code copied from https://github.com/celluloid/celluloid-fsm 2 | module Listen 3 | module FSM 4 | DEFAULT_STATE = :default # Default state name unless one is explicitly set 5 | 6 | # Included hook to extend class methods 7 | def self.included(klass) 8 | klass.send :extend, ClassMethods 9 | end 10 | 11 | module ClassMethods 12 | # Obtain or set the default state 13 | # Passing a state name sets the default state 14 | def default_state(new_default = nil) 15 | if new_default 16 | @default_state = new_default.to_sym 17 | else 18 | defined?(@default_state) ? @default_state : DEFAULT_STATE 19 | end 20 | end 21 | 22 | # Obtain the valid states for this FSM 23 | def states 24 | @states ||= {} 25 | end 26 | 27 | # Declare an FSM state and optionally provide a callback block to fire 28 | # Options: 29 | # * to: a state or array of states this state can transition to 30 | def state(*args, &block) 31 | if args.last.is_a? Hash 32 | # Stringify keys :/ 33 | options = args.pop.each_with_object({}) { |(k, v), h| h[k.to_s] = v } 34 | else 35 | options = {} 36 | end 37 | 38 | args.each do |name| 39 | name = name.to_sym 40 | default_state name if options['default'] 41 | states[name] = State.new(name, options['to'], &block) 42 | end 43 | end 44 | end 45 | 46 | # Be kind and call super if you must redefine initialize 47 | def initialize 48 | @state = self.class.default_state 49 | end 50 | 51 | # Obtain the current state of the FSM 52 | attr_reader :state 53 | 54 | def transition(state_name) 55 | new_state = validate_and_sanitize_new_state(state_name) 56 | return unless new_state 57 | transition_with_callbacks!(new_state) 58 | end 59 | 60 | # Immediate state transition with no checks, or callbacks. "Dangerous!" 61 | def transition!(state_name) 62 | @state = state_name 63 | end 64 | 65 | protected 66 | 67 | def validate_and_sanitize_new_state(state_name) 68 | state_name = state_name.to_sym 69 | 70 | return if current_state_name == state_name 71 | 72 | if current_state && !current_state.valid_transition?(state_name) 73 | valid = current_state.transitions.map(&:to_s).join(', ') 74 | msg = "#{self.class} can't change state from '#{@state}'"\ 75 | " to '#{state_name}', only to: #{valid}" 76 | fail ArgumentError, msg 77 | end 78 | 79 | new_state = states[state_name] 80 | 81 | unless new_state 82 | return if state_name == default_state 83 | fail ArgumentError, "invalid state for #{self.class}: #{state_name}" 84 | end 85 | 86 | new_state 87 | end 88 | 89 | def transition_with_callbacks!(state_name) 90 | transition! state_name.name 91 | state_name.call(self) 92 | end 93 | 94 | def states 95 | self.class.states 96 | end 97 | 98 | def default_state 99 | self.class.default_state 100 | end 101 | 102 | def current_state 103 | states[@state] 104 | end 105 | 106 | def current_state_name 107 | current_state && current_state.name || '' 108 | end 109 | 110 | class State 111 | attr_reader :name, :transitions 112 | 113 | def initialize(name, transitions = nil, &block) 114 | @name = name 115 | @block = block 116 | @transitions = nil 117 | @transitions = Array(transitions).map(&:to_sym) if transitions 118 | end 119 | 120 | def call(obj) 121 | obj.instance_eval(&@block) if @block 122 | end 123 | 124 | def valid_transition?(new_state) 125 | # All transitions are allowed unless expressly 126 | return true unless @transitions 127 | 128 | @transitions.include? new_state.to_sym 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/listen/adapter/base.rb: -------------------------------------------------------------------------------- 1 | require 'listen/options' 2 | require 'listen/record' 3 | require 'listen/change' 4 | 5 | module Listen 6 | module Adapter 7 | class Base 8 | attr_reader :options 9 | 10 | # TODO: only used by tests 11 | DEFAULTS = {}.freeze 12 | 13 | attr_reader :config 14 | 15 | def initialize(config) 16 | @started = false 17 | @config = config 18 | 19 | @configured = nil 20 | 21 | fail 'No directories to watch!' if config.directories.empty? 22 | 23 | defaults = self.class.const_get('DEFAULTS') 24 | @options = Listen::Options.new(config.adapter_options, defaults) 25 | rescue 26 | _log_exception 'adapter config failed: %s:%s called from: %s', caller 27 | raise 28 | end 29 | 30 | # TODO: it's a separate method as a temporary workaround for tests 31 | def configure 32 | if @configured 33 | _log(:warn, 'Adapter already configured!') 34 | return 35 | end 36 | 37 | @configured = true 38 | 39 | @callbacks ||= {} 40 | config.directories.each do |dir| 41 | callback = @callbacks[dir] || lambda do |event| 42 | _process_event(dir, event) 43 | end 44 | @callbacks[dir] = callback 45 | _configure(dir, &callback) 46 | end 47 | 48 | @snapshots ||= {} 49 | # TODO: separate config per directory (some day maybe) 50 | change_config = Change::Config.new(config.queue, config.silencer) 51 | config.directories.each do |dir| 52 | record = Record.new(dir) 53 | snapshot = Change.new(change_config, record) 54 | @snapshots[dir] = snapshot 55 | end 56 | end 57 | 58 | def started? 59 | @started 60 | end 61 | 62 | def start 63 | configure 64 | 65 | if started? 66 | _log(:warn, 'Adapter already started!') 67 | return 68 | end 69 | 70 | @started = true 71 | 72 | calling_stack = caller.dup 73 | Listen::Internals::ThreadPool.add do 74 | begin 75 | @snapshots.values.each do |snapshot| 76 | _timed('Record.build()') { snapshot.record.build } 77 | end 78 | _run 79 | rescue 80 | msg = 'run() in thread failed: %s:\n'\ 81 | ' %s\n\ncalled from:\n %s' 82 | _log_exception(msg, calling_stack) 83 | raise # for unit tests mostly 84 | end 85 | end 86 | end 87 | 88 | def stop 89 | _stop 90 | end 91 | 92 | def self.usable? 93 | const_get('OS_REGEXP') =~ RbConfig::CONFIG['target_os'] 94 | end 95 | 96 | private 97 | 98 | def _stop 99 | end 100 | 101 | def _timed(title) 102 | start = Time.now.to_f 103 | yield 104 | diff = Time.now.to_f - start 105 | Listen::Logger.info format('%s: %.05f seconds', title, diff) 106 | rescue 107 | Listen::Logger.warn "#{title} crashed: #{$ERROR_INFO.inspect}" 108 | raise 109 | end 110 | 111 | # TODO: allow backend adapters to pass specific invalidation objects 112 | # e.g. Darwin -> DirRescan, INotify -> MoveScan, etc. 113 | def _queue_change(type, dir, rel_path, options) 114 | @snapshots[dir].invalidate(type, rel_path, options) 115 | end 116 | 117 | def _log(*args, &block) 118 | self.class.send(:_log, *args, &block) 119 | end 120 | 121 | def _log_exception(msg, caller_stack) 122 | formatted = format( 123 | msg, 124 | $ERROR_INFO, 125 | $ERROR_POSITION * "\n", 126 | caller_stack * "\n" 127 | ) 128 | 129 | _log(:error, formatted) 130 | end 131 | 132 | class << self 133 | private 134 | 135 | def _log(*args, &block) 136 | Listen::Logger.send(*args, &block) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/listen/queue_optimizer.rb: -------------------------------------------------------------------------------- 1 | module Listen 2 | class QueueOptimizer 3 | class Config 4 | def initialize(adapter_class, silencer) 5 | @adapter_class = adapter_class 6 | @silencer = silencer 7 | end 8 | 9 | def exist?(path) 10 | Pathname(path).exist? 11 | end 12 | 13 | def silenced?(path, type) 14 | @silencer.silenced?(path, type) 15 | end 16 | 17 | def debug(*args, &block) 18 | Listen.logger.debug(*args, &block) 19 | end 20 | end 21 | 22 | def smoosh_changes(changes) 23 | # TODO: adapter could be nil at this point (shutdown) 24 | cookies = changes.group_by do |_, _, _, _, options| 25 | (options || {})[:cookie] 26 | end 27 | _squash_changes(_reinterpret_related_changes(cookies)) 28 | end 29 | 30 | def initialize(config) 31 | @config = config 32 | end 33 | 34 | private 35 | 36 | attr_reader :config 37 | 38 | # groups changes into the expected structure expected by 39 | # clients 40 | def _squash_changes(changes) 41 | # We combine here for backward compatibility 42 | # Newer clients should receive dir and path separately 43 | changes = changes.map { |change, dir, path| [change, dir + path] } 44 | 45 | actions = changes.group_by(&:last).map do |path, action_list| 46 | [_logical_action_for(path, action_list.map(&:first)), path.to_s] 47 | end 48 | 49 | config.debug("listen: raw changes: #{actions.inspect}") 50 | 51 | { modified: [], added: [], removed: [] }.tap do |squashed| 52 | actions.each do |type, path| 53 | squashed[type] << path unless type.nil? 54 | end 55 | config.debug("listen: final changes: #{squashed.inspect}") 56 | end 57 | end 58 | 59 | def _logical_action_for(path, actions) 60 | actions << :added if actions.delete(:moved_to) 61 | actions << :removed if actions.delete(:moved_from) 62 | 63 | modified = actions.detect { |x| x == :modified } 64 | _calculate_add_remove_difference(actions, path, modified) 65 | end 66 | 67 | def _calculate_add_remove_difference(actions, path, default_if_exists) 68 | added = actions.count { |x| x == :added } 69 | removed = actions.count { |x| x == :removed } 70 | diff = added - removed 71 | 72 | # TODO: avoid checking if path exists and instead assume the events are 73 | # in order (if last is :removed, it doesn't exist, etc.) 74 | if config.exist?(path) 75 | if diff > 0 76 | :added 77 | elsif diff.zero? && added > 0 78 | :modified 79 | else 80 | default_if_exists 81 | end 82 | else 83 | diff < 0 ? :removed : nil 84 | end 85 | end 86 | 87 | # remove extraneous rb-inotify events, keeping them only if it's a possible 88 | # editor rename() call (e.g. Kate and Sublime) 89 | def _reinterpret_related_changes(cookies) 90 | table = { moved_to: :added, moved_from: :removed } 91 | cookies.flat_map do |_, changes| 92 | data = _detect_possible_editor_save(changes) 93 | if data 94 | to_dir, to_file = data 95 | [[:modified, to_dir, to_file]] 96 | else 97 | not_silenced = changes.reject do |type, _, _, path, _| 98 | config.silenced?(Pathname(path), type) 99 | end 100 | not_silenced.map do |_, change, dir, path, _| 101 | [table.fetch(change, change), dir, path] 102 | end 103 | end 104 | end 105 | end 106 | 107 | def _detect_possible_editor_save(changes) 108 | return unless changes.size == 2 109 | 110 | from_type = from = nil 111 | to_type = to_dir = to = nil 112 | 113 | changes.each do |data| 114 | case data[1] 115 | when :moved_from 116 | from_type, _from_change, _, from, = data 117 | when :moved_to 118 | to_type, _to_change, to_dir, to, = data 119 | else 120 | return nil 121 | end 122 | end 123 | 124 | return unless from && to 125 | 126 | # Expect an ignored moved_from and non-ignored moved_to 127 | # to qualify as an "editor modify" 128 | return unless config.silenced?(Pathname(from), from_type) 129 | config.silenced?(Pathname(to), to_type) ? nil : [to_dir, to] 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/lib/listen/event/queue_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/event/queue' 2 | 3 | # TODO: not part of listener really 4 | RSpec.describe Listen::Event::Queue do 5 | let(:queue) { instance_double(Thread::Queue, 'my queue') } 6 | 7 | let(:config) { instance_double(Listen::Event::Queue::Config) } 8 | 9 | let(:relative) { false } 10 | 11 | let(:block) { proc {} } 12 | 13 | subject { described_class.new(config, &block) } 14 | 15 | before do 16 | allow(config).to receive(:relative?).and_return(relative) 17 | allow(Thread::Queue).to receive(:new).and_return(queue) 18 | end 19 | 20 | describe '#empty?' do 21 | before do 22 | allow(queue).to receive(:empty?).and_return(empty) 23 | end 24 | 25 | context 'when empty' do 26 | let(:empty) { true } 27 | it { is_expected.to be_empty } 28 | end 29 | 30 | context 'when not empty' do 31 | let(:empty) { false } 32 | let(:watched_dir) { fake_path('watched_dir') } 33 | before do 34 | allow(queue).to receive(:empty?).and_return(false) 35 | end 36 | it { is_expected.to_not be_empty } 37 | end 38 | end 39 | 40 | describe '#pop' do 41 | before do 42 | allow(queue).to receive(:pop).and_return('foo') 43 | end 44 | 45 | context 'when empty' do 46 | let(:value) { 'foo' } 47 | it 'forward the call to the queue' do 48 | expect(subject.pop).to eq('foo') 49 | end 50 | end 51 | end 52 | 53 | describe '#<<' do 54 | let(:watched_dir) { fake_path('watched_dir') } 55 | before do 56 | allow(queue).to receive(:<<) 57 | end 58 | 59 | context 'when a block is given' do 60 | let(:calls) { [] } 61 | let(:block) { proc { calls << 'called!' } } 62 | 63 | it 'calls the provided block' do 64 | subject.<<([:file, :modified, watched_dir, 'foo', {}]) 65 | expect(calls).to eq(['called!']) 66 | end 67 | end 68 | 69 | context 'when no block is given' do 70 | let(:calls) { [] } 71 | let(:block) { nil } 72 | 73 | it 'calls the provided block' do 74 | subject.<<([:file, :modified, watched_dir, 'foo', {}]) 75 | expect(calls).to eq([]) 76 | end 77 | end 78 | 79 | context 'when relative option is true' do 80 | let(:relative) { true } 81 | 82 | context 'when watched dir is the current dir' do 83 | let(:options) { { relative: true, directories: Pathname.pwd } } 84 | 85 | let(:dir_rel_path) { fake_path('.') } 86 | let(:foo_rel_path) { fake_path('foo', exist?: true) } 87 | 88 | it 'registers relative paths' do 89 | allow(dir_rel_path).to receive(:+).with('foo') { foo_rel_path } 90 | 91 | allow(watched_dir).to receive(:relative_path_from). 92 | with(Pathname.pwd). 93 | and_return(dir_rel_path) 94 | 95 | expect(queue).to receive(:<<). 96 | with([:file, :modified, dir_rel_path, 'foo', {}]) 97 | 98 | subject.<<([:file, :modified, watched_dir, 'foo', {}]) 99 | end 100 | end 101 | 102 | context 'when watched dir is not the current dir' do 103 | let(:options) { { relative: true } } 104 | let(:dir_rel_path) { fake_path('..') } 105 | let(:foo_rel_path) { fake_path('../foo', exist?: true) } 106 | 107 | it 'registers relative path' do 108 | allow(watched_dir).to receive(:relative_path_from). 109 | with(Pathname.pwd). 110 | and_return(dir_rel_path) 111 | 112 | expect(queue).to receive(:<<). 113 | with([:file, :modified, dir_rel_path, 'foo', {}]) 114 | 115 | subject.<<([:file, :modified, watched_dir, 'foo', {}]) 116 | end 117 | end 118 | 119 | context 'when watched dir is on another drive' do 120 | let(:watched_dir) { fake_path('watched_dir', realpath: 'd:/foo') } 121 | let(:foo_rel_path) { fake_path('d:/foo', exist?: true) } 122 | 123 | it 'registers full path' do 124 | allow(watched_dir).to receive(:relative_path_from). 125 | with(Pathname.pwd). 126 | and_raise(ArgumentError) 127 | 128 | allow(watched_dir).to receive(:+).with('foo') { foo_rel_path } 129 | 130 | expect(queue).to receive(:<<). 131 | with([:file, :modified, watched_dir, 'foo', {}]) 132 | 133 | subject.<<([:file, :modified, watched_dir, 'foo', {}]) 134 | end 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | if ENV["CI"] != "true" 7 | require "rubocop/rake_task" 8 | RuboCop::RakeTask.new(:rubocop) 9 | task default: [:spec, :rubocop] 10 | else 11 | task default: [:spec] 12 | end 13 | 14 | class Releaser 15 | def initialize(options = {}) 16 | @project_name = options.delete(:project_name) do 17 | fail "project_name is needed!" 18 | end 19 | 20 | @gem_name = options.delete(:gem_name) do 21 | fail "gem_name is needed!" 22 | end 23 | 24 | @github_repo = options.delete(:github_repo) do 25 | fail "github_repo is needed!" 26 | end 27 | 28 | @version = options.delete(:version) do 29 | fail "version is needed!" 30 | end 31 | end 32 | 33 | def full 34 | rubygems 35 | github 36 | end 37 | 38 | def rubygems 39 | begin 40 | STDOUT.puts "Release #{@project_name} #{@version} to RubyGems? (y/n)" 41 | input = STDIN.gets.chomp.downcase 42 | end while !%w(y n).include?(input) 43 | 44 | exit if input == "n" 45 | 46 | Rake::Task["release"].invoke 47 | end 48 | 49 | def github 50 | tag_name = "v#{@version}" 51 | 52 | require "gems" 53 | 54 | _verify_released 55 | _verify_tag_pushed 56 | 57 | require "octokit" 58 | gh_client = Octokit::Client.new(netrc: true) 59 | 60 | gh_release = _detect_gh_release(gh_client, tag_name, true) 61 | return unless gh_release 62 | 63 | STDOUT.puts "Draft release for #{tag_name}:\n" 64 | STDOUT.puts gh_release.body 65 | STDOUT.puts "\n-------------------------\n\n" 66 | 67 | _confirm_publish 68 | 69 | return unless _update_release(gh_client, gh_release, tag_name) 70 | 71 | gh_release = _detect_gh_release(gh_client, tag_name, false) 72 | 73 | _success_summary(gh_release, tag_name) 74 | end 75 | 76 | private 77 | 78 | def _verify_released 79 | latest = Gems.info(@gem_name)["version"] 80 | return if @version == latest 81 | STDOUT.puts format( 82 | "%s %s is not yet released (latest: %s)", 83 | @project_name, 84 | @version, 85 | latest.inspect 86 | ) 87 | STDOUT.puts "Please release it first with: rake release:gem" 88 | exit 89 | end 90 | 91 | def _verify_tag_pushed 92 | tags = `git ls-remote --tags origin`.split("\n") 93 | return if tags.detect { |tag| tag =~ /v#{@version}$/ } 94 | 95 | STDOUT.puts "The tag v#{@version} has not yet been pushed." 96 | STDOUT.puts "Please push it first with: rake release:gem" 97 | exit 98 | end 99 | 100 | def _success_summary(gh_release, tag_name) 101 | href = gh_release.rels[:html].href 102 | STDOUT.puts "GitHub release #{tag_name} has been published!" 103 | STDOUT.puts "\nPlease enjoy and spread the word!" 104 | STDOUT.puts "Lack of inspiration? Here's a tweet you could improve:\n\n" 105 | STDOUT.puts "Just released #{@project_name} #{@version}! #{href}" 106 | end 107 | 108 | def _detect_gh_release(gh_client, tag_name, draft) 109 | gh_releases = gh_client.releases(@github_repo) 110 | gh_releases.detect { |r| r.tag_name == tag_name && r.draft == draft } 111 | end 112 | 113 | def _confirm_publish 114 | begin 115 | STDOUT.puts "Would you like to publish this GitHub release now? (y/n)" 116 | input = STDIN.gets.chomp.downcase 117 | end while !%w(y n).include?(input) 118 | 119 | exit if input == "n" 120 | end 121 | 122 | def _update_release(gh_client, gh_release, tag_name) 123 | result = gh_client.update_release(gh_release.rels[:self].href, draft: false) 124 | return true if result 125 | STDOUT.puts "GitHub release #{tag_name} couldn't be published!" 126 | false 127 | end 128 | end 129 | 130 | PROJECT_NAME = "Listen" 131 | CURRENT_VERSION = Listen::VERSION 132 | 133 | def releaser 134 | $releaser ||= Releaser.new( 135 | project_name: PROJECT_NAME, 136 | gem_name: "listen", 137 | github_repo: "guard/listen", 138 | version: CURRENT_VERSION) 139 | end 140 | 141 | namespace :release do 142 | desc "Push #{PROJECT_NAME} #{CURRENT_VERSION} to RubyGems and publish"\ 143 | " its GitHub release" 144 | 145 | task full: ["release:gem", "release:github"] 146 | 147 | desc "Push #{PROJECT_NAME} #{CURRENT_VERSION} to RubyGems" 148 | task :gem do 149 | releaser.rubygems 150 | end 151 | 152 | desc "Publish #{PROJECT_NAME} #{CURRENT_VERSION} GitHub release" 153 | task :github do 154 | releaser.github 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/linux_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Adapter::Linux do 2 | describe 'class' do 3 | subject { described_class } 4 | 5 | if linux? 6 | it { should be_usable } 7 | else 8 | it { should_not be_usable } 9 | end 10 | end 11 | 12 | if linux? 13 | let(:dir1) {Pathname.new("/foo/dir1")} 14 | 15 | let(:config) { instance_double(Listen::Adapter::Config) } 16 | let(:queue) { instance_double(Queue) } 17 | let(:silencer) { instance_double(Listen::Silencer) } 18 | let(:snapshot) { instance_double(Listen::Change) } 19 | let(:record) { instance_double(Listen::Record) } 20 | 21 | # TODO: fix other adapters too! 22 | subject { described_class.new(config) } 23 | 24 | describe 'inotify limit message' do 25 | let(:directories) { [Pathname.pwd] } 26 | let(:adapter_options) { {} } 27 | 28 | before do 29 | require 'rb-inotify' 30 | fake_worker = double(:fake_worker) 31 | allow(fake_worker).to receive(:watch).and_raise(Errno::ENOSPC) 32 | 33 | fake_notifier = double(:fake_notifier, new: fake_worker) 34 | stub_const('INotify::Notifier', fake_notifier) 35 | 36 | allow(config).to receive(:directories).and_return(directories) 37 | allow(config).to receive(:adapter_options).and_return(adapter_options) 38 | end 39 | 40 | it 'should be shown before calling abort' do 41 | expected_message = described_class.const_get('INOTIFY_LIMIT_MESSAGE') 42 | expect { subject.start }.to raise_error SystemExit, expected_message 43 | end 44 | end 45 | 46 | # TODO: should probably be adapted to be more like adapter/base_spec.rb 47 | describe '_callback' do 48 | let(:directories) { [dir1] } 49 | let(:adapter_options) { { events: [:recursive, :close_write] } } 50 | 51 | before do 52 | fake_worker = double(:fake_worker) 53 | events = [:recursive, :close_write] 54 | allow(fake_worker).to receive(:watch).with('/foo/dir1', *events) 55 | 56 | fake_notifier = double(:fake_notifier, new: fake_worker) 57 | stub_const('INotify::Notifier', fake_notifier) 58 | 59 | allow(config).to receive(:directories).and_return(directories) 60 | allow(config).to receive(:adapter_options).and_return(adapter_options) 61 | allow(config).to receive(:queue).and_return(queue) 62 | allow(config).to receive(:silencer).and_return(silencer) 63 | 64 | allow(Listen::Record).to receive(:new).with(dir1).and_return(record) 65 | allow(Listen::Change::Config).to receive(:new).with(queue, silencer). 66 | and_return(config) 67 | allow(Listen::Change).to receive(:new).with(config, record). 68 | and_return(snapshot) 69 | 70 | allow(subject).to receive(:require).with('rb-inotify') 71 | subject.configure 72 | end 73 | 74 | let(:expect_change) do 75 | lambda do |change| 76 | expect(snapshot).to receive(:invalidate).with( 77 | :file, 78 | 'path/foo.txt', 79 | cookie: 123, 80 | change: change 81 | ) 82 | end 83 | end 84 | 85 | let(:event_callback) do 86 | lambda do |flags| 87 | callbacks = subject.instance_variable_get(:'@callbacks') 88 | callbacks.values.flatten.each do |callback| 89 | callback.call double( 90 | :inotify_event, 91 | name: 'foo.txt', 92 | watcher: double(:watcher, path: '/foo/dir1/path'), 93 | flags: flags, 94 | cookie: 123) 95 | end 96 | end 97 | end 98 | 99 | # TODO: get fsevent adapter working like INotify 100 | unless /1|true/ =~ ENV['LISTEN_GEM_SIMULATE_FSEVENT'] 101 | it 'recognizes close_write as modify' do 102 | expect_change.call(:modified) 103 | event_callback.call([:close_write]) 104 | end 105 | 106 | it 'recognizes moved_to as moved_to' do 107 | expect_change.call(:moved_to) 108 | event_callback.call([:moved_to]) 109 | end 110 | 111 | it 'recognizes moved_from as moved_from' do 112 | expect_change.call(:moved_from) 113 | event_callback.call([:moved_from]) 114 | end 115 | end 116 | end 117 | 118 | describe '#stop' do 119 | let(:fake_worker) { double(:fake_worker, close: true) } 120 | let(:directories) { [dir1] } 121 | let(:adapter_options) { { events: [:recursive, :close_write] } } 122 | 123 | before do 124 | allow(config).to receive(:directories).and_return(directories) 125 | allow(config).to receive(:adapter_options).and_return(adapter_options) 126 | end 127 | 128 | context 'when configured' do 129 | before do 130 | events = [:recursive, :close_write] 131 | allow(fake_worker).to receive(:watch).with('/foo/dir1', *events) 132 | 133 | fake_notifier = double(:fake_notifier, new: fake_worker) 134 | stub_const('INotify::Notifier', fake_notifier) 135 | 136 | allow(config).to receive(:queue).and_return(queue) 137 | allow(config).to receive(:silencer).and_return(silencer) 138 | 139 | allow(subject).to receive(:require).with('rb-inotify') 140 | subject.configure 141 | end 142 | 143 | it 'stops the worker' do 144 | expect(fake_worker).to receive(:close) 145 | subject.stop 146 | end 147 | end 148 | 149 | context 'when not even initialized' do 150 | it 'does not crash' do 151 | expect do 152 | subject.stop 153 | end.to_not raise_error 154 | end 155 | end 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/lib/listen/event/loop_spec.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'listen/event/config' 3 | require 'listen/event/loop' 4 | require 'listen/internals/thread_pool' 5 | 6 | RSpec.describe Listen::Event::Loop do 7 | let(:config) { instance_double(Listen::Event::Config, 'config') } 8 | let(:processor) { instance_double(Listen::Event::Processor, 'processor') } 9 | let(:thread) { instance_double(Thread) } 10 | 11 | let(:reasons) { instance_double(::Queue, 'reasons') } 12 | let(:ready) { instance_double(::Queue, 'ready') } 13 | 14 | let(:blocks) do 15 | { 16 | thread_block: proc { fail 'thread block stub called' }, 17 | timer_block: proc { fail 'thread block stub called' } 18 | } 19 | end 20 | 21 | subject { described_class.new(config) } 22 | 23 | # TODO: this is hideous 24 | before do 25 | allow(::Queue).to receive(:new).and_return(reasons, ready) 26 | allow(Listen::Event::Processor).to receive(:new).with(config, reasons). 27 | and_return(processor) 28 | 29 | allow(Listen::Internals::ThreadPool).to receive(:add) do |*args, &block| 30 | fail 'Unstubbed call:'\ 31 | " ThreadPool.add(#{args.map(&:inspect) * ','},&#{block.inspect})" 32 | end 33 | 34 | allow(config).to receive(:min_delay_between_events).and_return(1.234) 35 | 36 | allow(Listen::Internals::ThreadPool).to receive(:add) do |*_, &block| 37 | blocks[:thread_block] = block 38 | thread 39 | end 40 | 41 | allow(Timeout).to receive(:timeout) do |*_args, &block| 42 | blocks[:timer_block] = block 43 | end 44 | 45 | allow(Kernel).to receive(:sleep) do |*args| 46 | fail "stub called: sleep(#{args.map(&:inspect) * ','})" 47 | end 48 | 49 | allow(subject).to receive(:_nice_error) do |ex| 50 | indent = "\n -- " 51 | backtrace = ex.backtrace.reject { |line| line =~ %r{\/gems\/} } 52 | fail "error called: #{ex}: #{indent}#{backtrace * indent}" 53 | end 54 | end 55 | 56 | describe '#setup' do 57 | before do 58 | allow(thread).to receive(:wakeup) 59 | allow(thread).to receive(:alive?).and_return(true) 60 | allow(config).to receive(:min_delay_between_events).and_return(1.234) 61 | allow(ready).to receive(:<<).with(:ready) 62 | end 63 | 64 | it 'sets up the thread in a resumable state' do 65 | subject.setup 66 | 67 | expect(subject).to receive(:sleep).with(no_args) 68 | allow(processor).to receive(:loop_for).with(1.234) 69 | 70 | blocks[:thread_block].call 71 | end 72 | end 73 | 74 | context 'when stopped' do 75 | context 'when resume is called' do 76 | it 'fails' do 77 | expect { subject.resume }. 78 | to raise_error(Listen::Event::Loop::Error::NotStarted) 79 | end 80 | end 81 | 82 | context 'when wakeup_on_event is called' do 83 | it 'does nothing' do 84 | subject.wakeup_on_event 85 | end 86 | end 87 | end 88 | 89 | context 'when resumed' do 90 | before do 91 | subject.setup 92 | 93 | allow(thread).to receive(:wakeup) do 94 | allow(subject).to receive(:sleep).with(no_args) 95 | allow(processor).to receive(:loop_for).with(1.234) 96 | allow(ready).to receive(:<<).with(:ready) 97 | blocks[:thread_block].call 98 | end 99 | 100 | allow(reasons).to receive(:<<).with(:resume) 101 | subject.resume 102 | end 103 | 104 | it 'is not paused' do 105 | expect(subject).to_not be_paused 106 | end 107 | 108 | context 'when resume is called again' do 109 | it 'does nothing' do 110 | subject.resume 111 | end 112 | end 113 | 114 | context 'when wakeup_on_event is called' do 115 | let(:epoch) { 1234 } 116 | 117 | context 'when thread is alive' do 118 | before do 119 | allow(reasons).to receive(:<<) 120 | allow(thread).to receive(:alive?).and_return(true) 121 | end 122 | 123 | it 'wakes up the thread' do 124 | expect(thread).to receive(:wakeup) 125 | subject.wakeup_on_event 126 | end 127 | 128 | it 'sets the reason for waking up' do 129 | expect(reasons).to receive(:<<).with(:event) 130 | subject.wakeup_on_event 131 | end 132 | end 133 | 134 | context 'when thread is dead' do 135 | before do 136 | allow(thread).to receive(:alive?).and_return(false) 137 | end 138 | 139 | it 'does not wake up the thread' do 140 | expect(thread).to_not receive(:wakeup) 141 | subject.wakeup_on_event 142 | end 143 | end 144 | end 145 | end 146 | 147 | context 'when set up / paused' do 148 | before do 149 | allow(thread).to receive(:alive?).and_return(true) 150 | allow(config).to receive(:min_delay_between_events).and_return(1.234) 151 | 152 | allow(thread).to receive(:wakeup) 153 | 154 | subject.setup 155 | 156 | allow(subject).to receive(:sleep).with(no_args) do 157 | allow(processor).to receive(:loop_for).with(1.234) 158 | blocks[:timer_block].call 159 | end 160 | 161 | allow(ready).to receive(:<<).with(:ready) 162 | allow(ready).to receive(:pop) 163 | 164 | blocks[:thread_block].call 165 | end 166 | 167 | describe '#resume' do 168 | before do 169 | allow(reasons).to receive(:<<) 170 | allow(thread).to receive(:wakeup) 171 | end 172 | 173 | it 'resumes the thread' do 174 | expect(thread).to receive(:wakeup) 175 | subject.resume 176 | end 177 | 178 | it 'sets the reason for waking up' do 179 | expect(reasons).to receive(:<<).with(:resume) 180 | subject.resume 181 | end 182 | end 183 | 184 | describe '#teardown' do 185 | before do 186 | allow(reasons).to receive(:<<) 187 | allow(thread).to receive(:join) 188 | end 189 | 190 | it 'frees the thread' do 191 | subject.teardown 192 | end 193 | 194 | it 'waits for the thread to finish' do 195 | expect(thread).to receive(:join) 196 | subject.teardown 197 | end 198 | 199 | it 'sets the reason for waking up' do 200 | expect(reasons).to receive(:<<).with(:teardown) 201 | subject.teardown 202 | end 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /spec/lib/listen/adapter/darwin_spec.rb: -------------------------------------------------------------------------------- 1 | # This is just so stubs work 2 | require 'rb-fsevent' 3 | 4 | require 'listen/adapter/darwin' 5 | 6 | include Listen 7 | 8 | RSpec.describe Adapter::Darwin do 9 | describe 'class' do 10 | subject { described_class } 11 | 12 | context 'on darwin 13.0 (OS X Mavericks)' do 13 | before do 14 | allow(RbConfig::CONFIG).to receive(:[]).and_return('darwin13.0') 15 | end 16 | 17 | it { should be_usable } 18 | end 19 | 20 | context 'on darwin10.0 (OS X Snow Leopard)' do 21 | before do 22 | allow(RbConfig::CONFIG).to receive(:[]).and_return('darwin10.0') 23 | end 24 | 25 | context 'with rb-fsevent > 0.9.4' do 26 | before { stub_const('FSEvent::VERSION', '0.9.6') } 27 | it 'shows a warning and should not be usable' do 28 | expect(Kernel).to receive(:warn) 29 | expect(subject).to_not be_usable 30 | end 31 | end 32 | 33 | context 'with rb-fsevent <= 0.9.4' do 34 | before { stub_const('FSEvent::VERSION', '0.9.4') } 35 | it { should be_usable } 36 | end 37 | end 38 | 39 | context 'on another platform (linux)' do 40 | before { allow(RbConfig::CONFIG).to receive(:[]).and_return('linux') } 41 | it { should_not be_usable } 42 | end 43 | end 44 | 45 | let(:options) { {} } 46 | let(:config) { instance_double(Listen::Adapter::Config) } 47 | let(:queue) { instance_double(::Queue) } 48 | let(:silencer) { instance_double(Listen::Silencer) } 49 | 50 | let(:dir1) { fake_path('/foo/dir1', cleanpath: fake_path('/foo/dir1')) } 51 | let(:directories) { [dir1] } 52 | 53 | subject { described_class.new(config) } 54 | 55 | before do 56 | allow(config).to receive(:directories).and_return(directories) 57 | allow(config).to receive(:adapter_options).and_return(options) 58 | end 59 | 60 | describe '#_latency' do 61 | subject { described_class.new(config).options.latency } 62 | 63 | context 'with no overriding option' do 64 | it { should eq 0.1 } 65 | end 66 | 67 | context 'with custom latency overriding' do 68 | let(:options) { { latency: 1234 } } 69 | it { should eq 1234 } 70 | end 71 | end 72 | 73 | describe 'multiple dirs' do 74 | let(:dir1) { fake_path('/foo/dir1', cleanpath: fake_path('/foo/dir1')) } 75 | let(:dir2) { fake_path('/foo/dir2', cleanpath: fake_path('/foo/dir1')) } 76 | let(:dir3) { fake_path('/foo/dir3', cleanpath: fake_path('/foo/dir1')) } 77 | 78 | before do 79 | allow(config).to receive(:queue).and_return(queue) 80 | allow(config).to receive(:silencer).and_return(silencer) 81 | end 82 | 83 | let(:foo1) { double('fsevent1') } 84 | let(:foo2) { double('fsevent2') } 85 | let(:foo3) { double('fsevent3') } 86 | 87 | before do 88 | allow(FSEvent).to receive(:new).and_return(*expectations.values, nil) 89 | expectations.each do |dir, obj| 90 | allow(obj).to receive(:watch).with(dir.to_s, latency: 0.1) 91 | end 92 | end 93 | 94 | describe 'configuration' do 95 | before do 96 | subject.configure 97 | end 98 | 99 | context 'with 1 directory' do 100 | let(:directories) { expectations.keys.map { |p| Pathname(p.to_s) } } 101 | 102 | let(:expectations) { { '/foo/dir1': foo1 } } 103 | 104 | it 'configures directory' do 105 | expect(foo1).to have_received(:watch).with('/foo/dir1', latency: 0.1) 106 | end 107 | end 108 | 109 | context 'with 2 directories' do 110 | let(:directories) { expectations.keys.map { |p| Pathname(p.to_s) } } 111 | let(:expectations) { { dir1: foo1, dir2: foo2 } } 112 | 113 | it 'configures directories' do 114 | expect(foo1).to have_received(:watch).with('dir1', latency: 0.1) 115 | expect(foo2).to have_received(:watch).with('dir2', latency: 0.1) 116 | end 117 | end 118 | 119 | context 'with 3 directories' do 120 | let(:directories) { expectations.keys.map { |p| Pathname(p.to_s) } } 121 | let(:expectations) do 122 | { 123 | '/foo/dir1': foo1, 124 | '/foo/dir2': foo2, 125 | '/foo/dir3': foo3 126 | } 127 | end 128 | 129 | it 'configures directories' do 130 | expect(foo1).to have_received(:watch).with('/foo/dir1', latency: 0.1) 131 | expect(foo2).to have_received(:watch).with('/foo/dir2', latency: 0.1) 132 | expect(foo3).to have_received(:watch).with('/foo/dir3', latency: 0.1) 133 | end 134 | end 135 | end 136 | 137 | describe 'running threads' do 138 | let(:running) { [] } 139 | let(:directories) { expectations.keys.map { |p| Pathname(p.to_s) } } 140 | 141 | before do 142 | started = ::Queue.new 143 | threads = ::Queue.new 144 | left = ::Queue.new 145 | 146 | # NOTE: Travis has a hard time creating threads on OSX 147 | thread_start_overhead = 3 148 | max_test_time = 3 * thread_start_overhead 149 | block_time = max_test_time + thread_start_overhead 150 | 151 | expectations.each do |name, _| 152 | left << name 153 | end 154 | 155 | expectations.each do |_, obj| 156 | allow(obj).to receive(:run) do 157 | current_name = left.pop 158 | threads << Thread.current 159 | started << current_name 160 | sleep block_time 161 | end 162 | end 163 | 164 | Timeout.timeout(max_test_time) do 165 | subject.start 166 | sleep 0.1 until started.size == expectations.size 167 | end 168 | 169 | running << started.pop until started.empty? 170 | 171 | killed = ::Queue.new 172 | killed << threads.pop.kill until threads.empty? 173 | killed.pop.join until killed.empty? 174 | end 175 | 176 | context 'with 1 directory' do 177 | let(:expectations) { { dir1: foo1 } } 178 | it 'runs all the workers without blocking' do 179 | expect(running.sort).to eq(expectations.keys) 180 | end 181 | end 182 | 183 | context 'with 2 directories' do 184 | let(:expectations) { { dir1: foo1, dir2: foo2 } } 185 | it 'runs all the workers without blocking' do 186 | expect(running.sort).to eq(expectations.keys) 187 | end 188 | end 189 | 190 | context 'with 3 directories' do 191 | let(:expectations) { { dir1: foo1, dir2: foo2, dir3: foo3 } } 192 | it 'runs all the workers without blocking' do 193 | expect(running.sort).to eq(expectations.keys) 194 | end 195 | end 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /spec/lib/listen/event/processor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'listen/event/processor' 2 | require 'listen/event/config' 3 | 4 | RSpec.describe Listen::Event::Processor do 5 | let(:event_queue) { instance_double(::Queue, 'event_queue') } 6 | let(:config) { instance_double(Listen::Event::Config) } 7 | let(:reasons) { instance_double(::Queue, 'reasons') } 8 | 9 | subject { described_class.new(config, reasons) } 10 | 11 | # This is to simulate events over various points in time 12 | let(:sequence) do 13 | {} 14 | end 15 | 16 | let(:state) do 17 | { time: 0 } 18 | end 19 | 20 | def status_for_time(time) 21 | # find the status of the listener for a given point in time 22 | previous_state_timestamps = sequence.keys.reject { |k| k > time } 23 | last_state_before_given_time = previous_state_timestamps.max 24 | sequence[last_state_before_given_time] 25 | end 26 | 27 | before do 28 | allow(config).to receive(:event_queue).and_return(event_queue) 29 | 30 | allow(config).to receive(:stopped?) do 31 | status_for_time(state[:time]) == :stopped 32 | end 33 | 34 | allow(config).to receive(:paused?) do 35 | status_for_time(state[:time]) == :paused 36 | end 37 | 38 | allow(config).to receive(:timestamp) do 39 | state[:time] 40 | end 41 | end 42 | 43 | describe '#loop_for' do 44 | before do 45 | allow(reasons).to receive(:empty?).and_return(true) 46 | end 47 | 48 | context 'when stopped' do 49 | before do 50 | sequence[0.0] = :stopped 51 | end 52 | 53 | context 'with pending changes' do 54 | before do 55 | allow(event_queue).to receive(:empty?).and_return(false) 56 | end 57 | 58 | it 'does not change the event queue' do 59 | subject.loop_for(1) 60 | end 61 | 62 | it 'does not sleep' do 63 | expect(config).to_not receive(:sleep) 64 | t = Time.now.to_f 65 | subject.loop_for(1) 66 | diff = Time.now.to_f - t 67 | expect(diff).to be < 0.01 68 | end 69 | end 70 | end 71 | 72 | context 'when not stopped' do 73 | before do 74 | allow(event_queue).to receive(:empty?).and_return(true) 75 | end 76 | 77 | context 'when initially paused' do 78 | before do 79 | sequence[0.0] = :paused 80 | end 81 | 82 | context 'when stopped after sleeping' do 83 | before do 84 | sequence[0.2] = :stopped 85 | end 86 | 87 | it 'sleeps, waiting to be woken up' do 88 | expect(config).to receive(:sleep).once { state[:time] = 0.6 } 89 | subject.loop_for(1) 90 | end 91 | 92 | it 'breaks' do 93 | allow(config).to receive(:sleep).once { state[:time] = 0.6 } 94 | expect(config).to_not receive(:call) 95 | subject.loop_for(1) 96 | end 97 | end 98 | 99 | context 'when still paused after sleeping' do 100 | context 'when there were no events before' do 101 | before do 102 | sequence[1.0] = :stopped 103 | end 104 | 105 | it 'sleeps for latency to possibly later optimize some events' do 106 | # pretend we were woken up at 0.6 seconds since start 107 | allow(config).to receive(:sleep). 108 | with(no_args) { |*_args| state[:time] += 0.6 } 109 | 110 | # pretend we slept for latency (now: 1.6 seconds since start) 111 | allow(config).to receive(:sleep). 112 | with(1.0) { |*_args| state[:time] += 1.0 } 113 | 114 | subject.loop_for(1) 115 | end 116 | end 117 | 118 | context 'when there were no events for ages' do 119 | before do 120 | sequence[3.5] = :stopped # in the future to break from the loop 121 | end 122 | 123 | it 'still does not process events because it is paused' do 124 | # pretend we were woken up at 0.6 seconds since start 125 | allow(config).to receive(:sleep). 126 | with(no_args) { |*_args| state[:time] += 2.0 } 127 | 128 | # second loop starts here (no sleep, coz recent events, but no 129 | # processing coz paused 130 | 131 | # pretend we were woken up at 3.6 seconds since start 132 | allow(config).to receive(:sleep). 133 | with(no_args) { |*_args| state[:time] += 3.0 } 134 | 135 | subject.loop_for(1) 136 | end 137 | end 138 | end 139 | end 140 | 141 | context 'when initially processing' do 142 | before do 143 | sequence[0.0] = :processing 144 | end 145 | 146 | context 'when event queue is empty' do 147 | before do 148 | allow(event_queue).to receive(:empty?).and_return(true) 149 | end 150 | 151 | context 'when stopped after sleeping' do 152 | before do 153 | sequence[0.2] = :stopped 154 | end 155 | 156 | it 'sleeps, waiting to be woken up' do 157 | expect(config).to receive(:sleep). 158 | once { |*_args| state[:time] = 0.6 } 159 | 160 | subject.loop_for(1) 161 | end 162 | 163 | it 'breaks' do 164 | allow(config).to receive(:sleep). 165 | once { |*_args| state[:time] = 0.6 } 166 | 167 | expect(config).to_not receive(:call) 168 | subject.loop_for(1) 169 | end 170 | end 171 | end 172 | 173 | context 'when event queue has events' do 174 | before do 175 | end 176 | 177 | context 'when there were events ages ago' do 178 | before do 179 | sequence[3.5] = :stopped # in the future to break from the loop 180 | end 181 | 182 | it 'processes events' do 183 | allow(event_queue).to receive(:empty?). 184 | and_return(false, false, true) 185 | 186 | # resets latency check 187 | expect(config).to receive(:callable?).and_return(true) 188 | 189 | change = [:file, :modified, 'foo', 'bar'] 190 | resulting_changes = { modified: ['foo'], added: [], removed: [] } 191 | allow(event_queue).to receive(:pop).and_return(change) 192 | 193 | allow(config).to receive(:optimize_changes).with([change]). 194 | and_return(resulting_changes) 195 | 196 | final_changes = [['foo'], [], []] 197 | allow(config).to receive(:call) do |*changes| 198 | state[:time] = 4.0 # stopped 199 | expect(changes).to eq(final_changes) 200 | end 201 | 202 | subject.instance_variable_set(:@first_unprocessed_event_time, -3) 203 | subject.loop_for(1) 204 | end 205 | end 206 | 207 | # context "when stopped after sleeping" do 208 | # it "breaks from the loop" do 209 | # pending "todo" 210 | # end 211 | # end 212 | end 213 | end 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/lib/listen/file_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::File do 2 | let(:record) do 3 | instance_double( 4 | Listen::Record, 5 | root: '/foo/bar', 6 | file_data: record_data, 7 | add_dir: true, 8 | update_file: true, 9 | unset_path: true, 10 | ) 11 | end 12 | 13 | let(:path) { Pathname.pwd } 14 | let(:subject) { described_class.change(record, 'file.rb') } 15 | 16 | around { |example| fixtures { example.run } } 17 | 18 | before { allow(::File).to receive(:lstat) { fail 'Not stubbed!' } } 19 | 20 | describe '#change' do 21 | let(:expected_data) do 22 | { mtime: kind_of(Float), mode: kind_of(Integer) } 23 | end 24 | 25 | context 'with file record' do 26 | let(:record_mtime) { nil } 27 | let(:record_md5) { nil } 28 | let(:record_mode) { nil } 29 | 30 | let(:record_data) do 31 | { mtime: record_mtime, md5: record_md5, mode: record_mode } 32 | end 33 | 34 | context 'with non-existing file' do 35 | before { allow(::File).to receive(:lstat) { fail Errno::ENOENT } } 36 | 37 | it { is_expected.to eq(:removed) } 38 | 39 | it 'sets path in record' do 40 | expect(record).to receive(:unset_path).with('file.rb') 41 | subject 42 | end 43 | end 44 | 45 | context 'with existing file' do 46 | let(:stat_mtime) { Time.now.to_f - 1234.567 } 47 | let(:stat_ctime) { Time.now.to_f - 1234.567 } 48 | let(:stat_atime) { Time.now.to_f - 1234.567 } 49 | let(:stat_mode) { 0640 } 50 | let(:md5) { fail 'stub me (md5)' } 51 | 52 | let(:stat) do 53 | instance_double( 54 | File::Stat, 55 | mtime: stat_mtime, 56 | atime: stat_atime, 57 | ctime: stat_ctime, 58 | mode: stat_mode 59 | ) 60 | end 61 | 62 | before do 63 | allow(::File).to receive(:lstat) { stat } 64 | allow(Digest::MD5).to receive(:file) { double(:md5, digest: md5) } 65 | end 66 | 67 | context 'with different mode in record' do 68 | let(:record_mode) { 0722 } 69 | 70 | it { should be :modified } 71 | 72 | it 'sets path in record with expected data' do 73 | expect(record).to receive(:update_file). 74 | with('file.rb', expected_data) 75 | subject 76 | end 77 | end 78 | 79 | context 'with same mode in record' do 80 | let(:record_mode) { stat_mode } 81 | 82 | # e.g. file was overwritten by earlier copy 83 | context 'with earlier mtime than in record' do 84 | let(:record_mtime) { stat_mtime.to_f - 123.45 } 85 | 86 | it { should be :modified } 87 | 88 | it 'sets path in record with expected data' do 89 | expect(record).to receive(:update_file). 90 | with('file.rb', expected_data) 91 | subject 92 | end 93 | end 94 | 95 | context 'with later mtime than in record' do 96 | let(:record_mtime) { stat_mtime.to_f + 123.45 } 97 | 98 | it { should be :modified } 99 | 100 | it 'sets path in record with expected data' do 101 | expect(record).to receive(:update_file). 102 | with('file.rb', expected_data) 103 | subject 104 | end 105 | end 106 | 107 | context 'with indentical mtime in record' do 108 | let(:record_mtime) { stat_mtime.to_f } 109 | 110 | context 'with accurate stat times' do 111 | let(:stat_mtime) { Time.at(1_401_235_714.123).utc } 112 | let(:stat_atime) { Time.at(1_401_235_714.123).utc } 113 | let(:stat_ctime) { Time.at(1_401_235_714.123).utc } 114 | let(:record_mtime) { stat_mtime.to_f } 115 | it { should be_nil } 116 | end 117 | 118 | context 'with inaccurate stat times' do 119 | let(:stat_mtime) { Time.at(1_401_235_714.0).utc } 120 | let(:stat_atime) { Time.at(1_401_235_714.0).utc } 121 | let(:stat_ctime) { Time.at(1_401_235_714.0).utc } 122 | 123 | let(:record_mtime) { stat_mtime.to_f } 124 | 125 | context 'with real mtime barely not within last second' do 126 | before { allow(Time).to receive(:now) { now } } 127 | 128 | # NOTE: if real mtime is ???14.99, the 129 | # saved mtime is ???14.0 130 | let(:now) { Time.at(1_401_235_716.00).utc } 131 | it { should be_nil } 132 | end 133 | 134 | context 'with real mtime barely within last second' do 135 | # NOTE: real mtime is in range (???14.0 .. ???14.999), 136 | # so saved mtime at ???14.0 means it could be 137 | # ???14.999, so ???15.999 could still be within 1 second 138 | # range 139 | let(:now) { Time.at(1_401_235_715.999999).utc } 140 | 141 | before { allow(Time).to receive(:now) { now } } 142 | 143 | context 'without available md5' do 144 | let(:md5) { fail Errno::ENOENT } 145 | 146 | # Treat it as a removed file, because chances are ... 147 | # whatever is listening for changes won't be able to deal 148 | # with the file either (e.g. because of permissions) 149 | it { should be :removed } 150 | 151 | it 'should not unset record' do 152 | expect(record).to_not receive(:unset_path) 153 | end 154 | end 155 | 156 | context 'with available md5' do 157 | let(:md5) { 'd41d8cd98f00b204e9800998ecf8427e' } 158 | 159 | context 'with same md5 in record' do 160 | let(:record_md5) { md5 } 161 | it { should be_nil } 162 | end 163 | 164 | context 'with no md5 in record' do 165 | let(:record_md5) { nil } 166 | it { should be_nil } 167 | end 168 | 169 | context 'with different md5 in record' do 170 | let(:record_md5) { 'foo' } 171 | it { should be :modified } 172 | 173 | it 'sets path in record with expected data' do 174 | expected = expected_data. merge(md5: md5) 175 | expect(record).to receive(:update_file). 176 | with('file.rb', expected) 177 | subject 178 | end 179 | end 180 | end 181 | end 182 | end 183 | end 184 | end 185 | end 186 | end 187 | 188 | context 'with empty record' do 189 | let(:record_data) { {} } 190 | 191 | context 'with existing path' do 192 | let(:stat) do 193 | instance_double( 194 | File::Stat, 195 | mtime: 1234, 196 | mode: 0645 197 | ) 198 | end 199 | 200 | before do 201 | allow(::File).to receive(:lstat) { stat } 202 | end 203 | 204 | it 'returns added' do 205 | expect(subject).to eq :added 206 | end 207 | 208 | it 'sets path in record with expected data' do 209 | expect(record).to receive(:update_file). 210 | with('file.rb', expected_data) 211 | subject 212 | end 213 | end 214 | end 215 | end 216 | 217 | describe '#inaccurate_mac_time?' do 218 | let(:stat) do 219 | instance_double(File::Stat, mtime: mtime, atime: atime, ctime: ctime) 220 | end 221 | 222 | subject { Listen::File.inaccurate_mac_time?(stat) } 223 | 224 | context 'with no accurate times' do 225 | let(:mtime) { Time.at(1_234_567.0).utc } 226 | let(:atime) { Time.at(1_234_567.0).utc } 227 | let(:ctime) { Time.at(1_234_567.0).utc } 228 | it { should be_truthy } 229 | end 230 | 231 | context 'with all accurate times' do 232 | let(:mtime) { Time.at(1_234_567.89).utc } 233 | let(:atime) { Time.at(1_234_567.89).utc } 234 | let(:ctime) { Time.at(1_234_567.89).utc } 235 | it { should be_falsey } 236 | end 237 | 238 | context 'with one accurate time' do 239 | let(:mtime) { Time.at(1_234_567.0).utc } 240 | let(:atime) { Time.at(1_234_567.89).utc } 241 | let(:ctime) { Time.at(1_234_567.0).utc } 242 | it { should be_falsey } 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /vendor/hound/config/style_guides/ruby.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - "vendor/**/*" 4 | - "db/schema.rb" 5 | UseCache: false 6 | Style/CollectionMethods: 7 | Description: Preferred collection methods. 8 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#map-find-select-reduce-size 9 | Enabled: true 10 | PreferredMethods: 11 | collect: map 12 | collect!: map! 13 | find: detect 14 | find_all: select 15 | reduce: inject 16 | Layout/DotPosition: 17 | Description: Checks the position of the dot in multi-line method calls. 18 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-multi-line-chains 19 | Enabled: true 20 | EnforcedStyle: trailing 21 | SupportedStyles: 22 | - leading 23 | - trailing 24 | Naming/FileName: 25 | Description: Use snake_case for source file names. 26 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#snake-case-files 27 | Enabled: false 28 | Exclude: [] 29 | Style/GuardClause: 30 | Description: Check for conditionals that can be replaced with guard clauses 31 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals 32 | Enabled: false 33 | MinBodyLength: 1 34 | Style/IfUnlessModifier: 35 | Description: Favor modifier if/unless usage when you have a single-line body. 36 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#if-as-a-modifier 37 | Enabled: false 38 | Style/OptionHash: 39 | Description: Don't use option hashes when you can use keyword arguments. 40 | Enabled: false 41 | Style/PercentLiteralDelimiters: 42 | Description: Use `%`-literal delimiters consistently 43 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-literal-braces 44 | Enabled: false 45 | PreferredDelimiters: 46 | "%": "()" 47 | "%i": "()" 48 | "%q": "()" 49 | "%Q": "()" 50 | "%r": "{}" 51 | "%s": "()" 52 | "%w": "()" 53 | "%W": "()" 54 | "%x": "()" 55 | Naming/PredicateName: 56 | Description: Check the names of predicate methods. 57 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#bool-methods-qmark 58 | Enabled: true 59 | NamePrefix: 60 | - is_ 61 | - has_ 62 | - have_ 63 | NamePrefixBlacklist: 64 | - is_ 65 | Exclude: 66 | - spec/**/* 67 | Style/RaiseArgs: 68 | Description: Checks the arguments passed to raise/fail. 69 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#exception-class-messages 70 | Enabled: false 71 | EnforcedStyle: exploded 72 | SupportedStyles: 73 | - compact 74 | - exploded 75 | Style/SignalException: 76 | Description: Checks for proper usage of fail and raise. 77 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#fail-method 78 | Enabled: false 79 | EnforcedStyle: semantic 80 | SupportedStyles: 81 | - only_raise 82 | - only_fail 83 | - semantic 84 | Style/SingleLineBlockParams: 85 | Description: Enforces the names of some block params. 86 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#reduce-blocks 87 | Enabled: false 88 | Methods: 89 | - reduce: 90 | - a 91 | - e 92 | - inject: 93 | - a 94 | - e 95 | Style/SingleLineMethods: 96 | Description: Avoid single-line methods. 97 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-single-line-methods 98 | Enabled: false 99 | AllowIfMethodIsEmpty: true 100 | Style/StringLiterals: 101 | Description: Checks if uses of quotes match the configured preference. 102 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-string-literals 103 | Enabled: true 104 | EnforcedStyle: double_quotes 105 | SupportedStyles: 106 | - single_quotes 107 | - double_quotes 108 | Style/StringLiteralsInInterpolation: 109 | Description: Checks if uses of quotes inside expressions in interpolated strings 110 | match the configured preference. 111 | Enabled: true 112 | EnforcedStyle: single_quotes 113 | SupportedStyles: 114 | - single_quotes 115 | - double_quotes 116 | Style/TrailingCommaInArguments: 117 | Description: 'Checks for trailing comma in argument lists.' 118 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' 119 | Enabled: false 120 | EnforcedStyleForMultiline: no_comma 121 | SupportedStyles: 122 | - comma 123 | - consistent_comma 124 | - no_comma 125 | Style/TrailingCommaInArrayLiteral: 126 | Description: 'Checks for trailing comma in array literals.' 127 | Enabled: false 128 | EnforcedStyleForMultiline: no_comma 129 | SupportedStyles: 130 | - comma 131 | - consistent_comma 132 | - no_comma 133 | Style/TrailingCommaInHashLiteral: 134 | Description: 'Checks for trailing comma in hash literals.' 135 | Enabled: false 136 | EnforcedStyleForMultiline: no_comma 137 | SupportedStyles: 138 | - comma 139 | - consistent_comma 140 | - no_comma 141 | Metrics/AbcSize: 142 | Description: A calculated magnitude based on number of assignments, branches, and 143 | conditions. 144 | Enabled: false 145 | Max: 15 146 | Metrics/ClassLength: 147 | Description: Avoid classes longer than 100 lines of code. 148 | Enabled: false 149 | CountComments: false 150 | Max: 100 151 | Metrics/ModuleLength: 152 | CountComments: false 153 | Max: 100 154 | Description: Avoid modules longer than 100 lines of code. 155 | Enabled: false 156 | Metrics/CyclomaticComplexity: 157 | Description: A complexity metric that is strongly correlated to the number of test 158 | cases needed to validate a method. 159 | Enabled: false 160 | Max: 6 161 | Metrics/MethodLength: 162 | Description: Avoid methods longer than 10 lines of code. 163 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#short-methods 164 | Enabled: false 165 | CountComments: false 166 | Max: 10 167 | Metrics/ParameterLists: 168 | Description: Avoid parameter lists longer than three or four parameters. 169 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#too-many-params 170 | Enabled: false 171 | Max: 5 172 | CountKeywordArgs: true 173 | Metrics/PerceivedComplexity: 174 | Description: A complexity metric geared towards measuring complexity for a human 175 | reader. 176 | Enabled: false 177 | Max: 7 178 | Lint/AssignmentInCondition: 179 | Description: Don't use assignment in conditions. 180 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#safe-assignment-in-condition 181 | Enabled: false 182 | AllowSafeAssignment: true 183 | Style/InlineComment: 184 | Description: Avoid inline comments. 185 | Enabled: false 186 | Naming/AccessorMethodName: 187 | Description: Check the naming of accessor methods for get_/set_. 188 | Enabled: false 189 | Style/Alias: 190 | Description: Use alias_method instead of alias. 191 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#alias-method 192 | Enabled: false 193 | Style/Documentation: 194 | Description: Document classes and non-namespace modules. 195 | Enabled: false 196 | Style/DoubleNegation: 197 | Description: Checks for uses of double negation (!!). 198 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-bang-bang 199 | Enabled: false 200 | Style/EachWithObject: 201 | Description: Prefer `each_with_object` over `inject` or `reduce`. 202 | Enabled: false 203 | Style/EmptyLiteral: 204 | Description: Prefer literals to Array.new/Hash.new/String.new. 205 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#literal-array-hash 206 | Enabled: false 207 | Style/ModuleFunction: 208 | Description: Checks for usage of `extend self` in modules. 209 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#module-function 210 | Enabled: false 211 | Style/OneLineConditional: 212 | Description: Favor the ternary operator(?:) over if/then/else/end constructs. 213 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#ternary-operator 214 | Enabled: false 215 | Style/PerlBackrefs: 216 | Description: Avoid Perl-style regex back references. 217 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-perl-regexp-last-matchers 218 | Enabled: false 219 | Style/Send: 220 | Description: Prefer `Object#__send__` or `Object#public_send` to `send`, as `send` 221 | may overlap with existing methods. 222 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#prefer-public-send 223 | Enabled: false 224 | Style/SpecialGlobalVars: 225 | Description: Avoid Perl-style global variables. 226 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-cryptic-perlisms 227 | Enabled: false 228 | Style/VariableInterpolation: 229 | Description: Don't interpolate global, instance and class variables directly in 230 | strings. 231 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#curlies-interpolate 232 | Enabled: false 233 | Style/WhenThen: 234 | Description: Use when x then ... for one-line cases. 235 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#one-line-cases 236 | Enabled: false 237 | Lint/EachWithObjectArgument: 238 | Description: Check for immutable argument given to each_with_object. 239 | Enabled: true 240 | Lint/HandleExceptions: 241 | Description: Don't suppress exception. 242 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#dont-hide-exceptions 243 | Enabled: false 244 | Lint/LiteralInCondition: 245 | Description: Checks of literals used in conditions. 246 | Enabled: false 247 | Lint/LiteralInInterpolation: 248 | Description: Checks for literals used in interpolation. 249 | Enabled: false 250 | -------------------------------------------------------------------------------- /spec/acceptance/listen_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | RSpec.describe 'Listen', acceptance: true do 4 | let(:base_options) { { latency: 0.1 } } 5 | let(:polling_options) { {} } 6 | let(:options) { {} } 7 | let(:all_options) { base_options.merge(polling_options).merge(options) } 8 | 9 | let(:wrapper) { setup_listener(all_options, :track_changes) } 10 | before { wrapper.listener.start } 11 | after { wrapper.listener.stop } 12 | 13 | subject { wrapper } 14 | 15 | context 'with one listen dir' do 16 | let(:paths) { Pathname.new(Dir.pwd) } 17 | around { |example| fixtures { example.run } } 18 | 19 | context 'with change block raising' do 20 | let(:callback) { ->(_, _, _) { fail 'foo' } } 21 | let(:wrapper) { setup_listener(all_options, callback) } 22 | 23 | it 'warns the backtrace' do 24 | expect(Listen::Logger).to receive(:error). 25 | with(/exception while processing events: foo.*Backtrace:/) 26 | wrapper.listen { touch 'file.rb' } 27 | end 28 | end 29 | 30 | modes = 31 | case ENV['TEST_LISTEN_ADAPTER_MODES'] || 'both' 32 | when 'polling' 33 | [true] 34 | when 'native' 35 | [false] 36 | else 37 | [false, true] 38 | end 39 | 40 | # TODO: make it configurable 41 | # TODO: restore 42 | modes.each do |polling| 43 | context "force_polling option to #{polling}" do 44 | let(:polling_options) { { force_polling: polling } } 45 | 46 | if polling 47 | context 'when polling' do 48 | context 'with a large latency' do 49 | let(:options) { { latency: 10 } } 50 | it 'passes the latency option correctly' do 51 | expect(subject).to_not process_addition_of('file.rb') 52 | end 53 | end 54 | end 55 | else 56 | unless darwin? 57 | context 'when driver does not support option' do 58 | let(:options) { { latency: 10 } } 59 | it 'does not pass the latency option' do 60 | expect(subject).to process_addition_of('file.rb') 61 | end 62 | end 63 | end 64 | end 65 | 66 | context 'with default ignore options' do 67 | context 'with nothing in listen dir' do 68 | it { is_expected.to process_addition_of('file.rb') } 69 | it { is_expected.to process_addition_of('.hidden') } 70 | 71 | it 'listens to multiple files addition' do 72 | result = wrapper.listen do 73 | change_fs(:added, 'file1.rb') 74 | change_fs(:added, 'file2.rb') 75 | end 76 | 77 | expect(result).to eq(modified: [], 78 | added: %w(file1.rb file2.rb), 79 | removed: []) 80 | end 81 | 82 | it 'listens to file moved inside' do 83 | touch '../file.rb' 84 | expect(wrapper.listen do 85 | mv '../file.rb', 'file.rb' 86 | end).to eq(modified: [], added: ['file.rb'], removed: []) 87 | end 88 | end 89 | 90 | context 'existing file.rb in listen dir' do 91 | around do |example| 92 | change_fs(:added, 'file.rb') 93 | example.run 94 | end 95 | 96 | it { is_expected.to process_modification_of('file.rb') } 97 | it { is_expected.to process_removal_of('file.rb') } 98 | 99 | it 'listens to file.rb moved out' do 100 | expect(wrapper.listen do 101 | mv 'file.rb', '../file.rb' 102 | end).to eq(modified: [], added: [], removed: ['file.rb']) 103 | end 104 | 105 | it 'listens to file mode change' do 106 | prev_mode = File.stat('file.rb').mode 107 | 108 | result = wrapper.listen do 109 | windows? ? `attrib +r file.rb` : chmod(0444, 'file.rb') 110 | end 111 | 112 | new_mode = File.stat('file.rb').mode 113 | no_event = result[:modified].empty? && prev_mode == new_mode 114 | 115 | # Check if chmod actually works or an attrib event happens, 116 | # or expect nothing otherwise 117 | # 118 | # (e.g. fails for polling+vfat on Linux, but works with 119 | # INotify+vfat because you get an event regardless if mode 120 | # actually changes) 121 | # 122 | files = no_event ? [] : ['file.rb'] 123 | 124 | expect(result).to eq(modified: files, added: [], removed: []) 125 | end 126 | end 127 | 128 | context 'hidden file in listen dir' do 129 | around do |example| 130 | change_fs(:added, '.hidden') 131 | example.run 132 | end 133 | 134 | it { is_expected.to process_modification_of('.hidden') } 135 | end 136 | 137 | context 'dir in listen dir' do 138 | around do |example| 139 | mkdir_p 'dir' 140 | example.run 141 | end 142 | 143 | it { is_expected.to process_addition_of('dir/file.rb') } 144 | end 145 | 146 | context 'dir with file in listen dir' do 147 | around do |example| 148 | mkdir_p 'dir' 149 | touch 'dir/file.rb' 150 | example.run 151 | end 152 | 153 | it 'listens to file move' do 154 | expected = { modified: [], 155 | added: %w(file.rb), 156 | removed: %w(dir/file.rb) 157 | } 158 | 159 | expect(wrapper.listen do 160 | mv 'dir/file.rb', 'file.rb' 161 | end).to eq expected 162 | end 163 | end 164 | 165 | context 'two dirs with files in listen dir' do 166 | around do |example| 167 | mkdir_p 'dir1' 168 | touch 'dir1/file1.rb' 169 | mkdir_p 'dir2' 170 | touch 'dir2/file2.rb' 171 | example.run 172 | end 173 | 174 | it 'listens to multiple file moves' do 175 | expected = { 176 | modified: [], 177 | added: ['dir1/file2.rb', 'dir2/file1.rb'], 178 | removed: ['dir1/file1.rb', 'dir2/file2.rb'] 179 | } 180 | 181 | expect(wrapper.listen do 182 | mv 'dir1/file1.rb', 'dir2/file1.rb' 183 | mv 'dir2/file2.rb', 'dir1/file2.rb' 184 | end).to eq expected 185 | end 186 | 187 | it 'listens to dir move' do 188 | expected = { modified: [], 189 | added: ['dir2/dir1/file1.rb'], 190 | removed: ['dir1/file1.rb'] } 191 | 192 | expect(wrapper.listen do 193 | mv 'dir1', 'dir2/' 194 | end).to eq expected 195 | end 196 | end 197 | 198 | context 'with .bundle dir ignored by default' do 199 | around do |example| 200 | mkdir_p '.bundle' 201 | example.run 202 | end 203 | 204 | it { is_expected.not_to process_addition_of('.bundle/file.rb') } 205 | end 206 | end 207 | 208 | context 'when :ignore is *ignored_dir*' do 209 | context 'ignored dir with file in listen dir' do 210 | let(:options) { { ignore: /ignored_dir/ } } 211 | 212 | around do |example| 213 | mkdir_p 'ignored_dir' 214 | example.run 215 | end 216 | 217 | it { is_expected.not_to process_addition_of('ignored_dir/file.rb') } 218 | end 219 | 220 | context 'when :only is *.rb' do 221 | let(:options) { { only: /\.rb$/ } } 222 | 223 | it { is_expected.to process_addition_of('file.rb') } 224 | it { is_expected.not_to process_addition_of('file.txt') } 225 | end 226 | 227 | context 'when :ignore is bar.rb' do 228 | context 'when :only is *.rb' do 229 | let(:options) { { ignore: /bar\.rb$/, only: /\.rb$/ } } 230 | 231 | it { is_expected.to process_addition_of('file.rb') } 232 | it { is_expected.not_to process_addition_of('file.txt') } 233 | it { is_expected.not_to process_addition_of('bar.rb') } 234 | end 235 | end 236 | 237 | context 'when default ignore is *.rb' do 238 | let(:options) { { ignore: /\.rb$/ } } 239 | 240 | it { is_expected.not_to process_addition_of('file.rb') } 241 | 242 | context 'with #ignore on *.txt mask' do 243 | before { wrapper.listener.ignore(/\.txt/) } 244 | 245 | it { is_expected.not_to process_addition_of('file.rb') } 246 | it { is_expected.not_to process_addition_of('file.txt') } 247 | end 248 | 249 | context 'with #ignore! on *.txt mask' do 250 | before { wrapper.listener.ignore!(/\.txt/) } 251 | 252 | it { is_expected.to process_addition_of('file.rb') } 253 | it { is_expected.not_to process_addition_of('file.txt') } 254 | end 255 | end 256 | end 257 | end 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /spec/support/acceptance_helper.rb: -------------------------------------------------------------------------------- 1 | { 2 | modification: :modified, 3 | addition: :added, 4 | removal: :removed, 5 | queued_modification: :modified, 6 | queued_addition: :added 7 | }.each do |description, type| 8 | RSpec::Matchers.define "process_#{description}_of".to_sym do |expected| 9 | match do |actual| 10 | # Use cases: 11 | # 1. reset the changes so they don't have leftovers 12 | # 2. keep the queue if we're testing for existing accumulated changes 13 | 14 | # if were testing the queue (e.g. after unpause), don't reset 15 | check_already_queued = /queued_/ =~ description 16 | reset_queue = !check_already_queued 17 | 18 | actual.listen(reset_queue) do 19 | change_fs(type, expected) unless check_already_queued 20 | end 21 | actual.changes[type].include? expected 22 | end 23 | 24 | failure_message do |actual| 25 | result = actual.changes.inspect 26 | "expected #{result} to include #{description} of #{expected}" 27 | end 28 | 29 | failure_message_when_negated do |actual| 30 | result = actual.changes.inspect 31 | "expected #{result} to not include #{description} of #{expected}" 32 | end 33 | end 34 | end 35 | 36 | def change_fs(type, path) 37 | case type 38 | when :modified 39 | unless File.exist?(path) 40 | fail "Bad test: cannot modify #{path.inspect} (it doesn't exist)" 41 | end 42 | 43 | # wait until full second, because this might be followed by a modification 44 | # event (which otherwise may not be detected every time) 45 | _sleep_until_next_second(Pathname.pwd) 46 | 47 | open(path, 'a') { |f| f.write('foo') } 48 | 49 | # separate it from upcoming modifications" 50 | _sleep_to_separate_events 51 | when :added 52 | if File.exist?(path) 53 | fail "Bad test: cannot add #{path.inspect} (it already exists)" 54 | end 55 | 56 | # wait until full second, because this might be followed by a modification 57 | # event (which otherwise may not be detected every time) 58 | _sleep_until_next_second(Pathname.pwd) 59 | 60 | open(path, 'w') { |f| f.write('foo') } 61 | 62 | # separate it from upcoming modifications" 63 | _sleep_to_separate_events 64 | when :removed 65 | unless File.exist?(path) 66 | fail "Bad test: cannot remove #{path.inspect} (it doesn't exist)" 67 | end 68 | File.unlink(path) 69 | else 70 | fail "bad test: unknown type: #{type.inspect}" 71 | end 72 | end 73 | 74 | # Used by change_fs() above so that the FS change (e.g. file created) happens 75 | # as close to the start of a new second (time) as possible. 76 | # 77 | # E.g. if file is created at 1234567.999 (unix time), it's mtime on some 78 | # filesystems is rounded, so it becomes 1234567.0, but if the change 79 | # notification happens a little while later, e.g. at 1234568.111, now the file 80 | # mtime and the current time in seconds are different (1234567 vs 1234568), and 81 | # so the MD5 test won't kick in (see file.rb) - the file will not be considered 82 | # for content checking (md5), so File.change will consider the file unmodified. 83 | # 84 | # This means, that if a file is added at 1234567.888 (and updated in Record), 85 | # and then its content is modified at 1234567.999, and checking for changes 86 | # happens at 1234568.111, the modification won't be detected. 87 | # (because Record mtime is 1234567.0, current FS mtime from stat() is the 88 | # same, and the checking happens in another second - 1234568). 89 | # 90 | # So basically, adding a file and detecting its later modification should all 91 | # happen within 1 second (which makes testing and debugging difficult). 92 | # 93 | def _sleep_until_next_second(path) 94 | Listen::File.inaccurate_mac_time?(path) 95 | 96 | t = Time.now.utc 97 | diff = t.to_f - t.to_i 98 | 99 | sleep(1.05 - diff) 100 | end 101 | 102 | # Special class to only allow changes within a specific time window 103 | 104 | class TimedChanges 105 | attr_reader :changes 106 | 107 | def initialize 108 | # Set to non-nil, because changes can immediately come after unpausing 109 | # listener in an Rspec 'before()' block 110 | @changes = { modified: [], added: [], removed: [] } 111 | end 112 | 113 | def change_offset 114 | Time.now.to_f - @yield_time 115 | end 116 | 117 | def freeze_offset 118 | result = @freeze_time - @yield_time 119 | # Make an "almost zero" value more readable 120 | result < 1e-4 ? 1e-4 : result 121 | end 122 | 123 | # Allow changes only during specific time wine 124 | def allow_changes(reset_queue = true) 125 | @freeze_time = nil 126 | if reset_queue 127 | # Clear to prepare for collecting new FS events 128 | @changes = { modified: [], added: [], removed: [] } 129 | else 130 | # Since we're testing the queue and the listener callback is adding 131 | # changes to the same hash (e.g. after a pause), copy the existing data 132 | # to a new, unfrozen hash 133 | @changes = @changes.dup if @changes.frozen? 134 | @changes ||= { modified: [], added: [], removed: [] } 135 | end 136 | 137 | @yield_time = Time.now.to_f 138 | yield 139 | # Prevent recording changes after timeout 140 | @changes.freeze 141 | @freeze_time = Time.now.to_f 142 | end 143 | end 144 | 145 | # Conveniently wrap a Listener instance for testing 146 | class ListenerWrapper 147 | attr_reader :listener, :changes 148 | attr_accessor :lag 149 | 150 | def initialize(callback, paths, *args) 151 | # Lag depends mostly on wait_for_delay On Linux desktop, it's 0.06 - 0.11 152 | # 153 | # On Travis it used to be > 0.5, but that was before broadcaster sent 154 | # changes immediately, so 0.2-0.4 might be enough for Travis, but we set it 155 | # to 0.8 (because 0.75 wasn't enough recently) 156 | # 157 | # The value should be 2-3 x wait_for_delay + time between fs operation and 158 | # notification, which for polling and FSEvent means the configured latency 159 | @lag = Float(ENV['LISTEN_TESTS_DEFAULT_LAG'] || 0.2) 160 | 161 | @paths = paths 162 | 163 | # Isolate collected changes between tests/listener instances 164 | @timed_changes = TimedChanges.new 165 | 166 | if callback 167 | @listener = Listen.send(*args) do |modified, added, removed| 168 | # Add changes to trigger frozen Hash error, making sure lag is enough 169 | _add_changes(:modified, modified, changes) 170 | _add_changes(:added, added, changes) 171 | _add_changes(:removed, removed, changes) 172 | 173 | unless callback == :track_changes 174 | callback.call(modified, added, removed) 175 | end 176 | end 177 | else 178 | @listener = Listen.send(*args) 179 | end 180 | end 181 | 182 | def changes 183 | @timed_changes.changes 184 | end 185 | 186 | def listen(reset_queue = true) 187 | # Give previous events time to be received, queued and processed 188 | # so they complete and don't interfere 189 | sleep lag 190 | 191 | @timed_changes.allow_changes(reset_queue) do 192 | yield 193 | 194 | # Polling sleep (default: 1s) 195 | backend = @listener.instance_variable_get(:@backend) 196 | adapter = backend.instance_variable_get(:@adapter) 197 | sleep(1.0) if adapter.is_a?(Listen::Adapter::Polling) 198 | 199 | # Lag should include: 200 | # 0.1s - 0.2s if the test needs Listener queue to be processed 201 | # 0.1s in case the system is busy 202 | sleep lag 203 | end 204 | 205 | # Keep this to detect a lag too small (changes during this sleep 206 | # will trigger "frozen hash" error caught below (and displaying timeout 207 | # details) 208 | sleep 1 209 | 210 | changes 211 | end 212 | 213 | private 214 | 215 | def _add_changes(type, changes, dst) 216 | dst[type] += _relative_path(changes) 217 | dst[type].uniq! 218 | dst[type].sort! 219 | 220 | rescue RuntimeError => e 221 | raise unless e.message == "can't modify frozen Hash" 222 | 223 | # Show how by much the changes missed the timeout 224 | change_offset = @timed_changes.change_offset 225 | freeze_offset = @timed_changes.freeze_offset 226 | 227 | msg = "Changes took #{change_offset}s (allowed lag: #{freeze_offset})s" 228 | abort(msg) 229 | end 230 | 231 | def _relative_path(changes) 232 | changes.map do |change| 233 | unfrozen_copy = change.dup 234 | [@paths].flatten.each do |path| 235 | sub = path.sub(%r{\/$}, '').to_s 236 | unfrozen_copy.gsub!(%r{^#{sub}\/}, '') 237 | end 238 | unfrozen_copy 239 | end 240 | end 241 | end 242 | 243 | def setup_listener(options, callback = nil) 244 | ListenerWrapper.new(callback, paths, :to, paths, options) 245 | end 246 | 247 | def setup_recipient(port, callback = nil) 248 | ListenerWrapper.new(callback, paths, :on, port) 249 | end 250 | 251 | def _sleep_to_separate_events 252 | # separate the events or Darwin and Polling 253 | # will detect only the :added event 254 | # 255 | # (This is because both use directory scanning which may not kick in time 256 | # before the next filesystem change) 257 | # 258 | # The minimum for this is the time it takes between a syscall 259 | # changing the filesystem ... and ... an async 260 | # Listen::File.scan to finish comparing the file with the 261 | # Record 262 | # 263 | # This necessary for: 264 | # - Darwin Adapter 265 | # - Polling Adapter 266 | # - Linux Adapter in FSEvent emulation mode 267 | # - maybe Windows adapter (probably not) 268 | sleep 0.4 269 | end 270 | -------------------------------------------------------------------------------- /spec/lib/listen/directory_spec.rb: -------------------------------------------------------------------------------- 1 | include Listen 2 | 3 | RSpec.describe Directory do 4 | def fake_file_stat(name, options = {}) 5 | defaults = { directory?: false } 6 | instance_double(::File::Stat, name, defaults.merge(options)) 7 | end 8 | 9 | def fake_dir_stat(name, options = {}) 10 | defaults = { directory?: true } 11 | instance_double(::File::Stat, name, defaults.merge(options)) 12 | end 13 | 14 | def fake_children(ex, dir, *args, &block) 15 | if block_given? 16 | ex.send(:allow, dir).to receive(:children, &block) 17 | else 18 | ex.send(:allow, dir).to receive(:children).and_return(*args) 19 | end 20 | ex.send(:allow, dir).to receive(:exist?).and_return(true) 21 | ex.send(:allow, dir).to receive(:directory?).and_return(true) 22 | end 23 | 24 | let(:dir) { double(:dir) } 25 | let(:file) { fake_path('file.rb') } 26 | let(:file2) { fake_path('file2.rb') } 27 | let(:subdir) { fake_path('subdir') } 28 | 29 | let(:record) do 30 | instance_double( 31 | Record, 32 | root: 'some_dir', 33 | dir_entries: record_entries, 34 | add_dir: true, 35 | unset_path: true) 36 | end 37 | 38 | let(:snapshot) { instance_double(Change, record: record, invalidate: nil) } 39 | 40 | before do 41 | allow(dir).to receive(:+).with('.') { dir } 42 | allow(dir).to receive(:+).with('file.rb') { file } 43 | allow(dir).to receive(:+).with('subdir') { subdir } 44 | 45 | allow(file).to receive(:relative_path_from).with(dir) { 'file.rb' } 46 | allow(file2).to receive(:relative_path_from).with(dir) { 'file2.rb' } 47 | allow(subdir).to receive(:relative_path_from).with(dir) { 'subdir' } 48 | 49 | allow(Pathname).to receive(:new).with('some_dir').and_return(dir) 50 | allow(Pathname).to receive(:new).with('.').and_return(dir) 51 | 52 | allow(::File).to receive(:lstat) do |*args| 53 | fail "Not stubbed: File.lstat(#{args.map(&:inspect) * ','})" 54 | end 55 | end 56 | 57 | context '#scan with recursive off' do 58 | let(:options) { { recursive: false } } 59 | 60 | context 'with file & subdir in record' do 61 | let(:record_entries) do 62 | { 'file.rb' => { mtime: 1.1 }, 'subdir' => {} }.freeze 63 | end 64 | 65 | context 'with empty dir' do 66 | before { fake_children(self, dir, []) } 67 | 68 | it 'sets record dir path' do 69 | expect(record).to receive(:add_dir).with('.') 70 | described_class.scan(snapshot, '.', options) 71 | end 72 | 73 | it "snapshots changes for file path and dir that doesn't exist" do 74 | expect(snapshot).to receive(:invalidate).with(:file, 'file.rb', {}) 75 | 76 | expect(snapshot).to receive(:invalidate). 77 | with(:dir, 'subdir', recursive: false) 78 | 79 | described_class.scan(snapshot, '.', options) 80 | end 81 | end 82 | 83 | context 'when subdir is removed' do 84 | before do 85 | fake_children(self, dir, [file]) 86 | allow(::File).to receive(:lstat).with('file.rb'). 87 | and_return(fake_file_stat('file.rb')) 88 | end 89 | 90 | it 'notices subdir does not exist' do 91 | expect(snapshot).to receive(:invalidate). 92 | with(:dir, 'subdir', recursive: false) 93 | 94 | described_class.scan(snapshot, '.', options) 95 | end 96 | end 97 | 98 | context 'when file.rb removed' do 99 | before do 100 | fake_children(self, dir, [subdir]) 101 | 102 | allow(::File).to receive(:lstat).with('subdir'). 103 | and_return(fake_dir_stat('subdir')) 104 | end 105 | 106 | it 'notices file was removed' do 107 | expect(snapshot).to receive(:invalidate).with(:file, 'file.rb', {}) 108 | described_class.scan(snapshot, '.', options) 109 | end 110 | end 111 | 112 | context 'when file.rb no longer exists after scan' do 113 | before do 114 | fake_children(self, dir, [file], [file2]) 115 | 116 | allow(::File).to receive(:lstat).with('file.rb'). 117 | and_raise(Errno::ENOENT) 118 | 119 | allow(::File).to receive(:lstat).with('file2.rb'). 120 | and_return(fake_file_stat('file2.rb')) 121 | end 122 | 123 | it 'rescans' do 124 | expect(snapshot).to receive(:invalidate).with(:file, 'file2.rb', {}) 125 | described_class.scan(snapshot, '.', options) 126 | end 127 | end 128 | 129 | context 'when file2.rb is added' do 130 | before do 131 | fake_children(self, dir, [file, file2, subdir]) 132 | 133 | allow(::File).to receive(:lstat).with('file.rb'). 134 | and_return(fake_file_stat('file.rb')) 135 | 136 | allow(::File).to receive(:lstat).with('file2.rb'). 137 | and_return(fake_file_stat('file2.rb')) 138 | 139 | allow(::File).to receive(:lstat).with('subdir'). 140 | and_return(fake_dir_stat('subdir')) 141 | end 142 | 143 | it 'notices file removed and file2 changed' do 144 | expect(snapshot).to receive(:invalidate).with(:file, 'file2.rb', {}) 145 | described_class.scan(snapshot, '.', options) 146 | end 147 | end 148 | end 149 | 150 | context 'with empty record' do 151 | let(:record_entries) { {} } 152 | 153 | context 'with non-existing dir path' do 154 | before { fake_children(self, dir) { fail Errno::ENOENT } } 155 | 156 | it 'reports no changes' do 157 | expect(snapshot).to_not receive(:invalidate) 158 | described_class.scan(snapshot, '.', options) 159 | end 160 | 161 | it 'unsets record dir path' do 162 | expect(record).to receive(:unset_path).with('.') 163 | described_class.scan(snapshot, '.', options) 164 | end 165 | end 166 | 167 | context 'when network share is disconnected' do 168 | before { fake_children(self, dir) { fail Errno::EHOSTDOWN } } 169 | 170 | it 'reports no changes' do 171 | expect(snapshot).to_not receive(:invalidate) 172 | described_class.scan(snapshot, '.', options) 173 | end 174 | 175 | it 'unsets record dir path' do 176 | expect(record).to receive(:unset_path).with('.') 177 | described_class.scan(snapshot, '.', options) 178 | end 179 | end 180 | 181 | context 'with file.rb in dir' do 182 | before do 183 | fake_children(self, dir, [file]) 184 | 185 | allow(::File).to receive(:lstat).with('file.rb'). 186 | and_return(fake_file_stat('file.rb')) 187 | end 188 | 189 | it 'snapshots changes for file & file2 paths' do 190 | expect(snapshot).to receive(:invalidate). 191 | with(:file, 'file.rb', {}) 192 | 193 | expect(snapshot).to_not receive(:invalidate). 194 | with(:file, 'file2.rb', {}) 195 | 196 | expect(snapshot).to_not receive(:invalidate). 197 | with(:dir, 'subdir', recursive: false) 198 | 199 | described_class.scan(snapshot, '.', options) 200 | end 201 | end 202 | end 203 | end 204 | 205 | context '#scan with recursive on' do 206 | let(:options) { { recursive: true } } 207 | 208 | context 'with file.rb & subdir in record' do 209 | let(:record_entries) do 210 | { 'file.rb' => { mtime: 1.1 }, 'subdir' => {} } 211 | end 212 | 213 | context 'with empty dir' do 214 | before { fake_children(self, dir, []) } 215 | 216 | it 'snapshots changes for file & subdir path' do 217 | expect(snapshot).to receive(:invalidate).with(:file, 'file.rb', {}) 218 | 219 | expect(snapshot).to receive(:invalidate). 220 | with(:dir, 'subdir', recursive: true) 221 | 222 | described_class.scan(snapshot, '.', options) 223 | end 224 | end 225 | 226 | context 'with subdir2 path present' do 227 | let(:subdir2) { fake_path('subdir2', children: []) } 228 | 229 | before do 230 | fake_children(self, dir, [subdir2]) 231 | allow(subdir2).to receive(:relative_path_from).with(dir) { 'subdir2' } 232 | 233 | allow(::File).to receive(:lstat).with('subdir2'). 234 | and_return(fake_dir_stat('subdir2')) 235 | end 236 | 237 | it 'snapshots changes for file, file2 & subdir paths' do 238 | expect(snapshot).to receive(:invalidate).with(:file, 'file.rb', {}) 239 | 240 | expect(snapshot).to receive(:invalidate). 241 | with(:dir, 'subdir', recursive: true) 242 | 243 | expect(snapshot).to receive(:invalidate). 244 | with(:dir, 'subdir2', recursive: true) 245 | 246 | described_class.scan(snapshot, '.', options) 247 | end 248 | end 249 | end 250 | 251 | context 'with empty record' do 252 | let(:record_entries) { {} } 253 | 254 | context 'with non-existing dir' do 255 | before do 256 | fake_children(self, dir) { fail Errno::ENOENT } 257 | end 258 | 259 | it 'reports no changes' do 260 | expect(snapshot).to_not receive(:invalidate) 261 | described_class.scan(snapshot, '.', options) 262 | end 263 | end 264 | 265 | context 'with subdir present in dir' do 266 | before do 267 | fake_children(self, dir, [subdir]) 268 | fake_children(self, subdir, []) 269 | allow(::File).to receive(:lstat).with('subdir'). 270 | and_return(fake_dir_stat('subdir')) 271 | end 272 | 273 | it 'snapshots changes for subdir' do 274 | expect(snapshot).to receive(:invalidate). 275 | with(:dir, 'subdir', recursive: true) 276 | 277 | described_class.scan(snapshot, '.', options) 278 | end 279 | end 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /spec/lib/listen/listener_spec.rb: -------------------------------------------------------------------------------- 1 | include Listen 2 | 3 | RSpec.describe Listener do 4 | let(:realdir1) { fake_path('/foo/dir1', children: []) } 5 | let(:realdir2) { fake_path('/foo/dir2', children: []) } 6 | 7 | let(:dir1) { fake_path('dir1', realpath: realdir1) } 8 | let(:dir2) { fake_path('dir2', realpath: realdir2) } 9 | 10 | let(:dirs) { ['dir1'] } 11 | 12 | let(:block) { instance_double(Proc) } 13 | 14 | subject do 15 | described_class.new(*(dirs + [options]).compact) do |*changes| 16 | block.call(*changes) 17 | end 18 | end 19 | 20 | let(:options) { {} } 21 | 22 | let(:record) { instance_double(Record, build: true, root: 'dir2') } 23 | let(:silencer) { instance_double(Silencer, configure: nil) } 24 | 25 | let(:backend_class) { class_double('Listen::Backend') } 26 | 27 | let(:backend) { instance_double(Backend) } 28 | 29 | let(:optimizer_config) { instance_double(QueueOptimizer::Config) } 30 | let(:optimizer) { instance_double(QueueOptimizer) } 31 | 32 | let(:processor_config) { instance_double(Event::Config) } 33 | let(:processor) { instance_double(Event::Loop) } 34 | 35 | let(:event_queue) { instance_double(Event::Queue) } 36 | 37 | let(:default_latency) { 0.1 } 38 | let(:backend_wait_for_delay) { 0.123 } 39 | 40 | let(:processing_thread) { instance_double(Thread) } 41 | 42 | before do 43 | allow(Silencer).to receive(:new) { silencer } 44 | 45 | allow(Backend).to receive(:new). 46 | with(anything, event_queue, silencer, anything). 47 | and_return(backend) 48 | 49 | allow(backend).to receive(:min_delay_between_events). 50 | and_return(backend_wait_for_delay) 51 | 52 | # TODO: use a configuration object to clean this up 53 | 54 | allow(QueueOptimizer::Config).to receive(:new).with(backend, silencer). 55 | and_return(optimizer_config) 56 | 57 | allow(QueueOptimizer).to receive(:new).with(optimizer_config). 58 | and_return(optimizer) 59 | 60 | allow(Event::Queue).to receive(:new).and_return(event_queue) 61 | 62 | allow(Event::Config).to receive(:new). 63 | with(anything, event_queue, optimizer, backend_wait_for_delay). 64 | and_return(processor_config) 65 | 66 | allow(Event::Loop).to receive(:new).with(processor_config). 67 | and_return(processor) 68 | 69 | allow(Record).to receive(:new).and_return(record) 70 | 71 | allow(Pathname).to receive(:new).with('dir1').and_return(dir1) 72 | allow(Pathname).to receive(:new).with('dir2').and_return(dir2) 73 | 74 | allow(Internals::ThreadPool).to receive(:add).and_return(processing_thread) 75 | allow(processing_thread).to receive(:alive?).and_return(true) 76 | allow(processing_thread).to receive(:wakeup) 77 | allow(processing_thread).to receive(:join) 78 | 79 | allow(block).to receive(:call) 80 | end 81 | 82 | describe 'initialize' do 83 | it { should_not be_paused } 84 | 85 | context 'with a block' do 86 | let(:myblock) { instance_double(Proc) } 87 | let(:block) { proc { myblock.call } } 88 | subject do 89 | described_class.new('dir1') do |*args| 90 | myblock.call(*args) 91 | end 92 | end 93 | 94 | it 'passes the block to the event processor' do 95 | allow(Event::Config).to receive(:new) do |*_args, &some_block| 96 | expect(some_block).to be 97 | some_block.call 98 | processor_config 99 | end 100 | expect(myblock).to receive(:call) 101 | subject 102 | end 103 | end 104 | 105 | context 'with directories' do 106 | subject { described_class.new('dir1', 'dir2') } 107 | 108 | it 'passes directories to backend' do 109 | allow(Backend).to receive(:new). 110 | with(%w(dir1 dir2), anything, anything, anything). 111 | and_return(backend) 112 | subject 113 | end 114 | end 115 | end 116 | 117 | describe '#start' do 118 | before do 119 | allow(backend).to receive(:start) 120 | allow(silencer).to receive(:silenced?) { false } 121 | end 122 | 123 | it 'sets paused to false' do 124 | allow(processor).to receive(:setup) 125 | allow(processor).to receive(:resume) 126 | subject.start 127 | expect(subject).to_not be_paused 128 | end 129 | 130 | it 'starts adapter' do 131 | expect(backend).to receive(:start) 132 | allow(processor).to receive(:setup) 133 | allow(processor).to receive(:resume) 134 | subject.start 135 | end 136 | end 137 | 138 | describe '#stop' do 139 | before do 140 | allow(backend).to receive(:start) 141 | allow(processor).to receive(:setup) 142 | allow(processor).to receive(:resume) 143 | end 144 | 145 | context 'when fully started' do 146 | before do 147 | subject.start 148 | end 149 | 150 | it 'terminates' do 151 | allow(backend).to receive(:stop) 152 | allow(processor).to receive(:teardown) 153 | subject.stop 154 | end 155 | end 156 | 157 | context 'when frontend is ready' do 158 | before do 159 | subject.transition :backend_started 160 | subject.transition :frontend_ready 161 | end 162 | 163 | it 'terminates' do 164 | allow(backend).to receive(:stop) 165 | allow(processor).to receive(:teardown) 166 | subject.stop 167 | end 168 | end 169 | 170 | context 'when only backend is already started' do 171 | before do 172 | subject.transition :backend_started 173 | end 174 | 175 | it 'terminates' do 176 | allow(backend).to receive(:stop) 177 | allow(processor).to receive(:teardown) 178 | subject.stop 179 | end 180 | end 181 | 182 | context 'when only initialized' do 183 | before do 184 | subject 185 | end 186 | 187 | it 'terminates' do 188 | allow(backend).to receive(:stop) 189 | allow(processor).to receive(:teardown) 190 | subject.stop 191 | end 192 | end 193 | end 194 | 195 | describe '#pause' do 196 | before do 197 | allow(backend).to receive(:start) 198 | allow(processor).to receive(:setup) 199 | allow(processor).to receive(:resume) 200 | subject.start 201 | end 202 | it 'sets paused to true' do 203 | allow(processor).to receive(:pause) 204 | subject.pause 205 | expect(subject).to be_paused 206 | end 207 | end 208 | 209 | describe 'unpause with start' do 210 | before do 211 | allow(backend).to receive(:start) 212 | allow(processor).to receive(:setup) 213 | allow(processor).to receive(:resume) 214 | subject.start 215 | allow(processor).to receive(:pause) 216 | subject.pause 217 | end 218 | 219 | it 'sets paused to false' do 220 | subject.start 221 | expect(subject).to_not be_paused 222 | end 223 | end 224 | 225 | describe '#paused?' do 226 | before do 227 | allow(backend).to receive(:start) 228 | allow(processor).to receive(:setup) 229 | allow(processor).to receive(:resume) 230 | subject.start 231 | end 232 | 233 | it 'returns true when paused' do 234 | allow(processor).to receive(:pause) 235 | subject.pause 236 | expect(subject).to be_paused 237 | end 238 | 239 | it 'returns false when not paused' do 240 | expect(subject).not_to be_paused 241 | end 242 | end 243 | 244 | describe '#listen?' do 245 | context 'when processing' do 246 | before do 247 | allow(backend).to receive(:start) 248 | allow(processor).to receive(:setup) 249 | allow(processor).to receive(:resume) 250 | subject.start 251 | end 252 | it { should be_processing } 253 | end 254 | 255 | context 'when stopped' do 256 | it { should_not be_processing } 257 | end 258 | 259 | context 'when paused' do 260 | before do 261 | allow(backend).to receive(:start) 262 | allow(processor).to receive(:setup) 263 | allow(processor).to receive(:resume) 264 | subject.start 265 | allow(processor).to receive(:pause) 266 | subject.pause 267 | end 268 | 269 | it { should_not be_processing } 270 | end 271 | end 272 | 273 | # TODO: move these to silencer_controller? 274 | describe '#ignore' do 275 | context 'with existing ignore options' do 276 | let(:options) { { ignore: /bar/ } } 277 | 278 | it 'adds up to existing ignore options' do 279 | expect(silencer).to receive(:configure).once.with(ignore: [/bar/]) 280 | 281 | subject 282 | 283 | expect(silencer).to receive(:configure).once. 284 | with(ignore: [/bar/, /foo/]) 285 | 286 | subject.ignore(/foo/) 287 | end 288 | end 289 | 290 | context 'with existing ignore options (array)' do 291 | let(:options) { { ignore: [/bar/] } } 292 | 293 | it 'adds up to existing ignore options' do 294 | expect(silencer).to receive(:configure).once.with(ignore: [/bar/]) 295 | 296 | subject 297 | 298 | expect(silencer).to receive(:configure).once. 299 | with(ignore: [/bar/, /foo/]) 300 | 301 | subject.ignore(/foo/) 302 | end 303 | end 304 | end 305 | 306 | # TODO: move these to silencer_controller? 307 | describe '#ignore!' do 308 | context 'with no existing options' do 309 | let(:options) { {} } 310 | 311 | it 'sets options' do 312 | expect(silencer).to receive(:configure).with(options) 313 | subject 314 | end 315 | end 316 | 317 | context 'with existing ignore! options' do 318 | let(:options) { { ignore!: /bar/ } } 319 | 320 | it 'overwrites existing ignore options' do 321 | expect(silencer).to receive(:configure).once.with(ignore!: [/bar/]) 322 | subject 323 | expect(silencer).to receive(:configure).once.with(ignore!: [/foo/]) 324 | subject.ignore!([/foo/]) 325 | end 326 | end 327 | 328 | context 'with existing ignore options' do 329 | let(:options) { { ignore: /bar/ } } 330 | 331 | it 'deletes ignore options' do 332 | expect(silencer).to receive(:configure).once.with(ignore: [/bar/]) 333 | subject 334 | expect(silencer).to receive(:configure).once.with(ignore!: [/foo/]) 335 | subject.ignore!([/foo/]) 336 | end 337 | end 338 | end 339 | 340 | describe '#only' do 341 | context 'with existing only options' do 342 | let(:options) { { only: /bar/ } } 343 | 344 | it 'overwrites existing ignore options' do 345 | expect(silencer).to receive(:configure).once.with(only: [/bar/]) 346 | subject 347 | expect(silencer).to receive(:configure).once.with(only: [/foo/]) 348 | subject.only([/foo/]) 349 | end 350 | end 351 | end 352 | end 353 | -------------------------------------------------------------------------------- /spec/lib/listen/record_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Listen::Record do 2 | let(:dir) { instance_double(Pathname, to_s: '/dir') } 3 | let(:record) { Listen::Record.new(dir) } 4 | 5 | def dir_entries_for(hash) 6 | hash.each do |dir, entries| 7 | allow(::Dir).to receive(:entries).with(dir) { entries } 8 | end 9 | end 10 | 11 | def real_directory(hash) 12 | dir_entries_for(hash) 13 | hash.each do |dir, _| 14 | realpath(dir) 15 | end 16 | end 17 | 18 | def file(path) 19 | allow(::Dir).to receive(:entries).with(path).and_raise(Errno::ENOTDIR) 20 | path 21 | end 22 | 23 | def lstat(path, stat = nil) 24 | stat ||= instance_double(::File::Stat, mtime: 2.3, mode: 0755) 25 | allow(::File).to receive(:lstat).with(path).and_return(stat) 26 | stat 27 | end 28 | 29 | def realpath(path) 30 | allow(::File).to receive(:realpath).with(path).and_return(path) 31 | path 32 | end 33 | 34 | def symlink(hash_or_dir) 35 | if hash_or_dir.is_a?(String) 36 | allow(::File).to receive(:realpath).with(hash_or_dir). 37 | and_return(hash_or_dir) 38 | else 39 | hash_or_dir.each do |dir, real_path| 40 | allow(::File).to receive(:realpath).with(dir).and_return(real_path) 41 | end 42 | end 43 | end 44 | 45 | def record_tree(record) 46 | record.instance_variable_get(:@tree) 47 | end 48 | 49 | describe '#update_file' do 50 | context 'with path in watched dir' do 51 | it 'sets path by spliting dirname and basename' do 52 | record.update_file('file.rb', mtime: 1.1) 53 | expect(record_tree(record)).to eq('file.rb' => { mtime: 1.1 }) 54 | end 55 | 56 | it 'sets path and keeps old data not overwritten' do 57 | record.update_file('file.rb', foo: 1, bar: 2) 58 | record.update_file('file.rb', foo: 3) 59 | watched_dir = record_tree(record) 60 | expect(watched_dir).to eq('file.rb' => { foo: 3, bar: 2 }) 61 | end 62 | end 63 | 64 | context 'with subdir path' do 65 | it 'sets path by spliting dirname and basename' do 66 | record.update_file('path/file.rb', mtime: 1.1) 67 | expect(record_tree(record)['path']).to eq('file.rb' => { mtime: 1.1 }) 68 | end 69 | 70 | it 'sets path and keeps old data not overwritten' do 71 | record.update_file('path/file.rb', foo: 1, bar: 2) 72 | record.update_file('path/file.rb', foo: 3) 73 | file_data = record_tree(record)['path']['file.rb'] 74 | expect(file_data).to eq(foo: 3, bar: 2) 75 | end 76 | end 77 | end 78 | 79 | describe '#add_dir' do 80 | it 'sets itself when .' do 81 | record.add_dir('.') 82 | expect(record_tree(record)).to eq({}) 83 | end 84 | 85 | it 'sets itself when nil' do 86 | record.add_dir(nil) 87 | expect(record_tree(record)).to eq({}) 88 | end 89 | 90 | it 'sets itself when empty' do 91 | record.add_dir('') 92 | expect(record_tree(record)).to eq({}) 93 | end 94 | 95 | it 'correctly sets new directory data' do 96 | record.add_dir('path/subdir') 97 | expect(record_tree(record)).to eq('path/subdir' => {}) 98 | end 99 | 100 | it 'sets path and keeps old data not overwritten' do 101 | record.add_dir('path/subdir') 102 | record.update_file('path/subdir/file.rb', mtime: 1.1) 103 | record.add_dir('path/subdir') 104 | record.update_file('path/subdir/file2.rb', mtime: 1.2) 105 | record.add_dir('path/subdir') 106 | 107 | watched = record_tree(record) 108 | expect(watched.keys).to eq ['path/subdir'] 109 | expect(watched['path/subdir'].keys).to eq %w(file.rb file2.rb) 110 | 111 | subdir = watched['path/subdir'] 112 | expect(subdir['file.rb']).to eq(mtime: 1.1) 113 | expect(subdir['file2.rb']).to eq(mtime: 1.2) 114 | end 115 | end 116 | 117 | describe '#unset_path' do 118 | context 'within watched dir' do 119 | context 'when path is present' do 120 | before { record.update_file('file.rb', mtime: 1.1) } 121 | 122 | it 'unsets path' do 123 | record.unset_path('file.rb') 124 | expect(record_tree(record)).to eq({}) 125 | end 126 | end 127 | 128 | context 'when path not present' do 129 | it 'unsets path' do 130 | record.unset_path('file.rb') 131 | expect(record_tree(record)).to eq({}) 132 | end 133 | end 134 | end 135 | 136 | context 'within subdir' do 137 | context 'when path is present' do 138 | before { record.update_file('path/file.rb', mtime: 1.1) } 139 | 140 | it 'unsets path' do 141 | record.unset_path('path/file.rb') 142 | expect(record_tree(record)).to eq('path' => {}) 143 | end 144 | end 145 | 146 | context 'when path not present' do 147 | it 'unsets path' do 148 | record.unset_path('path/file.rb') 149 | expect(record_tree(record)).to eq({}) 150 | end 151 | end 152 | end 153 | end 154 | 155 | describe '#file_data' do 156 | context 'with path in watched dir' do 157 | context 'when path is present' do 158 | before { record.update_file('file.rb', mtime: 1.1) } 159 | 160 | it 'returns file data' do 161 | expect(record.file_data('file.rb')).to eq(mtime: 1.1) 162 | end 163 | end 164 | 165 | context 'path not present' do 166 | it 'return empty hash' do 167 | expect(record.file_data('file.rb')).to be_empty 168 | end 169 | end 170 | end 171 | 172 | context 'with path in subdir' do 173 | context 'when path is present' do 174 | before { record.update_file('path/file.rb', mtime: 1.1) } 175 | 176 | it 'returns file data' do 177 | expected = { mtime: 1.1 } 178 | expect(record.file_data('path/file.rb')).to eq expected 179 | end 180 | end 181 | 182 | context 'path not present' do 183 | it 'return empty hash' do 184 | expect(record.file_data('path/file.rb')).to be_empty 185 | end 186 | end 187 | end 188 | end 189 | 190 | describe '#dir_entries' do 191 | context 'in watched dir' do 192 | subject { record.dir_entries('.') } 193 | 194 | context 'with no entries' do 195 | it { should be_empty } 196 | end 197 | 198 | context 'with file.rb in record' do 199 | before { record.update_file('file.rb', mtime: 1.1) } 200 | it { should eq('file.rb' => { mtime: 1.1 }) } 201 | end 202 | 203 | context 'with subdir/file.rb in record' do 204 | before { record.update_file('subdir/file.rb', mtime: 1.1) } 205 | it { should eq('subdir' => {}) } 206 | end 207 | end 208 | 209 | context 'in subdir /path' do 210 | subject { record.dir_entries('path') } 211 | 212 | context 'with no entries' do 213 | it { should be_empty } 214 | end 215 | 216 | context 'with path/file.rb already in record' do 217 | before { record.update_file('path/file.rb', mtime: 1.1) } 218 | it { should eq('file.rb' => { mtime: 1.1 }) } 219 | end 220 | end 221 | end 222 | 223 | describe '#build' do 224 | let(:dir1) { Pathname('/dir1') } 225 | 226 | before do 227 | stubs = { 228 | ::File => %w(lstat realpath), 229 | ::Dir => %w(entries exist?) 230 | } 231 | 232 | stubs.each do |klass, meths| 233 | meths.each do |meth| 234 | allow(klass).to receive(meth.to_sym) do |*args| 235 | fail "stub called: #{klass}.#{meth}(#{args.map(&:inspect) * ', '})" 236 | end 237 | end 238 | end 239 | end 240 | 241 | it 're-inits paths' do 242 | real_directory('/dir1' => []) 243 | real_directory('/dir' => []) 244 | 245 | record.update_file('path/file.rb', mtime: 1.1) 246 | record.build 247 | expect(record_tree(record)).to eq({}) 248 | expect(record.file_data('path/file.rb')).to be_empty 249 | end 250 | 251 | let(:foo_stat) { instance_double(::File::Stat, mtime: 1.0, mode: 0644) } 252 | let(:bar_stat) { instance_double(::File::Stat, mtime: 2.3, mode: 0755) } 253 | 254 | context 'with no subdirs' do 255 | before do 256 | real_directory('/dir' => %w(foo bar)) 257 | lstat(file('/dir/foo'), foo_stat) 258 | lstat(file('/dir/bar'), bar_stat) 259 | real_directory('/dir2' => []) 260 | end 261 | 262 | it 'builds record' do 263 | record.build 264 | expect(record_tree(record)). 265 | to eq( 266 | 'foo' => { mtime: 1.0, mode: 0644 }, 267 | 'bar' => { mtime: 2.3, mode: 0755 }) 268 | end 269 | end 270 | 271 | context 'with subdir containing files' do 272 | before do 273 | real_directory('/dir' => %w(dir1 dir2)) 274 | real_directory('/dir/dir1' => %w(foo)) 275 | real_directory('/dir/dir1/foo' => %w(bar)) 276 | lstat(file('/dir/dir1/foo/bar')) 277 | real_directory('/dir/dir2' => []) 278 | end 279 | 280 | it 'builds record' do 281 | record.build 282 | expect(record_tree(record)). 283 | to eq( 284 | 'dir1' => {}, 285 | 'dir1/foo' => { 'bar' => { mtime: 2.3, mode: 0755 } }, 286 | 'dir2' => {}, 287 | ) 288 | end 289 | end 290 | 291 | context 'with subdir containing dirs' do 292 | before do 293 | real_directory('/dir' => %w(dir1 dir2)) 294 | real_directory('/dir/dir1' => %w(foo)) 295 | real_directory('/dir/dir1/foo' => %w(bar baz)) 296 | real_directory('/dir/dir1/foo/bar' => []) 297 | real_directory('/dir/dir1/foo/baz' => []) 298 | real_directory('/dir/dir2' => []) 299 | 300 | allow(::File).to receive(:realpath) { |path| path } 301 | end 302 | 303 | it 'builds record' do 304 | record.build 305 | expect(record_tree(record)). 306 | to eq( 307 | 'dir1' => {}, 308 | 'dir1/foo' => {}, 309 | 'dir1/foo/bar' => {}, 310 | 'dir1/foo/baz' => {}, 311 | 'dir2' => {}, 312 | ) 313 | end 314 | end 315 | 316 | context 'with subdir containing symlink to parent' do 317 | subject { record.paths } 318 | before do 319 | real_directory('/dir' => %w(dir1 dir2)) 320 | real_directory('/dir/dir1' => %w(foo)) 321 | dir_entries_for('/dir/dir1/foo' => %w(dir1)) 322 | symlink('/dir/dir1/foo' => '/dir/dir1') 323 | 324 | real_directory('/dir/dir2' => []) 325 | end 326 | 327 | it 'shows a warning' do 328 | expect(STDERR).to receive(:puts). 329 | with(/directory is already being watched/) 330 | 331 | record.build 332 | # expect { record.build }. 333 | # to raise_error(RuntimeError, /Failed due to looped symlinks/) 334 | end 335 | end 336 | 337 | context 'with a normal symlinked directory to another' do 338 | subject { record.paths } 339 | 340 | before do 341 | real_directory('/dir' => %w(dir1)) 342 | real_directory('/dir/dir1' => %w(foo)) 343 | 344 | symlink('/dir/dir1/foo' => '/dir/dir2') 345 | dir_entries_for('/dir/dir1/foo' => %w(bar)) 346 | lstat(realpath(file('/dir/dir1/foo/bar'))) 347 | 348 | real_directory('/dir/dir2' => %w(bar)) 349 | lstat(file('/dir/dir2/bar')) 350 | end 351 | 352 | it 'shows message' do 353 | expect(STDERR).to_not receive(:puts) 354 | record.build 355 | end 356 | end 357 | 358 | context 'with subdir containing symlinked file' do 359 | subject { record.paths } 360 | before do 361 | real_directory('/dir' => %w(dir1 dir2)) 362 | real_directory('/dir/dir1' => %w(foo)) 363 | lstat(file('/dir/dir1/foo')) 364 | real_directory('/dir/dir2' => []) 365 | end 366 | 367 | it 'shows a warning' do 368 | expect(STDERR).to_not receive(:puts) 369 | 370 | record.build 371 | end 372 | end 373 | end 374 | end 375 | --------------------------------------------------------------------------------