cruise 0.1.0-x86_64-linux-gnu → 0.2.0-x86_64-linux-gnu

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
+ }
@@ -0,0 +1,11 @@
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.
7
+
8
+ mod ffi;
9
+ mod watcher;
10
+
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
+ }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cruise
4
+ class Event
5
+ attr_reader :path, :kind
6
+
7
+ def initialize(path, kind)
8
+ @path = path
9
+ @kind = kind
10
+ end
11
+
12
+ def inspect
13
+ "#<Cruise::Event kind=#{kind.inspect} path=#{path.inspect}>"
14
+ end
15
+
16
+ def to_s
17
+ "#{kind}: #{path}"
18
+ end
19
+ end
20
+ 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.0"
5
5
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cruise
4
+ class Watcher
5
+ def initialize(*paths, glob: nil, debounce: DEFAULT_DEBOUNCE, only: nil)
6
+ paths = paths.flatten.grep(String)
7
+
8
+ raise ArgumentError, "Cruise::Watcher requires at least one path" if paths.empty?
9
+
10
+ glob_patterns = glob ? Array(glob) : []
11
+ only_kinds = only ? Array(only).map(&:to_s) : []
12
+
13
+ initialize_native(paths, debounce.to_f, glob_patterns, only_kinds)
14
+ end
15
+
16
+ def io
17
+ raise NotImplementedError, "Cruise::Watcher#io is provided by the native extension"
18
+ end
19
+
20
+ def poll
21
+ raise NotImplementedError, "Cruise::Watcher#poll is provided by the native extension"
22
+ end
23
+
24
+ def close
25
+ raise NotImplementedError, "Cruise::Watcher#close is provided by the native extension"
26
+ end
27
+
28
+ private
29
+
30
+ def initialize_native(_paths, _debounce, _globs, _only_kinds)
31
+ raise NotImplementedError, "Cruise::Watcher#initialize_native is provided by the native extension"
32
+ end
33
+ end
34
+ end
data/lib/cruise.rb CHANGED
@@ -1,6 +1,10 @@
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
10
  ruby_version = RUBY_VERSION.split(".")[0..1].join(".")
@@ -18,17 +22,44 @@ module Cruise
18
22
  DEFAULT_DEBOUNCE = 0.1
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
+ def watch(*paths, glob: nil, debounce: DEFAULT_DEBOUNCE, only: nil, callback: nil, &block)
22
32
  callback = block || callback
23
- paths = args.flatten.grep(String)
24
33
 
25
- raise ArgumentError, "Cruise.watch requires at least one path" if paths.empty?
26
34
  raise ArgumentError, "Cruise.watch requires a block or callback" unless callback
27
35
 
28
- glob_patterns = glob ? Array(glob) : []
29
- only_kinds = only ? Array(only).map(&:to_s) : []
36
+ watcher = Watcher.new(*paths, glob: glob, debounce: debounce, only: only)
37
+ io = watcher.io
38
+
39
+ begin
40
+ loop do
41
+ io.wait_readable
42
+ closed = drain_wakeups(io)
43
+
44
+ while (event = watcher.poll)
45
+ callback.call(event)
46
+ end
47
+
48
+ break if closed
49
+ end
50
+ ensure
51
+ watcher.close
52
+ end
53
+ end
54
+
55
+ private
30
56
 
31
- _watch(paths, callback, debounce.to_f, glob_patterns, only_kinds)
57
+ def drain_wakeups(io)
58
+ loop { io.read_nonblock(4096) }
59
+ rescue IO::WaitReadable
60
+ false
61
+ rescue IOError
62
+ true
32
63
  end
33
64
  end
34
65
  end
metadata CHANGED
@@ -1,32 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cruise
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Marco Roth
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-24 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Cruise is a Rust-powered file system watcher with native OS integration.
14
- Uses FSEvents on macOS and inotify on Linux.
13
+ description: Cruise is a file system watcher built on native OS events. Uses FSEvents
14
+ on macOS and inotify on Linux.
15
15
  email:
16
16
  - marco.roth@intergga.ch
17
17
  executables: []
18
18
  extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
+ - Cargo.lock
22
+ - Cargo.toml
21
23
  - LICENSE.txt
24
+ - README.md
22
25
  - Rakefile
23
26
  - cruise.gemspec
27
+ - ext/cruise/Cargo.toml
28
+ - ext/cruise/build.rs
29
+ - ext/cruise/cbindgen.toml
30
+ - ext/cruise/cruise.c
31
+ - ext/cruise/extconf.rb
32
+ - ext/cruise/src/ffi.rs
33
+ - ext/cruise/src/lib.rs
34
+ - ext/cruise/src/watcher.rs
24
35
  - lib/cruise.rb
25
36
  - lib/cruise/3.2/cruise.so
26
37
  - lib/cruise/3.3/cruise.so
27
38
  - lib/cruise/3.4/cruise.so
28
39
  - lib/cruise/4.0/cruise.so
40
+ - lib/cruise/event.rb
29
41
  - lib/cruise/version.rb
42
+ - lib/cruise/watcher.rb
30
43
  homepage: https://github.com/marcoroth/cruise
31
44
  licenses:
32
45
  - MIT
@@ -58,5 +71,5 @@ requirements: []
58
71
  rubygems_version: 3.5.23
59
72
  signing_key:
60
73
  specification_version: 4
61
- summary: A fast, native file watcher for Ruby
74
+ summary: A fast, OS-native file watcher for Ruby
62
75
  test_files: []