also looking at this
Make watch mode rerun affected tests
#28
5 files
+621
-105
| @@ -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 | } |