watchcat 0.3.0 → 0.6.1
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 +33 -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 +185 -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 +70 -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) => {
|
|
@@ -136,102 +192,65 @@ impl WatchcatWatcher {
|
|
|
136
192
|
continue;
|
|
137
193
|
}
|
|
138
194
|
|
|
195
|
+
let macos_ambiguous_metadata_touch = cfg!(target_os = "macos")
|
|
196
|
+
&& matches!(
|
|
197
|
+
event.kind,
|
|
198
|
+
notify::event::EventKind::Modify(
|
|
199
|
+
notify::event::ModifyKind::Metadata(
|
|
200
|
+
notify::event::MetadataKind::Any
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
if ignore_access
|
|
205
|
+
&& (matches!(
|
|
206
|
+
event.kind,
|
|
207
|
+
notify::event::EventKind::Access(_)
|
|
208
|
+
) || macos_ambiguous_metadata_touch)
|
|
209
|
+
{
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if ignore_create && matches!(event.kind, notify::event::EventKind::Create(_)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if ignore_modify && matches!(event.kind, notify::event::EventKind::Modify(_)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
139
219
|
// Yield to Ruby with GVL
|
|
140
|
-
let result = call_with_gvl(|
|
|
141
|
-
yield_value::<(Vec<String>, Vec<String>, String), Value>(
|
|
220
|
+
let result: Result<Value, String> = call_with_gvl(|ruby| {
|
|
221
|
+
ruby.yield_value::<(Vec<String>, Vec<String>, String), Value>(
|
|
142
222
|
(WatchatEvent::convert_kind(&event.kind), paths, format!("{:?}", event.kind))
|
|
143
|
-
)
|
|
223
|
+
).map_err(|e| e.to_string())
|
|
144
224
|
});
|
|
145
225
|
|
|
146
|
-
if
|
|
147
|
-
break Err(
|
|
226
|
+
if let Err(msg) = result {
|
|
227
|
+
break Err(WatchFailure::Runtime(format!("Error yielding to Ruby block: {msg}")));
|
|
148
228
|
}
|
|
149
229
|
}
|
|
150
230
|
Err(e) => {
|
|
151
|
-
break Err(
|
|
231
|
+
break Err(WatchFailure::Runtime(e.to_string()));
|
|
152
232
|
}
|
|
153
233
|
}
|
|
154
234
|
}
|
|
155
235
|
Err(e) => {
|
|
156
|
-
break Err(
|
|
236
|
+
break Err(WatchFailure::Runtime(e.to_string()));
|
|
157
237
|
}
|
|
158
238
|
}
|
|
159
239
|
}
|
|
160
240
|
}
|
|
161
241
|
}
|
|
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
|
-
}
|
|
183
|
-
|
|
184
|
-
loop {
|
|
185
|
-
if terminated.load(Ordering::SeqCst) {
|
|
186
|
-
break Ok(true);
|
|
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
|
-
});
|
|
242
|
+
});
|
|
209
243
|
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
}
|
|
244
|
+
result.map_err(|err| match err {
|
|
245
|
+
WatchFailure::Arg(msg) => Error::new(ruby.exception_arg_error(), msg),
|
|
246
|
+
WatchFailure::Runtime(msg) => Error::new(ruby.exception_runtime_error(), msg),
|
|
227
247
|
})
|
|
228
248
|
}
|
|
229
249
|
|
|
230
250
|
#[allow(clippy::let_unit_value, clippy::type_complexity)]
|
|
231
|
-
fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool,
|
|
251
|
+
fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool, bool, bool, bool), Error> {
|
|
232
252
|
type KwArgBool = Option<Option<bool>>;
|
|
233
253
|
type KwArgU64 = Option<Option<u64>>;
|
|
234
|
-
type KwArgi64 = Option<Option<i64>>;
|
|
235
254
|
|
|
236
255
|
let args = scan_args(args)?;
|
|
237
256
|
let (paths,): (Vec<String>,) = args.required;
|
|
@@ -243,9 +262,9 @@ impl WatchcatWatcher {
|
|
|
243
262
|
let kwargs = get_kwargs(
|
|
244
263
|
args.keywords,
|
|
245
264
|
&[],
|
|
246
|
-
&["recursive", "force_polling", "poll_interval", "ignore_remove", "
|
|
265
|
+
&["recursive", "force_polling", "poll_interval", "ignore_remove", "ignore_access", "ignore_create", "ignore_modify"],
|
|
247
266
|
)?;
|
|
248
|
-
let (recursive, force_polling, poll_interval, ignore_remove,
|
|
267
|
+
let (recursive, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify): (KwArgBool, KwArgBool, KwArgU64, KwArgBool, KwArgBool, KwArgBool, KwArgBool) =
|
|
249
268
|
kwargs.optional;
|
|
250
269
|
let _: () = kwargs.required;
|
|
251
270
|
let _: () = kwargs.splat;
|
|
@@ -256,19 +275,76 @@ impl WatchcatWatcher {
|
|
|
256
275
|
force_polling.flatten().unwrap_or(false),
|
|
257
276
|
poll_interval.flatten().unwrap_or(200),
|
|
258
277
|
ignore_remove.flatten().unwrap_or(false),
|
|
259
|
-
|
|
278
|
+
ignore_access.flatten().unwrap_or(false),
|
|
279
|
+
ignore_create.flatten().unwrap_or(false),
|
|
280
|
+
ignore_modify.flatten().unwrap_or(false),
|
|
260
281
|
))
|
|
261
282
|
}
|
|
283
|
+
|
|
284
|
+
fn add(&self, args: &[Value]) -> Result<bool, Error> {
|
|
285
|
+
let (paths, recursive) = Self::parse_add_args(args)?;
|
|
286
|
+
// `send` only fails when every receiver is disconnected, but `self`
|
|
287
|
+
// holds `cmd_rx` for the whole lifetime of this object, so it cannot
|
|
288
|
+
// fail here. If the watch loop has already stopped, the command is
|
|
289
|
+
// simply buffered and never applied (a harmless no-op).
|
|
290
|
+
let _ = self.cmd_tx.send(Command::Watch(paths, recursive));
|
|
291
|
+
Ok(true)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn unwatch(&self, args: &[Value]) -> Result<bool, Error> {
|
|
295
|
+
let paths = Self::parse_unwatch_args(args)?;
|
|
296
|
+
// See `add`: `send` cannot fail while `self` retains `cmd_rx`.
|
|
297
|
+
let _ = self.cmd_tx.send(Command::Unwatch(paths));
|
|
298
|
+
Ok(true)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[allow(clippy::let_unit_value)]
|
|
302
|
+
fn parse_add_args(args: &[Value]) -> Result<(Vec<String>, bool), Error> {
|
|
303
|
+
type KwArgBool = Option<Option<bool>>;
|
|
304
|
+
|
|
305
|
+
let args = scan_args(args)?;
|
|
306
|
+
let (paths,): (Vec<String>,) = args.required;
|
|
307
|
+
let _: () = args.optional;
|
|
308
|
+
let _: () = args.splat;
|
|
309
|
+
let _: () = args.trailing;
|
|
310
|
+
let _: () = args.block;
|
|
311
|
+
|
|
312
|
+
let kwargs = get_kwargs(args.keywords, &[], &["recursive"])?;
|
|
313
|
+
let (recursive,): (KwArgBool,) = kwargs.optional;
|
|
314
|
+
let _: () = kwargs.required;
|
|
315
|
+
let _: () = kwargs.splat;
|
|
316
|
+
|
|
317
|
+
Ok((paths, recursive.flatten().unwrap_or(true)))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[allow(clippy::let_unit_value)]
|
|
321
|
+
fn parse_unwatch_args(args: &[Value]) -> Result<Vec<String>, Error> {
|
|
322
|
+
let args = scan_args(args)?;
|
|
323
|
+
let (paths,): (Vec<String>,) = args.required;
|
|
324
|
+
let _: () = args.optional;
|
|
325
|
+
let _: () = args.splat;
|
|
326
|
+
let _: () = args.trailing;
|
|
327
|
+
let _: () = args.block;
|
|
328
|
+
|
|
329
|
+
let kwargs = get_kwargs::<&str, (), (), ()>(args.keywords, &[], &[])?;
|
|
330
|
+
let _: () = kwargs.optional;
|
|
331
|
+
let _: () = kwargs.required;
|
|
332
|
+
let _: () = kwargs.splat;
|
|
333
|
+
|
|
334
|
+
Ok(paths)
|
|
335
|
+
}
|
|
262
336
|
}
|
|
263
337
|
|
|
264
338
|
#[magnus::init]
|
|
265
|
-
fn init() -> Result<(), Error> {
|
|
266
|
-
let module = define_module("Watchcat")?;
|
|
339
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
340
|
+
let module = ruby.define_module("Watchcat")?;
|
|
267
341
|
|
|
268
|
-
let watcher_class = module.define_class("Watcher",
|
|
342
|
+
let watcher_class = module.define_class("Watcher", ruby.class_object())?;
|
|
269
343
|
watcher_class.define_singleton_method("new", function!(WatchcatWatcher::new, 0))?;
|
|
270
344
|
watcher_class.define_method("watch", method!(WatchcatWatcher::watch, -1))?;
|
|
271
345
|
watcher_class.define_method("close", method!(WatchcatWatcher::close, 0))?;
|
|
346
|
+
watcher_class.define_method("add", method!(WatchcatWatcher::add, -1))?;
|
|
347
|
+
watcher_class.define_method("unwatch", method!(WatchcatWatcher::unwatch, -1))?;
|
|
272
348
|
|
|
273
349
|
Ok(())
|
|
274
350
|
}
|
|
@@ -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
|