Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
# 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-<filename>
```

### 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;
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
```
15 changes: 15 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
61 changes: 53 additions & 8 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
90 changes: 51 additions & 39 deletions src/watchers/linux.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,42 @@ 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);
errdefer std.posix.close(fd);

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,
);
Expand All @@ -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;
}
Expand All @@ -62,43 +73,44 @@ 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 => {
return err;
},
};

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);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/watchers/macos.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down