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/README.md b/README.md index 1625113..c627200 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,29 @@ # 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 + ### 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 = 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; @@ -23,4 +31,12 @@ 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; -```` +``` + +### Testing + +Run the test suite: + +```sh +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..c2404af 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 == .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(); +} diff --git a/src/watchers/linux.zig b/src/watchers/linux.zig index b38e028..6f6a4e3 100644 --- a/src/watchers/linux.zig +++ b/src/watchers/linux.zig @@ -3,12 +3,15 @@ const interfaces = @import("interfaces.zig"); pub const LinuxWatcher = struct { allocator: std.mem.Allocator, - inotify_fd: i32, paths: std.ArrayList([]const u8), - offset: usize, + inotify: struct { + fd: i32, + /// inotify wd offset from removing files + 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); @@ -16,24 +19,26 @@ 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, + }, .callback = null, - .running = false, .context = null, + .running = false, }; } pub fn deinit(self: *LinuxWatcher) void { self.stop(); self.paths.deinit(); - std.posix.close(self.inotify_fd); + std.posix.close(self.inotify.fd); } pub fn addFile(self: *LinuxWatcher, path: []const u8) !void { _ = try std.posix.inotify_add_watch( - self.inotify_fd, + self.inotify.fd, path, std.os.linux.IN.MODIFY, ); @@ -42,17 +47,23 @@ 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; - return; - } + for (0.., self.paths.items) |idx, mem_path| { + if (!std.mem.eql(u8, mem_path, path)) + continue; + + _ = std.posix.inotify_rm_watch(self.inotify.fd, @intCast(idx + self.inotify.offset)); + self.inotify.offset += 1; + _ = self.paths.orderedRemove(idx); + + return; } } - 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; } @@ -62,12 +73,18 @@ 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))))); + std.time.sleep(@as(u64, @intFromFloat(@as(f64, opts.latency) * @as( + f64, + @floatFromInt(std.time.ns_per_s), + )))); continue; }, else => { @@ -75,30 +92,25 @@ pub const LinuxWatcher = struct { }, }; - var ptr: [*]u8 = &buffer; - const end_ptr = ptr + @as(usize, @intCast(length)); + // 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) { + 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) + continue; - while (@intFromPtr(ptr) < @intFromPtr(end_ptr)) { - const ev = @as(*const std.os.linux.inotify_event, @ptrCast(@alignCast(ptr))); + 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))); - if (wd_usize < self.offset) { - return error.InvalidWatchDescriptor; - } - const index = wd_usize - self.offset; + if (ev.mask & std.os.linux.IN.IGNORED != 0) try self.addFile(self.paths.items[index]); - if (self.callback) |callback| { - callback(self.context, interfaces.Event.modified); - } - } else if (ev.mask & std.os.linux.IN.MODIFY != 0) { - if (self.callback) |callback| { - callback(self.context, interfaces.Event.modified); - } - } - - ptr = @alignCast(ptr + @sizeOf(std.os.linux.inotify_event) + ev.len); + if (self.callback) |callback| callback(self.context, .modified); } } } diff --git a/src/watchers/macos.zig b/src/watchers/macos.zig index de600cd..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, interfaces.Event.modified); + self.callback.?(self.context, .modified); } } }