| @@ -3,199 +3,655 @@ const discovery = ; |
| 3 | 3 | const test_loader = ; |
| 4 | 4 | const compat = ; |
| 5 | 5 | |
| 6 | | -/// Watch mode options |
| 6 | +/// Watch mode options. |
| 7 | 7 | pub const WatchOptions = struct { |
| 8 | | - /// Directory to watch |
| 8 | + /// Directory containing discoverable tests. |
| 9 | 9 | watch_dir: []const u8 = ".", |
| 10 | | - /// Test file pattern |
| 10 | + /// Project root scanned for Zig source changes. |
| 11 | + project_root: []const u8 = ".", |
| 12 | + /// Test file pattern. |
| 11 | 13 | pattern: []const u8 = "*.test.zig", |
| 12 | | - /// Whether to watch recursively |
| 14 | + /// Whether test discovery is recursive. |
| 13 | 15 | recursive: bool = true, |
| 14 | | - /// Debounce delay in milliseconds |
| 16 | + /// Debounce delay in milliseconds. |
| 15 | 17 | debounce_ms: u64 = 300, |
| 16 | | - /// Clear screen between runs |
| 18 | + /// Clear screen between runs. |
| 17 | 19 | clear_screen: bool = true, |
| 18 | | - /// Verbose output |
| 20 | + /// Verbose output. |
| 19 | 21 | verbose: bool = false, |
| 22 | + /// Listen for `r` followed by Enter to request a full rerun. |
| 23 | + interactive_commands: bool = false, |
| 20 | 24 | }; |
| 21 | 25 | |
| 22 | | -/// File watcher for test files |
| 26 | +pub const ChangeKind = enum { added, modified, deleted }; |
| 27 | + |
| 28 | +pub const FileChange = struct { |
| 29 | + path: []const u8, |
| 30 | + kind: ChangeKind, |
| 31 | +}; |
| 32 | + |
| 33 | +const ChangeSet = struct { |
| 34 | + allocator: std.mem.Allocator, |
| 35 | + items: std.ArrayList(FileChange) = .empty, |
| 36 | + |
| 37 | + fn deinit(self: *ChangeSet) void { |
| 38 | + for (self.items.items) |change| self.allocator.free(change.path); |
| 39 | + self.items.deinit(self.allocator); |
| 40 | + } |
| 41 | + |
| 42 | + fn append(self: *ChangeSet, path: []const u8, kind: ChangeKind) !void { |
| 43 | + const path_copy = try self.allocator.dupe(u8, path); |
| 44 | + errdefer self.allocator.free(path_copy); |
| 45 | + try self.items.append(self.allocator, .{ |
| 46 | + .path = path_copy, |
| 47 | + .kind = kind, |
| 48 | + }); |
| 49 | + } |
| 50 | +}; |
| 51 | + |
| 52 | +const FileStamp = struct { |
| 53 | + modified_ns: i96, |
| 54 | + size: u64, |
| 55 | + |
| 56 | + fn eql(a: FileStamp, b: FileStamp) bool { |
| 57 | + return a.modified_ns == b.modified_ns and a.size == b.size; |
| 58 | + } |
| 59 | +}; |
| 60 | + |
| 61 | +const Snapshot = struct { |
| 62 | + allocator: std.mem.Allocator, |
| 63 | + files: std.StringHashMap(FileStamp), |
| 64 | + |
| 65 | + fn init(allocator: std.mem.Allocator) Snapshot { |
| 66 | + return .{ .allocator = allocator, .files = .init(allocator) }; |
| 67 | + } |
| 68 | + |
| 69 | + fn deinit(self: *Snapshot) void { |
| 70 | + var keys = self.files.keyIterator(); |
| 71 | + while (keys.next()) |path| self.allocator.free(path.*); |
| 72 | + self.files.deinit(); |
| 73 | + } |
| 74 | + |
| 75 | + fn put(self: *Snapshot, path: []u8, stamp: FileStamp) !void { |
| 76 | + const entry = try self.files.getOrPut(path); |
| 77 | + if (entry.found_existing) { |
| 78 | + self.allocator.free(path); |
| 79 | + } else { |
| 80 | + entry.key_ptr.* = path; |
| 81 | + } |
| 82 | + entry.value_ptr.* = stamp; |
| 83 | + } |
| 84 | + |
| 85 | + fn changesSince(self: *const Snapshot, previous: *const Snapshot, allocator: std.mem.Allocator) !ChangeSet { |
| 86 | + var changes = ChangeSet{ .allocator = allocator }; |
| 87 | + errdefer changes.deinit(); |
| 88 | + |
| 89 | + var current = self.files.iterator(); |
| 90 | + while (current.next()) |entry| { |
| 91 | + if (previous.files.get(entry.key_ptr.*)) |old_stamp| { |
| 92 | + if (!entry.value_ptr.eql(old_stamp)) try changes.append(entry.key_ptr.*, .modified); |
| 93 | + } else { |
| 94 | + try changes.append(entry.key_ptr.*, .added); |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + var old = previous.files.iterator(); |
| 99 | + while (old.next()) |entry| { |
| 100 | + if (!self.files.contains(entry.key_ptr.*)) try changes.append(entry.key_ptr.*, .deleted); |
| 101 | + } |
| 102 | + return changes; |
| 103 | + } |
| 104 | +}; |
| 105 | + |
| 106 | +const SelectionReason = enum { changed_test, affected_source }; |
| 107 | + |
| 108 | +const SelectedTest = struct { |
| 109 | + index: usize, |
| 110 | + reason: SelectionReason, |
| 111 | + cause: []const u8, |
| 112 | +}; |
| 113 | + |
| 114 | +const FullRunReason = enum { |
| 115 | + initial, |
| 116 | + manual, |
| 117 | + test_topology_changed, |
| 118 | + dependency_information_unavailable, |
| 119 | +}; |
| 120 | + |
| 121 | +const Selection = struct { |
| 122 | + allocator: std.mem.Allocator, |
| 123 | + full_run: ?FullRunReason = null, |
| 124 | + tests: std.ArrayList(SelectedTest) = .empty, |
| 125 | + |
| 126 | + fn deinit(self: *Selection) void { |
| 127 | + self.tests.deinit(self.allocator); |
| 128 | + } |
| 129 | + |
| 130 | + fn select(self: *Selection, index: usize, reason: SelectionReason, cause: []const u8) !void { |
| 131 | + for (self.tests.items) |existing| { |
| 132 | + if (existing.index == index) return; |
| 133 | + } |
| 134 | + try self.tests.append(self.allocator, .{ .index = index, .reason = reason, .cause = cause }); |
| 135 | + } |
| 136 | +}; |
| 137 | + |
| 138 | +const DependencyInfo = struct { |
| 139 | + allocator: std.mem.Allocator, |
| 140 | + paths: std.StringHashMap(void), |
| 141 | + complete: bool = true, |
| 142 | + |
| 143 | + fn init(allocator: std.mem.Allocator) DependencyInfo { |
| 144 | + return .{ .allocator = allocator, .paths = .init(allocator) }; |
| 145 | + } |
| 146 | + |
| 147 | + fn deinit(self: *DependencyInfo) void { |
| 148 | + var keys = self.paths.keyIterator(); |
| 149 | + while (keys.next()) |path| self.allocator.free(path.*); |
| 150 | + self.paths.deinit(); |
| 151 | + } |
| 152 | + |
| 153 | + fn add(self: *DependencyInfo, path: []u8) !bool { |
| 154 | + const entry = try self.paths.getOrPut(path); |
| 155 | + if (entry.found_existing) { |
| 156 | + self.allocator.free(path); |
| 157 | + return false; |
| 158 | + } |
| 159 | + entry.key_ptr.* = path; |
| 160 | + entry.value_ptr.* = {}; |
| 161 | + return true; |
| 162 | + } |
| 163 | +}; |
| 164 | + |
| 165 | +var manual_rerun_requested = std.atomic.Value(bool).init(false); |
| 166 | +var command_listener_started = std.atomic.Value(bool).init(false); |
| 167 | + |
| 168 | +/// Polling watcher that selects only tests affected by a change when it can do |
| 169 | +/// so safely. |
| 23 | 170 | pub const TestWatcher = struct { |
| 24 | 171 | allocator: std.mem.Allocator, |
| 25 | 172 | options: WatchOptions, |
| 26 | 173 | running: *std.atomic.Value(bool), |
| 27 | | - last_run_time: std.atomic.Value(i64) = std.atomic.Value(i64).init(0), |
| 174 | + last_run_time: std.atomic.Value(i64) = .init(0), |
| 28 | 175 | |
| 29 | 176 | const Self = @This(); |
| 30 | 177 | |
| 31 | 178 | pub fn init(allocator: std.mem.Allocator, options: WatchOptions, running: *std.atomic.Value(bool)) Self { |
| 32 | | - return .{ |
| 33 | | - .allocator = allocator, |
| 34 | | - .options = options, |
| 35 | | - .running = running, |
| 36 | | - }; |
| 179 | + return .{ .allocator = allocator, .options = options, .running = running }; |
| 180 | + } |
| 181 | + |
| 182 | + /// Ask a running watcher to execute every discovered test at its next poll. |
| 183 | + pub fn requestFullRerun(_: *Self) void { |
| 184 | + manual_rerun_requested.store(true, .release); |
| 37 | 185 | } |
| 38 | 186 | |
| 39 | | - /// Start watching for file changes |
| 187 | + /// Run tests initially, then poll source and test files for changes. |
| 40 | 188 | pub fn watch(self: *Self, loader_options: test_loader.LoaderOptions) !void { |
| 41 | 189 | if (self.options.verbose) { |
| 42 | | - std.debug.print("Watching for changes in '{s}' (pattern: {s})...\n", .{ self.options.watch_dir, self.options.pattern }); |
| 190 | + std.debug.print("Watching project '{s}' for tests in '{s}' (pattern: {s})...\n", .{ |
| 191 | + self.options.project_root, |
| 192 | + self.options.watch_dir, |
| 193 | + self.options.pattern, |
| 194 | + }); |
| 195 | + } |
| 196 | + if (self.options.interactive_commands) { |
| 197 | + std.debug.print("Press Ctrl+C to stop; type 'r' and press Enter for a full rerun.\n\n", .{}); |
| 198 | + } else if (self.options.verbose) { |
| 43 | 199 | std.debug.print("Press Ctrl+C to stop.\n\n", .{}); |
| 44 | 200 | } |
| 45 | 201 | |
| 46 | | - // Run tests initially |
| 47 | | - try self.runTests(loader_options); |
| 48 | | - |
| 49 | | - // Watch for file changes |
| 50 | | - while (self.running.load(.monotonic)) { |
| 51 | | - compat.sleep(self.options.debounce_ms * std.time.ns_per_ms); |
| 202 | + manual_rerun_requested.store(false, .release); |
| 203 | + if (self.options.interactive_commands) startCommandListener(); |
| 52 | 204 | |
| 53 | | - if (try self.checkForChanges()) { |
| 54 | | - const current_time = compat.milliTimestamp(); |
| 55 | | - const last_run = self.last_run_time.load(.monotonic); |
| 205 | + var snapshot = try captureSnapshot(self.allocator, self.options.project_root); |
| 206 | + defer snapshot.deinit(); |
| 207 | + try self.runFull(loader_options, .initial); |
| 208 | + self.last_run_time.store(compat.milliTimestamp(), .release); |
| 56 | 209 | |
| 57 | | - // Debounce: only run if enough time has passed |
| 58 | | - if (current_time - last_run >= self.options.debounce_ms) { |
| 59 | | - if (self.options.clear_screen) { |
| 60 | | - self.clearScreen(); |
| 61 | | - } |
| 210 | + while (self.running.load(.acquire)) { |
| 211 | + compat.sleep(self.options.debounce_ms * std.time.ns_per_ms); |
| 62 | 212 | |
| 63 | | - std.debug.print("Change detected, re-running tests...\n\n", .{}); |
| 213 | + var current = try captureSnapshot(self.allocator, self.options.project_root); |
| 214 | + var changes = try current.changesSince(&snapshot, self.allocator); |
| 215 | + snapshot.deinit(); |
| 216 | + snapshot = current; |
| 217 | + defer changes.deinit(); |
| 64 | 218 | |
| 65 | | - try self.runTests(loader_options); |
| 219 | + const manual = manual_rerun_requested.swap(false, .acq_rel); |
| 220 | + if (changes.items.items.len == 0 and !manual) continue; |
| 66 | 221 | |
| 67 | | - self.last_run_time.store(current_time, .monotonic); |
| 68 | | - } |
| 222 | + if (self.options.clear_screen) self.clearScreen(); |
| 223 | + if (manual) { |
| 224 | + try self.runFull(loader_options, .manual); |
| 225 | + } else { |
| 226 | + try self.runAffected(loader_options, changes.items.items); |
| 69 | 227 | } |
| 228 | + self.last_run_time.store(compat.milliTimestamp(), .release); |
| 70 | 229 | } |
| 71 | 230 | } |
| 72 | 231 | |
| 73 | | - /// Check if any test files have changed |
| 74 | | - fn checkForChanges(self: *Self) !bool { |
| 75 | | - // TODO: Re-implement with std.Io.Dir when Io is available in Zig 0.16 |
| 76 | | - _ = self; |
| 77 | | - return false; |
| 232 | + fn runFull(self: *Self, loader_options: test_loader.LoaderOptions, reason: FullRunReason) !void { |
| 233 | + printFullRunReason(reason); |
| 234 | + var discovered = try self.discover(); |
| 235 | + defer discovered.deinit(); |
| 236 | + printOutcome(try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options)); |
| 78 | 237 | } |
| 79 | 238 | |
| 80 | | - /// Run tests with discovery |
| 81 | | - fn runTests(self: *Self, loader_options: test_loader.LoaderOptions) !void { |
| 82 | | - const discovery_options = discovery.DiscoveryOptions{ |
| 239 | + fn runAffected(self: *Self, loader_options: test_loader.LoaderOptions, changes: []const FileChange) !void { |
| 240 | + var discovered = try self.discover(); |
| 241 | + defer discovered.deinit(); |
| 242 | + var selection = try planAffected(self.allocator, self.options, &discovered, changes); |
| 243 | + defer selection.deinit(); |
| 244 | + |
| 245 | + if (selection.full_run) |reason| { |
| 246 | + printChanges(changes); |
| 247 | + printFullRunReason(reason); |
| 248 | + printOutcome(try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options)); |
| 249 | + return; |
| 250 | + } |
| 251 | + |
| 252 | + var selected = discovery.DiscoveryResult.init(self.allocator); |
| 253 | + defer selected.deinit(); |
| 254 | + std.debug.print("Watch selection:\n", .{}); |
| 255 | + for (selection.tests.items) |item| { |
| 256 | + const file = discovered.files.items[item.index]; |
| 257 | + try selected.addFile(file.path, file.relative_path, file.name); |
| 258 | + std.debug.print(" - {s} ({s}: {s})\n", .{ |
| 259 | + file.relative_path, |
| 260 | + switch (item.reason) { |
| 261 | + .changed_test => "changed test file", |
| 262 | + .affected_source => "imports changed source", |
| 263 | + }, |
| 264 | + item.cause, |
| 265 | + }); |
| 266 | + } |
| 267 | + std.debug.print("\n", .{}); |
| 268 | + printOutcome(try test_loader.runDiscoveredTests(self.allocator, &selected, loader_options)); |
| 269 | + } |
| 270 | + |
| 271 | + fn discover(self: *Self) !discovery.DiscoveryResult { |
| 272 | + return discovery.discoverTests(self.allocator, .{ |
| 83 | 273 | .root_path = self.options.watch_dir, |
| 84 | 274 | .pattern = self.options.pattern, |
| 85 | 275 | .recursive = self.options.recursive, |
| 86 | | - }; |
| 276 | + }); |
| 277 | + } |
| 87 | 278 | |
| 88 | | - var discovered = try discovery.discoverTests(self.allocator, discovery_options); |
| 89 | | - defer discovered.deinit(); |
| 279 | + fn clearScreen(_: *Self) void { |
| 280 | + std.debug.print("\x1b[2J\x1b[H", .{}); |
| 281 | + } |
| 282 | +}; |
| 90 | 283 | |
| 91 | | - const passed = try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options); |
| 284 | +fn printOutcome(passed: bool) void { |
| 285 | + if (passed) { |
| 286 | + std.debug.print("\n✓ Selected tests passed. Watching for changes...\n", .{}); |
| 287 | + } else { |
| 288 | + std.debug.print("\n✗ Selected tests failed. Fix them and save to re-run.\n", .{}); |
| 289 | + } |
| 290 | +} |
| 92 | 291 | |
| 93 | | - if (passed) { |
| 94 | | - std.debug.print("\n✓ All tests passed! Watching for changes...\n", .{}); |
| 95 | | - } else { |
| 96 | | - std.debug.print("\n✗ Some tests failed. Fix them and save to re-run.\n", .{}); |
| 292 | +fn printChanges(changes: []const FileChange) void { |
| 293 | + std.debug.print("Detected changes:\n", .{}); |
| 294 | + for (changes) |change| std.debug.print(" - {s}: {s}\n", .{ @tagName(change.kind), change.path }); |
| 295 | +} |
| 296 | + |
| 297 | +fn printFullRunReason(reason: FullRunReason) void { |
| 298 | + const message = switch (reason) { |
| 299 | + .initial => "initial watch run", |
| 300 | + .manual => "manual full-rerun command", |
| 301 | + .test_topology_changed => "a test was deleted or renamed", |
| 302 | + .dependency_information_unavailable => "dependency information was incomplete or no affected test was found", |
| 303 | + }; |
| 304 | + std.debug.print("Watch selection: full suite ({s})\n\n", .{message}); |
| 305 | +} |
| 306 | + |
| 307 | +fn startCommandListener() void { |
| 308 | + if (command_listener_started.cmpxchgStrong(false, true, .acq_rel, .acquire) != null) return; |
| 309 | + const thread = std.Thread.spawn(.{}, commandLoop, .{}) catch { |
| 310 | + command_listener_started.store(false, .release); |
| 311 | + return; |
| 312 | + }; |
| 313 | + thread.detach(); |
| 314 | +} |
| 315 | + |
| 316 | +fn commandLoop() void { |
| 317 | + var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{ .environ = .empty }); |
| 318 | + defer threaded.deinit(); |
| 319 | + var buffer: [256]u8 = undefined; |
| 320 | + var reader = std.Io.File.stdin().reader(threaded.io(), &buffer); |
| 321 | + while (true) { |
| 322 | + const raw = (reader.interface.takeDelimiter('\n') catch return) orelse return; |
| 323 | + const command = std.mem.trim(u8, raw, " \t\r"); |
| 324 | + if (std.ascii.eqlIgnoreCase(command, "r") or std.ascii.eqlIgnoreCase(command, "run all")) { |
| 325 | + manual_rerun_requested.store(true, .release); |
| 97 | 326 | } |
| 98 | 327 | } |
| 328 | +} |
| 99 | 329 | |
| 100 | | - /// Check if filename matches pattern |
| 101 | | - fn matchesPattern(self: *Self, filename: []const u8, pattern: []const u8) bool { |
| 102 | | - _ = self; |
| 330 | +fn captureSnapshot(allocator: std.mem.Allocator, root: []const u8) !Snapshot { |
| 331 | + var snapshot = Snapshot.init(allocator); |
| 332 | + errdefer snapshot.deinit(); |
| 333 | + var threaded: std.Io.Threaded = .init(allocator, .{ .environ = .empty }); |
| 334 | + defer threaded.deinit(); |
| 335 | + try scanSnapshot(allocator, &snapshot, threaded.io(), root); |
| 336 | + return snapshot; |
| 337 | +} |
| 103 | 338 | |
| 104 | | - // Simple wildcard matching |
| 105 | | - if (std.mem.indexOf(u8, pattern, "*")) |star_pos| { |
| 106 | | - const prefix = pattern[0..star_pos]; |
| 107 | | - const suffix = pattern[star_pos + 1 ..]; |
| 339 | +fn scanSnapshot(allocator: std.mem.Allocator, snapshot: *Snapshot, io: std.Io, current_path: []const u8) !void { |
| 340 | + var dir = compat.DirIterator.open(current_path) catch return; |
| 341 | + defer dir.close(); |
| 342 | + |
| 343 | + while (try dir.next()) |entry| { |
| 344 | + if (entry.kind == .directory and shouldSkipDirectory(entry.name)) continue; |
| 345 | + const joined = try std.fs.path.join(allocator, &.{ current_path, entry.name }); |
| 346 | + defer allocator.free(joined); |
| 347 | + |
| 348 | + switch (entry.kind) { |
| 349 | + .directory => try scanSnapshot(allocator, snapshot, io, joined), |
| 350 | + .file => { |
| 351 | + if (!std.mem.endsWith(u8, entry.name, ".zig")) continue; |
| 352 | + const normalized = try std.fs.path.resolveAlloc(allocator, &.{joined}); |
| 353 | + errdefer allocator.free(normalized); |
| 354 | + const stat = std.Io.Dir.cwd().statFile(io, normalized, .{}) catch { |
| 355 | + allocator.free(normalized); |
| 356 | + continue; |
| 357 | + }; |
| 358 | + try snapshot.put(normalized, .{ .modified_ns = stat.mtime.nanoseconds, .size = stat.size }); |
| 359 | + }, |
| 360 | + else => {}, |
| 361 | + } |
| 362 | + } |
| 363 | +} |
| 108 | 364 | |
| 109 | | - if (prefix.len > 0 and !std.mem.startsWith(u8, filename, prefix)) { |
| 110 | | - return false; |
| 365 | +fn shouldSkipDirectory(name: []const u8) bool { |
| 366 | + const excluded = [_][]const u8{ ".git", ".zig-cache", "zig-cache", "zig-out", "node_modules", ".codex" }; |
| 367 | + for (excluded) |candidate| { |
| 368 | + if (std.mem.eql(u8, name, candidate)) return true; |
| 369 | + } |
| 370 | + return false; |
| 371 | +} |
| 372 | + |
| 373 | +fn planAffected( |
| 374 | + allocator: std.mem.Allocator, |
| 375 | + options: WatchOptions, |
| 376 | + discovered: *const discovery.DiscoveryResult, |
| 377 | + changes: []const FileChange, |
| 378 | +) !Selection { |
| 379 | + var selection = Selection{ .allocator = allocator }; |
| 380 | + errdefer selection.deinit(); |
| 381 | + var sources: std.ArrayList([]const u8) = .empty; |
| 382 | + defer sources.deinit(allocator); |
| 383 | + |
| 384 | + for (changes) |change| { |
| 385 | + if (isTestPath(change.path, options.pattern)) { |
| 386 | + if (change.kind == .deleted) { |
| 387 | + selection.full_run = .test_topology_changed; |
| 388 | + return selection; |
| 389 | + } |
| 390 | + if (findTest(discovered, change.path, allocator)) |index| { |
| 391 | + try selection.select(index, .changed_test, change.path); |
| 392 | + } else { |
| 393 | + selection.full_run = .test_topology_changed; |
| 394 | + return selection; |
| 111 | 395 | } |
| 396 | + } else { |
| 397 | + try sources.append(allocator, change.path); |
| 398 | + } |
| 399 | + } |
| 112 | 400 | |
| 113 | | - if (suffix.len > 0 and !std.mem.endsWith(u8, filename, suffix)) { |
| 114 | | - return false; |
| 401 | + if (sources.items.len == 0) return selection; |
| 402 | + const mapped = try allocator.alloc(bool, sources.items.len); |
| 403 | + defer allocator.free(mapped); |
| 404 | + @memset(mapped, false); |
| 405 | + |
| 406 | + for (discovered.files.items, 0..) |file, index| { |
| 407 | + var dependencies = try resolveDependencies(allocator, file.path); |
| 408 | + defer dependencies.deinit(); |
| 409 | + if (!dependencies.complete) { |
| 410 | + selection.full_run = .dependency_information_unavailable; |
| 411 | + return selection; |
| 412 | + } |
| 413 | + for (sources.items, 0..) |source, source_index| { |
| 414 | + if (dependencies.paths.contains(source)) { |
| 415 | + mapped[source_index] = true; |
| 416 | + try selection.select(index, .affected_source, source); |
| 115 | 417 | } |
| 418 | + } |
| 419 | + } |
| 116 | 420 | |
| 117 | | - return true; |
| 118 | | - } else { |
| 119 | | - return std.mem.eql(u8, filename, pattern); |
| 421 | + for (mapped) |was_mapped| { |
| 422 | + if (!was_mapped) { |
| 423 | + selection.full_run = .dependency_information_unavailable; |
| 424 | + return selection; |
| 120 | 425 | } |
| 121 | 426 | } |
| 427 | + return selection; |
| 428 | +} |
| 122 | 429 | |
| 123 | | - /// Clear terminal screen |
| 124 | | - fn clearScreen(self: *Self) void { |
| 125 | | - _ = self; |
| 126 | | - // ANSI escape code to clear screen |
| 127 | | - std.debug.print("\x1b[2J\x1b[H", .{}); |
| 430 | +fn findTest(discovered: *const discovery.DiscoveryResult, changed_path: []const u8, allocator: std.mem.Allocator) ?usize { |
| 431 | + for (discovered.files.items, 0..) |file, index| { |
| 432 | + const normalized = std.fs.path.resolveAlloc(allocator, &.{file.path}) catch continue; |
| 433 | + defer allocator.free(normalized); |
| 434 | + if (std.mem.eql(u8, normalized, changed_path)) return index; |
| 128 | 435 | } |
| 129 | | -}; |
| 436 | + return null; |
| 437 | +} |
| 130 | 438 | |
| 131 | | -/// Simple file watcher using polling |
| 132 | | -/// Note: In production, use std.fs.Watch or platform-specific APIs (inotify, kqueue, etc.) |
| 439 | +fn isTestPath(path: []const u8, pattern: []const u8) bool { |
| 440 | + const name = std.fs.path.basename(path); |
| 441 | + if (std.mem.indexOfScalar(u8, pattern, '*')) |star| { |
| 442 | + const prefix = pattern[0..star]; |
| 443 | + const suffix = pattern[star + 1 ..]; |
| 444 | + return std.mem.startsWith(u8, name, prefix) and std.mem.endsWith(u8, name, suffix); |
| 445 | + } |
| 446 | + return std.mem.eql(u8, name, pattern); |
| 447 | +} |
| 448 | + |
| 449 | +fn resolveDependencies(allocator: std.mem.Allocator, test_path: []const u8) !DependencyInfo { |
| 450 | + var info = DependencyInfo.init(allocator); |
| 451 | + errdefer info.deinit(); |
| 452 | + const normalized = try std.fs.path.resolveAlloc(allocator, &.{test_path}); |
| 453 | + defer allocator.free(normalized); |
| 454 | + try scanImports(&info, normalized); |
| 455 | + if (info.paths.fetchRemove(normalized)) |removed| allocator.free(removed.key); |
| 456 | + return info; |
| 457 | +} |
| 458 | + |
| 459 | +fn scanImports(info: *DependencyInfo, importer_path: []const u8) !void { |
| 460 | + const content = compat.readFileAlloc(info.allocator, importer_path) catch { |
| 461 | + info.complete = false; |
| 462 | + return; |
| 463 | + }; |
| 464 | + defer info.allocator.free(content); |
| 465 | + |
| 466 | + const marker = ") |start| { |
| 469 | + const value_start = start + marker.len; |
| 470 | + const value_end = std.mem.indexOfScalarPos(u8, content, value_start, '"') orelse { |
| 471 | + info.complete = false; |
| 472 | + return; |
| 473 | + }; |
| 474 | + const import_name = content[value_start..value_end]; |
| 475 | + cursor = value_end + 1; |
| 476 | + |
| 477 | + if (std.mem.eql(u8, import_name, "std") or |
| 478 | + std.mem.eql(u8, import_name, "builtin") or |
| 479 | + std.mem.eql(u8, import_name, "root")) continue; |
| 480 | + |
| 481 | + if (!std.mem.endsWith(u8, import_name, ".zig") or |
| 482 | + std.mem.indexOfScalar(u8, import_name, '\\') != null) |
| 483 | + { |
| 484 | + info.complete = false; |
| 485 | + continue; |
| 486 | + } |
| 487 | + |
| 488 | + const parent = std.fs.path.dirname(importer_path) orelse "."; |
| 489 | + const dependency = try std.fs.path.resolveAlloc(info.allocator, &.{ parent, import_name }); |
| 490 | + if (try info.add(dependency)) try scanImports(info, dependency); |
| 491 | + } |
| 492 | +} |
| 493 | + |
| 494 | +/// Simple watcher for explicitly registered paths. |
| 133 | 495 | pub const FileWatcher = struct { |
| 134 | 496 | allocator: std.mem.Allocator, |
| 135 | 497 | watch_paths: std.ArrayList([]const u8), |
| 136 | | - file_times: std.StringHashMap(i64), |
| 498 | + file_stamps: std.StringHashMap(?FileStamp), |
| 137 | 499 | |
| 138 | 500 | const Self = @This(); |
| 139 | 501 | |
| 140 | 502 | pub fn init(allocator: std.mem.Allocator) Self { |
| 141 | | - return .{ |
| 142 | | - .allocator = allocator, |
| 143 | | - .watch_paths = std.ArrayList([]const u8).empty, |
| 144 | | - .file_times = std.StringHashMap(i64).init(allocator), |
| 145 | | - }; |
| 503 | + return .{ .allocator = allocator, .watch_paths = .empty, .file_stamps = .init(allocator) }; |
| 146 | 504 | } |
| 147 | 505 | |
| 148 | 506 | pub fn deinit(self: *Self) void { |
| 149 | | - for (self.watch_paths.items) |path| { |
| 150 | | - self.allocator.free(path); |
| 151 | | - } |
| 507 | + for (self.watch_paths.items) |path| self.allocator.free(path); |
| 152 | 508 | self.watch_paths.deinit(self.allocator); |
| 153 | | - self.file_times.deinit(); |
| 509 | + self.file_stamps.deinit(); |
| 154 | 510 | } |
| 155 | 511 | |
| 156 | | - /// Add a path to watch |
| 157 | 512 | pub fn addPath(self: *Self, path: []const u8) !void { |
| 158 | 513 | const path_copy = try self.allocator.dupe(u8, path); |
| 514 | + errdefer self.allocator.free(path_copy); |
| 159 | 515 | try self.watch_paths.append(self.allocator, path_copy); |
| 160 | | - |
| 161 | | - // Initialize modification time |
| 162 | | - // TODO: statFile needs Io in Zig 0.16 |
| 163 | | - try self.file_times.put(path_copy, 0); |
| 516 | + errdefer _ = self.watch_paths.pop(); |
| 517 | + var threaded: std.Io.Threaded = .init(self.allocator, .{ .environ = .empty }); |
| 518 | + defer threaded.deinit(); |
| 519 | + const initial: ?FileStamp = if (std.Io.Dir.cwd().statFile(threaded.io(), path_copy, .{})) |stat| |
| 520 | + .{ .modified_ns = stat.mtime.nanoseconds, .size = stat.size } |
| 521 | + else |_| |
| 522 | + null; |
| 523 | + try self.file_stamps.put(path_copy, initial); |
| 164 | 524 | } |
| 165 | 525 | |
| 166 | | - /// Check if any watched files have changed |
| 167 | 526 | pub fn hasChanges(self: *Self) !bool { |
| 527 | + var threaded: std.Io.Threaded = .init(self.allocator, .{ .environ = .empty }); |
| 528 | + defer threaded.deinit(); |
| 529 | + var changed = false; |
| 168 | 530 | for (self.watch_paths.items) |path| { |
| 169 | | - // TODO: statFile needs Io in Zig 0.16 |
| 170 | | - const mtime_ms: i64 = 0; |
| 171 | | - const last_mtime = self.file_times.get(path) orelse 0; |
| 172 | | - |
| 173 | | - if (mtime_ms > last_mtime) { |
| 174 | | - try self.file_times.put(path, mtime_ms); |
| 175 | | - return true; |
| 176 | | - } |
| 531 | + const current: ?FileStamp = if (std.Io.Dir.cwd().statFile(threaded.io(), path, .{})) |stat| |
| 532 | + .{ .modified_ns = stat.mtime.nanoseconds, .size = stat.size } |
| 533 | + else |_| |
| 534 | + null; |
| 535 | + const previous = self.file_stamps.get(path) orelse null; |
| 536 | + if (!std.meta.eql(current, previous)) changed = true; |
| 537 | + try self.file_stamps.put(path, current); |
| 177 | 538 | } |
| 178 | | - |
| 179 | | - return false; |
| 539 | + return changed; |
| 180 | 540 | } |
| 181 | 541 | }; |
| 182 | 542 | |
| 183 | | -// Tests |
| 184 | 543 | test "WatchOptions default values" { |
| 185 | 544 | const options = WatchOptions{}; |
| 186 | | - |
| 187 | 545 | try std.testing.expectEqualStrings(".", options.watch_dir); |
| 546 | + try std.testing.expectEqualStrings(".", options.project_root); |
| 188 | 547 | try std.testing.expectEqualStrings("*.test.zig", options.pattern); |
| 189 | | - try std.testing.expectEqual(true, options.recursive); |
| 548 | + try std.testing.expect(options.recursive); |
| 190 | 549 | try std.testing.expectEqual(@as(u64, 300), options.debounce_ms); |
| 191 | | - try std.testing.expectEqual(true, options.clear_screen); |
| 550 | + try std.testing.expect(options.clear_screen); |
| 551 | + try std.testing.expect(!options.interactive_commands); |
| 192 | 552 | } |
| 193 | 553 | |
| 194 | | -test "FileWatcher initialization" { |
| 554 | +test "test pattern classification handles wildcard and exact patterns" { |
| 555 | + try std.testing.expect(isTestPath("tests/math.test.zig", "*.test.zig")); |
| 556 | + try std.testing.expect(!isTestPath("src/math.zig", "*.test.zig")); |
| 557 | + try std.testing.expect(isTestPath("tests/specific.zig", "specific.zig")); |
| 558 | +} |
| 559 | + |
| 560 | +test "snapshot diff reports additions modifications and deletions" { |
| 195 | 561 | const allocator = std.testing.allocator; |
| 562 | + var old = Snapshot.init(allocator); |
| 563 | + defer old.deinit(); |
| 564 | + try old.put(try allocator.dupe(u8, "deleted.zig"), .{ .modified_ns = 1, .size = 1 }); |
| 565 | + try old.put(try allocator.dupe(u8, "modified.zig"), .{ .modified_ns = 1, .size = 1 }); |
| 566 | + var current = Snapshot.init(allocator); |
| 567 | + defer current.deinit(); |
| 568 | + try current.put(try allocator.dupe(u8, "added.zig"), .{ .modified_ns = 1, .size = 1 }); |
| 569 | + try current.put(try allocator.dupe(u8, "modified.zig"), .{ .modified_ns = 2, .size = 1 }); |
| 570 | + |
| 571 | + var changes = try current.changesSince(&old, allocator); |
| 572 | + defer changes.deinit(); |
| 573 | + try std.testing.expectEqual(@as(usize, 3), changes.items.items.len); |
| 574 | +} |
| 196 | 575 | |
| 197 | | - var watcher = FileWatcher.init(allocator); |
| 198 | | - defer watcher.deinit(); |
| 576 | +test "affected selection handles direct tests dependencies and safe fallbacks" { |
| 577 | + const allocator = std.testing.allocator; |
| 578 | + var tmp = std.testing.tmpDir(.{}); |
| 579 | + defer tmp.cleanup(); |
| 580 | + const root = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path}); |
| 581 | + defer allocator.free(root); |
| 582 | + const tests_dir = try std.fs.path.join(allocator, &.{ root, "tests" }); |
| 583 | + defer allocator.free(tests_dir); |
| 584 | + const src_dir = try std.fs.path.join(allocator, &.{ root, "src" }); |
| 585 | + defer allocator.free(src_dir); |
| 586 | + try compat.makePath(allocator, tests_dir); |
| 587 | + try compat.makePath(allocator, src_dir); |
| 588 | + |
| 589 | + const helper_path = try std.fs.path.join(allocator, &.{ src_dir, "helper.zig" }); |
| 590 | + defer allocator.free(helper_path); |
| 591 | + const math_path = try std.fs.path.join(allocator, &.{ src_dir, "math.zig" }); |
| 592 | + defer allocator.free(math_path); |
| 593 | + const test_path = try std.fs.path.join(allocator, &.{ tests_dir, "math.test.zig" }); |
| 594 | + defer allocator.free(test_path); |
| 595 | + try compat.writeFile(allocator, helper_path, "pub const value = 1;\n"); |
| 596 | + try compat.writeFile(allocator, math_path, "pub const helper = ;\n"); |
| 597 | + try compat.writeFile(allocator, test_path, "const math = ;\ntest \"math\" { _ = math; }\n"); |
| 598 | + |
| 599 | + var discovered = discovery.DiscoveryResult.init(allocator); |
| 600 | + defer discovered.deinit(); |
| 601 | + try discovered.addFile(test_path, "math.test.zig", "math.test.zig"); |
| 602 | + const normalized_test = try std.fs.path.resolveAlloc(allocator, &.{test_path}); |
| 603 | + defer allocator.free(normalized_test); |
| 604 | + const normalized_helper = try std.fs.path.resolveAlloc(allocator, &.{helper_path}); |
| 605 | + defer allocator.free(normalized_helper); |
| 606 | + const options = WatchOptions{ .watch_dir = tests_dir, .project_root = root }; |
| 607 | + |
| 608 | + var direct = try planAffected(allocator, options, &discovered, &.{.{ |
| 609 | + .path = normalized_test, |
| 610 | + .kind = .modified, |
| 611 | + }}); |
| 612 | + defer direct.deinit(); |
| 613 | + try std.testing.expect(direct.full_run == null); |
| 614 | + try std.testing.expectEqual(@as(usize, 1), direct.tests.items.len); |
| 615 | + try std.testing.expectEqual(SelectionReason.changed_test, direct.tests.items[0].reason); |
| 616 | + |
| 617 | + var added = try planAffected(allocator, options, &discovered, &.{.{ |
| 618 | + .path = normalized_test, |
| 619 | + .kind = .added, |
| 620 | + }}); |
| 621 | + defer added.deinit(); |
| 622 | + try std.testing.expect(added.full_run == null); |
| 623 | + try std.testing.expectEqual(@as(usize, 1), added.tests.items.len); |
| 624 | + |
| 625 | + var dependency = try planAffected(allocator, options, &discovered, &.{.{ |
| 626 | + .path = normalized_helper, |
| 627 | + .kind = .modified, |
| 628 | + }}); |
| 629 | + defer dependency.deinit(); |
| 630 | + try std.testing.expect(dependency.full_run == null); |
| 631 | + try std.testing.expectEqual(@as(usize, 1), dependency.tests.items.len); |
| 632 | + try std.testing.expectEqual(SelectionReason.affected_source, dependency.tests.items[0].reason); |
| 633 | + |
| 634 | + var deleted = try planAffected(allocator, options, &discovered, &.{.{ |
| 635 | + .path = normalized_test, |
| 636 | + .kind = .deleted, |
| 637 | + }}); |
| 638 | + defer deleted.deinit(); |
| 639 | + try std.testing.expectEqual(FullRunReason.test_topology_changed, deleted.full_run.?); |
| 640 | + |
| 641 | + const opaque_path = try std.fs.path.join(allocator, &.{ tests_dir, "opaque.test.zig" }); |
| 642 | + defer allocator.free(opaque_path); |
| 643 | + try compat.writeFile(allocator, opaque_path, "const app = ;\ntest \"opaque\" { _ = app; }\n"); |
| 644 | + try discovered.addFile(opaque_path, "opaque.test.zig", "opaque.test.zig"); |
| 645 | + var fallback = try planAffected(allocator, options, &discovered, &.{.{ |
| 646 | + .path = normalized_helper, |
| 647 | + .kind = .modified, |
| 648 | + }}); |
| 649 | + defer fallback.deinit(); |
| 650 | + try std.testing.expectEqual(FullRunReason.dependency_information_unavailable, fallback.full_run.?); |
| 651 | +} |
| 199 | 652 | |
| 653 | +test "FileWatcher initialization" { |
| 654 | + var watcher = FileWatcher.init(std.testing.allocator); |
| 655 | + defer watcher.deinit(); |
| 200 | 656 | try std.testing.expectEqual(@as(usize, 0), watcher.watch_paths.items.len); |
| 201 | 657 | } |