watchcat 0.3.0 → 0.6.0
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 +4 -4
- data/CHANGELOG.md +28 -0
- data/Cargo.lock +33 -109
- data/Cargo.toml +1 -1
- data/Gemfile +1 -1
- data/README.md +188 -2
- data/cli_example.yml +14 -0
- data/exe/watchcat +14 -0
- data/ext/watchcat/Cargo.toml +3 -4
- data/ext/watchcat/src/gvl_helpers.rs +1 -1
- data/ext/watchcat/src/lib.rs +170 -109
- data/lib/watchcat/cli/action_executor.rb +44 -0
- data/lib/watchcat/cli/config.rb +76 -0
- data/lib/watchcat/cli/watcher.rb +83 -0
- data/lib/watchcat/cli.rb +52 -0
- data/lib/watchcat/debouncer.rb +41 -0
- data/lib/watchcat/event.rb +37 -0
- data/lib/watchcat/event_handler.rb +29 -0
- data/lib/watchcat/executor.rb +64 -13
- data/lib/watchcat/kind.rb +8 -0
- data/lib/watchcat/version.rb +1 -1
- data/lib/watchcat.rb +15 -5
- metadata +46 -7
- data/Gemfile.lock +0 -84
- data/watchcat.gemspec +0 -37
data/ext/watchcat/src/lib.rs
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
use crossbeam_channel::{select, unbounded};
|
|
2
2
|
use magnus::{
|
|
3
|
-
|
|
4
|
-
class::object,
|
|
5
|
-
define_module, function, method,
|
|
3
|
+
function, method,
|
|
6
4
|
scan_args::{get_kwargs, scan_args},
|
|
7
|
-
Error, Module, Object, Value,
|
|
5
|
+
Error, Module, Object, Value, Ruby
|
|
8
6
|
};
|
|
9
7
|
use notify::{Config, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
|
10
|
-
use notify_debouncer_mini::new_debouncer;
|
|
11
8
|
use std::{path::Path, time::Duration, sync::{Arc, atomic::{AtomicBool, Ordering}}};
|
|
12
9
|
|
|
13
10
|
mod event;
|
|
@@ -20,93 +17,135 @@ struct WatchcatWatcher {
|
|
|
20
17
|
tx: crossbeam_channel::Sender<bool>,
|
|
21
18
|
rx: crossbeam_channel::Receiver<bool>,
|
|
22
19
|
terminated: Arc<AtomicBool>,
|
|
20
|
+
cmd_tx: crossbeam_channel::Sender<Command>,
|
|
21
|
+
cmd_rx: crossbeam_channel::Receiver<Command>,
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
#[derive(Debug)]
|
|
26
25
|
enum WatcherEnum {
|
|
27
|
-
#[allow(dead_code)]
|
|
28
26
|
Poll(PollWatcher),
|
|
29
|
-
#[allow(dead_code)]
|
|
30
27
|
Recommended(RecommendedWatcher),
|
|
31
28
|
}
|
|
32
29
|
|
|
30
|
+
fn watcher_watch(w: &mut WatcherEnum, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
|
|
31
|
+
match w {
|
|
32
|
+
WatcherEnum::Poll(x) => x.watch(path, mode),
|
|
33
|
+
WatcherEnum::Recommended(x) => x.watch(path, mode),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn watcher_unwatch(w: &mut WatcherEnum, path: &Path) -> notify::Result<()> {
|
|
38
|
+
match w {
|
|
39
|
+
WatcherEnum::Poll(x) => x.unwatch(path),
|
|
40
|
+
WatcherEnum::Recommended(x) => x.unwatch(path),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Carries a failure out of the GVL-released section without touching Ruby.
|
|
45
|
+
// `magnus::Error` (and the `Ruby` handle needed to build one) must only be
|
|
46
|
+
// used while the GVL is held, so the actual `magnus::Error` is constructed
|
|
47
|
+
// after control returns from `call_without_gvl`.
|
|
48
|
+
enum WatchFailure {
|
|
49
|
+
Arg(String),
|
|
50
|
+
Runtime(String),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
enum Command {
|
|
54
|
+
Watch(Vec<String>, bool), // paths, recursive
|
|
55
|
+
Unwatch(Vec<String>), // paths
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
impl WatchcatWatcher {
|
|
34
59
|
fn new() -> Self {
|
|
35
60
|
let (tx_executor, rx_executor) = unbounded::<bool>();
|
|
61
|
+
let (cmd_tx, cmd_rx) = unbounded::<Command>();
|
|
36
62
|
Self {
|
|
37
63
|
tx: tx_executor,
|
|
38
64
|
rx: rx_executor,
|
|
39
65
|
terminated: Arc::new(AtomicBool::new(false)),
|
|
66
|
+
cmd_tx,
|
|
67
|
+
cmd_rx,
|
|
40
68
|
}
|
|
41
69
|
}
|
|
42
70
|
|
|
43
71
|
fn close(&self) {
|
|
44
72
|
self.terminated.store(true, Ordering::SeqCst);
|
|
45
|
-
self
|
|
73
|
+
// See `add`/`unwatch`: `send` cannot fail while `self` retains `rx`,
|
|
74
|
+
// but `.unwrap()` would still turn a hypothetical failure into a
|
|
75
|
+
// Rust panic, and a panic crossing the FFI boundary aborts the whole
|
|
76
|
+
// process instead of raising in Ruby. Not worth the risk for a
|
|
77
|
+
// result we already know.
|
|
78
|
+
let _ = self.tx.send(true);
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
fn watch(&self, args: &[Value]) -> Result<bool, Error> {
|
|
49
|
-
|
|
50
|
-
|
|
82
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
83
|
+
let ruby_ref = &ruby;
|
|
84
|
+
if !ruby_ref.block_given() {
|
|
85
|
+
return Err(Error::new(ruby_ref.exception_arg_error(), "no block given"));
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
let (pathnames, recursive, force_polling, poll_interval, ignore_remove,
|
|
88
|
+
let (pathnames, recursive, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify) = Self::parse_args(args)?;
|
|
54
89
|
let mode = if recursive {
|
|
55
90
|
RecursiveMode::Recursive
|
|
56
91
|
} else {
|
|
57
92
|
RecursiveMode::NonRecursive
|
|
58
93
|
};
|
|
59
94
|
|
|
60
|
-
// Clone necessary data for the call_without_gvl
|
|
61
95
|
let terminated = self.terminated.clone();
|
|
62
96
|
let rx_clone = self.rx.clone();
|
|
97
|
+
let cmd_rx = self.cmd_rx.clone();
|
|
63
98
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
pathnames, mode, ignore_remove, debounce, terminated, rx_clone
|
|
68
|
-
)
|
|
69
|
-
} else {
|
|
70
|
-
Self::watch_without_debounce_threaded(
|
|
71
|
-
pathnames, mode, force_polling, poll_interval, ignore_remove, terminated, rx_clone
|
|
72
|
-
)
|
|
73
|
-
}
|
|
99
|
+
Self::watch_threaded(
|
|
100
|
+
pathnames, mode, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify, terminated, rx_clone, cmd_rx, ruby_ref
|
|
101
|
+
)
|
|
74
102
|
}
|
|
75
103
|
|
|
76
|
-
|
|
104
|
+
#[allow(clippy::too_many_arguments)]
|
|
105
|
+
fn watch_threaded(
|
|
77
106
|
pathnames: Vec<String>,
|
|
78
107
|
mode: RecursiveMode,
|
|
79
108
|
force_polling: bool,
|
|
80
109
|
poll_interval: u64,
|
|
81
110
|
ignore_remove: bool,
|
|
111
|
+
ignore_access: bool,
|
|
112
|
+
ignore_create: bool,
|
|
113
|
+
ignore_modify: bool,
|
|
82
114
|
terminated: Arc<AtomicBool>,
|
|
83
|
-
rx: crossbeam_channel::Receiver<bool
|
|
115
|
+
rx: crossbeam_channel::Receiver<bool>,
|
|
116
|
+
cmd_rx: crossbeam_channel::Receiver<Command>,
|
|
117
|
+
ruby: &Ruby
|
|
84
118
|
) -> Result<bool, Error> {
|
|
85
|
-
|
|
119
|
+
// `ruby` (and any `magnus::Error`/`ExceptionClass` built from it) must only be
|
|
120
|
+
// touched while the GVL is held, so it is intentionally NOT captured by the
|
|
121
|
+
// `call_without_gvl` closure below. Failures are carried out as plain
|
|
122
|
+
// `WatchFailure` values and converted to a real `magnus::Error` afterwards,
|
|
123
|
+
// once control has returned here with the GVL held again.
|
|
124
|
+
let result: Result<bool, WatchFailure> = call_without_gvl(move || {
|
|
86
125
|
let (tx, watcher_rx) = unbounded();
|
|
87
126
|
// This variable is needed to keep `watcher` active.
|
|
88
|
-
let _watcher = match force_polling {
|
|
127
|
+
let mut _watcher = match force_polling {
|
|
89
128
|
true => {
|
|
90
129
|
let delay = Duration::from_millis(poll_interval);
|
|
91
130
|
let config = notify::Config::default().with_poll_interval(delay);
|
|
92
131
|
let mut watcher = PollWatcher::new(tx, config)
|
|
93
|
-
.map_err(|e|
|
|
132
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
94
133
|
for pathname in &pathnames {
|
|
95
134
|
let path = Path::new(pathname);
|
|
96
135
|
watcher
|
|
97
136
|
.watch(path, mode)
|
|
98
|
-
.map_err(|e|
|
|
137
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
99
138
|
}
|
|
100
139
|
WatcherEnum::Poll(watcher)
|
|
101
140
|
}
|
|
102
141
|
false => {
|
|
103
142
|
let mut watcher = RecommendedWatcher::new(tx, Config::default())
|
|
104
|
-
.map_err(|e|
|
|
143
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
105
144
|
for pathname in &pathnames {
|
|
106
145
|
let path = Path::new(pathname);
|
|
107
146
|
watcher
|
|
108
147
|
.watch(path, mode)
|
|
109
|
-
.map_err(|e|
|
|
148
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
110
149
|
}
|
|
111
150
|
WatcherEnum::Recommended(watcher)
|
|
112
151
|
}
|
|
@@ -121,6 +160,23 @@ impl WatchcatWatcher {
|
|
|
121
160
|
recv(rx) -> _res => {
|
|
122
161
|
break Ok(true);
|
|
123
162
|
}
|
|
163
|
+
recv(cmd_rx) -> cmd => {
|
|
164
|
+
if let Ok(cmd) = cmd {
|
|
165
|
+
match cmd {
|
|
166
|
+
Command::Watch(paths, recursive) => {
|
|
167
|
+
let m = if recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
|
|
168
|
+
for p in &paths {
|
|
169
|
+
let _ = watcher_watch(&mut _watcher, Path::new(p), m);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
Command::Unwatch(paths) => {
|
|
173
|
+
for p in &paths {
|
|
174
|
+
let _ = watcher_unwatch(&mut _watcher, Path::new(p));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
124
180
|
recv(watcher_rx) -> res => {
|
|
125
181
|
match res {
|
|
126
182
|
Ok(event) => {
|
|
@@ -135,103 +191,51 @@ impl WatchcatWatcher {
|
|
|
135
191
|
if ignore_remove && matches!(event.kind, notify::event::EventKind::Remove(_)) {
|
|
136
192
|
continue;
|
|
137
193
|
}
|
|
194
|
+
if ignore_access && matches!(event.kind, notify::event::EventKind::Access(_)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if ignore_create && matches!(event.kind, notify::event::EventKind::Create(_)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if ignore_modify && matches!(event.kind, notify::event::EventKind::Modify(_)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
138
203
|
|
|
139
204
|
// Yield to Ruby with GVL
|
|
140
|
-
let result = call_with_gvl(|
|
|
141
|
-
yield_value::<(Vec<String>, Vec<String>, String), Value>(
|
|
205
|
+
let result: Result<Value, String> = call_with_gvl(|ruby| {
|
|
206
|
+
ruby.yield_value::<(Vec<String>, Vec<String>, String), Value>(
|
|
142
207
|
(WatchatEvent::convert_kind(&event.kind), paths, format!("{:?}", event.kind))
|
|
143
|
-
)
|
|
208
|
+
).map_err(|e| e.to_string())
|
|
144
209
|
});
|
|
145
210
|
|
|
146
|
-
if
|
|
147
|
-
break Err(
|
|
211
|
+
if let Err(msg) = result {
|
|
212
|
+
break Err(WatchFailure::Runtime(format!("Error yielding to Ruby block: {msg}")));
|
|
148
213
|
}
|
|
149
214
|
}
|
|
150
215
|
Err(e) => {
|
|
151
|
-
break Err(
|
|
216
|
+
break Err(WatchFailure::Runtime(e.to_string()));
|
|
152
217
|
}
|
|
153
218
|
}
|
|
154
219
|
}
|
|
155
220
|
Err(e) => {
|
|
156
|
-
break Err(
|
|
221
|
+
break Err(WatchFailure::Runtime(e.to_string()));
|
|
157
222
|
}
|
|
158
223
|
}
|
|
159
224
|
}
|
|
160
225
|
}
|
|
161
226
|
}
|
|
162
|
-
})
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
fn watch_with_debounce_threaded(
|
|
166
|
-
pathnames: Vec<String>,
|
|
167
|
-
mode: RecursiveMode,
|
|
168
|
-
ignore_remove: bool,
|
|
169
|
-
debounce: i64,
|
|
170
|
-
terminated: Arc<AtomicBool>,
|
|
171
|
-
rx: crossbeam_channel::Receiver<bool>
|
|
172
|
-
) -> Result<bool, Error> {
|
|
173
|
-
call_without_gvl(move || {
|
|
174
|
-
let (tx, watcher_rx) = unbounded();
|
|
175
|
-
let mut debouncer = new_debouncer(Duration::from_millis(debounce.try_into().unwrap()), tx).unwrap();
|
|
176
|
-
for pathname in &pathnames {
|
|
177
|
-
let path = Path::new(pathname);
|
|
178
|
-
debouncer
|
|
179
|
-
.watcher()
|
|
180
|
-
.watch(path, mode)
|
|
181
|
-
.map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
|
|
182
|
-
}
|
|
227
|
+
});
|
|
183
228
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
select! {
|
|
190
|
-
recv(rx) -> _res => {
|
|
191
|
-
break Ok(true);
|
|
192
|
-
}
|
|
193
|
-
recv(watcher_rx) -> res => {
|
|
194
|
-
match res {
|
|
195
|
-
Ok(events) => {
|
|
196
|
-
match events {
|
|
197
|
-
Ok(events) => {
|
|
198
|
-
for event in events.iter() {
|
|
199
|
-
if ignore_remove && !Path::new(&event.path).exists() {
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Yield to Ruby with GVL
|
|
204
|
-
let result = call_with_gvl(|_| {
|
|
205
|
-
yield_value::<(Vec<String>, Vec<String>, String), Value>(
|
|
206
|
-
(vec![], vec![event.path.to_string_lossy().into_owned()], format!("{:?}", event.kind))
|
|
207
|
-
)
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
if result.is_err() {
|
|
211
|
-
return Err(Error::new(magnus::exception::runtime_error(), "Error yielding to Ruby block"));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
Err(e) => {
|
|
216
|
-
break Err(Error::new(magnus::exception::runtime_error(), e.to_string()));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
Err(e) => {
|
|
221
|
-
break Err(Error::new(magnus::exception::runtime_error(), e.to_string()));
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
229
|
+
result.map_err(|err| match err {
|
|
230
|
+
WatchFailure::Arg(msg) => Error::new(ruby.exception_arg_error(), msg),
|
|
231
|
+
WatchFailure::Runtime(msg) => Error::new(ruby.exception_runtime_error(), msg),
|
|
227
232
|
})
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
#[allow(clippy::let_unit_value, clippy::type_complexity)]
|
|
231
|
-
fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool,
|
|
236
|
+
fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool, bool, bool, bool), Error> {
|
|
232
237
|
type KwArgBool = Option<Option<bool>>;
|
|
233
238
|
type KwArgU64 = Option<Option<u64>>;
|
|
234
|
-
type KwArgi64 = Option<Option<i64>>;
|
|
235
239
|
|
|
236
240
|
let args = scan_args(args)?;
|
|
237
241
|
let (paths,): (Vec<String>,) = args.required;
|
|
@@ -243,9 +247,9 @@ impl WatchcatWatcher {
|
|
|
243
247
|
let kwargs = get_kwargs(
|
|
244
248
|
args.keywords,
|
|
245
249
|
&[],
|
|
246
|
-
&["recursive", "force_polling", "poll_interval", "ignore_remove", "
|
|
250
|
+
&["recursive", "force_polling", "poll_interval", "ignore_remove", "ignore_access", "ignore_create", "ignore_modify"],
|
|
247
251
|
)?;
|
|
248
|
-
let (recursive, force_polling, poll_interval, ignore_remove,
|
|
252
|
+
let (recursive, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify): (KwArgBool, KwArgBool, KwArgU64, KwArgBool, KwArgBool, KwArgBool, KwArgBool) =
|
|
249
253
|
kwargs.optional;
|
|
250
254
|
let _: () = kwargs.required;
|
|
251
255
|
let _: () = kwargs.splat;
|
|
@@ -256,19 +260,76 @@ impl WatchcatWatcher {
|
|
|
256
260
|
force_polling.flatten().unwrap_or(false),
|
|
257
261
|
poll_interval.flatten().unwrap_or(200),
|
|
258
262
|
ignore_remove.flatten().unwrap_or(false),
|
|
259
|
-
|
|
263
|
+
ignore_access.flatten().unwrap_or(false),
|
|
264
|
+
ignore_create.flatten().unwrap_or(false),
|
|
265
|
+
ignore_modify.flatten().unwrap_or(false),
|
|
260
266
|
))
|
|
261
267
|
}
|
|
268
|
+
|
|
269
|
+
fn add(&self, args: &[Value]) -> Result<bool, Error> {
|
|
270
|
+
let (paths, recursive) = Self::parse_add_args(args)?;
|
|
271
|
+
// `send` only fails when every receiver is disconnected, but `self`
|
|
272
|
+
// holds `cmd_rx` for the whole lifetime of this object, so it cannot
|
|
273
|
+
// fail here. If the watch loop has already stopped, the command is
|
|
274
|
+
// simply buffered and never applied (a harmless no-op).
|
|
275
|
+
let _ = self.cmd_tx.send(Command::Watch(paths, recursive));
|
|
276
|
+
Ok(true)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fn unwatch(&self, args: &[Value]) -> Result<bool, Error> {
|
|
280
|
+
let paths = Self::parse_unwatch_args(args)?;
|
|
281
|
+
// See `add`: `send` cannot fail while `self` retains `cmd_rx`.
|
|
282
|
+
let _ = self.cmd_tx.send(Command::Unwatch(paths));
|
|
283
|
+
Ok(true)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[allow(clippy::let_unit_value)]
|
|
287
|
+
fn parse_add_args(args: &[Value]) -> Result<(Vec<String>, bool), Error> {
|
|
288
|
+
type KwArgBool = Option<Option<bool>>;
|
|
289
|
+
|
|
290
|
+
let args = scan_args(args)?;
|
|
291
|
+
let (paths,): (Vec<String>,) = args.required;
|
|
292
|
+
let _: () = args.optional;
|
|
293
|
+
let _: () = args.splat;
|
|
294
|
+
let _: () = args.trailing;
|
|
295
|
+
let _: () = args.block;
|
|
296
|
+
|
|
297
|
+
let kwargs = get_kwargs(args.keywords, &[], &["recursive"])?;
|
|
298
|
+
let (recursive,): (KwArgBool,) = kwargs.optional;
|
|
299
|
+
let _: () = kwargs.required;
|
|
300
|
+
let _: () = kwargs.splat;
|
|
301
|
+
|
|
302
|
+
Ok((paths, recursive.flatten().unwrap_or(true)))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#[allow(clippy::let_unit_value)]
|
|
306
|
+
fn parse_unwatch_args(args: &[Value]) -> Result<Vec<String>, Error> {
|
|
307
|
+
let args = scan_args(args)?;
|
|
308
|
+
let (paths,): (Vec<String>,) = args.required;
|
|
309
|
+
let _: () = args.optional;
|
|
310
|
+
let _: () = args.splat;
|
|
311
|
+
let _: () = args.trailing;
|
|
312
|
+
let _: () = args.block;
|
|
313
|
+
|
|
314
|
+
let kwargs = get_kwargs::<&str, (), (), ()>(args.keywords, &[], &[])?;
|
|
315
|
+
let _: () = kwargs.optional;
|
|
316
|
+
let _: () = kwargs.required;
|
|
317
|
+
let _: () = kwargs.splat;
|
|
318
|
+
|
|
319
|
+
Ok(paths)
|
|
320
|
+
}
|
|
262
321
|
}
|
|
263
322
|
|
|
264
323
|
#[magnus::init]
|
|
265
|
-
fn init() -> Result<(), Error> {
|
|
266
|
-
let module = define_module("Watchcat")?;
|
|
324
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
325
|
+
let module = ruby.define_module("Watchcat")?;
|
|
267
326
|
|
|
268
|
-
let watcher_class = module.define_class("Watcher",
|
|
327
|
+
let watcher_class = module.define_class("Watcher", ruby.class_object())?;
|
|
269
328
|
watcher_class.define_singleton_method("new", function!(WatchcatWatcher::new, 0))?;
|
|
270
329
|
watcher_class.define_method("watch", method!(WatchcatWatcher::watch, -1))?;
|
|
271
330
|
watcher_class.define_method("close", method!(WatchcatWatcher::close, 0))?;
|
|
331
|
+
watcher_class.define_method("add", method!(WatchcatWatcher::add, -1))?;
|
|
332
|
+
watcher_class.define_method("unwatch", method!(WatchcatWatcher::unwatch, -1))?;
|
|
272
333
|
|
|
273
334
|
Ok(())
|
|
274
335
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Watchcat
|
|
2
|
+
module CLI
|
|
3
|
+
class ActionExecutor
|
|
4
|
+
def initialize(file_path, event)
|
|
5
|
+
@file_path = file_path
|
|
6
|
+
@event = event
|
|
7
|
+
@file_dir = File.dirname(file_path)
|
|
8
|
+
@file_name = File.basename(file_path)
|
|
9
|
+
@file_ext = File.extname(file_path)
|
|
10
|
+
@file_base = File.basename(file_path, @file_ext)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute(action)
|
|
14
|
+
execute_command(action)
|
|
15
|
+
rescue => e
|
|
16
|
+
puts "Error executing action #{action}: #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def execute_command(action)
|
|
22
|
+
command = substitute_variables(action["command"])
|
|
23
|
+
puts "Executing: #{command}"
|
|
24
|
+
|
|
25
|
+
success = system(command)
|
|
26
|
+
unless success
|
|
27
|
+
puts "Command failed with exit code: #{$?.exitstatus}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def substitute_variables(template)
|
|
32
|
+
return template unless template.is_a?(String)
|
|
33
|
+
|
|
34
|
+
template
|
|
35
|
+
.gsub("{{file_path}}", @file_path)
|
|
36
|
+
.gsub("{{file_dir}}", @file_dir)
|
|
37
|
+
.gsub("{{file_name}}", @file_name)
|
|
38
|
+
.gsub("{{file_base}}", @file_base)
|
|
39
|
+
.gsub("{{file_ext}}", @file_ext)
|
|
40
|
+
.gsub("{{event_type}}", @event.kind.event_type)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "psych"
|
|
2
|
+
|
|
3
|
+
module Watchcat
|
|
4
|
+
module CLI
|
|
5
|
+
class Config
|
|
6
|
+
attr_reader :watches
|
|
7
|
+
|
|
8
|
+
def initialize(data)
|
|
9
|
+
@watches = parse_watches(data["watches"] || [])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.load(file_path)
|
|
13
|
+
unless File.exist?(file_path)
|
|
14
|
+
raise Error, "Configuration file not found: #{file_path}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
data = Psych.load_file(file_path)
|
|
19
|
+
new(data)
|
|
20
|
+
rescue Psych::SyntaxError => e
|
|
21
|
+
raise Error, "Invalid YAML syntax in #{file_path}: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.generate_template(file_path)
|
|
26
|
+
template = <<~YAML
|
|
27
|
+
# Watchcat Configuration File
|
|
28
|
+
|
|
29
|
+
watches:
|
|
30
|
+
- path: "./src"
|
|
31
|
+
recursive: true
|
|
32
|
+
debounce: 300
|
|
33
|
+
filters:
|
|
34
|
+
ignore_access: true
|
|
35
|
+
patterns:
|
|
36
|
+
- "*.js"
|
|
37
|
+
- "*.ts"
|
|
38
|
+
- "*.css"
|
|
39
|
+
actions:
|
|
40
|
+
- command: "echo 'File changed: {{file_path}}'"
|
|
41
|
+
|
|
42
|
+
- path: "./docs"
|
|
43
|
+
recursive: true
|
|
44
|
+
filters:
|
|
45
|
+
ignore_access: true
|
|
46
|
+
patterns:
|
|
47
|
+
- "*.md"
|
|
48
|
+
actions:
|
|
49
|
+
- command: "echo 'Documentation updated: {{file_name}}'"
|
|
50
|
+
YAML
|
|
51
|
+
|
|
52
|
+
if File.exist?(file_path)
|
|
53
|
+
raise Error, "File already exists: #{file_path}. Won't overwrite."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
File.write(file_path, template)
|
|
57
|
+
puts "Config template generated at #{file_path}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def parse_watches(watches_data)
|
|
63
|
+
watches_data.map do |watch_config|
|
|
64
|
+
{
|
|
65
|
+
path: watch_config["path"],
|
|
66
|
+
recursive: watch_config.fetch("recursive", true),
|
|
67
|
+
patterns: watch_config["patterns"] || [],
|
|
68
|
+
actions: watch_config["actions"] || [],
|
|
69
|
+
debounce: watch_config.fetch("debounce", -1),
|
|
70
|
+
filters: watch_config["filters"]&.transform_keys(&:to_sym) || {},
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Watchcat
|
|
2
|
+
module CLI
|
|
3
|
+
class Watcher
|
|
4
|
+
def initialize(config)
|
|
5
|
+
@config = config
|
|
6
|
+
@watchers = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def start
|
|
10
|
+
puts "Starting Watchcat file watcher..."
|
|
11
|
+
|
|
12
|
+
@config.watches.each do |watch_config|
|
|
13
|
+
start_watching_path(watch_config)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
puts "Watchcat is now watching for file changes. Press Ctrl+C to stop."
|
|
17
|
+
|
|
18
|
+
# Keep the main thread alive
|
|
19
|
+
begin
|
|
20
|
+
sleep
|
|
21
|
+
rescue Interrupt
|
|
22
|
+
puts "\nStopping Watchcat..."
|
|
23
|
+
stop
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stop
|
|
28
|
+
@watchers.each(&:stop)
|
|
29
|
+
@watchers.clear
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def start_watching_path(watch_config)
|
|
35
|
+
path = watch_config[:path]
|
|
36
|
+
|
|
37
|
+
unless File.exist?(path)
|
|
38
|
+
puts "Warning: Path does not exist: #{path}"
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
puts "Watching: #{path} (recursive: #{watch_config[:recursive]}, debounce: #{watch_config[:debounce]}ms)"
|
|
43
|
+
|
|
44
|
+
watcher = Watchcat.watch(
|
|
45
|
+
path,
|
|
46
|
+
recursive: watch_config[:recursive],
|
|
47
|
+
filters: watch_config[:filters],
|
|
48
|
+
debounce: watch_config[:debounce],
|
|
49
|
+
) do |event|
|
|
50
|
+
handle_file_event(event, watch_config)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@watchers << watcher
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_file_event(event, watch_config)
|
|
57
|
+
event.paths.each do |file_path|
|
|
58
|
+
next unless should_process_file?(file_path, watch_config[:patterns])
|
|
59
|
+
|
|
60
|
+
puts "File changed: #{file_path}"
|
|
61
|
+
execute_actions(file_path, event, watch_config[:actions])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def should_process_file?(file_path, patterns)
|
|
66
|
+
return true if patterns.empty?
|
|
67
|
+
|
|
68
|
+
patterns.any? do |pattern|
|
|
69
|
+
File.fnmatch?(pattern, File.basename(file_path)) ||
|
|
70
|
+
File.fnmatch?(pattern, file_path)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def execute_actions(file_path, event, actions)
|
|
75
|
+
executor = ActionExecutor.new(file_path, event)
|
|
76
|
+
|
|
77
|
+
actions.each do |action|
|
|
78
|
+
executor.execute(action)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/watchcat/cli.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require_relative "cli/watcher"
|
|
3
|
+
require_relative "cli/config"
|
|
4
|
+
require_relative "cli/action_executor"
|
|
5
|
+
|
|
6
|
+
module Watchcat
|
|
7
|
+
module CLI
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
class << self
|
|
10
|
+
def start(argv)
|
|
11
|
+
options = parse(argv)
|
|
12
|
+
|
|
13
|
+
if options[:init]
|
|
14
|
+
Config.generate_template(options[:init])
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
config = Config.load(options[:config])
|
|
19
|
+
watcher = Watcher.new(config)
|
|
20
|
+
watcher.start
|
|
21
|
+
rescue => e
|
|
22
|
+
raise Error, "Failed to start Watchcat: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse(argv)
|
|
26
|
+
options = { config: 'watchcat.yml' }
|
|
27
|
+
OptionParser.new do |opts|
|
|
28
|
+
opts.banner = "Usage: watchcat [options]"
|
|
29
|
+
|
|
30
|
+
opts.on("-C", "--config PATH", "Path to the config file. Default is 'watchcat.yml'.") do |v|
|
|
31
|
+
options[:config] = v
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
opts.on("--init PATH", "Generate a template config file at the specified path") do |v|
|
|
35
|
+
options[:init] = v
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
39
|
+
puts opts
|
|
40
|
+
exit
|
|
41
|
+
end
|
|
42
|
+
end.parse!(argv)
|
|
43
|
+
|
|
44
|
+
if !options[:init] && options[:config].nil?
|
|
45
|
+
raise OptionParser::MissingArgument.new("-C")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
options
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|