const std = @import("std"); const INotify = @import("inotify.zig"); const Io = std.Io; const Allocator = std.mem.Allocator; const assert = std.debug.assert; const FileWatcher = @This(); root_dir: []u8, inotify: INotify, files: std.ArrayList(File), watches: std.ArrayList(Watch), const File = struct { path: []u8, debounce: Io.Duration, last_event_at: ?Io.Timestamp, pub fn deinit(self: File, gpa: Allocator) void { gpa.free(self.path); } }; const Watch = struct { wd: ?u32, path: []u8, pub fn deinit(self: Watch, gpa: Allocator) void { gpa.free(self.path); } }; pub fn init(gpa: Allocator, root_dir: []const u8) !FileWatcher { if (!std.fs.path.isAbsolute(root_dir)) { return error.PathIsNotAbsolute; } const inotify_buf = try gpa.alloc(u8, INotify.max_event_size * 10); errdefer gpa.free(inotify_buf); const root_dir_owned = try gpa.dupe(u8, root_dir); errdefer gpa.free(root_dir_owned); return FileWatcher{ .root_dir = root_dir_owned, .inotify = try .init(inotify_buf), .files = .empty, .watches = .empty }; } pub fn deinit(self: *FileWatcher, gpa: Allocator) void { gpa.free(self.root_dir); gpa.free(self.inotify.buf); self.inotify.deinit(); for (self.files.items) |file| { file.deinit(gpa); } self.files.deinit(gpa); for (self.watches.items) |watch| { watch.deinit(gpa); } self.watches.deinit(gpa); } fn findFileIndex(self: *FileWatcher, path: []const u8) ?usize { for (0.., self.files.items) |i, file| { if (std.mem.eql(u8, file.path, path)) { return i; } } return null; } fn findFile(self: *FileWatcher, path: []const u8) ?*File { if (self.findFileIndex(path)) |file_index| { return &self.files.items[file_index]; } else { return null; } } fn findWatchByPath(self: *FileWatcher, path: []const u8) ?usize { for (0.., self.watches.items) |i, watch| { if (std.mem.eql(u8, watch.path, path)) { return i; } } return null; } fn findWatchByDescriptor(self: *FileWatcher, wd: u32) ?usize { for (0.., self.watches.items) |i, watch| { if (watch.wd == wd) { return i; } } return null; } fn bufJoinPaths(path_buff: []u8, paths: []const []const u8) ![:0]u8 { return try std.fmt.bufPrintZ(path_buff, "{f}", .{ std.fs.path.fmtJoin(paths) }); } fn attemptRegisteringWatch(self: *FileWatcher, watch: *Watch) !void { if (watch.wd != null) { return; } var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; const path = try bufJoinPaths(&path_buff, &.{ self.root_dir, watch.path }); watch.wd = self.inotify.add(path, INotify.Mask{ .create = true, .modify = true, .delete_self = true, .move_self = true, .moved_to = true, .moved_from = true, }) catch |err| switch (err) { error.FileNotFound => null, else => return err }; } pub fn toRelativePath(outer_path: []const u8, inner_path: []const u8) ?[]const u8 { if (inner_path.len == outer_path.len) { if (std.mem.eql(u8, inner_path, outer_path)) { return ""; } } else if (inner_path.len > outer_path.len) { if (std.mem.startsWith(u8, inner_path, outer_path) and inner_path[outer_path.len] == std.fs.path.sep) { return inner_path[(outer_path.len+1)..]; } } return null; } fn isInside(outer_path: []const u8, inner_path: []const u8) bool { return toRelativePath(outer_path, inner_path) != null; } fn unregisterWatchesRecursively(self: *FileWatcher, dir: []const u8) !void { for (self.watches.items) |*watch| { const wd = watch.wd orelse continue; if (isInside(dir, watch.path)) { try self.inotify.remove(wd); watch.wd = null; } } } fn registerWatchesRecursively(self: *FileWatcher, dir: []const u8) !void { for (self.watches.items) |*watch| { if (watch.wd == null and isInside(dir, watch.path)) { try self.attemptRegisteringWatch(watch); } } } fn ensureWatchExists(self: *FileWatcher, gpa: Allocator, path: []const u8) !usize { var watch_index: usize = undefined; if (self.findWatchByPath(path)) |found_watch_index| { watch_index = found_watch_index; } else { try self.watches.ensureUnusedCapacity(gpa, 1); const path_owned = try gpa.dupe(u8, path); errdefer gpa.free(path_owned); watch_index = self.watches.items.len; self.watches.appendAssumeCapacity(Watch{ .wd = null, .path = path_owned }); } try self.attemptRegisteringWatch(&self.watches.items[watch_index]); return watch_index; } fn removeWatch(self: *FileWatcher, gpa: Allocator, index: usize) void { const watch = self.watches.swapRemove(index); watch.deinit(gpa); } pub fn add(self: *FileWatcher, gpa: Allocator, path: []const u8, debounce: Io.Duration) !void { if (std.fs.path.isAbsolute(path)) { return error.PathIsAbsolute; } if (self.findFileIndex(path) != null) { return; } var dir_iter: ?[]const u8 = path; while (dir_iter) |current| { _ = try self.ensureWatchExists(gpa, current); dir_iter = std.fs.path.dirname(current); } _ = try self.ensureWatchExists(gpa, ""); try self.files.ensureUnusedCapacity(gpa, 1); const path_owned = try gpa.dupe(u8, path); errdefer gpa.free(path_owned); self.files.appendAssumeCapacity(File{ .path = path_owned, .debounce = debounce, .last_event_at = null }); } pub fn remove(self: *FileWatcher, gpa: Allocator, path: []const u8) void { const index = self.findFileIndex(path); if (index == null) { return; } const file = self.files.items[index]; self.files.swapRemove(index); file.deinit(gpa); } fn nextEvents(self: *FileWatcher) !?*File { while (try self.inotify.next()) |e| { const watch_index = self.findWatchByDescriptor(e.wd) orelse continue; var watch = &self.watches.items[watch_index]; if (e.mask.ignored) { watch.wd = null; } else if (e.mask.q_overflow) { return error.QueueOverflow; } else if (e.mask.delete_self or e.mask.modify) { if (self.findFile(watch.path)) |file| { return file; } } else if (e.mask.create) { assert(e.name != null); var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) }); if (self.findWatchByPath(path)) |child_watch_index| { try self.attemptRegisteringWatch(&self.watches.items[child_watch_index]); } if (self.findFile(path)) |file| { return file; } } else if (e.mask.moved_from) { assert(e.name != null); var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) }); try self.unregisterWatchesRecursively(path); if (self.findFile(path)) |file| { return file; } } else if (e.mask.moved_to) { assert(e.name != null); var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) }); try self.registerWatchesRecursively(path); if (self.findFile(path)) |file| { return file; } } } return null; } pub fn next(self: *FileWatcher, io: Io) !?[]const u8 { const now = Io.Clock.real.now(io); var queue_overflow = false; while (true) { const file = (self.nextEvents() catch |err| switch (err) { error.QueueOverflow => { queue_overflow = true; break; }, else => return err }) orelse break; file.last_event_at = now; } if (queue_overflow) { for (self.files.items) |*file| { file.last_event_at = now; } } for (self.files.items) |*file| { const last_event_at = file.last_event_at orelse continue; if (now.nanoseconds > last_event_at.addDuration(file.debounce).nanoseconds) { file.last_event_at = null; return file.path; } } return null; }