tiny.smg.watch
Defined in tiny.smg.
API (14)
Actions
Public operations.
Snapshot.deinitchangedFileseventTextisSupportedrescanrunsnapshotstartTextstoppedTextwatchPaths
Types and contracts
Public types and contracts.
Source
Source: tools/smg/src/root.zig:38
zig
pub const watch = @import("watch.zig");Source: tools/smg/src/watch.zig
zig
const std = @import("std");const alloc = @import("alloc");const pretty = @import("pretty");const sql = @import("sql");const sys = @import("sys");const diff = @import("diff.zig");const graph_mod = @import("graph.zig");const limits_mod = @import("limits/root.zig");const scan = @import("scan/root.zig");const storage = @import("storage/root.zig");const text = @import("text/root.zig");var interrupted: std.atomic.Value(bool) = .init(false);pub const FileState = struct { path: []const u8, size: u64, mtime: i96,};pub const Snapshot = struct { states: []FileState, pub fn deinit(self: *Snapshot, allocator: std.mem.Allocator) void { for (self.states) |state| allocator.free(state.path); allocator.free(self.states); self.* = undefined; } fn replace(self: *Snapshot, allocator: std.mem.Allocator, next: Snapshot) void { self.deinit(allocator); self.* = next; } fn retainedBytes(self: Snapshot) usize { std.debug.assert(self.states.len <= std.math.maxInt(usize) / @sizeOf(FileState)); var bytes = self.states.len * @sizeOf(FileState); for (self.states) |state| { std.debug.assert(bytes <= std.math.maxInt(usize) - state.path.len); bytes += state.path.len; } return bytes; }};pub const Result = struct { files: []const []const u8, stats: scan.Stats, changes: diff.Result,};pub const Save = *const fn ( std.mem.Allocator, []const u8, graph_mod.Graph, sql.Hash, limits_mod.Limits,) anyerror!void;pub fn run( allocator: std.mem.Allocator, phases: std.mem.Allocator, root: []const u8, raw_paths: []const []const u8, debounce_seconds: f64, limits: limits_mod.Limits, save: Save,) !void { interrupted.store(false, .release); sys.signal.installInterruptHandler(handleInterrupt); defer sys.signal.restoreInterruptDefault(); const paths = try watchPaths(allocator, raw_paths); var stdout_buffer: [4096]u8 = undefined; var stdout = std.Io.File.stdout().writer(std.Options.debug_io, &stdout_buffer); var iteration_arena = std.heap.ArenaAllocator.init(phases); defer iteration_arena.deinit(); try pretty.write(&stdout.interface, .{ .text = try startText(iteration_arena.allocator(), paths) }, .{ .width = 88 }); try stdout.interface.flush(); resetArena(&iteration_arena); var previous = try snapshot(phases, paths); defer previous.deinit(phases); while (!interrupted.load(.acquire)) { defer resetArena(&iteration_arena); const scratch = iteration_arena.allocator(); sys.time.sleepMilliseconds(100); if (interrupted.load(.acquire)) break; var current = try snapshot(phases, paths); var current_live = true; defer if (current_live) current.deinit(phases); const first_changed = try changedFiles(scratch, previous.states, current.states); if (first_changed.len == 0) { previous.replace(phases, current); current_live = false; continue; } current.deinit(phases); current_live = false; sleepDebounce(debounce_seconds); if (interrupted.load(.acquire)) break; var after_debounce = try snapshot(phases, paths); var after_debounce_live = true; defer if (after_debounce_live) after_debounce.deinit(phases); const files = try changedFiles(scratch, previous.states, after_debounce.states); const result = rescan(scratch, phases, root, files, limits, save) catch |err| switch (err) { error.StaleSourceHead => continue, else => return err, }; try pretty.write(&stdout.interface, .{ .text = try eventText(scratch, root, result) }, .{ .width = 88 }); try stdout.interface.flush(); previous.replace(phases, after_debounce); after_debounce_live = false; } try pretty.write(&stdout.interface, .{ .text = stoppedText() }, .{ .width = 88 }); try stdout.interface.flush();}pub fn startText(allocator: std.mem.Allocator, paths: []const []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); errdefer out.deinit(); try out.writer.writeAll("Watching "); for (paths, 0..) |path, index| { if (index != 0) try out.writer.writeAll(", "); try out.writer.writeAll(path); } try out.writer.writeAll("\nPress Ctrl+C to stop.\n\n"); return try out.toOwnedSlice();}pub fn eventText(allocator: std.mem.Allocator, root: []const u8, result: Result) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); errdefer out.deinit(); try out.writer.writeAll(try fileListText(allocator, root, result.files)); if (result.changes.empty()) { try out.writer.writeAll(" → no structural changes\n"); return try out.toOwnedSlice(); } try out.writer.writeAll(" → "); var wrote = false; if (result.changes.added_nodes.len != 0) { try out.writer.print("+{d} nodes", .{result.changes.added_nodes.len}); wrote = true; } if (result.changes.removed_nodes.len != 0) { if (wrote) try out.writer.writeAll(", "); try out.writer.print("-{d} nodes", .{result.changes.removed_nodes.len}); wrote = true; } if (result.changes.changed_nodes.len != 0) { if (wrote) try out.writer.writeAll(", "); try out.writer.print("~{d} changed", .{result.changes.changed_nodes.len}); wrote = true; } if (result.changes.added_edges.len != 0) { if (wrote) try out.writer.writeAll(", "); try out.writer.print("+{d} edges", .{result.changes.added_edges.len}); wrote = true; } if (result.changes.removed_edges.len != 0) { if (wrote) try out.writer.writeAll(", "); try out.writer.print("-{d} edges", .{result.changes.removed_edges.len}); } try out.writer.writeByte('\n'); for (result.changes.added_nodes[0..@min(result.changes.added_nodes.len, 3)]) |node| try out.writer.print(" + [{s}] {s}\n", .{ node.type, node.name }); for (result.changes.removed_nodes[0..@min(result.changes.removed_nodes.len, 3)]) |node| try out.writer.print(" - [{s}] {s}\n", .{ node.type, node.name }); for (result.changes.changed_nodes[0..@min(result.changes.changed_nodes.len, 3)]) |changed| { for (changed.changes) |change| try out.writer.print(" ~ {s} {s}: {s} → {s}\n", .{ changed.node.name, change.field, change.old orelse "None", change.new orelse "None" }); } for (result.stats.orphaned_manual_edges.items) |edge| try out.writer.print(" orphaned: {s} --{s}--> {s}\n", .{ edge.source, edge.rel, edge.target }); return try out.toOwnedSlice();}pub fn stoppedText() []const u8 { return "\nStopped.\n";}pub fn rescan( allocator: std.mem.Allocator, phases: std.mem.Allocator, root: []const u8, files: []const []const u8, limits: limits_mod.Limits, save: Save,) !Result { const snapshot_value = try storage.graph.loadSnapshot( allocator, phases, root, limits.storage, ); const old = snapshot_value.graph; var graph = try graph_mod.clone(allocator, phases, old); const stats = try scan.scanPathsWithOptions(allocator, &graph, root, files, true, .{ .limits = limits, .phases = phases }); try save(phases, root, graph, snapshot_value.head, limits); return .{ .files = files, .stats = stats, .changes = try diff.graphs(allocator, old, graph, true) };}pub fn watchPaths(allocator: std.mem.Allocator, raw_paths: []const []const u8) ![]const []const u8 { var out: std.ArrayList([]const u8) = .empty; if (raw_paths.len == 0) { try out.append(allocator, try sys.fs.realPathAlloc(allocator, ".")); } else { for (raw_paths) |path| try out.append(allocator, try sys.fs.realPathAlloc(allocator, path)); } return try out.toOwnedSlice(allocator);}pub fn snapshot(allocator: std.mem.Allocator, paths: []const []const u8) !Snapshot { var states: std.ArrayList(FileState) = .empty; errdefer { for (states.items) |state| allocator.free(state.path); states.deinit(allocator); } for (paths) |path| try collect(allocator, path, &states); std.mem.sort(FileState, states.items, {}, stateLess); return .{ .states = try states.toOwnedSlice(allocator) };}pub fn changedFiles(allocator: std.mem.Allocator, previous: []const FileState, current: []const FileState) ![]const []const u8 { return (try scanChangedFiles(allocator, previous, current)).files;}const ChangedFileScan = struct { files: []const []const u8, path_comparisons: usize,};fn scanChangedFiles(allocator: std.mem.Allocator, previous: []const FileState, current: []const FileState) !ChangedFileScan { var out: std.ArrayList([]const u8) = .empty; errdefer out.deinit(allocator); var previous_index: usize = 0; var current_index: usize = 0; var path_comparisons: usize = 0; const entry_max = std.math.add(usize, previous.len, current.len) catch return error.CapacityOverflow; for (0..entry_max) |_| { if (previous_index == previous.len or current_index == current.len) break; const old = previous[previous_index]; const new = current[current_index]; switch (pathOrder(old.path, new.path, &path_comparisons)) { .lt => { try out.append(allocator, old.path); previous_index = stateGroupEnd(previous, previous_index, &path_comparisons); }, .gt => { try out.append(allocator, new.path); current_index = stateGroupEnd(current, current_index, &path_comparisons); }, .eq => { previous_index = stateGroupEnd(previous, previous_index, &path_comparisons); const current_end = stateGroupEnd(current, current_index, &path_comparisons); var changed_path: ?[]const u8 = null; for (current[current_index..current_end]) |state| { if (old.size != state.size or old.mtime != state.mtime) { if (changed_path == null) changed_path = state.path; } } if (changed_path) |path| try out.append(allocator, path); current_index = current_end; }, } } const previous_remaining_max = previous.len - previous_index; for (0..previous_remaining_max) |_| { if (previous_index == previous.len) break; try out.append(allocator, previous[previous_index].path); previous_index = stateGroupEnd(previous, previous_index, &path_comparisons); } const current_remaining_max = current.len - current_index; for (0..current_remaining_max) |_| { if (current_index == current.len) break; try out.append(allocator, current[current_index].path); current_index = stateGroupEnd(current, current_index, &path_comparisons); } std.debug.assert(previous_index == previous.len); std.debug.assert(current_index == current.len); return .{ .files = try out.toOwnedSlice(allocator), .path_comparisons = path_comparisons, };}pub fn isSupported(path: []const u8) bool { var parts = std.mem.splitAny(u8, path, "/\\"); while (parts.next()) |part| { if (part.len == 0) continue; if (part[0] == '.') return false; if (excludedPart(part)) return false; } return supportedExtension(path);}fn collect(allocator: std.mem.Allocator, path: []const u8, states: *std.ArrayList(FileState)) !void { const stat = sys.fs.statFile(path) catch |err| switch (err) { error.FileNotFound => return, else => return err, }; if (stat.kind == .directory) { if (!directoryAllowed(path)) return; const entries = sys.fs.listDirAlloc(allocator, path) catch return; defer sys.fs.freeEntries(allocator, entries); for (entries) |entry| try collect(allocator, entry.path, states); return; } if (stat.kind != .file or !isSupported(path)) return; const owned_path = try allocator.dupe(u8, path); errdefer allocator.free(owned_path); try states.append(allocator, .{ .path = owned_path, .size = stat.size, .mtime = stat.mtime.nanoseconds, });}fn directoryAllowed(path: []const u8) bool { var parts = std.mem.splitAny(u8, path, "/\\"); while (parts.next()) |part| { if (part.len == 0) continue; if (part[0] == '.') return false; if (excludedPart(part)) return false; } return true;}fn excludedPart(part: []const u8) bool { const exact = [_][]const u8{ ".git", "__pycache__", ".venv", "venv", ".env", "node_modules", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".hypothesis", ".tox", "dist", "build", ".smg", "site-packages", "vendor", "third_party", "zig-cache", ".zig-cache", "zig-out" }; for (exact) |item| if (std.mem.eql(u8, part, item)) return true; return std.mem.endsWith(u8, part, ".egg-info");}fn supportedExtension(path: []const u8) bool { const extensions = [_][]const u8{ ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".zig", ".chic", ".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", ".hh", ".hxx", ".cu", ".cuh", ".metal" }; for (extensions) |extension| if (std.mem.endsWith(u8, path, extension)) return true; return false;}fn fileListText(allocator: std.mem.Allocator, root: []const u8, files: []const []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); errdefer out.deinit(); const count = @min(files.len, 3); for (files[0..count], 0..) |file, index| { if (index != 0) try out.writer.writeAll(", "); try out.writer.writeAll(try relativeName(allocator, root, file)); } if (files.len > 3) try out.writer.print(" (+{d} more)", .{files.len - 3}); return try out.toOwnedSlice();}fn relativeName(allocator: std.mem.Allocator, root: []const u8, path: []const u8) ![]const u8 { var rel = path; if (std.fs.path.isAbsolute(path) and std.mem.startsWith(u8, path, root)) { rel = path[root.len..]; while (rel.len != 0 and (rel[0] == '/' or rel[0] == '\\')) rel = rel[1..]; } return try text.replaceSeparators(allocator, rel, '/');}fn stateGroupEnd(states: []const FileState, start: usize, path_comparisons: *usize) usize { std.debug.assert(start < states.len); const path = states[start].path; var end = start + 1; const remaining_max = states.len - end; for (0..remaining_max) |_| { if (!pathsEqual(path, states[end].path, path_comparisons)) break; end += 1; } return end;}fn pathOrder(a: []const u8, b: []const u8, path_comparisons: *usize) std.math.Order { std.debug.assert(path_comparisons.* < std.math.maxInt(usize)); path_comparisons.* += 1; return std.mem.order(u8, a, b);}fn pathsEqual(a: []const u8, b: []const u8, path_comparisons: *usize) bool { std.debug.assert(path_comparisons.* < std.math.maxInt(usize)); path_comparisons.* += 1; return std.mem.eql(u8, a, b);}fn sleepDebounce(debounce_seconds: f64) void { if (!std.math.isFinite(debounce_seconds) or debounce_seconds <= 0) return; var remaining_ms = secondsToMilliseconds(debounce_seconds); while (remaining_ms != 0 and !interrupted.load(.acquire)) { const step = @min(remaining_ms, 100); sys.time.sleepMilliseconds(step); remaining_ms -= step; }}fn resetArena(arena: *std.heap.ArenaAllocator) void { _ = arena.reset(.free_all);}fn secondsToMilliseconds(seconds: f64) u64 { const max_seconds = @as(f64, @floatFromInt(std.math.maxInt(u64))) / 1000.0; if (seconds >= max_seconds) return std.math.maxInt(u64); return @intFromFloat(@ceil(seconds * 1000.0));}fn handleInterrupt(_: sys.signal.RawSignal) callconv(.c) void { interrupted.store(true, .release);}fn stateLess(_: void, a: FileState, b: FileState) bool { return std.mem.lessThan(u8, a.path, b.path);}fn stringLess(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b);}fn changedFilesReference(allocator: std.mem.Allocator, previous: []const FileState, current: []const FileState) ![]const []const u8 { var out: std.ArrayList([]const u8) = .empty; errdefer out.deinit(allocator); for (current) |state| { const old = referenceFindState(previous, state.path); if (old == null or old.?.size != state.size or old.?.mtime != state.mtime) { try referenceAppendUnique(allocator, &out, state.path); } } for (previous) |state| { if (referenceFindState(current, state.path) == null) { try referenceAppendUnique(allocator, &out, state.path); } } std.mem.sort([]const u8, out.items, {}, stringLess); return try out.toOwnedSlice(allocator);}fn referenceFindState(states: []const FileState, path: []const u8) ?FileState { for (states) |state| if (std.mem.eql(u8, state.path, path)) return state; return null;}fn referenceAppendUnique(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), path: []const u8) !void { for (out.items) |item| if (std.mem.eql(u8, item, path)) return; try out.append(allocator, path);}fn expectChangedFixture(previous: []const FileState, current: []const FileState, expected: []const []const u8) !void { const allocator = std.testing.allocator; const merged = try changedFiles(allocator, previous, current); defer allocator.free(merged); const reference = try changedFilesReference(allocator, previous, current); defer allocator.free(reference); try std.testing.expectEqual(reference.len, merged.len); try std.testing.expectEqual(expected.len, merged.len); for (merged, reference, expected) |actual, old, wanted| { try std.testing.expectEqualStrings(old, actual); try std.testing.expectEqualStrings(wanted, actual); }}test "watch filters supported and excluded paths" { try std.testing.expect(isSupported("src/app/core.py")); try std.testing.expect(isSupported("lib/server.ts")); try std.testing.expect(isSupported("lib/chic/kernel.chic")); try std.testing.expect(!isSupported("__pycache__/core.cpython-311.pyc")); try std.testing.expect(!isSupported(".git/hooks/pre-commit")); try std.testing.expect(!isSupported("node_modules/foo/index.js")); try std.testing.expect(!isSupported(".venv/lib/site.py")); try std.testing.expect(!isSupported("README.md")); try std.testing.expect(!isSupported("data.csv"));}test "watch detects changed and deleted files" { const allocator = std.testing.allocator; const previous = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, .{ .path = "b.py", .size = 2, .mtime = 2 }, }; const current = [_]FileState{ .{ .path = "a.py", .size = 3, .mtime = 1 }, .{ .path = "c.py", .size = 1, .mtime = 1 }, }; const changed = try changedFiles(allocator, previous[0..], current[0..]); defer allocator.free(changed); try std.testing.expectEqual(@as(usize, 3), changed.len); try std.testing.expectEqualStrings("a.py", changed[0]); try std.testing.expectEqualStrings("b.py", changed[1]); try std.testing.expectEqualStrings("c.py", changed[2]);}test "watch merge walk matches reference change classifications" { const one = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, }; const two = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, .{ .path = "b.py", .size = 2, .mtime = 2 }, }; const modified = [_]FileState{ .{ .path = "a.py", .size = 2, .mtime = 1 }, }; try expectChangedFixture(&one, &one, &.{}); try expectChangedFixture(&one, &two, &.{"b.py"}); try expectChangedFixture(&two, &one, &.{"b.py"}); try expectChangedFixture(&one, &modified, &.{"a.py"}); const mixed_previous = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, .{ .path = "b.py", .size = 2, .mtime = 2 }, .{ .path = "c.py", .size = 3, .mtime = 3 }, .{ .path = "d.py", .size = 4, .mtime = 4 }, }; const mixed_current = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, .{ .path = "b.py", .size = 5, .mtime = 2 }, .{ .path = "d.py", .size = 4, .mtime = 4 }, .{ .path = "e.py", .size = 5, .mtime = 5 }, }; try expectChangedFixture( &mixed_previous, &mixed_current, &.{ "b.py", "c.py", "e.py" }, ); const duplicate_previous = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, .{ .path = "a.py", .size = 2, .mtime = 2 }, }; const duplicate_current = [_]FileState{ .{ .path = "a.py", .size = 1, .mtime = 1 }, .{ .path = "a.py", .size = 3, .mtime = 3 }, }; try expectChangedFixture(&duplicate_previous, &duplicate_current, &.{"a.py"});}test "watch merge walk path comparisons grow linearly" { const testing = std.testing; const entry_count: usize = 4096; var paths: [entry_count][4]u8 = undefined; var previous: [entry_count]FileState = undefined; var current: [entry_count]FileState = undefined; for (0..entry_count) |index| { std.mem.writeInt(u32, &paths[index], @intCast(index), .big); previous[index] = .{ .path = &paths[index], .size = 1, .mtime = 1 }; current[index] = .{ .path = &paths[index], .size = if (index + 1 == entry_count) 2 else 1, .mtime = 1, }; } const scanned = try scanChangedFiles(testing.allocator, &previous, ¤t); defer testing.allocator.free(scanned.files); try testing.expectEqual(@as(usize, 1), scanned.files.len); try testing.expectEqualSlices(u8, &paths[entry_count - 1], scanned.files[0]); const comparison_max = entry_count * 3; try testing.expect(scanned.path_comparisons <= comparison_max);}test "watch snapshot replacements retain only live snapshot bytes" { const testing = std.testing; const root = try std.fmt.allocPrint( testing.allocator, "/tmp/smg-watch-retention-{x}", .{@intFromPtr(&interrupted)}, ); defer testing.allocator.free(root); sys.fs.deleteTree(root) catch {}; defer sys.fs.deleteTree(root) catch {}; try sys.fs.createDirPath(root); const file_path = try std.fs.path.join(testing.allocator, &.{ root, "main.zig" }); defer testing.allocator.free(file_path); try text.writeFile(file_path, "pub fn main() void {}\n"); var meter = alloc.LimitAllocator.init(testing.allocator, 1024 * 1024); defer meter.deinit(); const paths = [_][]const u8{root}; var stable = try snapshot(meter.allocator(), &paths); defer stable.deinit(meter.allocator()); try testing.expectEqual(@as(usize, 1), stable.states.len); const retained_bytes = stable.retainedBytes(); try testing.expectEqual(retained_bytes, meter.liveBytes()); const replacement_count: usize = 32; for (0..replacement_count) |_| { const next = try snapshot(meter.allocator(), &paths); stable.replace(meter.allocator(), next); try testing.expectEqual(retained_bytes, stable.retainedBytes()); try testing.expectEqual(retained_bytes, meter.liveBytes()); }}test "watch stop text matches Python interrupt footer" { try std.testing.expectEqualStrings("\nStopped.\n", stoppedText());}test "watch debounce sleep observes interrupts" { interrupted.store(false, .release); handleInterrupt(.INT); sleepDebounce(10); try std.testing.expect(interrupted.load(.acquire)); interrupted.store(false, .release);}Complete call list for watch.run
9 direct calls.
tiny.smg.watch.changedFiles[function] attools/smg/src/watch.zig:219tiny.smg.watch.eventText[function] attools/smg/src/watch.zig:130tiny.smg.watch.rescan[function] attools/smg/src/watch.zig:177tools.smg.src.watch.resetArena[function] — private; no exact target attools/smg/src/watch.zig:392in nearest public ownertiny.smg.watchtools.smg.src.watch.sleepDebounce[function] — private; no exact target attools/smg/src/watch.zig:382in nearest public ownertiny.smg.watchtiny.smg.watch.snapshot[function] attools/smg/src/watch.zig:208tiny.smg.watch.startText[function] attools/smg/src/watch.zig:118tiny.smg.watch.stoppedText[function] attools/smg/src/watch.zig:173tiny.smg.watch.watchPaths[function] attools/smg/src/watch.zig:198
Audit
| Definitions | 15 |
|---|---|
| Public names | 15 |
| Members | 7 |
| Version | 26.7.0 |
| Revision | daab053ee433 |