From cb93a76c114a36ff7693501b33de7b9441c5689d Mon Sep 17 00:00:00 2001 From: julia Date: Wed, 8 Jan 2025 13:34:53 +1100 Subject: [PATCH 01/14] bug fixes syntax/compilation errors in `removeFile`, and remove `offset` since its information is stored in `self.paths.items.len` --- README.md | 4 ++-- examples/basic.zig | 3 +++ src/watchers/linux.zig | 17 +++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1625113..e9bf085 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fzwatch A lightweight and cross-platform file watcher for your Zig projects. -> [!NOTE] +> [!NOTE] > This project exists to support [fancy-cat](https://github.com/freref/fancy-cat) and has limited features. ## Instructions @@ -15,7 +15,7 @@ A basic example can be found under [examples](./examples/basic.zig). The API is pub const Event = enum { modified }; pub const Callback = fn (context: *anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; - + pub fn init(allocator: std.mem.Allocator) !Watcher; pub fn deinit(self: *Watcher) void; pub fn addFile(self: *Watcher, path: []const u8) !void; diff --git a/examples/basic.zig b/examples/basic.zig index 575fac8..3c0eece 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -19,6 +19,9 @@ pub fn main() !void { defer watcher.deinit(); try watcher.addFile("README.md"); + try watcher.addFile("build.zig"); + try watcher.addFile("build.zig.zon"); + try watcher.removeFile("build.zig.zon"); watcher.setCallback(callback, null); const thread = try std.Thread.spawn(.{}, watcherThread, .{&watcher}); diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index b38e028..bc5a69b 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -5,7 +5,6 @@ pub const LinuxWatcher = struct { allocator: std.mem.Allocator, inotify_fd: i32, paths: std.ArrayList([]const u8), - offset: usize, callback: ?*const interfaces.Callback, running: bool, context: ?*anyopaque, @@ -18,7 +17,6 @@ pub const LinuxWatcher = struct { .allocator = allocator, .inotify_fd = @intCast(fd), .paths = std.ArrayList([]const u8).init(allocator), - .offset = 1, .callback = null, .running = false, .context = null, @@ -42,11 +40,10 @@ pub const LinuxWatcher = struct { } pub fn removeFile(self: *LinuxWatcher, path: []const u8) !void { - for (0.., self.paths) |idx, mem_path| { - if (mem_path == path) { - _ = std.posix.inotify_rm_watch(self.inotify_fd, idx - self.offset); - try self.paths.items().remove(idx); - self.offset += 1; + for (0.., self.paths.items) |idx, mem_path| { + if (std.mem.eql(u8, mem_path, path)) { + std.posix.inotify_rm_watch(self.inotify_fd, @intCast(idx + @intFromBool(idx == self.paths.items.len))); + _ = self.paths.swapRemove(idx); return; } } @@ -84,11 +81,11 @@ pub const LinuxWatcher = struct { // So we have to re-add the file to the watcher if (ev.mask & std.os.linux.IN.IGNORED != 0) { const wd_usize = @as(usize, @intCast(@max(0, ev.wd))); - if (wd_usize < self.offset) { + if (wd_usize > self.paths.items.len) { return error.InvalidWatchDescriptor; } - const index = wd_usize - self.offset; - try self.addFile(self.paths.items[index]); + // TODO: remove previous buffer + try self.addFile(self.paths.items[wd_usize - 1]); if (self.callback) |callback| { callback(self.context, interfaces.Event.modified); } From 14335084a52b7d841bb2c51d70e9be1b491f5b1e Mon Sep 17 00:00:00 2001 From: julia Date: Wed, 8 Jan 2025 16:06:33 +1100 Subject: [PATCH 02/14] idea to update wd for prev files doesnt compile but the idea is that it would be more safe to update the watch descriptors of the previous files so that we can check valid descriptors in the event loop --- examples/basic.zig | 6 ++-- src/watchers/interfaces.zig | 6 +++- src/watchers/linux.zig | 64 ++++++++++++++++++++++++++----------- src/watchers/macos.zig | 5 ++- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/examples/basic.zig b/examples/basic.zig index 3c0eece..c1e3ad0 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -3,8 +3,8 @@ const fzwatch = @import("fzwatch"); fn callback(context: ?*anyopaque, event: fzwatch.Event) void { _ = context; - switch (event) { - .modified => std.debug.print("File was modified!\n", .{}), + switch (event.kind) { + .modified => std.debug.print("File {d} was modified!\n", .{event.item}), } } @@ -19,7 +19,9 @@ pub fn main() !void { defer watcher.deinit(); try watcher.addFile("README.md"); + // try watcher.removeFile("README.md"); try watcher.addFile("build.zig"); + try watcher.removeFile("build.zig"); try watcher.addFile("build.zig.zon"); try watcher.removeFile("build.zig.zon"); watcher.setCallback(callback, null); diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index 403016e..30c57db 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -1,3 +1,7 @@ -pub const Event = enum { modified }; +pub const Event = struct { + kind: enum { modified }, + /// the index into `Watcher.paths.items` which this event came from + item: usize +}; pub const Callback = fn (context: ?*anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index bc5a69b..7cff6be 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -5,6 +5,8 @@ pub const LinuxWatcher = struct { allocator: std.mem.Allocator, inotify_fd: i32, paths: std.ArrayList([]const u8), + /// inotify wd offset from removing files + offset: usize, callback: ?*const interfaces.Callback, running: bool, context: ?*anyopaque, @@ -17,6 +19,7 @@ pub const LinuxWatcher = struct { .allocator = allocator, .inotify_fd = @intCast(fd), .paths = std.ArrayList([]const u8).init(allocator), + .offset = 1, .callback = null, .running = false, .context = null, @@ -41,11 +44,30 @@ pub const LinuxWatcher = struct { pub fn removeFile(self: *LinuxWatcher, path: []const u8) !void { for (0.., self.paths.items) |idx, mem_path| { - if (std.mem.eql(u8, mem_path, path)) { - std.posix.inotify_rm_watch(self.inotify_fd, @intCast(idx + @intFromBool(idx == self.paths.items.len))); - _ = self.paths.swapRemove(idx); - return; + if (!std.mem.eql(u8, mem_path, path)) + continue; + + // need to update wd of all previous files so they are above the offset + // so we just remove and add them back from inotify watch + // TODO: 100% better way to do this + for(0..idx) |i| { + std.posix.inotify_rm_watch(self.inotify_fd, @intCast(idx + self.offset - i)); + + const t = try std.posix.inotify_add_watch( + self.inotify_fd, + self.paths.items[i], + std.os.linux.IN.MODIFY, + ); + + std.log.debug("removed: {d} added {d}", .{idx + self.offset - i, t}); } + + self.offset += idx; + std.posix.inotify_rm_watch(self.inotify_fd, @intCast(idx + self.offset)); + self.offset += 1; + _ = self.paths.orderedRemove(idx); + + return; } } @@ -59,10 +81,10 @@ pub const LinuxWatcher = struct { if (self.paths.items.len == 0) return error.NoFilesToWatch; self.running = true; - var buffer: [4096]u8 align(@alignOf(std.os.linux.inotify_event)) = undefined; + var buffer: [4096]std.os.linux.inotify_event = undefined; while (self.running) { - const length = std.posix.read(self.inotify_fd, &buffer) catch |err| switch (err) { + const length = std.posix.read(self.inotify_fd, std.mem.sliceAsBytes(&buffer)) catch |err| switch (err) { error.WouldBlock => { std.time.sleep(@as(u64, @intFromFloat(@as(f64, opts.latency) * @as(f64, @floatFromInt(std.time.ns_per_s))))); continue; @@ -72,30 +94,34 @@ pub const LinuxWatcher = struct { }, }; - var ptr: [*]u8 = &buffer; - const end_ptr = ptr + @as(usize, @intCast(length)); - - while (@intFromPtr(ptr) < @intFromPtr(end_ptr)) { - const ev = @as(*const std.os.linux.inotify_event, @ptrCast(@alignCast(ptr))); + var i: usize = 0; + while (i < length) : (i += buffer[i].len + @sizeOf(std.os.linux.inotify_event)) { + const ev = buffer[i]; // Editors like vim create temporary files when saving // So we have to re-add the file to the watcher if (ev.mask & std.os.linux.IN.IGNORED != 0) { const wd_usize = @as(usize, @intCast(@max(0, ev.wd))); - if (wd_usize > self.paths.items.len) { + std.log.info("w {d}, l {d}, o {d}", .{wd_usize, self.paths.items.len, self.offset}); + std.log.info("{d}", .{self.paths.items.len}); + if(wd_usize == 0) continue; + if (wd_usize > self.paths.items.len + self.offset or wd_usize < self.offset) return error.InvalidWatchDescriptor; - } - // TODO: remove previous buffer - try self.addFile(self.paths.items[wd_usize - 1]); + + try self.addFile(self.paths.items[wd_usize - self.offset]); if (self.callback) |callback| { - callback(self.context, interfaces.Event.modified); + callback(self.context, .{ + .kind = .modified, + .item = wd_usize - self.offset + }); } } else if (ev.mask & std.os.linux.IN.MODIFY != 0) { if (self.callback) |callback| { - callback(self.context, interfaces.Event.modified); + callback(self.context, .{ + .kind = .modified, + .item = @as(usize, @intCast(ev.wd)) - self.offset + }); } } - - ptr = @alignCast(ptr + @sizeOf(std.os.linux.inotify_event) + ev.len); } } } diff --git a/src/watchers/macos.zig b/src/watchers/macos.zig index de600cd..c190820 100644 --- a/src/watchers/macos.zig +++ b/src/watchers/macos.zig @@ -87,7 +87,10 @@ pub const MacosWatcher = struct { while (i < numEvents) : (i += 1) { const flags = eventFlags[i]; if (flags & c.kFSEventStreamEventFlagItemModified != 0) { - self.callback.?(self.context, interfaces.Event.modified); + self.callback.?(self.context, .{ + .kind = .modified, + .item = i + }); } } } From f077375f20f0bbf7830752e1490140f213943806 Mon Sep 17 00:00:00 2001 From: julia Date: Wed, 8 Jan 2025 18:41:29 +1100 Subject: [PATCH 03/14] using an `old` hashmap i think its safe not to even check if the wd is below offset, it would be much faster and if inotify is sending it than it must already be valid. ill leave this commit as the basis for an idea though --- examples/basic.zig | 4 +- src/watchers/linux.zig | 98 +++++++++++++++++++++--------------------- 2 files changed, 50 insertions(+), 52 deletions(-) diff --git a/examples/basic.zig b/examples/basic.zig index c1e3ad0..3e8c3e3 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -21,9 +21,9 @@ pub fn main() !void { try watcher.addFile("README.md"); // try watcher.removeFile("README.md"); try watcher.addFile("build.zig"); - try watcher.removeFile("build.zig"); + // try watcher.removeFile("build.zig"); try watcher.addFile("build.zig.zon"); - try watcher.removeFile("build.zig.zon"); + // try watcher.removeFile("build.zig.zon"); watcher.setCallback(callback, null); const thread = try std.Thread.spawn(.{}, watcherThread, .{&watcher}); diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index 7cff6be..601e38f 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -3,10 +3,17 @@ const interfaces = @import("interfaces.zig"); pub const LinuxWatcher = struct { allocator: std.mem.Allocator, - inotify_fd: i32, paths: std.ArrayList([]const u8), - /// inotify wd offset from removing files - offset: usize, + inotify: struct { + fd: i32, + /// inotify wd offset from removing files + offset: usize, + /// the old watch descriptors that would have a lower wd than `offset` + /// making them unsafe to normally check. by keeping them here we know that + /// these specific low wd's are safe + /// maps wd to offset at the time of population + old: std.AutoHashMap(i32, i32) + }, callback: ?*const interfaces.Callback, running: bool, context: ?*anyopaque, @@ -17,9 +24,12 @@ pub const LinuxWatcher = struct { return LinuxWatcher{ .allocator = allocator, - .inotify_fd = @intCast(fd), .paths = std.ArrayList([]const u8).init(allocator), - .offset = 1, + .inotify = .{ + .fd = @intCast(fd), + .offset = 1, + .old = std.AutoHashMap(i32, i32).init(allocator) + }, .callback = null, .running = false, .context = null, @@ -29,16 +39,19 @@ pub const LinuxWatcher = struct { pub fn deinit(self: *LinuxWatcher) void { self.stop(); self.paths.deinit(); - std.posix.close(self.inotify_fd); + self.inotify.old.deinit(); + std.posix.close(self.inotify.fd); } pub fn addFile(self: *LinuxWatcher, path: []const u8) !void { - _ = try std.posix.inotify_add_watch( - self.inotify_fd, + const wd = try std.posix.inotify_add_watch( + self.inotify.fd, path, std.os.linux.IN.MODIFY, ); + try self.inotify.old.putNoClobber(wd, @intCast(self.inotify.offset)); + try self.paths.append(path); } @@ -47,26 +60,13 @@ pub const LinuxWatcher = struct { if (!std.mem.eql(u8, mem_path, path)) continue; - // need to update wd of all previous files so they are above the offset - // so we just remove and add them back from inotify watch - // TODO: 100% better way to do this - for(0..idx) |i| { - std.posix.inotify_rm_watch(self.inotify_fd, @intCast(idx + self.offset - i)); - - const t = try std.posix.inotify_add_watch( - self.inotify_fd, - self.paths.items[i], - std.os.linux.IN.MODIFY, - ); - - std.log.debug("removed: {d} added {d}", .{idx + self.offset - i, t}); - } - - self.offset += idx; - std.posix.inotify_rm_watch(self.inotify_fd, @intCast(idx + self.offset)); - self.offset += 1; + _ = std.posix.inotify_rm_watch(self.inotify.fd, @intCast(idx + self.inotify.offset)); + self.inotify.offset += 1; _ = self.paths.orderedRemove(idx); + // self.inotify.old.clearRetainingCapacity(); + // for(0..idx) |i| try self.inotify.old.putNoClobber(@intCast(i), 0); + return; } } @@ -84,7 +84,7 @@ pub const LinuxWatcher = struct { var buffer: [4096]std.os.linux.inotify_event = undefined; while (self.running) { - const length = std.posix.read(self.inotify_fd, std.mem.sliceAsBytes(&buffer)) catch |err| switch (err) { + const length = std.posix.read(self.inotify.fd, std.mem.sliceAsBytes(&buffer)) catch |err| switch (err) { error.WouldBlock => { std.time.sleep(@as(u64, @intFromFloat(@as(f64, opts.latency) * @as(f64, @floatFromInt(std.time.ns_per_s))))); continue; @@ -94,34 +94,32 @@ pub const LinuxWatcher = struct { }, }; + // in bytes var i: usize = 0; while (i < length) : (i += buffer[i].len + @sizeOf(std.os.linux.inotify_event)) { const ev = buffer[i]; + if(ev.wd < self.inotify.offset) { + try if(self.inotify.old.get(ev.wd)) |offset| { + std.log.info("{d}, {d}", .{offset, ev.wd}); + try self.addFile(self.paths.items[@intCast(ev.wd - offset)]); + } + else if(ev.wd == 0) {continue;} + else error.InvalidWatchDescriptor; + } else if (ev.wd > self.paths.items.len + self.inotify.offset) + return error.InvalidWatchDescriptor; + + const index = @as(usize, @intCast(@max(0, ev.wd))) - self.inotify.offset; // Editors like vim create temporary files when saving // So we have to re-add the file to the watcher - if (ev.mask & std.os.linux.IN.IGNORED != 0) { - const wd_usize = @as(usize, @intCast(@max(0, ev.wd))); - std.log.info("w {d}, l {d}, o {d}", .{wd_usize, self.paths.items.len, self.offset}); - std.log.info("{d}", .{self.paths.items.len}); - if(wd_usize == 0) continue; - if (wd_usize > self.paths.items.len + self.offset or wd_usize < self.offset) - return error.InvalidWatchDescriptor; - - try self.addFile(self.paths.items[wd_usize - self.offset]); - if (self.callback) |callback| { - callback(self.context, .{ - .kind = .modified, - .item = wd_usize - self.offset - }); - } - } else if (ev.mask & std.os.linux.IN.MODIFY != 0) { - if (self.callback) |callback| { - callback(self.context, .{ - .kind = .modified, - .item = @as(usize, @intCast(ev.wd)) - self.offset - }); - } - } + if (ev.mask & std.os.linux.IN.IGNORED == 0 and ev.mask & std.os.linux.IN.MODIFY == 0) + continue; + + if(ev.mask & std.os.linux.IN.IGNORED != 0) + try self.addFile(self.paths.items[index]); + if (self.callback) |callback| callback(self.context, .{ + .kind = .modified, + .item = index + }); } } } From 3070b7a682bc58272bf688ba6a28740fb8b8800b Mon Sep 17 00:00:00 2001 From: julia Date: Wed, 8 Jan 2025 18:46:40 +1100 Subject: [PATCH 04/14] ignore old watch descriptors creates a bit of a bug where the wd will only be updated once another file is modified --- examples/basic.zig | 2 +- src/watchers/linux.zig | 26 +++++--------------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/examples/basic.zig b/examples/basic.zig index 3e8c3e3..e68dbcf 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -23,7 +23,7 @@ pub fn main() !void { try watcher.addFile("build.zig"); // try watcher.removeFile("build.zig"); try watcher.addFile("build.zig.zon"); - // try watcher.removeFile("build.zig.zon"); + try watcher.removeFile("build.zig.zon"); watcher.setCallback(callback, null); const thread = try std.Thread.spawn(.{}, watcherThread, .{&watcher}); diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index 601e38f..fe3302f 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -8,11 +8,6 @@ pub const LinuxWatcher = struct { fd: i32, /// inotify wd offset from removing files offset: usize, - /// the old watch descriptors that would have a lower wd than `offset` - /// making them unsafe to normally check. by keeping them here we know that - /// these specific low wd's are safe - /// maps wd to offset at the time of population - old: std.AutoHashMap(i32, i32) }, callback: ?*const interfaces.Callback, running: bool, @@ -28,7 +23,6 @@ pub const LinuxWatcher = struct { .inotify = .{ .fd = @intCast(fd), .offset = 1, - .old = std.AutoHashMap(i32, i32).init(allocator) }, .callback = null, .running = false, @@ -39,19 +33,16 @@ pub const LinuxWatcher = struct { pub fn deinit(self: *LinuxWatcher) void { self.stop(); self.paths.deinit(); - self.inotify.old.deinit(); std.posix.close(self.inotify.fd); } pub fn addFile(self: *LinuxWatcher, path: []const u8) !void { - const wd = try std.posix.inotify_add_watch( + _ = try std.posix.inotify_add_watch( self.inotify.fd, path, std.os.linux.IN.MODIFY, ); - try self.inotify.old.putNoClobber(wd, @intCast(self.inotify.offset)); - try self.paths.append(path); } @@ -64,9 +55,6 @@ pub const LinuxWatcher = struct { self.inotify.offset += 1; _ = self.paths.orderedRemove(idx); - // self.inotify.old.clearRetainingCapacity(); - // for(0..idx) |i| try self.inotify.old.putNoClobber(@intCast(i), 0); - return; } } @@ -98,14 +86,10 @@ pub const LinuxWatcher = struct { var i: usize = 0; while (i < length) : (i += buffer[i].len + @sizeOf(std.os.linux.inotify_event)) { const ev = buffer[i]; - if(ev.wd < self.inotify.offset) { - try if(self.inotify.old.get(ev.wd)) |offset| { - std.log.info("{d}, {d}", .{offset, ev.wd}); - try self.addFile(self.paths.items[@intCast(ev.wd - offset)]); - } - else if(ev.wd == 0) {continue;} - else error.InvalidWatchDescriptor; - } else if (ev.wd > self.paths.items.len + self.inotify.offset) + + if(ev.wd == 0) {continue;} + else if(ev.wd < self.inotify.offset) {continue;} + else if (ev.wd > self.paths.items.len + self.inotify.offset) return error.InvalidWatchDescriptor; const index = @as(usize, @intCast(@max(0, ev.wd))) - self.inotify.offset; From cebc0d30a7cd1bdecf4a94eb86a8c1b73f016fd6 Mon Sep 17 00:00:00 2001 From: julia Date: Fri, 10 Jan 2025 14:13:39 +1100 Subject: [PATCH 05/14] cleanup --- README.md | 2 +- examples/context.zig | 2 +- src/watchers/interfaces.zig | 2 +- src/watchers/linux.zig | 15 +++++++-------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e9bf085..f981c22 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ zig build run- ### Usage A basic example can be found under [examples](./examples/basic.zig). The API is defined as follows: ```zig -pub const Event = enum { modified }; +pub const Event = struct { kind: enum { modified }, item: usize }; pub const Callback = fn (context: *anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; diff --git a/examples/context.zig b/examples/context.zig index 049a764..4787dd0 100644 --- a/examples/context.zig +++ b/examples/context.zig @@ -8,7 +8,7 @@ const Object = struct { thread: ?std.Thread, fn callback(context: ?*anyopaque, event: fzwatch.Event) void { - switch (event) { + switch (event.kind) { .modified => { const to_increment: *usize = @as(*usize, @ptrCast(@alignCast(context.?))); to_increment.* += 1; diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index 30c57db..45ad688 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -4,4 +4,4 @@ pub const Event = struct { item: usize }; pub const Callback = fn (context: ?*anyopaque, event: Event) void; -pub const Opts = struct { latency: f16 = 1.0 }; +pub const Opts = struct { latency: f16 = 1.0, notify: bool = false}; diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index fe3302f..54cbc72 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -10,8 +10,8 @@ pub const LinuxWatcher = struct { offset: usize, }, callback: ?*const interfaces.Callback, - running: bool, context: ?*anyopaque, + running: bool, pub fn init(allocator: std.mem.Allocator) !LinuxWatcher { const fd = try std.posix.inotify_init1(std.os.linux.IN.NONBLOCK); @@ -22,11 +22,11 @@ pub const LinuxWatcher = struct { .paths = std.ArrayList([]const u8).init(allocator), .inotify = .{ .fd = @intCast(fd), - .offset = 1, + .offset = 1, }, .callback = null, - .running = false, .context = null, + .running = false, }; } @@ -87,17 +87,16 @@ pub const LinuxWatcher = struct { while (i < length) : (i += buffer[i].len + @sizeOf(std.os.linux.inotify_event)) { const ev = buffer[i]; - if(ev.wd == 0) {continue;} - else if(ev.wd < self.inotify.offset) {continue;} + if(ev.wd < self.inotify.offset) {continue;} else if (ev.wd > self.paths.items.len + self.inotify.offset) return error.InvalidWatchDescriptor; - const index = @as(usize, @intCast(@max(0, ev.wd))) - self.inotify.offset; - // Editors like vim create temporary files when saving - // So we have to re-add the file to the watcher if (ev.mask & std.os.linux.IN.IGNORED == 0 and ev.mask & std.os.linux.IN.MODIFY == 0) continue; + const index = @as(usize, @intCast(@max(0, ev.wd))) - self.inotify.offset; + // Editors like vim create temporary files when saving + // So we have to re-add the file to the watcher if(ev.mask & std.os.linux.IN.IGNORED != 0) try self.addFile(self.paths.items[index]); if (self.callback) |callback| callback(self.context, .{ From acb1b516f625542cd80289697dd4fcdc29369dd9 Mon Sep 17 00:00:00 2001 From: julia Date: Sat, 11 Jan 2025 13:29:58 +1100 Subject: [PATCH 06/14] oops remove artifact from poll testing --- src/watchers/interfaces.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index 45ad688..30c57db 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -4,4 +4,4 @@ pub const Event = struct { item: usize }; pub const Callback = fn (context: ?*anyopaque, event: Event) void; -pub const Opts = struct { latency: f16 = 1.0, notify: bool = false}; +pub const Opts = struct { latency: f16 = 1.0 }; From 5db75e015a0d4322dbf30e64891978717d716597 Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 21:27:52 +0100 Subject: [PATCH 07/14] cleanup: basic example --- examples/basic.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/basic.zig b/examples/basic.zig index e68dbcf..5185070 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -19,11 +19,6 @@ pub fn main() !void { defer watcher.deinit(); try watcher.addFile("README.md"); - // try watcher.removeFile("README.md"); - try watcher.addFile("build.zig"); - // try watcher.removeFile("build.zig"); - try watcher.addFile("build.zig.zon"); - try watcher.removeFile("build.zig.zon"); watcher.setCallback(callback, null); const thread = try std.Thread.spawn(.{}, watcherThread, .{&watcher}); From 97ee9d8def357c7848e93b16bff28b3c263b23c0 Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 21:57:14 +0100 Subject: [PATCH 08/14] fmt: everything --- README.md | 12 ++++++++++-- examples/basic.zig | 2 +- src/watchers/interfaces.zig | 8 +++----- src/watchers/linux.zig | 25 ++++++++++++++++++------- src/watchers/macos.zig | 5 +---- 5 files changed, 33 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f981c22..f413acf 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # fzwatch + A lightweight and cross-platform file watcher for your Zig projects. + > [!NOTE] > This project exists to support [fancy-cat](https://github.com/freref/fancy-cat) and has limited features. ## Instructions + ### Run example + You can run the [examples](./examples/) like so: + ```sh zig build run- ``` + ### Usage + A basic example can be found under [examples](./examples/basic.zig). The API is defined as follows: + ```zig -pub const Event = struct { kind: enum { modified }, item: usize }; +pub const Event = struct { kind: enum { modified }, index: usize }; pub const Callback = fn (context: *anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; @@ -23,4 +31,4 @@ pub fn removeFile(self: *Watcher, path: []const u8) !void; pub fn setCallback(self: *Watcher, callback: Callback) void; pub fn start(self: *Watcher, opts: Opts) !void; pub fn stop(self: *Watcher) !void; -```` +``` diff --git a/examples/basic.zig b/examples/basic.zig index 5185070..c8a7695 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -4,7 +4,7 @@ const fzwatch = @import("fzwatch"); fn callback(context: ?*anyopaque, event: fzwatch.Event) void { _ = context; switch (event.kind) { - .modified => std.debug.print("File {d} was modified!\n", .{event.item}), + .modified => std.debug.print("File {d} was modified!\n", .{event.index}), } } diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index 30c57db..37d1f99 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -1,7 +1,5 @@ -pub const Event = struct { - kind: enum { modified }, - /// the index into `Watcher.paths.items` which this event came from - item: usize -}; +/// Event provides the type of file change and which file was affected via its index +/// in the watcher's file list +pub const Event = struct { kind: enum { modified }, index: usize }; pub const Callback = fn (context: ?*anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index 54cbc72..b239e3c 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -59,7 +59,11 @@ pub const LinuxWatcher = struct { } } - pub fn setCallback(self: *LinuxWatcher, callback: interfaces.Callback, context: ?*anyopaque) void { + pub fn setCallback( + self: *LinuxWatcher, + callback: interfaces.Callback, + context: ?*anyopaque, + ) void { self.callback = callback; self.context = context; } @@ -72,9 +76,15 @@ pub const LinuxWatcher = struct { var buffer: [4096]std.os.linux.inotify_event = undefined; while (self.running) { - const length = std.posix.read(self.inotify.fd, std.mem.sliceAsBytes(&buffer)) catch |err| switch (err) { + const length = std.posix.read( + self.inotify.fd, + std.mem.sliceAsBytes(&buffer), + ) catch |err| switch (err) { error.WouldBlock => { - std.time.sleep(@as(u64, @intFromFloat(@as(f64, opts.latency) * @as(f64, @floatFromInt(std.time.ns_per_s))))); + std.time.sleep(@as(u64, @intFromFloat(@as(f64, opts.latency) * @as( + f64, + @floatFromInt(std.time.ns_per_s), + )))); continue; }, else => { @@ -87,8 +97,9 @@ pub const LinuxWatcher = struct { while (i < length) : (i += buffer[i].len + @sizeOf(std.os.linux.inotify_event)) { const ev = buffer[i]; - if(ev.wd < self.inotify.offset) {continue;} - else if (ev.wd > self.paths.items.len + self.inotify.offset) + if (ev.wd < self.inotify.offset) { + continue; + } else if (ev.wd > self.paths.items.len + self.inotify.offset) return error.InvalidWatchDescriptor; if (ev.mask & std.os.linux.IN.IGNORED == 0 and ev.mask & std.os.linux.IN.MODIFY == 0) @@ -97,11 +108,11 @@ pub const LinuxWatcher = struct { const index = @as(usize, @intCast(@max(0, ev.wd))) - self.inotify.offset; // Editors like vim create temporary files when saving // So we have to re-add the file to the watcher - if(ev.mask & std.os.linux.IN.IGNORED != 0) + if (ev.mask & std.os.linux.IN.IGNORED != 0) try self.addFile(self.paths.items[index]); if (self.callback) |callback| callback(self.context, .{ .kind = .modified, - .item = index + .item = index, }); } } diff --git a/src/watchers/macos.zig b/src/watchers/macos.zig index c190820..8c611d4 100644 --- a/src/watchers/macos.zig +++ b/src/watchers/macos.zig @@ -87,10 +87,7 @@ pub const MacosWatcher = struct { while (i < numEvents) : (i += 1) { const flags = eventFlags[i]; if (flags & c.kFSEventStreamEventFlagItemModified != 0) { - self.callback.?(self.context, .{ - .kind = .modified, - .item = i - }); + self.callback.?(self.context, .{ .kind = .modified, .index = i }); } } } From bcb225d517f6cf59a99f9f27e6983da68b600da5 Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 23:06:52 +0100 Subject: [PATCH 09/14] remove: event index --- examples/basic.zig | 2 +- src/watchers/interfaces.zig | 2 +- src/watchers/linux.zig | 1 - src/watchers/macos.zig | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/basic.zig b/examples/basic.zig index c8a7695..7b4799c 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -4,7 +4,7 @@ const fzwatch = @import("fzwatch"); fn callback(context: ?*anyopaque, event: fzwatch.Event) void { _ = context; switch (event.kind) { - .modified => std.debug.print("File {d} was modified!\n", .{event.index}), + .modified => std.debug.print("File was modified!\n", .{}), } } diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index 37d1f99..cebbe9a 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -1,5 +1,5 @@ /// Event provides the type of file change and which file was affected via its index /// in the watcher's file list -pub const Event = struct { kind: enum { modified }, index: usize }; +pub const Event = struct { kind: enum { modified } }; pub const Callback = fn (context: ?*anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index b239e3c..6b5280f 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -112,7 +112,6 @@ pub const LinuxWatcher = struct { try self.addFile(self.paths.items[index]); if (self.callback) |callback| callback(self.context, .{ .kind = .modified, - .item = index, }); } } diff --git a/src/watchers/macos.zig b/src/watchers/macos.zig index 8c611d4..45f4664 100644 --- a/src/watchers/macos.zig +++ b/src/watchers/macos.zig @@ -87,7 +87,7 @@ pub const MacosWatcher = struct { while (i < numEvents) : (i += 1) { const flags = eventFlags[i]; if (flags & c.kFSEventStreamEventFlagItemModified != 0) { - self.callback.?(self.context, .{ .kind = .modified, .index = i }); + self.callback.?(self.context, .{ .kind = .modified }); } } } From 7540e9bc7c9ff9e704f0e01d4fa3f2101d6754df Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 23:07:15 +0100 Subject: [PATCH 10/14] docs: remove event index --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f413acf..567c1d9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ zig build run- A basic example can be found under [examples](./examples/basic.zig). The API is defined as follows: ```zig -pub const Event = struct { kind: enum { modified }, index: usize }; +pub const Event = struct { kind: enum { modified } }; pub const Callback = fn (context: *anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; From 28f1ce894cbbd188ab940f9d07943687e820f33e Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 23:28:01 +0100 Subject: [PATCH 11/14] testing: add test --- .github/workflows/test.yml | 13 ++++++++ build.zig | 15 ++++++++++ src/main.zig | 61 +++++++++++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 730b77b..8eca66c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,3 +29,16 @@ jobs: - uses: actions/checkout@v4 - uses: mlugg/setup-zig@v1 - run: zig fmt --check src/*.zig + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v1 + with: + version: 0.13.0 + - run: zig build test diff --git a/build.zig b/build.zig index 4fb1b6f..423e340 100644 --- a/build.zig +++ b/build.zig @@ -57,5 +57,20 @@ pub fn build(b: *std.Build) void { const run_step_context = b.step("run-context", "Run the example"); run_step_context.dependOn(&run_cmd_context.step); + const test_step = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + if (target.result.os.tag == .macos) { + test_step.linkFramework("CoreServices"); + } + test_step.linkLibC(); + + const test_run = b.addRunArtifact(test_step); + const test_step_cmd = b.step("test", "Run library tests"); + test_step_cmd.dependOn(&test_run.step); + b.installArtifact(lib); } diff --git a/src/main.zig b/src/main.zig index b0e78bc..5ea2397 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,18 +5,63 @@ pub const Event = @import("watchers/interfaces.zig").Event; const watchers = struct { pub const macos = @import("watchers/macos.zig"); pub const linux = @import("watchers/linux.zig"); - // pub const windows = @import("watchers/windows.zig"); }; -pub const watcher_os = switch (builtin.os.tag) { +pub const Watcher = switch (builtin.os.tag) { .macos => watchers.macos.MacosWatcher, .linux => watchers.linux.LinuxWatcher, - .windows => @compileError("Windows not supported"), - else => @compileError("Unsupported operating system"), + else => @compileError("Unsupported OS"), }; -comptime { - _ = watcher_os; -} +test "detects file modification" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const file_path = "testfile.txt"; + try tmp.dir.writeFile(.{ + .sub_path = file_path, + .data = "initial content", + }); + const abs_path = try tmp.dir.realpathAlloc(std.testing.allocator, file_path); + defer std.testing.allocator.free(abs_path); + + var watcher = try Watcher.init(std.testing.allocator); + defer watcher.deinit(); + try watcher.addFile(abs_path); + + var event_received = std.atomic.Value(bool).init(false); + const callback = struct { + fn handle(ctx: ?*anyopaque, ev: Event) void { + std.debug.assert(ev.kind == .modified); + + const flag = @as(*std.atomic.Value(bool), @ptrCast(ctx.?)); + flag.store(true, .release); + } + }.handle; + watcher.setCallback(callback, &event_received); -pub const Watcher = watcher_os; + const watcher_thread = try std.Thread.spawn(.{}, struct { + fn run(w: *Watcher) !void { + try w.start(.{ .latency = 0.1 }); + } + }.run, .{&watcher}); + + std.time.sleep(100_000_000); + + try tmp.dir.writeFile(.{ + .sub_path = file_path, + .data = "modified content", + }); + + const start = std.time.milliTimestamp(); + while (!event_received.load(.acquire)) { + if (std.time.milliTimestamp() - start > 2000) { + std.debug.print("Timeout waiting for event\n", .{}); + return error.TestFailed; + } + std.time.sleep(10_000_000); + } + + watcher.stop(); + watcher_thread.join(); +} From 618b84a7f0df6efe2e21cc5aad203f64ed7f1975 Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 23:31:23 +0100 Subject: [PATCH 12/14] docs: testing --- README.md | 8 ++++++++ src/watchers/interfaces.zig | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 567c1d9..685aaf4 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,11 @@ pub fn setCallback(self: *Watcher, callback: Callback) void; pub fn start(self: *Watcher, opts: Opts) !void; pub fn stop(self: *Watcher) !void; ``` + +### Testing + +Run the test suite: + +```sh +zig build test +``` diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index cebbe9a..dd4a996 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -1,5 +1,3 @@ -/// Event provides the type of file change and which file was affected via its index -/// in the watcher's file list pub const Event = struct { kind: enum { modified } }; pub const Callback = fn (context: ?*anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; From 6b90bc39f93ea3858022ba92af275f3afcfc79e3 Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 23:34:03 +0100 Subject: [PATCH 13/14] ci: test --- README.md | 2 +- examples/basic.zig | 2 +- examples/context.zig | 2 +- src/main.zig | 2 +- src/watchers/interfaces.zig | 2 +- src/watchers/linux.zig | 2 +- src/watchers/macos.zig | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 685aaf4..c627200 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ zig build run- A basic example can be found under [examples](./examples/basic.zig). The API is defined as follows: ```zig -pub const Event = struct { kind: enum { modified } }; +pub const Event = enum { modified }; pub const Callback = fn (context: *anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; diff --git a/examples/basic.zig b/examples/basic.zig index 7b4799c..575fac8 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -3,7 +3,7 @@ const fzwatch = @import("fzwatch"); fn callback(context: ?*anyopaque, event: fzwatch.Event) void { _ = context; - switch (event.kind) { + switch (event) { .modified => std.debug.print("File was modified!\n", .{}), } } diff --git a/examples/context.zig b/examples/context.zig index 4787dd0..049a764 100644 --- a/examples/context.zig +++ b/examples/context.zig @@ -8,7 +8,7 @@ const Object = struct { thread: ?std.Thread, fn callback(context: ?*anyopaque, event: fzwatch.Event) void { - switch (event.kind) { + switch (event) { .modified => { const to_increment: *usize = @as(*usize, @ptrCast(@alignCast(context.?))); to_increment.* += 1; diff --git a/src/main.zig b/src/main.zig index 5ea2397..c2404af 100644 --- a/src/main.zig +++ b/src/main.zig @@ -32,7 +32,7 @@ test "detects file modification" { var event_received = std.atomic.Value(bool).init(false); const callback = struct { fn handle(ctx: ?*anyopaque, ev: Event) void { - std.debug.assert(ev.kind == .modified); + std.debug.assert(ev == .modified); const flag = @as(*std.atomic.Value(bool), @ptrCast(ctx.?)); flag.store(true, .release); diff --git a/src/watchers/interfaces.zig b/src/watchers/interfaces.zig index dd4a996..403016e 100644 --- a/src/watchers/interfaces.zig +++ b/src/watchers/interfaces.zig @@ -1,3 +1,3 @@ -pub const Event = struct { kind: enum { modified } }; +pub const Event = enum { modified }; pub const Callback = fn (context: ?*anyopaque, event: Event) void; pub const Opts = struct { latency: f16 = 1.0 }; diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index 6b5280f..ecb9850 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -111,7 +111,7 @@ pub const LinuxWatcher = struct { if (ev.mask & std.os.linux.IN.IGNORED != 0) try self.addFile(self.paths.items[index]); if (self.callback) |callback| callback(self.context, .{ - .kind = .modified, + .modified, }); } } diff --git a/src/watchers/macos.zig b/src/watchers/macos.zig index 45f4664..7d34965 100644 --- a/src/watchers/macos.zig +++ b/src/watchers/macos.zig @@ -87,7 +87,7 @@ pub const MacosWatcher = struct { while (i < numEvents) : (i += 1) { const flags = eventFlags[i]; if (flags & c.kFSEventStreamEventFlagItemModified != 0) { - self.callback.?(self.context, .{ .kind = .modified }); + self.callback.?(self.context, .modified); } } } From 8648011d5b33fc8cae6394423c8eacb3b43e0064 Mon Sep 17 00:00:00 2001 From: frehml Date: Wed, 19 Feb 2025 23:35:00 +0100 Subject: [PATCH 14/14] ci: success --- src/watchers/linux.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index ecb9850..6f6a4e3 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -110,9 +110,7 @@ pub const LinuxWatcher = struct { // So we have to re-add the file to the watcher if (ev.mask & std.os.linux.IN.IGNORED != 0) try self.addFile(self.paths.items[index]); - if (self.callback) |callback| callback(self.context, .{ - .modified, - }); + if (self.callback) |callback| callback(self.context, .modified); } } }