diff --git a/src/main.zig b/src/main.zig index d711b8f..ee32037 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,36 +24,27 @@ const CliHandler = struct { .wait_readable = if (nightwatch.linux_poll_mode) wait_readable_cb else {}, }; - fn change_cb(h: *nightwatch.Handler, path: []const u8, event_type: nightwatch.EventType, object_type: nightwatch.ObjectType) error{HandlerFailed}!void { + fn change_cb(h: *nightwatch.Handler, path: []const u8, event_type: nightwatch.EventType) error{HandlerFailed}!void { const self: *CliHandler = @fieldParentPtr("handler", h); var buf: [4096]u8 = undefined; var stdout = self.out.writer(&buf); defer stdout.interface.flush() catch {}; - const event_label = switch (event_type) { + const label = switch (event_type) { .created => "create ", .modified => "modify ", .deleted => "delete ", + .dir_created => "mkdir ", .renamed => "rename ", }; - const type_label = switch (object_type) { - .file => "file", - .dir => "dir ", - .unknown => "? ", - }; - stdout.interface.print("{s} {s} {s}\n", .{ event_label, type_label, path }) catch return error.HandlerFailed; + stdout.interface.print("{s} {s}\n", .{ label, path }) catch return error.HandlerFailed; } - fn rename_cb(h: *nightwatch.Handler, src: []const u8, dst: []const u8, object_type: nightwatch.ObjectType) error{HandlerFailed}!void { + fn rename_cb(h: *nightwatch.Handler, src: []const u8, dst: []const u8) error{HandlerFailed}!void { const self: *CliHandler = @fieldParentPtr("handler", h); var buf: [4096]u8 = undefined; var stdout = self.out.writer(&buf); defer stdout.interface.flush() catch {}; - const type_label = switch (object_type) { - .file => "file", - .dir => "dir ", - .unknown => "? ", - }; - stdout.interface.print("rename {s} {s} -> {s}\n", .{ type_label, src, dst }) catch return error.HandlerFailed; + stdout.interface.print("rename {s} -> {s}\n", .{ src, dst }) catch return error.HandlerFailed; } fn wait_readable_cb(_: *nightwatch.Handler) error{HandlerFailed}!nightwatch.ReadableStatus { @@ -110,14 +101,13 @@ fn usage(out: std.fs.File) !void { \\ \\The Watch never sleeps. \\ - \\Events printed to stdout (columns: event type path): - \\ create a file or directory was created + \\Events printed to stdout: + \\ create a file was created \\ modify a file was modified \\ delete a file or directory was deleted + \\ mkdir a directory was created \\ rename a file or directory was renamed \\ - \\Type column: file, dir, or ? (unknown) - \\ \\Stand down with Ctrl-C. \\ , .{}); diff --git a/src/nightwatch.zig b/src/nightwatch.zig index 04374a3..9b19dcd 100644 --- a/src/nightwatch.zig +++ b/src/nightwatch.zig @@ -6,19 +6,14 @@ pub const EventType = enum { created, modified, deleted, + /// A new directory was created inside a watched directory. + /// The library automatically begins watching it; no action is required. + dir_created, /// Only produced on macOS and Windows where the OS gives no pairing info. - /// On Linux, paired renames are emitted as a rename event with both paths instead. + /// On Linux, paired renames are emitted as a { "FW", "rename", from, to } message instead. renamed, }; -pub const ObjectType = enum { - file, - dir, - /// The object type could not be determined (e.g. a deleted file on Windows - /// where the path no longer exists to query). - unknown, -}; - pub const Error = error{ HandlerFailed, OutOfMemory, @@ -34,18 +29,18 @@ pub const Handler = struct { vtable: *const VTable, pub const VTable = struct { - change: *const fn (handler: *Handler, path: []const u8, event_type: EventType, object_type: ObjectType) error{HandlerFailed}!void, - rename: *const fn (handler: *Handler, src_path: []const u8, dst_path: []const u8, object_type: ObjectType) error{HandlerFailed}!void, + change: *const fn (handler: *Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void, + rename: *const fn (handler: *Handler, src_path: []const u8, dst_path: []const u8) error{HandlerFailed}!void, /// Only present in Linux poll mode (linux_poll_mode == true). wait_readable: if (linux_poll_mode) *const fn (handler: *Handler) error{HandlerFailed}!ReadableStatus else void, }; - fn change(handler: *Handler, path: []const u8, event_type: EventType, object_type: ObjectType) error{HandlerFailed}!void { - return handler.vtable.change(handler, path, event_type, object_type); + fn change(handler: *Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void { + return handler.vtable.change(handler, path, event_type); } - fn rename(handler: *Handler, src_path: []const u8, dst_path: []const u8, object_type: ObjectType) error{HandlerFailed}!void { - return handler.vtable.rename(handler, src_path, dst_path, object_type); + fn rename(handler: *Handler, src_path: []const u8, dst_path: []const u8) error{HandlerFailed}!void { + return handler.vtable.rename(handler, src_path, dst_path); } fn wait_readable(handler: *Handler) error{HandlerFailed}!ReadableStatus { @@ -143,18 +138,18 @@ const Interceptor = struct { .wait_readable = if (linux_poll_mode) wait_readable_cb else {}, }; - fn change_cb(h: *Handler, path: []const u8, event_type: EventType, object_type: ObjectType) error{HandlerFailed}!void { + fn change_cb(h: *Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void { const self: *Interceptor = @fieldParentPtr("handler", h); - if (event_type == .created and object_type == .dir and !Backend.watches_recursively) { + if (event_type == .dir_created and !Backend.watches_recursively) { self.backend.add_watch(self.allocator, path) catch {}; recurse_watch(&self.backend, self.allocator, path); } - return self.user_handler.change(path, event_type, object_type); + return self.user_handler.change(path, event_type); } - fn rename_cb(h: *Handler, src: []const u8, dst: []const u8, object_type: ObjectType) error{HandlerFailed}!void { + fn rename_cb(h: *Handler, src: []const u8, dst: []const u8) error{HandlerFailed}!void { const self: *Interceptor = @fieldParentPtr("handler", h); - return self.user_handler.rename(src, dst, object_type); + return self.user_handler.rename(src, dst); } fn wait_readable_cb(h: *Handler) error{HandlerFailed}!ReadableStatus { @@ -316,7 +311,6 @@ const INotifyBackend = struct { const PendingRename = struct { cookie: u32, path: []u8, // owned by drain's allocator - object_type: ObjectType, }; var buf: [4096]u8 align(@alignOf(InotifyEvent)) = undefined; @@ -324,7 +318,7 @@ const INotifyBackend = struct { defer { // Any unpaired MOVED_FROM means the file was moved out of the watched tree. for (pending_renames.items) |r| { - self.handler.change(r.path, EventType.deleted, r.object_type) catch {}; + self.handler.change(r.path, EventType.deleted) catch {}; allocator.free(r.path); } pending_renames.deinit(allocator); @@ -361,7 +355,6 @@ const INotifyBackend = struct { try pending_renames.append(allocator, .{ .cookie = ev.cookie, .path = try allocator.dupe(u8, full_path), - .object_type = if (ev.mask & IN.ISDIR != 0) .dir else .file, }); } else if (ev.mask & IN.MOVED_TO != 0) { // Look for a paired MOVED_FROM. @@ -376,32 +369,29 @@ const INotifyBackend = struct { // Complete rename pair: emit a single atomic rename message. const r = pending_renames.swapRemove(i); defer allocator.free(r.path); - try self.handler.rename(r.path, full_path, r.object_type); + try self.handler.rename(r.path, full_path); } else { // No paired MOVED_FROM, file was moved in from outside the watched tree. - const ot: ObjectType = if (ev.mask & IN.ISDIR != 0) .dir else .file; - try self.handler.change(full_path, EventType.created, ot); + try self.handler.change(full_path, EventType.created); } } else if (ev.mask & IN.MOVE_SELF != 0) { // The watched directory itself was renamed/moved away. - try self.handler.change(full_path, EventType.deleted, .dir); + try self.handler.change(full_path, EventType.deleted); } else { - const is_dir = ev.mask & IN.ISDIR != 0; - const object_type: ObjectType = if (is_dir) .dir else .file; const event_type: EventType = if (ev.mask & IN.CREATE != 0) - .created + if (ev.mask & IN.ISDIR != 0) .dir_created else .created else if (ev.mask & (IN.DELETE | IN.DELETE_SELF) != 0) blk: { // Suppress IN_DELETE|IN_ISDIR for subdirs that have their // own watch: IN_DELETE_SELF on that watch will fire the // same path without duplication. - if (is_dir and self.has_watch_for_path(full_path)) + if (ev.mask & IN.ISDIR != 0 and self.has_watch_for_path(full_path)) continue; break :blk .deleted; } else if (ev.mask & (IN.MODIFY | IN.CLOSE_WRITE) != 0) .modified else continue; - try self.handler.change(full_path, event_type, object_type); + try self.handler.change(full_path, event_type); } } } @@ -609,18 +599,19 @@ const FSEventsBackend = struct { // FSEvents coalesces operations, so multiple flags may be set on // a single event. Emit one change call per applicable flag so // callers see all relevant event types (e.g. created + modified). - const ot: ObjectType = if (flags & kFSEventStreamEventFlagItemIsDir != 0) .dir else .file; + const is_dir = flags & kFSEventStreamEventFlagItemIsDir != 0; if (flags & kFSEventStreamEventFlagItemCreated != 0) { - ctx.handler.change(path, .created, ot) catch {}; + const et: EventType = if (is_dir) .dir_created else .created; + ctx.handler.change(path, et) catch {}; } if (flags & kFSEventStreamEventFlagItemRemoved != 0) { - ctx.handler.change(path, .deleted, ot) catch {}; + ctx.handler.change(path, .deleted) catch {}; } if (flags & kFSEventStreamEventFlagItemRenamed != 0) { - ctx.handler.change(path, .renamed, ot) catch {}; + ctx.handler.change(path, .renamed) catch {}; } if (flags & kFSEventStreamEventFlagItemModified != 0) { - ctx.handler.change(path, .modified, ot) catch {}; + ctx.handler.change(path, .modified) catch {}; } } } @@ -764,7 +755,7 @@ const KQueueBackend = struct { self.file_watches_mutex.unlock(); if (file_path) |fp| { if (ev.fflags & (NOTE_WRITE | NOTE_EXTEND) != 0) - self.handler.change(fp, EventType.modified, .file) catch return; + self.handler.change(fp, EventType.modified) catch return; continue; } @@ -777,9 +768,9 @@ const KQueueBackend = struct { self.watches_mutex.unlock(); if (dir_path == null) continue; if (ev.fflags & NOTE_DELETE != 0) { - self.handler.change(dir_path.?, EventType.deleted, .dir) catch return; + self.handler.change(dir_path.?, EventType.deleted) catch return; } else if (ev.fflags & NOTE_RENAME != 0) { - self.handler.change(dir_path.?, EventType.renamed, .dir) catch return; + self.handler.change(dir_path.?, EventType.renamed) catch return; } else if (ev.fflags & NOTE_WRITE != 0) { self.scan_dir(allocator, dir_path.?) catch {}; } @@ -876,10 +867,10 @@ const KQueueBackend = struct { self.snapshots_mutex.unlock(); // Emit all events outside the lock so handlers may safely call watch()/unwatch(). - // Emit created dirs, then deletions, then creations. Deletions first ensures that + // Emit dir_created, then deletions, then creations. Deletions first ensures that // a rename (old disappears, new appears) reports the source path before the dest. for (new_dirs.items) |full_path| - try self.handler.change(full_path, EventType.created, .dir); + try self.handler.change(full_path, EventType.dir_created); for (to_delete.items) |name| { var path_buf: [std.fs.max_path_bytes]u8 = undefined; const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, name }) catch { @@ -887,14 +878,14 @@ const KQueueBackend = struct { continue; }; self.deregister_file_watch(allocator, full_path); - try self.handler.change(full_path, EventType.deleted, .file); + try self.handler.change(full_path, EventType.deleted); allocator.free(name); } for (to_create.items) |name| { var path_buf: [std.fs.max_path_bytes]u8 = undefined; const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, name }) catch continue; self.register_file_watch(allocator, full_path); - try self.handler.change(full_path, EventType.created, .file); + try self.handler.change(full_path, EventType.created); } } @@ -1122,7 +1113,6 @@ const WindowsBackend = struct { thread: ?std.Thread, watches: std.StringHashMapUnmanaged(Watch), watches_mutex: std.Thread.Mutex, - path_types: std.StringHashMapUnmanaged(ObjectType), // A completion key of zero is used to signal the background thread to exit. const SHUTDOWN_KEY: windows.ULONG_PTR = 0; @@ -1159,7 +1149,7 @@ const WindowsBackend = struct { fn init(handler: *Handler) windows.CreateIoCompletionPortError!@This() { const iocp = try windows.CreateIoCompletionPort(windows.INVALID_HANDLE_VALUE, null, 0, 1); - return .{ .handler = handler, .iocp = iocp, .thread = null, .watches = .empty, .watches_mutex = .{}, .path_types = .empty }; + return .{ .handler = handler, .iocp = iocp, .thread = null, .watches = .empty, .watches_mutex = .{} }; } fn deinit(self: *@This(), allocator: std.mem.Allocator) void { @@ -1173,23 +1163,19 @@ const WindowsBackend = struct { allocator.free(entry.value_ptr.*.buf); } self.watches.deinit(allocator); - var pt_it = self.path_types.iterator(); - while (pt_it.next()) |entry| allocator.free(entry.key_ptr.*); - self.path_types.deinit(allocator); _ = win32.CloseHandle(self.iocp); } fn arm(self: *@This(), allocator: std.mem.Allocator) (error{AlreadyArmed} || std.Thread.SpawnError)!void { + _ = allocator; if (self.thread != null) return error.AlreadyArmed; - self.thread = try std.Thread.spawn(.{}, thread_fn, .{ allocator, self.iocp, &self.watches, &self.watches_mutex, &self.path_types, self.handler }); + self.thread = try std.Thread.spawn(.{}, thread_fn, .{ self.iocp, &self.watches, &self.watches_mutex, self.handler }); } fn thread_fn( - allocator: std.mem.Allocator, iocp: windows.HANDLE, watches: *std.StringHashMapUnmanaged(Watch), watches_mutex: *std.Thread.Mutex, - path_types: *std.StringHashMapUnmanaged(ObjectType), handler: *Handler, ) void { var bytes: windows.DWORD = 0; @@ -1229,41 +1215,29 @@ const WindowsBackend = struct { offset += info.NextEntryOffset; continue; }; - // Determine object_type: try GetFileAttributesW; cache result. - const object_type: ObjectType = if (event_type == .deleted) blk: { - // Path no longer exists; use cached type if available. - const cached = path_types.fetchRemove(full_path); - break :blk if (cached) |kv| blk2: { - allocator.free(kv.key); - break :blk2 kv.value; - } else .unknown; - } else blk: { + // Distinguish files from directories. + const is_dir = blk: { var full_path_w: [std.fs.max_path_bytes]windows.WCHAR = undefined; - const len = std.unicode.utf8ToUtf16Le(&full_path_w, full_path) catch break :blk .unknown; + const len = std.unicode.utf8ToUtf16Le(&full_path_w, full_path) catch break :blk false; full_path_w[len] = 0; const attrs = win32.GetFileAttributesW(full_path_w[0..len :0]); const INVALID: windows.DWORD = 0xFFFFFFFF; const FILE_ATTRIBUTE_DIRECTORY: windows.DWORD = 0x10; - const ot: ObjectType = if (attrs == INVALID) .unknown else if (attrs & FILE_ATTRIBUTE_DIRECTORY != 0) .dir else .file; - // Cache the determined type. - if (ot != .unknown) { - const gop = path_types.getOrPut(allocator, full_path) catch break :blk ot; - if (!gop.found_existing) { - gop.key_ptr.* = allocator.dupe(u8, full_path) catch { - _ = path_types.remove(full_path); - break :blk ot; - }; - } - gop.value_ptr.* = ot; - } - break :blk ot; + break :blk attrs != INVALID and (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0; }; + const adjusted_event_type: EventType = if (is_dir and event_type == .created) + .dir_created + else if (is_dir) { // Other directory events (modified, deleted, renamed), skip. + if (info.NextEntryOffset == 0) break; + offset += info.NextEntryOffset; + continue; + } else event_type; // Capture next_entry_offset before releasing the mutex: after unlock, // the main thread may call remove_watch() which frees w.buf, making // the `info` pointer (which points into w.buf) a dangling reference. const next_entry_offset = info.NextEntryOffset; watches_mutex.unlock(); - handler.change(full_path, event_type, object_type) catch { + handler.change(full_path, adjusted_event_type) catch { watches_mutex.lock(); break; }; diff --git a/src/nightwatch_test.zig b/src/nightwatch_test.zig index ab3c0d0..129c291 100644 --- a/src/nightwatch_test.zig +++ b/src/nightwatch_test.zig @@ -7,7 +7,7 @@ const nw = @import("nightwatch"); // --------------------------------------------------------------------------- const RecordedEvent = union(enum) { - change: struct { path: []u8, event_type: nw.EventType, object_type: nw.ObjectType }, + change: struct { path: []u8, event_type: nw.EventType }, rename: struct { src: []u8, dst: []u8 }, fn deinit(self: RecordedEvent, allocator: std.mem.Allocator) void { @@ -52,18 +52,18 @@ const TestHandler = struct { .wait_readable = if (nw.linux_poll_mode) wait_readable_cb else {}, }; - fn change_cb(handler: *nw.Handler, path: []const u8, event_type: nw.EventType, object_type: nw.ObjectType) error{HandlerFailed}!void { + fn change_cb(handler: *nw.Handler, path: []const u8, event_type: nw.EventType) error{HandlerFailed}!void { const self: *TestHandler = @fieldParentPtr("handler", handler); const owned = self.allocator.dupe(u8, path) catch return error.HandlerFailed; self.events.append(self.allocator, .{ - .change = .{ .path = owned, .event_type = event_type, .object_type = object_type }, + .change = .{ .path = owned, .event_type = event_type }, }) catch { self.allocator.free(owned); return error.HandlerFailed; }; } - fn rename_cb(handler: *nw.Handler, src: []const u8, dst: []const u8, _: nw.ObjectType) error{HandlerFailed}!void { + fn rename_cb(handler: *nw.Handler, src: []const u8, dst: []const u8) error{HandlerFailed}!void { const self: *TestHandler = @fieldParentPtr("handler", handler); const owned_src = self.allocator.dupe(u8, src) catch return error.HandlerFailed; errdefer self.allocator.free(owned_src); @@ -88,8 +88,8 @@ const TestHandler = struct { // Query helpers // ----------------------------------------------------------------------- - fn hasChange(self: *const TestHandler, path: []const u8, event_type: nw.EventType, object_type: nw.ObjectType) bool { - return self.indexOfChange(path, event_type, object_type) != null; + fn hasChange(self: *const TestHandler, path: []const u8, event_type: nw.EventType) bool { + return self.indexOfChange(path, event_type) != null; } fn hasRename(self: *const TestHandler, src: []const u8, dst: []const u8) bool { @@ -97,11 +97,10 @@ const TestHandler = struct { } /// Returns the list index of the first matching change event, or null. - fn indexOfChange(self: *const TestHandler, path: []const u8, event_type: nw.EventType, object_type: nw.ObjectType) ?usize { + fn indexOfChange(self: *const TestHandler, path: []const u8, event_type: nw.EventType) ?usize { for (self.events.items, 0..) |e, i| { if (e == .change and e.change.event_type == event_type and - e.change.object_type == object_type and std.mem.eql(u8, e.change.path, path)) return i; } return null; @@ -217,7 +216,7 @@ test "creating a file emits a 'created' event" { try drainEvents(&watcher); - try std.testing.expect(th.hasChange(file_path, .created, .file)); + try std.testing.expect(th.hasChange(file_path, .created)); } test "writing to a file emits a 'modified' event" { @@ -252,7 +251,7 @@ test "writing to a file emits a 'modified' event" { try drainEvents(&watcher); - try std.testing.expect(th.hasChange(file_path, .modified, .file)); + try std.testing.expect(th.hasChange(file_path, .modified)); } test "deleting a file emits a 'deleted' event" { @@ -269,25 +268,23 @@ test "deleting a file emits a 'deleted' event" { const file_path = try std.fs.path.join(allocator, &.{ tmp, "gone.txt" }); defer allocator.free(file_path); - - var watcher = try Watcher.init(allocator, &th.handler); - defer watcher.deinit(); - try watcher.watch(tmp); - - // Create the file after the watcher is active so the backend can cache its type. { const f = try std.fs.createFileAbsolute(file_path, .{}); f.close(); } + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + try std.fs.deleteFileAbsolute(file_path); try drainEvents(&watcher); - try std.testing.expect(th.hasChange(file_path, .deleted, .file)); + try std.testing.expect(th.hasChange(file_path, .deleted)); } -test "creating a sub-directory emits a 'created' event with object_type dir" { +test "creating a sub-directory emits a 'dir_created' event" { const allocator = std.testing.allocator; const tmp = try makeTempDir(allocator); @@ -309,7 +306,7 @@ test "creating a sub-directory emits a 'created' event with object_type dir" { try drainEvents(&watcher); - try std.testing.expect(th.hasChange(dir_path, .created, .dir)); + try std.testing.expect(th.hasChange(dir_path, .dir_created)); } test "renaming a file is reported correctly per-platform" { @@ -347,8 +344,8 @@ test "renaming a file is reported correctly per-platform" { try std.testing.expect(th.hasRename(src_path, dst_path)); } else { // macOS/Windows emit individual .renamed change events per path. - const has_old = th.hasChange(src_path, .renamed, .file) or th.hasChange(src_path, .deleted, .file); - const has_new = th.hasChange(dst_path, .renamed, .file) or th.hasChange(dst_path, .created, .file); + const has_old = th.hasChange(src_path, .renamed) or th.hasChange(src_path, .deleted); + const has_new = th.hasChange(dst_path, .renamed) or th.hasChange(dst_path, .created); try std.testing.expect(has_old or has_new); } } @@ -410,7 +407,7 @@ test "unwatch stops delivering events for that directory" { f.close(); } try drainEvents(&watcher); - try std.testing.expect(th.hasChange(file1, .created, .file)); + try std.testing.expect(th.hasChange(file1, .created)); // Stop watching, then create another file - must NOT appear. watcher.unwatch(tmp); @@ -457,7 +454,7 @@ test "multiple files created sequentially all appear in the event list" { try drainEvents(&watcher); for (paths) |p| { - try std.testing.expect(th.hasChange(p, .created, .file)); + try std.testing.expect(th.hasChange(p, .created)); } } @@ -498,8 +495,8 @@ test "rename: old-name event precedes new-name event" { // Both paths must have produced some event. const src_idx = th.indexOfAnyPath(src_path) orelse return error.MissingSrcEvent; - const dst_idx = th.indexOfChange(dst_path, .renamed, .file) orelse - th.indexOfChange(dst_path, .created, .file) orelse + const dst_idx = th.indexOfChange(dst_path, .renamed) orelse + th.indexOfChange(dst_path, .created) orelse return error.MissingDstEvent; // The source (old name) event must precede the destination (new name) event. @@ -555,7 +552,7 @@ test "rename-then-modify: rename event precedes the subsequent modify event" { th.indexOfAnyPath(src_path) orelse return error.MissingSrcEvent; // The modify event on the new name must come strictly after the rename. - const modify_idx = th.indexOfChange(dst_path, .modified, .file) orelse + const modify_idx = th.indexOfChange(dst_path, .modified) orelse return error.MissingModifyEvent; try std.testing.expect(rename_idx < modify_idx);