sass-listen 3.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1 -0
- data/CONTRIBUTING.md +38 -0
- data/LICENSE.txt +22 -0
- data/README.md +297 -0
- data/bin/listen +12 -0
- data/lib/listen.rb +55 -0
- data/lib/listen/adapter.rb +43 -0
- data/lib/listen/adapter/base.rb +137 -0
- data/lib/listen/adapter/bsd.rb +106 -0
- data/lib/listen/adapter/config.rb +26 -0
- data/lib/listen/adapter/darwin.rb +71 -0
- data/lib/listen/adapter/linux.rb +106 -0
- data/lib/listen/adapter/polling.rb +37 -0
- data/lib/listen/adapter/windows.rb +99 -0
- data/lib/listen/backend.rb +41 -0
- data/lib/listen/change.rb +78 -0
- data/lib/listen/cli.rb +65 -0
- data/lib/listen/directory.rb +76 -0
- data/lib/listen/event/config.rb +59 -0
- data/lib/listen/event/loop.rb +117 -0
- data/lib/listen/event/processor.rb +122 -0
- data/lib/listen/event/queue.rb +56 -0
- data/lib/listen/file.rb +80 -0
- data/lib/listen/fsm.rb +131 -0
- data/lib/listen/internals/thread_pool.rb +21 -0
- data/lib/listen/listener.rb +132 -0
- data/lib/listen/listener/config.rb +45 -0
- data/lib/listen/logger.rb +32 -0
- data/lib/listen/options.rb +23 -0
- data/lib/listen/queue_optimizer.rb +132 -0
- data/lib/listen/record.rb +120 -0
- data/lib/listen/record/entry.rb +51 -0
- data/lib/listen/record/symlink_detector.rb +39 -0
- data/lib/listen/silencer.rb +97 -0
- data/lib/listen/silencer/controller.rb +48 -0
- data/lib/listen/version.rb +3 -0
- metadata +124 -0
@@ -0,0 +1,21 @@
|
|
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
|
+
killed << @threads.pop.kill until @threads.empty?
|
17
|
+
killed.pop.join until killed.empty?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,132 @@
|
|
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
|
+
include Listen::FSM
|
23
|
+
|
24
|
+
# Initializes the directories listener.
|
25
|
+
#
|
26
|
+
# @param [String] directory the directories to listen to
|
27
|
+
# @param [Hash] options the listen options (see Listen::Listener::Options)
|
28
|
+
#
|
29
|
+
# @yield [modified, added, removed] the changed files
|
30
|
+
# @yieldparam [Array<String>] modified the list of modified files
|
31
|
+
# @yieldparam [Array<String>] added the list of added files
|
32
|
+
# @yieldparam [Array<String>] removed the list of removed files
|
33
|
+
#
|
34
|
+
def initialize(*dirs, &block)
|
35
|
+
options = dirs.last.is_a?(Hash) ? dirs.pop : {}
|
36
|
+
|
37
|
+
@config = Config.new(options)
|
38
|
+
|
39
|
+
eq_config = Event::Queue::Config.new(@config.relative?)
|
40
|
+
queue = Event::Queue.new(eq_config) { @processor.wakeup_on_event }
|
41
|
+
|
42
|
+
silencer = Silencer.new
|
43
|
+
rules = @config.silencer_rules
|
44
|
+
@silencer_controller = Silencer::Controller.new(silencer, rules)
|
45
|
+
|
46
|
+
@backend = Backend.new(dirs, queue, silencer, @config)
|
47
|
+
|
48
|
+
optimizer_config = QueueOptimizer::Config.new(@backend, silencer)
|
49
|
+
|
50
|
+
pconfig = Event::Config.new(
|
51
|
+
self,
|
52
|
+
queue,
|
53
|
+
QueueOptimizer.new(optimizer_config),
|
54
|
+
@backend.min_delay_between_events,
|
55
|
+
&block)
|
56
|
+
|
57
|
+
@processor = Event::Loop.new(pconfig)
|
58
|
+
|
59
|
+
super() # FSM
|
60
|
+
end
|
61
|
+
|
62
|
+
default_state :initializing
|
63
|
+
|
64
|
+
state :initializing, to: :backend_started
|
65
|
+
|
66
|
+
state :backend_started, to: [:frontend_ready, :stopped] do
|
67
|
+
backend.start
|
68
|
+
end
|
69
|
+
|
70
|
+
state :frontend_ready, to: [:processing_events, :stopped] do
|
71
|
+
processor.setup
|
72
|
+
end
|
73
|
+
|
74
|
+
state :processing_events, to: [:paused, :stopped] do
|
75
|
+
processor.resume
|
76
|
+
end
|
77
|
+
|
78
|
+
state :paused, to: [:processing_events, :stopped] do
|
79
|
+
processor.pause
|
80
|
+
end
|
81
|
+
|
82
|
+
state :stopped, to: [:backend_started] do
|
83
|
+
backend.stop # should be before processor.teardown to halt events ASAP
|
84
|
+
processor.teardown
|
85
|
+
end
|
86
|
+
|
87
|
+
# Starts processing events and starts adapters
|
88
|
+
# or resumes invoking callbacks if paused
|
89
|
+
def start
|
90
|
+
transition :backend_started if state == :initializing
|
91
|
+
transition :frontend_ready if state == :backend_started
|
92
|
+
transition :processing_events if state == :frontend_ready
|
93
|
+
transition :processing_events if state == :paused
|
94
|
+
end
|
95
|
+
|
96
|
+
# Stops both listening for events and processing them
|
97
|
+
def stop
|
98
|
+
transition :stopped
|
99
|
+
end
|
100
|
+
|
101
|
+
# Stops invoking callbacks (messages pile up)
|
102
|
+
def pause
|
103
|
+
transition :paused
|
104
|
+
end
|
105
|
+
|
106
|
+
# processing means callbacks are called
|
107
|
+
def processing?
|
108
|
+
state == :processing_events
|
109
|
+
end
|
110
|
+
|
111
|
+
def paused?
|
112
|
+
state == :paused
|
113
|
+
end
|
114
|
+
|
115
|
+
def ignore(regexps)
|
116
|
+
@silencer_controller.append_ignores(regexps)
|
117
|
+
end
|
118
|
+
|
119
|
+
def ignore!(regexps)
|
120
|
+
@silencer_controller.replace_with_bang_ignores(regexps)
|
121
|
+
end
|
122
|
+
|
123
|
+
def only(regexps)
|
124
|
+
@silencer_controller.replace_with_only(regexps)
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
attr_reader :processor
|
130
|
+
attr_reader :backend
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,45 @@
|
|
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
|
+
}
|
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
|
+
def min_delay_between_events
|
27
|
+
@min_delay_between_events
|
28
|
+
end
|
29
|
+
|
30
|
+
def silencer_rules
|
31
|
+
@silencer_rules
|
32
|
+
end
|
33
|
+
|
34
|
+
def adapter_instance_options(klass)
|
35
|
+
valid_keys = klass.const_get('DEFAULTS').keys
|
36
|
+
Hash[@options.select { |key, _| valid_keys.include?(key) }]
|
37
|
+
end
|
38
|
+
|
39
|
+
def adapter_select_options
|
40
|
+
valid_keys = %w(force_polling polling_fallback_message).map(&:to_sym)
|
41
|
+
Hash[@options.select { |key, _| valid_keys.include?(key) }]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,32 @@
|
|
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
|
@@ -0,0 +1,23 @@
|
|
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
|
@@ -0,0 +1,132 @@
|
|
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.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.flatten(1)
|
105
|
+
end
|
106
|
+
|
107
|
+
def _detect_possible_editor_save(changes)
|
108
|
+
return unless changes.size == 2
|
109
|
+
|
110
|
+
from_type = from_change = from = nil
|
111
|
+
to_type = to_change = 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
|
@@ -0,0 +1,120 @@
|
|
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
|
+
def tree
|
78
|
+
@tree
|
79
|
+
end
|
80
|
+
|
81
|
+
def _fast_update_file(dirname, basename, data)
|
82
|
+
if [nil, '', '.'].include? dirname
|
83
|
+
tree[basename] = (tree[basename] || {}).merge(data)
|
84
|
+
else
|
85
|
+
tree[dirname] ||= {}
|
86
|
+
tree[dirname][basename] = (tree[dirname][basename] || {}).merge(data)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def _fast_unset_path(dirname, basename)
|
91
|
+
# this may need to be reworked to properly remove
|
92
|
+
# entries from a tree, without adding non-existing dirs to the record
|
93
|
+
if [nil, '', '.'].include? dirname
|
94
|
+
return unless tree.key?(basename)
|
95
|
+
tree.delete(basename)
|
96
|
+
else
|
97
|
+
return unless tree.key?(dirname)
|
98
|
+
tree[dirname].delete(basename)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def _fast_build_dir(remaining, symlink_detector)
|
103
|
+
entry = remaining.pop
|
104
|
+
children = entry.children # NOTE: children() implicitly tests if dir
|
105
|
+
symlink_detector.verify_unwatched!(entry)
|
106
|
+
children.each { |child| remaining << child }
|
107
|
+
add_dir(entry.record_dir_key)
|
108
|
+
rescue Errno::ENOTDIR
|
109
|
+
_fast_try_file(entry)
|
110
|
+
rescue SystemCallError, SymlinkDetector::Error
|
111
|
+
_fast_unset_path(entry.relative, entry.name)
|
112
|
+
end
|
113
|
+
|
114
|
+
def _fast_try_file(entry)
|
115
|
+
_fast_update_file(entry.relative, entry.name, entry.meta)
|
116
|
+
rescue SystemCallError
|
117
|
+
_fast_unset_path(entry.relative, entry.name)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|