cruise 0.1.0 → 0.2.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.
@@ -0,0 +1,124 @@
1
+ //! C ABI for Cruise.
2
+ //!
3
+ //! # Safety
4
+ //!
5
+ //! Pointer arguments must be valid (or null where documented). String arrays
6
+ //! are `(pointer, count)` pairs of NUL-terminated UTF-8 C strings. Any C string
7
+ //! returned through an out-parameter is heap-allocated by Rust and must be
8
+ //! released with [`cruise_string_free`]; the watcher handle with
9
+ //! [`cruise_watcher_free`]. The read fd returned by [`cruise_watcher_new`] is
10
+ //! transferred to the caller, who is responsible for closing it.
11
+
12
+ #![allow(clippy::missing_safety_doc)]
13
+
14
+ use std::ffi::{CStr, CString};
15
+ use std::os::raw::{c_char, c_int};
16
+ use std::ptr;
17
+
18
+ use crate::Watcher;
19
+
20
+ /// A single filesystem event handed to the C side. Both strings are owned by
21
+ /// the caller once populated and must be freed with [`cruise_string_free`].
22
+ #[repr(C)]
23
+ pub struct CruiseEvent {
24
+ pub path: *mut c_char,
25
+ pub kind: *mut c_char,
26
+ }
27
+
28
+ unsafe fn c_string_array(pointer: *const *const c_char, count: usize) -> Vec<String> {
29
+ if pointer.is_null() || count == 0 {
30
+ return Vec::new();
31
+ }
32
+
33
+ std::slice::from_raw_parts(pointer, count)
34
+ .iter()
35
+ .filter_map(|&entry| {
36
+ if entry.is_null() {
37
+ None
38
+ } else {
39
+ CStr::from_ptr(entry).to_str().ok().map(|value| value.to_string())
40
+ }
41
+ })
42
+ .collect()
43
+ }
44
+
45
+ /// Create a watcher over `paths`, filtered by `globs` and `only_kinds`.
46
+ ///
47
+ /// On success returns a heap-allocated handle and writes the read end of the
48
+ /// event pipe to `out_read_fd` (ownership transfers to the caller). On error
49
+ /// returns null and, when `out_error` is non-null, writes a message to be freed
50
+ /// with [`cruise_string_free`].
51
+ #[no_mangle]
52
+ pub unsafe extern "C" fn cruise_watcher_new(
53
+ paths: *const *const c_char,
54
+ path_count: usize,
55
+ debounce: f64,
56
+ globs: *const *const c_char,
57
+ glob_count: usize,
58
+ only_kinds: *const *const c_char,
59
+ only_count: usize,
60
+ out_read_fd: *mut c_int,
61
+ out_error: *mut *mut c_char,
62
+ ) -> *mut Watcher {
63
+ let paths = c_string_array(paths, path_count);
64
+ let globs = c_string_array(globs, glob_count);
65
+ let only = c_string_array(only_kinds, only_count);
66
+
67
+ match Watcher::new(&paths, debounce, &globs, only) {
68
+ Ok((watcher, read_fd)) => {
69
+ if !out_read_fd.is_null() {
70
+ *out_read_fd = read_fd;
71
+ }
72
+
73
+ Box::into_raw(Box::new(watcher))
74
+ }
75
+ Err(message) => {
76
+ if !out_error.is_null() {
77
+ *out_error = CString::new(message).unwrap_or_default().into_raw();
78
+ }
79
+
80
+ ptr::null_mut()
81
+ }
82
+ }
83
+ }
84
+
85
+ /// Non-blocking: pop the next queued event into `out_event` and return true, or
86
+ /// return false if the queue is empty. Never touches Ruby and never blocks — the
87
+ /// C wrapper waits on the pipe fd instead.
88
+ #[no_mangle]
89
+ pub unsafe extern "C" fn cruise_watcher_poll(watcher: *mut Watcher, out_event: *mut CruiseEvent) -> bool {
90
+ if watcher.is_null() {
91
+ return false;
92
+ }
93
+
94
+ let watcher = &*watcher;
95
+
96
+ match watcher.poll() {
97
+ Some((path, kind)) => {
98
+ if !out_event.is_null() {
99
+ (*out_event).path = CString::new(path).unwrap_or_default().into_raw();
100
+ (*out_event).kind = CString::new(kind).unwrap_or_default().into_raw();
101
+ }
102
+
103
+ true
104
+ }
105
+ None => false,
106
+ }
107
+ }
108
+
109
+ /// Free a watcher handle. Stops watching, joins the background thread, and
110
+ /// closes the write end of the pipe (the reader then observes EOF).
111
+ #[no_mangle]
112
+ pub unsafe extern "C" fn cruise_watcher_free(watcher: *mut Watcher) {
113
+ if !watcher.is_null() {
114
+ drop(Box::from_raw(watcher));
115
+ }
116
+ }
117
+
118
+ /// Free a string previously returned by this library.
119
+ #[no_mangle]
120
+ pub unsafe extern "C" fn cruise_string_free(s: *mut c_char) {
121
+ if !s.is_null() {
122
+ drop(CString::from_raw(s));
123
+ }
124
+ }
@@ -1,213 +1,11 @@
1
- use std::path::PathBuf;
1
+ //! Cruise - a fast, OS-native file watcher.
2
+ //!
3
+ //! This crate contains the platform-agnostic watching logic ([`watcher`]) and a
4
+ //! C ABI ([`ffi`]) that a thin Ruby C extension links against. It deliberately
5
+ //! knows nothing about Ruby: it hands filtered `(path, kind)` pairs across the
6
+ //! FFI boundary and lets the C wrapper turn them into Ruby objects.
2
7
 
3
- use globset::{Glob, GlobSet, GlobSetBuilder};
4
- use magnus::{method, prelude::*, value::Lazy, Error, IntoValue, RClass, RModule, Ruby, Value};
8
+ mod ffi;
9
+ mod watcher;
5
10
 
6
- use notify::event::{CreateKind, ModifyKind, RemoveKind};
7
- use notify::{EventKind, RecursiveMode};
8
- use notify_debouncer_full::{new_debouncer, DebounceEventResult};
9
-
10
- static MODULE: Lazy<RModule> = Lazy::new(|ruby| ruby.define_module("Cruise").expect("failed to define Cruise module"));
11
-
12
- static EVENT_CLASS: Lazy<RClass> = Lazy::new(|ruby| {
13
- let module = ruby.get_inner(&MODULE);
14
- let class = module.define_class("Event", ruby.class_object()).expect("failed to define Cruise::Event class");
15
-
16
- class
17
- .define_method("path", method!(CruiseEvent::path, 0))
18
- .expect("failed to define path method");
19
-
20
- class
21
- .define_method("kind", method!(CruiseEvent::kind, 0))
22
- .expect("failed to define kind method");
23
-
24
- class
25
- .define_method("inspect", method!(CruiseEvent::inspect, 0))
26
- .expect("failed to define inspect method");
27
-
28
- class
29
- .define_method("to_s", method!(CruiseEvent::to_s, 0))
30
- .expect("failed to define to_s method");
31
-
32
- class
33
- });
34
-
35
- #[magnus::wrap(class = "Cruise::Event", free_immediately)]
36
- struct CruiseEvent {
37
- path: String,
38
- kind: String,
39
- }
40
-
41
- impl CruiseEvent {
42
- fn path(&self) -> &str {
43
- &self.path
44
- }
45
-
46
- fn kind(&self) -> &str {
47
- &self.kind
48
- }
49
-
50
- fn inspect(&self) -> String {
51
- format!("#<Cruise::Event kind={:?} path={:?}>", self.kind, self.path)
52
- }
53
-
54
- fn to_s(&self) -> String {
55
- format!("{}: {}", self.kind, self.path)
56
- }
57
- }
58
-
59
- fn event_kind_to_string(kind: &EventKind) -> &'static str {
60
- match kind {
61
- EventKind::Create(CreateKind::File) => "created",
62
- EventKind::Create(CreateKind::Folder) => "created",
63
- EventKind::Create(_) => "created",
64
- EventKind::Modify(ModifyKind::Data(_)) => "modified",
65
- EventKind::Modify(ModifyKind::Name(_)) => "renamed",
66
- EventKind::Modify(_) => "modified",
67
- EventKind::Remove(RemoveKind::File) => "removed",
68
- EventKind::Remove(RemoveKind::Folder) => "removed",
69
- EventKind::Remove(_) => "removed",
70
- EventKind::Access(_) => "accessed",
71
- EventKind::Any | EventKind::Other => "changed",
72
- }
73
- }
74
-
75
- fn build_glob_set(patterns: Vec<String>) -> Result<Option<GlobSet>, Error> {
76
- if patterns.is_empty() {
77
- return Ok(None);
78
- }
79
-
80
- let mut builder = GlobSetBuilder::new();
81
-
82
- for pattern in &patterns {
83
- let glob = Glob::new(pattern).map_err(|error| {
84
- Error::new(
85
- magnus::Ruby::get().unwrap().exception_arg_error(),
86
- format!("Invalid glob pattern '{}': {}", pattern, error),
87
- )
88
- })?;
89
- builder.add(glob);
90
- }
91
-
92
- let set = builder.build().map_err(|error| {
93
- Error::new(
94
- magnus::Ruby::get().unwrap().exception_runtime_error(),
95
- format!("Failed to build glob set: {}", error),
96
- )
97
- })?;
98
-
99
- Ok(Some(set))
100
- }
101
-
102
- enum WaitResult {
103
- Event(notify::Event),
104
- Timeout,
105
- Disconnected,
106
- }
107
-
108
- unsafe extern "C" fn wait_for_event(data: *mut std::ffi::c_void) -> *mut std::ffi::c_void {
109
- let receiver = unsafe { &*(data as *const std::sync::mpsc::Receiver<notify::Event>) };
110
-
111
- let result = match receiver.recv_timeout(std::time::Duration::from_millis(200)) {
112
- Ok(event) => Box::new(WaitResult::Event(event)),
113
- Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Box::new(WaitResult::Timeout),
114
- Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Box::new(WaitResult::Disconnected),
115
- };
116
-
117
- Box::into_raw(result) as *mut std::ffi::c_void
118
- }
119
-
120
- unsafe extern "C" fn unblock_wait(_data: *mut std::ffi::c_void) {}
121
-
122
- fn watch(
123
- ruby: &Ruby,
124
- paths: Vec<String>,
125
- callback: magnus::block::Proc,
126
- debounce: f64,
127
- glob_patterns: Vec<String>,
128
- only_kinds: Vec<String>,
129
- ) -> Result<magnus::Value, Error> {
130
- let debounce_duration = std::time::Duration::from_secs_f64(debounce);
131
- let glob_set = build_glob_set(glob_patterns)?;
132
- let filter_kinds = if only_kinds.is_empty() { None } else { Some(only_kinds) };
133
-
134
- let (sender, receiver) = std::sync::mpsc::channel::<notify::Event>();
135
-
136
- let mut debouncer = new_debouncer(debounce_duration, None, move |result: DebounceEventResult| {
137
- if let Ok(events) = result {
138
- for debounced_event in events {
139
- let _ = sender.send(debounced_event.event);
140
- }
141
- }
142
- })
143
- .map_err(|error| Error::new(ruby.exception_runtime_error(), format!("Failed to create watcher: {error}")))?;
144
-
145
- for path in &paths {
146
- let watch_path = PathBuf::from(path);
147
-
148
- if !watch_path.exists() {
149
- return Err(Error::new(ruby.exception_arg_error(), format!("Path does not exist: {}", watch_path.display())));
150
- }
151
-
152
- debouncer
153
- .watch(&watch_path, RecursiveMode::Recursive)
154
- .map_err(|error| Error::new(ruby.exception_runtime_error(), format!("Failed to watch path: {error}")))?;
155
- }
156
-
157
- loop {
158
- let result = unsafe {
159
- rb_sys::rb_thread_call_without_gvl(
160
- Some(wait_for_event),
161
- &receiver as *const _ as *mut std::ffi::c_void,
162
- Some(unblock_wait),
163
- std::ptr::null_mut(),
164
- )
165
- };
166
-
167
- let wait_result = unsafe { *Box::from_raw(result as *mut WaitResult) };
168
-
169
- match &wait_result {
170
- WaitResult::Event(event) => {
171
- let kind_string = event_kind_to_string(&event.kind);
172
-
173
- if let Some(ref allowed) = filter_kinds {
174
- if !allowed.iter().any(|kind| kind == kind_string) {
175
- continue;
176
- }
177
- }
178
-
179
- for path in &event.paths {
180
- if let Some(ref globs) = glob_set {
181
- if !globs.is_match(path) {
182
- continue;
183
- }
184
- }
185
-
186
- let cruise_event = CruiseEvent {
187
- path: path.to_string_lossy().to_string(),
188
- kind: kind_string.to_string(),
189
- };
190
-
191
- let value: Value = cruise_event.into_value_with(ruby);
192
- callback.call::<_, Value>((value,))?;
193
- }
194
- }
195
- WaitResult::Timeout => {
196
- continue;
197
- }
198
- WaitResult::Disconnected => {
199
- return Err(Error::new(ruby.exception_runtime_error(), "Watcher channel disconnected unexpectedly"));
200
- }
201
- }
202
- }
203
- }
204
-
205
- #[magnus::init]
206
- fn init(ruby: &Ruby) -> Result<(), Error> {
207
- let module = ruby.get_inner(&MODULE);
208
- let _ = ruby.get_inner(&EVENT_CLASS);
209
-
210
- module.define_module_function("_watch", magnus::function!(watch, 5))?;
211
-
212
- Ok(())
213
- }
11
+ pub use watcher::Watcher;
@@ -0,0 +1,196 @@
1
+ //! The platform-agnostic file watcher.
2
+ //!
3
+ //! Knows nothing about Ruby or the C ABI. It watches paths, applies the
4
+ //! configured kind/glob filters on the debouncer's background thread, and
5
+ //! pushes matching `(path, kind)` pairs onto a shared queue. A self-pipe is
6
+ //! written to on every batch so a consumer can wait on a file descriptor
7
+ //! (`IO#wait_readable`) instead of blocking a thread — which is what makes the
8
+ //! watcher cooperate with Ruby's fiber scheduler.
9
+
10
+ use std::any::Any;
11
+ use std::collections::VecDeque;
12
+ use std::os::fd::RawFd;
13
+ use std::os::raw::{c_int, c_void};
14
+ use std::path::PathBuf;
15
+ use std::sync::{Arc, Mutex};
16
+ use std::time::Duration;
17
+
18
+ use globset::{Glob, GlobSet, GlobSetBuilder};
19
+ use notify::event::{CreateKind, ModifyKind, RemoveKind};
20
+ use notify::{EventKind, RecursiveMode};
21
+ use notify_debouncer_full::{new_debouncer, DebounceEventResult};
22
+
23
+ struct Shared {
24
+ pending: Mutex<VecDeque<(String, String)>>,
25
+ glob_set: Option<GlobSet>,
26
+ only_kinds: Vec<String>,
27
+ write_fd: RawFd,
28
+ }
29
+
30
+ impl Shared {
31
+ fn enqueue(&self, event: notify::Event) -> bool {
32
+ let kind = event_kind_to_string(&event.kind);
33
+
34
+ if !self.only_kinds.is_empty() && !self.only_kinds.iter().any(|allowed| allowed == kind) {
35
+ return false;
36
+ }
37
+
38
+ let mut queue = self.pending.lock().unwrap();
39
+ let mut added = false;
40
+
41
+ for path in event.paths {
42
+ if let Some(ref globs) = self.glob_set {
43
+ if !globs.is_match(&path) {
44
+ continue;
45
+ }
46
+ }
47
+
48
+ queue.push_back((path.to_string_lossy().into_owned(), kind.to_string()));
49
+ added = true;
50
+ }
51
+
52
+ added
53
+ }
54
+
55
+ fn wake(&self) {
56
+ let byte = [1u8];
57
+ let _ = unsafe { libc::write(self.write_fd, byte.as_ptr() as *const c_void, 1) };
58
+ }
59
+ }
60
+
61
+ impl Drop for Shared {
62
+ fn drop(&mut self) {
63
+ unsafe { libc::close(self.write_fd) };
64
+ }
65
+ }
66
+
67
+ /// A live filesystem watcher.
68
+ ///
69
+ /// Holds the debouncer alive (dropping it stops watching) and the shared queue
70
+ /// the consumer drains. The read end of the self-pipe is handed to the caller
71
+ /// by [`Watcher::new`] and owned by them from that point on.
72
+ pub struct Watcher {
73
+ _debouncer: Box<dyn Any>,
74
+ shared: Arc<Shared>,
75
+ }
76
+
77
+ impl Watcher {
78
+ pub fn new(paths: &[String], debounce: f64, globs: &[String], only_kinds: Vec<String>) -> Result<(Self, RawFd), String> {
79
+ let glob_set = build_glob_set(globs)?;
80
+ let (read_fd, write_fd) = make_pipe()?;
81
+
82
+ let shared = Arc::new(Shared {
83
+ pending: Mutex::new(VecDeque::new()),
84
+ glob_set,
85
+ only_kinds,
86
+ write_fd,
87
+ });
88
+
89
+ let callback_shared = Arc::clone(&shared);
90
+
91
+ let mut debouncer = match new_debouncer(Duration::from_secs_f64(debounce), None, move |result: DebounceEventResult| {
92
+ if let Ok(events) = result {
93
+ let mut added = false;
94
+
95
+ for debounced_event in events {
96
+ if callback_shared.enqueue(debounced_event.event) {
97
+ added = true;
98
+ }
99
+ }
100
+
101
+ if added {
102
+ callback_shared.wake();
103
+ }
104
+ }
105
+ }) {
106
+ Ok(debouncer) => debouncer,
107
+
108
+ Err(error) => {
109
+ unsafe { libc::close(read_fd) };
110
+ return Err(format!("Failed to create watcher: {error}"));
111
+ }
112
+ };
113
+
114
+ for path in paths {
115
+ let watch_path = PathBuf::from(path);
116
+
117
+ if !watch_path.exists() {
118
+ unsafe { libc::close(read_fd) };
119
+ return Err(format!("Path does not exist: {}", watch_path.display()));
120
+ }
121
+
122
+ if let Err(error) = debouncer.watch(&watch_path, RecursiveMode::Recursive) {
123
+ unsafe { libc::close(read_fd) };
124
+ return Err(format!("Failed to watch path: {error}"));
125
+ }
126
+ }
127
+
128
+ Ok((
129
+ Watcher {
130
+ _debouncer: Box::new(debouncer),
131
+ shared,
132
+ },
133
+ read_fd,
134
+ ))
135
+ }
136
+
137
+ pub fn poll(&self) -> Option<(String, String)> {
138
+ self.shared.pending.lock().unwrap().pop_front()
139
+ }
140
+ }
141
+
142
+ fn make_pipe() -> Result<(RawFd, RawFd), String> {
143
+ let mut fds = [0 as c_int; 2];
144
+
145
+ if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
146
+ return Err("Failed to create event pipe".to_string());
147
+ }
148
+
149
+ set_nonblock_cloexec(fds[0]);
150
+ set_nonblock_cloexec(fds[1]);
151
+
152
+ Ok((fds[0], fds[1]))
153
+ }
154
+
155
+ fn set_nonblock_cloexec(fd: c_int) {
156
+ unsafe {
157
+ let flags = libc::fcntl(fd, libc::F_GETFL);
158
+ libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
159
+
160
+ let fd_flags = libc::fcntl(fd, libc::F_GETFD);
161
+ libc::fcntl(fd, libc::F_SETFD, fd_flags | libc::FD_CLOEXEC);
162
+ }
163
+ }
164
+
165
+ fn build_glob_set(patterns: &[String]) -> Result<Option<GlobSet>, String> {
166
+ if patterns.is_empty() {
167
+ return Ok(None);
168
+ }
169
+
170
+ let mut builder = GlobSetBuilder::new();
171
+
172
+ for pattern in patterns {
173
+ let glob = Glob::new(pattern).map_err(|error| format!("Invalid glob pattern '{}': {}", pattern, error))?;
174
+ builder.add(glob);
175
+ }
176
+
177
+ let set = builder.build().map_err(|error| format!("Failed to build glob set: {}", error))?;
178
+
179
+ Ok(Some(set))
180
+ }
181
+
182
+ fn event_kind_to_string(kind: &EventKind) -> &'static str {
183
+ match kind {
184
+ EventKind::Create(CreateKind::File) => "created",
185
+ EventKind::Create(CreateKind::Folder) => "created",
186
+ EventKind::Create(_) => "created",
187
+ EventKind::Modify(ModifyKind::Data(_)) => "modified",
188
+ EventKind::Modify(ModifyKind::Name(_)) => "renamed",
189
+ EventKind::Modify(_) => "modified",
190
+ EventKind::Remove(RemoveKind::File) => "removed",
191
+ EventKind::Remove(RemoveKind::Folder) => "removed",
192
+ EventKind::Remove(_) => "removed",
193
+ EventKind::Access(_) => "accessed",
194
+ EventKind::Any | EventKind::Other => "changed",
195
+ }
196
+ }
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cruise
4
+ class Event
5
+ attr_reader :path #: String
6
+ attr_reader :kind #: String
7
+
8
+ #: (String path, String kind) -> void
9
+ def initialize(path, kind)
10
+ @path = path
11
+ @kind = kind
12
+ end
13
+
14
+ #: () -> String
15
+ def inspect
16
+ "#<Cruise::Event kind=#{kind.inspect} path=#{path.inspect}>"
17
+ end
18
+
19
+ #: () -> String
20
+ def to_s
21
+ "#{kind}: #{path}"
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cruise
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cruise
4
+ class Watcher
5
+ #: (*paths paths, ?glob: globs?, ?debounce: debounce, ?only: kinds?) -> void
6
+ def initialize(*paths, glob: nil, debounce: DEFAULT_DEBOUNCE, only: nil)
7
+ paths = paths.flatten.grep(String)
8
+
9
+ raise ArgumentError, "Cruise::Watcher requires at least one path" if paths.empty?
10
+
11
+ glob_patterns = glob ? Array(glob) : [] #: Array[String]
12
+ only_kinds = only ? Array(only).map(&:to_s) : [] #: Array[String]
13
+
14
+ initialize_native(paths, debounce.to_f, glob_patterns, only_kinds)
15
+ end
16
+
17
+ #: () -> IO
18
+ def io
19
+ raise NotImplementedError, "Cruise::Watcher#io is provided by the native extension"
20
+ end
21
+
22
+ #: () -> Event?
23
+ def poll
24
+ raise NotImplementedError, "Cruise::Watcher#poll is provided by the native extension"
25
+ end
26
+
27
+ #: () -> void
28
+ def close
29
+ raise NotImplementedError, "Cruise::Watcher#close is provided by the native extension"
30
+ end
31
+
32
+ private
33
+
34
+ #: (Array[String] paths, Float debounce, Array[String] globs, Array[String] only_kinds) -> void
35
+ def initialize_native(_paths, _debounce, _globs, _only_kinds)
36
+ raise NotImplementedError, "Cruise::Watcher#initialize_native is provided by the native extension"
37
+ end
38
+ end
39
+ end
data/lib/cruise.rb CHANGED
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/wait"
4
+
3
5
  require_relative "cruise/version"
6
+ require_relative "cruise/event"
7
+ require_relative "cruise/watcher"
4
8
 
5
9
  begin
6
- ruby_version = RUBY_VERSION.split(".")[0..1].join(".")
10
+ ruby_version = RUBY_VERSION.split(".").take(2).join(".")
7
11
 
8
12
  begin
9
13
  require "cruise/#{ruby_version}/cruise"
@@ -15,20 +19,49 @@ rescue LoadError => e
15
19
  end
16
20
 
17
21
  module Cruise
18
- DEFAULT_DEBOUNCE = 0.1
22
+ DEFAULT_DEBOUNCE = 0.1 #: Float
19
23
 
20
24
  class << self
21
- def watch(*args, glob: nil, debounce: DEFAULT_DEBOUNCE, only: nil, callback: nil, &block)
25
+ # Watch one or more paths and yield a Cruise::Event for each change.
26
+ #
27
+ # Blocks until interrupted. Waiting happens on the watcher's pipe via
28
+ # IO#wait_readable, so this releases the GVL for other threads and, when a
29
+ # Fiber scheduler is set (e.g. inside Async), yields to other fibers instead
30
+ # of blocking the reactor.
31
+ #: (*paths paths, ?glob: globs?, ?debounce: debounce, ?only: kinds?, ?callback: callback?) ?{ (Event) -> void } -> void
32
+ def watch(*paths, glob: nil, debounce: DEFAULT_DEBOUNCE, only: nil, callback: nil, &block)
22
33
  callback = block || callback
23
- paths = args.flatten.grep(String)
24
34
 
25
- raise ArgumentError, "Cruise.watch requires at least one path" if paths.empty?
26
35
  raise ArgumentError, "Cruise.watch requires a block or callback" unless callback
27
36
 
28
- glob_patterns = glob ? Array(glob) : []
29
- only_kinds = only ? Array(only).map(&:to_s) : []
37
+ watcher = Watcher.new(*paths, glob: glob, debounce: debounce, only: only)
38
+ io = watcher.io
39
+
40
+ begin
41
+ loop do
42
+ io.wait_readable
43
+ closed = drain_wakeups(io)
44
+
45
+ while (event = watcher.poll)
46
+ callback.call(event)
47
+ end
48
+
49
+ break if closed
50
+ end
51
+ ensure
52
+ watcher.close
53
+ end
54
+ end
55
+
56
+ private
30
57
 
31
- _watch(paths, callback, debounce.to_f, glob_patterns, only_kinds)
58
+ #: (IO io) -> bool
59
+ def drain_wakeups(io)
60
+ loop { io.read_nonblock(4096) }
61
+ rescue IO::WaitReadable
62
+ false
63
+ rescue IOError
64
+ true
32
65
  end
33
66
  end
34
67
  end
@@ -0,0 +1,18 @@
1
+ # Generated from lib/cruise/event.rb with RBS::Inline
2
+
3
+ module Cruise
4
+ class Event
5
+ attr_reader path: String
6
+
7
+ attr_reader kind: String
8
+
9
+ # : (String path, String kind) -> void
10
+ def initialize: (String path, String kind) -> void
11
+
12
+ # : () -> String
13
+ def inspect: () -> String
14
+
15
+ # : () -> String
16
+ def to_s: () -> String
17
+ end
18
+ end