watchcat 0.2.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 +31 -0
- data/Cargo.lock +93 -189
- data/Cargo.toml +1 -1
- data/Gemfile +1 -1
- data/README.md +190 -4
- data/cli_example.yml +14 -0
- data/exe/watchcat +14 -0
- data/ext/watchcat/Cargo.toml +5 -6
- data/ext/watchcat/src/gvl_helpers.rs +65 -0
- data/ext/watchcat/src/lib.rs +233 -126
- 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 +84 -34
- data/lib/watchcat/kind.rb +8 -0
- data/lib/watchcat/version.rb +1 -1
- data/lib/watchcat.rb +15 -5
- metadata +36 -15
- data/Gemfile.lock +0 -70
- data/lib/watchcat/client.rb +0 -26
- data/lib/watchcat/server.rb +0 -14
- data/watchcat.gemspec +0 -38
data/ext/watchcat/src/lib.rs
CHANGED
|
@@ -1,191 +1,241 @@
|
|
|
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
|
|
11
|
-
use std::{path::Path, time::Duration};
|
|
8
|
+
use std::{path::Path, time::Duration, sync::{Arc, atomic::{AtomicBool, Ordering}}};
|
|
12
9
|
|
|
13
10
|
mod event;
|
|
11
|
+
mod gvl_helpers;
|
|
14
12
|
use crate::event::WatchatEvent;
|
|
13
|
+
use crate::gvl_helpers::{call_with_gvl, call_without_gvl};
|
|
15
14
|
|
|
16
15
|
#[magnus::wrap(class = "Watchcat::Watcher")]
|
|
17
16
|
struct WatchcatWatcher {
|
|
18
17
|
tx: crossbeam_channel::Sender<bool>,
|
|
19
18
|
rx: crossbeam_channel::Receiver<bool>,
|
|
19
|
+
terminated: Arc<AtomicBool>,
|
|
20
|
+
cmd_tx: crossbeam_channel::Sender<Command>,
|
|
21
|
+
cmd_rx: crossbeam_channel::Receiver<Command>,
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
#[derive(Debug)]
|
|
23
25
|
enum WatcherEnum {
|
|
24
|
-
#[allow(dead_code)]
|
|
25
26
|
Poll(PollWatcher),
|
|
26
|
-
#[allow(dead_code)]
|
|
27
27
|
Recommended(RecommendedWatcher),
|
|
28
28
|
}
|
|
29
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
|
+
|
|
30
58
|
impl WatchcatWatcher {
|
|
31
59
|
fn new() -> Self {
|
|
32
60
|
let (tx_executor, rx_executor) = unbounded::<bool>();
|
|
61
|
+
let (cmd_tx, cmd_rx) = unbounded::<Command>();
|
|
33
62
|
Self {
|
|
34
63
|
tx: tx_executor,
|
|
35
64
|
rx: rx_executor,
|
|
65
|
+
terminated: Arc::new(AtomicBool::new(false)),
|
|
66
|
+
cmd_tx,
|
|
67
|
+
cmd_rx,
|
|
36
68
|
}
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
fn close(&self) {
|
|
40
|
-
self.
|
|
72
|
+
self.terminated.store(true, Ordering::SeqCst);
|
|
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);
|
|
41
79
|
}
|
|
42
80
|
|
|
43
81
|
fn watch(&self, args: &[Value]) -> Result<bool, Error> {
|
|
44
|
-
|
|
45
|
-
|
|
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"));
|
|
46
86
|
}
|
|
47
87
|
|
|
48
|
-
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)?;
|
|
49
89
|
let mode = if recursive {
|
|
50
90
|
RecursiveMode::Recursive
|
|
51
91
|
} else {
|
|
52
92
|
RecursiveMode::NonRecursive
|
|
53
93
|
};
|
|
54
94
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
let terminated = self.terminated.clone();
|
|
96
|
+
let rx_clone = self.rx.clone();
|
|
97
|
+
let cmd_rx = self.cmd_rx.clone();
|
|
98
|
+
|
|
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
|
+
)
|
|
60
102
|
}
|
|
61
103
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
#[allow(clippy::too_many_arguments)]
|
|
105
|
+
fn watch_threaded(
|
|
106
|
+
pathnames: Vec<String>,
|
|
107
|
+
mode: RecursiveMode,
|
|
108
|
+
force_polling: bool,
|
|
109
|
+
poll_interval: u64,
|
|
110
|
+
ignore_remove: bool,
|
|
111
|
+
ignore_access: bool,
|
|
112
|
+
ignore_create: bool,
|
|
113
|
+
ignore_modify: bool,
|
|
114
|
+
terminated: Arc<AtomicBool>,
|
|
115
|
+
rx: crossbeam_channel::Receiver<bool>,
|
|
116
|
+
cmd_rx: crossbeam_channel::Receiver<Command>,
|
|
117
|
+
ruby: &Ruby
|
|
118
|
+
) -> Result<bool, Error> {
|
|
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 || {
|
|
125
|
+
let (tx, watcher_rx) = unbounded();
|
|
126
|
+
// This variable is needed to keep `watcher` active.
|
|
127
|
+
let mut _watcher = match force_polling {
|
|
128
|
+
true => {
|
|
129
|
+
let delay = Duration::from_millis(poll_interval);
|
|
130
|
+
let config = notify::Config::default().with_poll_interval(delay);
|
|
131
|
+
let mut watcher = PollWatcher::new(tx, config)
|
|
132
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
133
|
+
for pathname in &pathnames {
|
|
134
|
+
let path = Path::new(pathname);
|
|
135
|
+
watcher
|
|
136
|
+
.watch(path, mode)
|
|
137
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
138
|
+
}
|
|
139
|
+
WatcherEnum::Poll(watcher)
|
|
76
140
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
141
|
+
false => {
|
|
142
|
+
let mut watcher = RecommendedWatcher::new(tx, Config::default())
|
|
143
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
144
|
+
for pathname in &pathnames {
|
|
145
|
+
let path = Path::new(pathname);
|
|
146
|
+
watcher
|
|
147
|
+
.watch(path, mode)
|
|
148
|
+
.map_err(|e| WatchFailure::Arg(e.to_string()))?;
|
|
149
|
+
}
|
|
150
|
+
WatcherEnum::Recommended(watcher)
|
|
87
151
|
}
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
};
|
|
152
|
+
};
|
|
91
153
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return Ok(true)
|
|
154
|
+
loop {
|
|
155
|
+
if terminated.load(Ordering::SeqCst) {
|
|
156
|
+
break Ok(true);
|
|
96
157
|
}
|
|
97
|
-
recv(rx) -> res => {
|
|
98
|
-
match res {
|
|
99
|
-
Ok(event) => {
|
|
100
|
-
match event {
|
|
101
|
-
Ok(event) => {
|
|
102
|
-
let paths = event
|
|
103
|
-
.paths
|
|
104
|
-
.iter()
|
|
105
|
-
.map(|p| p.to_string_lossy().into_owned())
|
|
106
|
-
.collect::<Vec<_>>();
|
|
107
|
-
|
|
108
|
-
if ignore_remove && matches!(event.kind, notify::event::EventKind::Remove(_)) {
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
158
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
159
|
+
select! {
|
|
160
|
+
recv(rx) -> _res => {
|
|
161
|
+
break Ok(true);
|
|
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
|
+
}
|
|
116
171
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
172
|
+
Command::Unwatch(paths) => {
|
|
173
|
+
for p in &paths {
|
|
174
|
+
let _ = watcher_unwatch(&mut _watcher, Path::new(p));
|
|
175
|
+
}
|
|
121
176
|
}
|
|
122
177
|
}
|
|
123
178
|
}
|
|
124
|
-
Err(e) => {
|
|
125
|
-
return Err(
|
|
126
|
-
Error::new(magnus::exception::runtime_error(), e.to_string())
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
179
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
180
|
+
recv(watcher_rx) -> res => {
|
|
181
|
+
match res {
|
|
182
|
+
Ok(event) => {
|
|
183
|
+
match event {
|
|
184
|
+
Ok(event) => {
|
|
185
|
+
let paths = event
|
|
186
|
+
.paths
|
|
187
|
+
.iter()
|
|
188
|
+
.map(|p| p.to_string_lossy().into_owned())
|
|
189
|
+
.collect::<Vec<_>>();
|
|
134
190
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
loop {
|
|
147
|
-
select! {
|
|
148
|
-
recv(self.rx) -> _res => {
|
|
149
|
-
return Ok(true)
|
|
150
|
-
}
|
|
151
|
-
recv(rx) -> res => {
|
|
152
|
-
match res {
|
|
153
|
-
Ok(events) => {
|
|
154
|
-
match events {
|
|
155
|
-
Ok(events) => {
|
|
156
|
-
events.iter().for_each(|event| {
|
|
157
|
-
if ignore_remove && !Path::new(&event.path).exists() {
|
|
158
|
-
return;
|
|
191
|
+
if ignore_remove && matches!(event.kind, notify::event::EventKind::Remove(_)) {
|
|
192
|
+
continue;
|
|
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;
|
|
159
202
|
}
|
|
160
203
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
204
|
+
// Yield to Ruby with GVL
|
|
205
|
+
let result: Result<Value, String> = call_with_gvl(|ruby| {
|
|
206
|
+
ruby.yield_value::<(Vec<String>, Vec<String>, String), Value>(
|
|
207
|
+
(WatchatEvent::convert_kind(&event.kind), paths, format!("{:?}", event.kind))
|
|
208
|
+
).map_err(|e| e.to_string())
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if let Err(msg) = result {
|
|
212
|
+
break Err(WatchFailure::Runtime(format!("Error yielding to Ruby block: {msg}")));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
Err(e) => {
|
|
216
|
+
break Err(WatchFailure::Runtime(e.to_string()));
|
|
217
|
+
}
|
|
170
218
|
}
|
|
171
219
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
Error::new(magnus::exception::runtime_error(), e.to_string())
|
|
176
|
-
)
|
|
220
|
+
Err(e) => {
|
|
221
|
+
break Err(WatchFailure::Runtime(e.to_string()));
|
|
222
|
+
}
|
|
177
223
|
}
|
|
178
224
|
}
|
|
179
225
|
}
|
|
180
226
|
}
|
|
181
|
-
}
|
|
227
|
+
});
|
|
228
|
+
|
|
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),
|
|
232
|
+
})
|
|
182
233
|
}
|
|
183
234
|
|
|
184
235
|
#[allow(clippy::let_unit_value, clippy::type_complexity)]
|
|
185
|
-
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> {
|
|
186
237
|
type KwArgBool = Option<Option<bool>>;
|
|
187
238
|
type KwArgU64 = Option<Option<u64>>;
|
|
188
|
-
type KwArgi64 = Option<Option<i64>>;
|
|
189
239
|
|
|
190
240
|
let args = scan_args(args)?;
|
|
191
241
|
let (paths,): (Vec<String>,) = args.required;
|
|
@@ -197,9 +247,9 @@ impl WatchcatWatcher {
|
|
|
197
247
|
let kwargs = get_kwargs(
|
|
198
248
|
args.keywords,
|
|
199
249
|
&[],
|
|
200
|
-
&["recursive", "force_polling", "poll_interval", "ignore_remove", "
|
|
250
|
+
&["recursive", "force_polling", "poll_interval", "ignore_remove", "ignore_access", "ignore_create", "ignore_modify"],
|
|
201
251
|
)?;
|
|
202
|
-
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) =
|
|
203
253
|
kwargs.optional;
|
|
204
254
|
let _: () = kwargs.required;
|
|
205
255
|
let _: () = kwargs.splat;
|
|
@@ -210,19 +260,76 @@ impl WatchcatWatcher {
|
|
|
210
260
|
force_polling.flatten().unwrap_or(false),
|
|
211
261
|
poll_interval.flatten().unwrap_or(200),
|
|
212
262
|
ignore_remove.flatten().unwrap_or(false),
|
|
213
|
-
|
|
263
|
+
ignore_access.flatten().unwrap_or(false),
|
|
264
|
+
ignore_create.flatten().unwrap_or(false),
|
|
265
|
+
ignore_modify.flatten().unwrap_or(false),
|
|
214
266
|
))
|
|
215
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
|
+
}
|
|
216
321
|
}
|
|
217
322
|
|
|
218
323
|
#[magnus::init]
|
|
219
|
-
fn init() -> Result<(), Error> {
|
|
220
|
-
let module = define_module("Watchcat")?;
|
|
324
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
325
|
+
let module = ruby.define_module("Watchcat")?;
|
|
221
326
|
|
|
222
|
-
let watcher_class = module.define_class("Watcher",
|
|
327
|
+
let watcher_class = module.define_class("Watcher", ruby.class_object())?;
|
|
223
328
|
watcher_class.define_singleton_method("new", function!(WatchcatWatcher::new, 0))?;
|
|
224
329
|
watcher_class.define_method("watch", method!(WatchcatWatcher::watch, -1))?;
|
|
225
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))?;
|
|
226
333
|
|
|
227
334
|
Ok(())
|
|
228
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
|