cruise 0.0.0 → 0.2.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.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "fileutils"
5
+
6
+ crate_dir = __dir__
7
+ root_dir = File.expand_path("../..", __dir__)
8
+
9
+ unless system("cargo --version > /dev/null 2>&1")
10
+ abort <<~MESSAGE
11
+
12
+ ERROR: Rust toolchain not found.
13
+
14
+ cruise requires the Rust toolchain to compile from source.
15
+
16
+ Install Rust: https://rustup.rs
17
+
18
+ MESSAGE
19
+ end
20
+
21
+ RUST_TARGETS = {
22
+ "aarch64-linux-gnu" => "aarch64-unknown-linux-gnu",
23
+ "aarch64-linux-musl" => "aarch64-unknown-linux-musl",
24
+ "arm-linux-gnu" => "armv7-unknown-linux-gnueabihf",
25
+ "arm-linux-musl" => "armv7-unknown-linux-musleabihf",
26
+ "arm64-darwin" => "aarch64-apple-darwin",
27
+ "x86_64-darwin" => "x86_64-apple-darwin",
28
+ "x86_64-linux-gnu" => "x86_64-unknown-linux-gnu",
29
+ "x86_64-linux-musl" => "x86_64-unknown-linux-musl",
30
+ "x86-linux-gnu" => "i686-unknown-linux-gnu",
31
+ "x86-linux-musl" => "i686-unknown-linux-musl",
32
+ }.freeze
33
+
34
+ cross_compiling = ENV.key?("RUBY_CC_VERSION")
35
+ target_platform = ENV.fetch("CARGO_BUILD_TARGET", nil)
36
+
37
+ if cross_compiling && target_platform.nil?
38
+ rcd_platform = ENV.fetch("RCD_PLATFORM", "")
39
+ target_platform = RUST_TARGETS[rcd_platform]
40
+
41
+ if target_platform.nil?
42
+ ruby_platform = RbConfig::CONFIG["arch"]
43
+ target_platform = RUST_TARGETS.values.find { |t| ruby_platform.include?(t.split("-").first) }
44
+ end
45
+ end
46
+
47
+ workspace_target_dir = File.join(root_dir, "target")
48
+ crate_target_dir = File.join(crate_dir, "target")
49
+
50
+ if target_platform
51
+ puts "cruise: Cross-compiling Rust for target: #{target_platform}"
52
+ system("rustup target add #{target_platform}") || warn("cruise: Failed to add Rust target #{target_platform}")
53
+
54
+ cargo_args = "--release --target #{target_platform}"
55
+ else
56
+ puts "cruise: Compiling Rust library for native platform..."
57
+
58
+ cargo_args = "--release"
59
+ end
60
+
61
+ unless system("cd #{root_dir} && cargo build #{cargo_args}")
62
+ abort "ERROR: Failed to compile cruise from Rust source."
63
+ end
64
+
65
+ lib_dir = if target_platform
66
+ [workspace_target_dir, crate_target_dir]
67
+ .map { |dir| File.join(dir, target_platform, "release") }
68
+ .find { |dir| Dir.exist?(dir) } || File.join(crate_target_dir, target_platform, "release")
69
+ else
70
+ [workspace_target_dir, crate_target_dir]
71
+ .map { |dir| File.join(dir, "release") }
72
+ .find { |dir| Dir.exist?(dir) } || File.join(crate_target_dir, "release")
73
+ end
74
+
75
+ host_os = target_platform || RbConfig::CONFIG["host_os"]
76
+
77
+ lib_name = case host_os
78
+ when /darwin/ then "libcruise.dylib"
79
+ when /mingw|mswin|windows/ then "cruise.dll"
80
+ else "libcruise.so"
81
+ end
82
+
83
+ static_lib = File.join(lib_dir, "libcruise.a")
84
+
85
+ if File.exist?(static_lib)
86
+ puts "cruise: Static library found at #{static_lib}"
87
+ $LDFLAGS << " #{static_lib}"
88
+ else
89
+ lib_path = File.join(lib_dir, lib_name)
90
+
91
+ unless File.exist?(lib_path)
92
+ abort "ERROR: cruise library not found at #{static_lib} or #{lib_path}"
93
+ end
94
+
95
+ puts "cruise: Shared library found at #{lib_path} (dynamic)"
96
+
97
+ $LDFLAGS << " -L#{lib_dir} -lcruise"
98
+
99
+ if host_os.match?(/darwin|linux/)
100
+ $LDFLAGS << " -Wl,-rpath,#{lib_dir}"
101
+ end
102
+ end
103
+
104
+ case host_os
105
+ when /darwin/
106
+ $LDFLAGS << " -framework CoreServices -framework CoreFoundation -framework Security"
107
+ when /linux/
108
+ $LDFLAGS << " -lpthread -ldl -lm"
109
+ end
110
+
111
+ $CFLAGS << " -I#{File.join(__dir__, "include")}"
112
+
113
+ create_makefile("cruise/cruise")
@@ -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
+ }
@@ -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.0.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,8 +1,65 @@
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"
8
+
9
+ begin
10
+ ruby_version = RUBY_VERSION.split(".")[0..1].join(".")
11
+
12
+ begin
13
+ require "cruise/#{ruby_version}/cruise"
14
+ rescue LoadError
15
+ require "cruise/cruise"
16
+ end
17
+ rescue LoadError => e
18
+ raise LoadError, "Failed to load Cruise native extension: #{e.message}"
19
+ end
4
20
 
5
21
  module Cruise
6
- class Error < StandardError; end
7
- # Your code goes here...
22
+ DEFAULT_DEBOUNCE = 0.1
23
+
24
+ class << self
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)
32
+ callback = block || callback
33
+
34
+ raise ArgumentError, "Cruise.watch requires a block or callback" unless callback
35
+
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
56
+
57
+ def drain_wakeups(io)
58
+ loop { io.read_nonblock(4096) }
59
+ rescue IO::WaitReadable
60
+ false
61
+ rescue IOError
62
+ true
63
+ end
64
+ end
8
65
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cruise
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Roth
@@ -9,18 +9,33 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: Cruise is a Rust-powered file system watcher with native OS integration.
13
- Uses FSEvents on macOS and inotify on Linux.
12
+ description: Cruise is a file system watcher built on native OS events. Uses FSEvents
13
+ on macOS and inotify on Linux.
14
14
  email:
15
15
  - marco.roth@intergga.ch
16
16
  executables: []
17
- extensions: []
17
+ extensions:
18
+ - ext/cruise/extconf.rb
18
19
  extra_rdoc_files: []
19
20
  files:
21
+ - Cargo.lock
22
+ - Cargo.toml
23
+ - LICENSE.txt
24
+ - README.md
20
25
  - Rakefile
21
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
22
35
  - lib/cruise.rb
36
+ - lib/cruise/event.rb
23
37
  - lib/cruise/version.rb
38
+ - lib/cruise/watcher.rb
24
39
  homepage: https://github.com/marcoroth/cruise
25
40
  licenses:
26
41
  - MIT
@@ -38,7 +53,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
38
53
  requirements:
39
54
  - - ">="
40
55
  - !ruby/object:Gem::Version
41
- version: 3.1.0
56
+ version: 3.2.0
42
57
  required_rubygems_version: !ruby/object:Gem::Requirement
43
58
  requirements:
44
59
  - - ">="
@@ -47,5 +62,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
62
  requirements: []
48
63
  rubygems_version: 4.0.3
49
64
  specification_version: 4
50
- summary: A fast, native file watcher for Ruby
65
+ summary: A fast, OS-native file watcher for Ruby
51
66
  test_files: []