ReviewOS

also looking at this

zig-utils/zig-test-framework

Make watch mode rerun affected tests

#28
Merged glennmichael123 wants to merge codex/issue-11-watch-affected into main
5 files +621 -105
README.mdmodified+12-0
Changes to README.md
@@ -882,6 +882,17 @@ The async, snapshot, progress, and timeout examples are compile-checked rather
882882than executed by this step because they intentionally demonstrate delays,
883883generated files, interactive output, or failure handling.
884884
885## Watch Mode
886
887```bash
888zig-test --test-dir tests --watch
889```
890
891After the initial full run, watch mode reruns changed tests or tests affected by
892relative Zig imports. It falls back to the full suite when dependency data is
893incomplete. Type `r` and press Enter to force a full rerun. See
894[docs/watch-mode.md](docs/watch-mode.md) for selection and debounce behavior.
895
885896## Requirements
886897
887898- The exact Zig development build recorded in [`.zig-version`](.zig-version)
@@ -905,6 +916,7 @@ Comprehensive guides and API documentation:
905916- **[Mocking & Spying](docs/mocks.md)** - Complete Jest-compatible mocking API with 20+ methods
906917- **[Snapshot Testing](docs/snapshots.md)** - Multiple formats, named snapshots, and diff visualization
907918- **[Date & Time Mocking](docs/dates-and-times.md)** - Control time with `setSystemTime()` and Jest-compatible APIs
919- **[Watch Mode](docs/watch-mode.md)** - Affected-test selection, safe fallbacks, and full reruns
908920
909921### Additional Resources
910922
docs/watch-mode.mdadded+45-0
Changes to docs/watch-mode.md
@@ -0,0 +1,45 @@
1# Watch mode
2
3Watch mode runs the full discovered suite once, then polls Zig source and test
4files after the configured debounce interval. Start it with a test directory:
5
6```bash
7zig-test --test-dir tests --watch
8```
9
10Type `r` and press Enter at any time to request an explicit full-suite rerun.
11Ctrl-C stops the watcher and any UI server cleanly.
12
13## Affected-test selection
14
15Each rerun prints both the selected test and the reason it was selected:
16
17```text
18Watch selection:
19 - math.test.zig (imports changed source: src/math.zig)
20```
21
22The selection rules are conservative:
23
24- A modified or newly added test file runs by itself. Multiple changed tests
25 are batched by the debounce interval and run together.
26- For a source change, the watcher follows relative `.zig` imports from every
27 discovered test, including transitive imports, and runs tests whose import
28 graph contains the changed file.
29- Named module imports such as `` cannot be resolved from
30 source text alone. If any test has unresolved imports, or a changed source
31 file maps to no test, the watcher safely runs the full suite.
32- Deleting a test, or renaming one (observed as a delete plus an add), runs the
33 full suite so reporters and the live UI receive the new test topology.
34- Deleting or renaming an imported source file also falls back to the full
35 suite when its dependency graph can no longer be resolved.
36
37The watcher scans the project root for `.zig` changes while using `--test-dir`,
38`--pattern`, and recursive-discovery settings to decide which files are tests.
39It excludes Git metadata, Zig caches/output, `node_modules`, and `.codex`.
40
41## Debouncing
42
43`--watch-debounce <ms>` controls the polling and batching interval. The default
44is 300 milliseconds. A test run is never interrupted: edits made while tests
45are running are detected on the next poll and cause another affected run.
src/cli.zigmodified+1-0
Changes to src/cli.zig
@@ -423,6 +423,7 @@ pub const CLI = struct {
423423 \\WATCH MODE:
424424 \\ -w, --watch Watch files and re-run tests on changes
425425 \\ --watch-debounce <ms> Debounce delay in milliseconds (default: 300)
426 \\ Type r + Enter to force a full rerun
426427 \\
427428 \\MEMORY PROFILING:
428429 \\ --profile-memory Enable memory profiling for tests
src/main.zigmodified+2-0
Changes to src/main.zig
@@ -154,11 +154,13 @@ pub fn main(init: std.process.Init.Minimal) !void {
154154
155155 const watch_options = lib.WatchOptions{
156156 .watch_dir = test_dir,
157 .project_root = ".",
157158 .pattern = cli_parser.options.pattern,
158159 .recursive = !cli_parser.options.no_recursive,
159160 .debounce_ms = cli_parser.options.watch_debounce,
160161 .clear_screen = true,
161162 .verbose = cli_parser.options.verbose,
163 .interactive_commands = true,
162164 };
163165
164166 var watcher = lib.TestWatcher.init(allocator, watch_options, &keep_running);
src/watch.zigmodified+561-105
Changes to src/watch.zig
@@ -3,199 +3,655 @@ const discovery = ;
33const test_loader = ;
44const compat = ;
55
6/// Watch mode options
6/// Watch mode options.
77pub const WatchOptions = struct {
8 /// Directory to watch
8 /// Directory containing discoverable tests.
99 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.
1113 pattern: []const u8 = "*.test.zig",
12 /// Whether to watch recursively
14 /// Whether test discovery is recursive.
1315 recursive: bool = true,
14 /// Debounce delay in milliseconds
16 /// Debounce delay in milliseconds.
1517 debounce_ms: u64 = 300,
16 /// Clear screen between runs
18 /// Clear screen between runs.
1719 clear_screen: bool = true,
18 /// Verbose output
20 /// Verbose output.
1921 verbose: bool = false,
22 /// Listen for `r` followed by Enter to request a full rerun.
23 interactive_commands: bool = false,
2024};
2125
22/// File watcher for test files
26pub const ChangeKind = enum { added, modified, deleted };
27
28pub const FileChange = struct {
29 path: []const u8,
30 kind: ChangeKind,
31};
32
33const 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
52const 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
61const 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
106const SelectionReason = enum { changed_test, affected_source };
107
108const SelectedTest = struct {
109 index: usize,
110 reason: SelectionReason,
111 cause: []const u8,
112};
113
114const FullRunReason = enum {
115 initial,
116 manual,
117 test_topology_changed,
118 dependency_information_unavailable,
119};
120
121const 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
138const 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
165var manual_rerun_requested = std.atomic.Value(bool).init(false);
166var 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.
23170pub const TestWatcher = struct {
24171 allocator: std.mem.Allocator,
25172 options: WatchOptions,
26173 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),
28175
29176 const Self = @This();
30177
31178 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);
37185 }
38186
39 /// Start watching for file changes
187 /// Run tests initially, then poll source and test files for changes.
40188 pub fn watch(self: *Self, loader_options: test_loader.LoaderOptions) !void {
41189 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) {
43199 std.debug.print("Press Ctrl+C to stop.\n\n", .{});
44200 }
45201
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();
52204
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);
56209
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);
62212
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();
64218
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;
66221
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);
69227 }
228 self.last_run_time.store(compat.milliTimestamp(), .release);
70229 }
71230 }
72231
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));
78237 }
79238
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, .{
83273 .root_path = self.options.watch_dir,
84274 .pattern = self.options.pattern,
85275 .recursive = self.options.recursive,
86 };
276 });
277 }
87278
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};
90283
91 const passed = try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options);
284fn 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}
92291
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", .{});
292fn 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
297fn 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
307fn 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
316fn 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);
97326 }
98327 }
328}
99329
100 /// Check if filename matches pattern
101 fn matchesPattern(self: *Self, filename: []const u8, pattern: []const u8) bool {
102 _ = self;
330fn 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}
103338
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 ..];
339fn 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}
108364
109 if (prefix.len > 0 and !std.mem.startsWith(u8, filename, prefix)) {
110 return false;
365fn 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
373fn 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;
111395 }
396 } else {
397 try sources.append(allocator, change.path);
398 }
399 }
112400
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);
115417 }
418 }
419 }
116420
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;
120425 }
121426 }
427 return selection;
428}
122429
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", .{});
430fn 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;
128435 }
129};
436 return null;
437}
130438
131/// Simple file watcher using polling
132/// Note: In production, use std.fs.Watch or platform-specific APIs (inotify, kqueue, etc.)
439fn 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
449fn 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
459fn 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.
133495pub const FileWatcher = struct {
134496 allocator: std.mem.Allocator,
135497 watch_paths: std.ArrayList([]const u8),
136 file_times: std.StringHashMap(i64),
498 file_stamps: std.StringHashMap(?FileStamp),
137499
138500 const Self = @This();
139501
140502 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) };
146504 }
147505
148506 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);
152508 self.watch_paths.deinit(self.allocator);
153 self.file_times.deinit();
509 self.file_stamps.deinit();
154510 }
155511
156 /// Add a path to watch
157512 pub fn addPath(self: *Self, path: []const u8) !void {
158513 const path_copy = try self.allocator.dupe(u8, path);
514 errdefer self.allocator.free(path_copy);
159515 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);
164524 }
165525
166 /// Check if any watched files have changed
167526 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;
168530 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);
177538 }
178
179 return false;
539 return changed;
180540 }
181541};
182542
183// Tests
184543test "WatchOptions default values" {
185544 const options = WatchOptions{};
186
187545 try std.testing.expectEqualStrings(".", options.watch_dir);
546 try std.testing.expectEqualStrings(".", options.project_root);
188547 try std.testing.expectEqualStrings("*.test.zig", options.pattern);
189 try std.testing.expectEqual(true, options.recursive);
548 try std.testing.expect(options.recursive);
190549 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);
192552}
193553
194test "FileWatcher initialization" {
554test "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
560test "snapshot diff reports additions modifications and deletions" {
195561 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}
196575
197 var watcher = FileWatcher.init(allocator);
198 defer watcher.deinit();
576test "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}
199652
653test "FileWatcher initialization" {
654 var watcher = FileWatcher.init(std.testing.allocator);
655 defer watcher.deinit();
200656 try std.testing.expectEqual(@as(usize, 0), watcher.watch_paths.items.len);
201657}