add file watcher

This commit is contained in:
Rokas Puzonas 2026-06-25 20:30:43 +03:00
parent 8ed6347466
commit 9217965ec1
10 changed files with 1003 additions and 283 deletions

207
build.zig
View File

@ -1,156 +1,87 @@
const std = @import("std");
// Although this function looks imperative, it does not perform the build
// directly and instead it mutates the build graph (`b`) that will be then
// executed by an external runner. The functions in `std.Build` implement a DSL
// for defining build steps and express dependencies between them, allowing the
// build runner to parallelize the build automatically (and the cache system to
// know when a step doesn't need to be re-run).
pub fn build(b: *std.Build) void {
// Standard target options allow the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
// for restricting supported target set are available.
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
// Standard optimization options allow the person running `zig build` to select
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
// It's also possible to define more custom flags to toggle optional features
// of this build script using `b.option()`. All defined flags (including
// target and optimize options) will be listed when running `zig build --help`
// in this directory.
// This creates a module, which represents a collection of source files alongside
// some compilation options, such as optimization mode and linked system libraries.
// Zig modules are the preferred way of making Zig code available to consumers.
// addModule defines a module that we intend to make available for importing
// to our consumers. We must give it a name because a Zig package can expose
// multiple modules and consumers will need to be able to specify which
// module they want to access.
const mod = b.addModule("sokol_template_v2", .{
// The root source file is the "entry point" of this module. Users of
// this module will only be able to access public declarations contained
// in this file, which means that if you have declarations that you
// intend to expose to consumers that were defined in other files part
// of this module, you will have to make sure to re-export them from
// the root file.
.root_source_file = b.path("src/root.zig"),
// Later on we'll use this module as the root module of a test executable
// which requires us to specify a target.
const plugin_dynamic_enabled = b.option(bool, "plugin-dynamic", "Enable loading plugin through a dynamic library") orelse true;
const plugin_static_enabled = b.option(bool, "plugin-static", "Enable loading plugin through a dynamic library") orelse true;
const build_options = b.addOptions();
build_options.addOption(bool, "plugin_dynamic", plugin_dynamic_enabled);
build_options.addOption(bool, "plugin_static", plugin_static_enabled);
const plugin_api_mod = b.createModule(.{
.root_source_file = b.path("src/plugin_api.zig"),
});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize
});
exe_mod.addOptions("build_options", build_options);
exe_mod.addImport("plugin_api", plugin_api_mod);
// Here we define an executable. An executable needs to have a root module
// which needs to expose a `main` function. While we could add a main function
// to the module defined above, it's sometimes preferable to split business
// logic and the CLI into two separate modules.
//
// If your goal is to create a Zig library for others to use, consider if
// it might benefit from also exposing a CLI tool. A parser library for a
// data serialization format could also bundle a CLI syntax checker, for example.
//
// If instead your goal is to create an executable, consider if users might
// be interested in also being able to embed the core functionality of your
// program in their own executable in order to avoid the overhead involved in
// subprocessing your CLI tool.
//
// If neither case applies to you, feel free to delete the declaration you
// don't need and to put everything under a single module.
const exe = b.addExecutable(.{
.name = "sokol_template_v2",
.root_module = b.createModule(.{
// b.createModule defines a new module just like b.addModule but,
// unlike b.addModule, it does not expose the module to consumers of
// this package, which is why in this case we don't have to give it a name.
.root_source_file = b.path("src/main.zig"),
// Target and optimization levels must be explicitly wired in when
// defining an executable or library (in the root module), and you
// can also hardcode a specific target for an executable or library
// definition if desireable (e.g. firmware for embedded devices).
.target = target,
.optimize = optimize,
// List of modules available for import in source files part of the
// root module.
.imports = &.{
// Here "sokol_template_v2" is the name you will use in your source code to
// import this module (e.g. `@import("sokol_template_v2")`). The name is
// repeated because you are allowed to rename your imports, which
// can be extremely useful in case of collisions (which can happen
// importing modules from different packages).
.{ .name = "sokol_template_v2", .module = mod },
},
}),
const plugin_mod = b.createModule(.{
.root_source_file = b.path("src/plugin.zig"),
.target = target,
.optimize = optimize
});
// This declares intent for the executable to be installed into the
// install prefix when running `zig build` (i.e. when executing the default
// step). By default the install prefix is `zig-out/` but can be overridden
// by passing `--prefix` or `-p`.
b.installArtifact(exe);
// This creates a top level step. Top level steps have a name and can be
// invoked by name when running `zig build` (e.g. `zig build run`).
// This will evaluate the `run` step rather than the default step.
// For a top level step to actually do something, it must depend on other
// steps (e.g. a Run step, as we will see in a moment).
const run_step = b.step("run", "Run the app");
// This creates a RunArtifact step in the build graph. A RunArtifact step
// invokes an executable compiled by Zig. Steps will only be executed by the
// runner if invoked directly by the user (in the case of top level steps)
// or if another step depends on it, so it's up to you to define when and
// how this Run step will be executed. In our case we want to run it when
// the user runs `zig build run`, so we create a dependency link.
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
// By making the run step depend on the default step, it will be run from the
// installation directory rather than directly from within the cache directory.
run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
plugin_mod.addImport("plugin_api", plugin_api_mod);
if (plugin_static_enabled) {
exe_mod.addImport("plugin", plugin_mod);
}
// Creates an executable that will run `test` blocks from the provided module.
// Here `mod` needs to define a target, which is why earlier we made sure to
// set the releative field.
const mod_tests = b.addTest(.{
.root_module = mod,
const exe = b.addExecutable(.{
.name = "sokol_template_v2",
.root_module = exe_mod,
});
// A run step that will run the test executable.
const run_mod_tests = b.addRunArtifact(mod_tests);
// Creates an executable that will run `test` blocks from the executable's
// root module. Note that test executables only test one module at a time,
// hence why we have to create two separate ones.
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
const plugin_dynamic = b.addLibrary(.{
.name = "plugin",
.linkage = .dynamic,
.root_module = b.createModule(.{
.root_source_file = b.path("src/plugin_dynamic.zig"),
.target = target,
.optimize = optimize
})
});
plugin_dynamic.root_module.addImport("plugin_api", plugin_api_mod);
plugin_dynamic.root_module.addImport("plugin", plugin_mod);
// A run step that will run the second test executable.
const run_exe_tests = b.addRunArtifact(exe_tests);
const plugin_install_artifact = b.addInstallArtifact(plugin_dynamic, .{ });
if (plugin_dynamic_enabled) {
exe.step.dependOn(&plugin_install_artifact.step);
}
// A top level step for running all tests. dependOn can be called multiple
// times and since the two run steps do not depend on one another, this will
// make the two of them run in parallel.
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
b.installArtifact(exe);
// Just like flags, top level steps are also listed in the `--help` menu.
//
// The Zig build system is entirely implemented in userland, which means
// that it cannot hook into private compiler APIs. All compilation work
// orchestrated by the build system will result in other Zig compiler
// subcommands being invoked with the right flags defined. You can observe
// these invocations when one fails (or you pass a flag to increase
// verbosity) to validate assumptions and diagnose problems.
//
// Lastly, the Zig build system is relatively simple and self-contained,
// and reading its source code will allow you to master it.
{
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (plugin_dynamic_enabled) {
run_cmd.addArg("--plugin-path");
run_cmd.addArg(b.getInstallPath(.lib, plugin_dynamic.out_lib_filename));
}
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}
{
const run_step = b.step("build-plugin", "Build plugin");
run_step.dependOn(&plugin_install_artifact.step);
}
{
const exe_tests = b.addTest(.{ .root_module = exe_mod, });
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_exe_tests.step);
}
}

View File

@ -1,81 +1,12 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .sokol_template_v2,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0xf440b4e6e51f5206, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.16.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL. If the contents of a URL change this will result in a hash mismatch
// // which will prevent zig from using it.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
//
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package. Only files listed here will remain on disk
// when using the zig package manager. As a rule of thumb, one should list
// files required for compilation plus any license(s).
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.dependencies = .{},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}

301
src/cli.zig Normal file
View File

@ -0,0 +1,301 @@
const std = @import("std");
const Io = std.Io;
const log = std.log.scoped(.engine);
const CLI = @This();
gpa: std.mem.Allocator,
arena: std.heap.ArenaAllocator,
options: std.ArrayList(Option),
commands: std.ArrayList(Command),
stdout: Channel,
stderr: Channel,
pub const Command = struct {
name: []const u8,
description: []const u8,
const Id = enum (u32) { _ };
};
pub const Option = struct {
long_name: []const u8,
description: []const u8,
kind: Kind,
pub const Kind = enum {
toggle,
single_argument
};
pub const Id = enum (u32) { _ };
const Value = struct {
argument: ?[]const u8 = null
};
};
const Channel = struct {
file: Io.File,
buffer: [4 * 4096]u8,
writer: Io.File.Writer,
pub fn init(self: *Channel, io: Io, file: Io.File) void {
self.* = Channel{
.file = file,
.buffer = undefined,
.writer = file.writer(io, &self.buffer)
};
}
pub fn write(self: *Channel, comptime fmt: []const u8, args: anytype) void {
self.writer.interface.print(fmt, args) catch |e| {
log.err("Failed to write to channel: {}", .{e});
};
}
pub fn flush(self: *Channel) void {
self.writer.interface.flush() catch |e| {
log.err("Failed to flush channel: {}", .{e});
};
}
};
pub fn init(self: *CLI, io: Io, gpa: std.mem.Allocator) void {
self.* = CLI{
.gpa = gpa,
.arena = .init(gpa),
.options = .empty,
.commands = .empty,
.stdout = undefined,
.stderr = undefined
};
self.stdout.init(io, Io.File.stdout());
self.stderr.init(io, Io.File.stderr());
}
pub fn deinit(self: *CLI) void {
self.stderr.flush();
self.stdout.flush();
self.arena.deinit();
self.options.deinit(self.gpa);
self.commands.deinit(self.gpa);
}
pub const AddOptionOptions = struct {
long_name: []const u8,
description: ?[]const u8 = null,
kind: Option.Kind = .toggle,
};
pub fn addOption(self: *CLI, opts: AddOptionOptions) !Option.Id {
const arena = self.arena.allocator();
var owned_description: []const u8 = "";
if (opts.description) |description| {
owned_description = try arena.dupe(u8, description);
}
const index = self.options.items.len;
try self.options.append(self.gpa, .{
.long_name = try arena.dupe(u8, opts.long_name),
.description = owned_description,
.kind = opts.kind
});
return @enumFromInt(index);
}
fn getOptionByLongName(self: *CLI, long_name: []const u8) ?Option.Id {
for (0.., self.options.items) |i, option| {
if (std.mem.eql(u8, long_name, option.long_name)) {
return @enumFromInt(i);
}
}
return null;
}
pub const AddCommandOptions = struct {
name: []const u8,
description: ?[]const u8 = null
};
pub fn addCommand(self: *CLI, opts: AddCommandOptions) !Command.Id {
const arena = self.arena.allocator();
var owned_description: []const u8 = "";
if (opts.description) |description| {
owned_description = try arena.dupe(u8, description);
}
const index = self.commands.items.len;
try self.commands.append(self.gpa, Command{
.name = try arena.dupe(u8, opts.name),
.description = owned_description,
});
return @enumFromInt(index);
}
pub fn showUsage(self: *CLI, program_name: []const u8) void {
self.stderr.write("Usage: {s}", .{program_name});
if (self.options.items.len > 0) {
self.stderr.write(" [options]", .{});
}
if (self.commands.items.len > 0) {
self.stderr.write(" <command>", .{});
}
self.stderr.write("\n", .{});
if (self.commands.items.len > 0) {
self.stderr.write("\n", .{});
self.stderr.write("Commands:\n", .{});
for (self.commands.items) |command| {
self.stderr.write(" {s} {s}\n", .{command.name, command.description});
}
}
if (self.options.items.len > 0) {
self.stderr.write("\n", .{});
self.stderr.write("Options:\n", .{});
for (0.., self.options.items) |i, option| {
if (i > 0) {
self.stderr.write("\n", .{});
}
self.stderr.write(" --{s}", .{option.long_name});
if (option.kind == .single_argument) {
// TODO: Make this hint renameable
self.stderr.write(" <value>", .{});
}
self.stderr.write("\n", .{});
if (option.description.len > 0) {
self.stderr.write(" {s}\n", .{option.description});
}
}
}
self.stderr.flush();
}
pub const ParseResult = struct {
arena: std.heap.ArenaAllocator,
command: ?Command.Id,
options: []?Option.Value,
pub fn init(gpa: std.mem.Allocator, options_len: usize) !ParseResult {
var arena = std.heap.ArenaAllocator.init(gpa);
errdefer arena.deinit();
const options = try arena.allocator().alloc(?Option.Value, options_len);
@memset(options, null);
return ParseResult{
.arena = arena,
.command = null,
.options = options
};
}
pub fn deinit(self: *ParseResult) void {
self.arena.deinit();
}
pub fn getOption(self: ParseResult, id: Option.Id) ?Option.Value {
return self.options[@intFromEnum(id)];
}
pub fn isSet(self: ParseResult, id: Option.Id) bool {
return self.getOption(id) != null;
}
// TODO: I don't like this function name
pub fn getOptionArgument(self: ParseResult, id: Option.Id) ?[]const u8 {
if (self.getOption(id)) |option| {
return option.argument;
}
return null;
}
};
const Parser = struct {
args: []const []const u8,
cursor: u32,
pub fn peek(self: *Parser) ?[]const u8 {
if (self.cursor < self.args.len) {
return self.args[self.cursor];
}
return null;
}
pub fn take(self: *Parser) ?[]const u8 {
if (self.peek()) |value| {
self.cursor += 1;
return value;
}
return null;
}
};
fn printErrorAndExit(self: *CLI, comptime fmt: []const u8, args: anytype) noreturn {
self.stderr.write("ERROR: " ++ fmt ++ "\n", args);
self.stderr.flush();
std.process.exit(1);
}
// TODO: I don't like the fact that this call `std.process.exit()`
// But for now this is the most convenient thing.
pub fn parse(self: *CLI, gpa: std.mem.Allocator, args: []const []const u8) !ParseResult {
var result = try ParseResult.init(gpa, self.options.items.len);
errdefer result.deinit();
var parser = Parser{
.args = args,
.cursor = 1
};
next_arg: while (parser.take()) |arg| {
if (std.mem.startsWith(u8, arg, "--")) {
const long_option_name = arg[2..];
if (self.getOptionByLongName(long_option_name)) |option_id| {
const option = self.options.items[@intFromEnum(option_id)];
var result_value = Option.Value{};
if (option.kind == .single_argument) {
const argument = parser.take() orelse {
self.printErrorAndExit("Missing required argument for '{s}'", .{arg});
};
result_value.argument = argument;
}
result.options[@intFromEnum(option_id)] = result_value;
continue :next_arg;
}
self.printErrorAndExit("Unknown option '{s}'", .{arg});
}
if (result.command == null) {
for (0.., self.commands.items) |i, command| {
if (std.mem.eql(u8, command.name, arg)) {
result.command = @enumFromInt(i);
continue :next_arg;
}
}
self.printErrorAndExit("Unknown command '{s}'", .{arg});
} else {
self.printErrorAndExit("Unknown argument '{s}'", .{arg});
}
}
return result;
}

312
src/file_watcher.zig Normal file
View File

@ -0,0 +1,312 @@
const std = @import("std");
const INotify = @import("inotify.zig");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const FileWatcher = @This();
root_dir: []u8,
inotify: INotify,
files: std.ArrayList(File),
watches: std.ArrayList(Watch),
const File = struct {
path: []u8,
debounce: Io.Duration,
last_event_at: ?Io.Timestamp,
pub fn deinit(self: File, gpa: Allocator) void {
gpa.free(self.path);
}
};
const Watch = struct {
wd: ?u32,
path: []u8,
pub fn deinit(self: Watch, gpa: Allocator) void {
gpa.free(self.path);
}
};
pub fn init(gpa: Allocator, root_dir: []const u8) !FileWatcher {
if (!std.fs.path.isAbsolute(root_dir)) {
return error.PathIsNotAbsolute;
}
const inotify_buf = try gpa.alloc(u8, INotify.max_event_size * 10);
errdefer gpa.free(inotify_buf);
const root_dir_owned = try gpa.dupe(u8, root_dir);
errdefer gpa.free(root_dir_owned);
return FileWatcher{
.root_dir = root_dir_owned,
.inotify = try .init(inotify_buf),
.files = .empty,
.watches = .empty
};
}
fn findFileIndex(self: *FileWatcher, path: []const u8) ?usize {
for (0.., self.files.items) |i, file| {
if (std.mem.eql(u8, file.path, path)) {
return i;
}
}
return null;
}
fn findFile(self: *FileWatcher, path: []const u8) ?*File {
if (self.findFileIndex(path)) |file_index| {
return &self.files.items[file_index];
} else {
return null;
}
}
fn findWatchByPath(self: *FileWatcher, path: []const u8) ?usize {
for (0.., self.watches.items) |i, watch| {
if (std.mem.eql(u8, watch.path, path)) {
return i;
}
}
return null;
}
fn findWatchByDescriptor(self: *FileWatcher, wd: u32) ?usize {
for (0.., self.watches.items) |i, watch| {
if (watch.wd == wd) {
return i;
}
}
return null;
}
fn bufJoinPaths(path_buff: []u8, paths: []const []const u8) ![:0]u8 {
return try std.fmt.bufPrintZ(path_buff, "{f}", .{
std.fs.path.fmtJoin(paths)
});
}
fn attemptRegisteringWatch(self: *FileWatcher, watch: *Watch) !void {
if (watch.wd != null) {
return;
}
var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined;
const path = try bufJoinPaths(&path_buff, &.{ self.root_dir, watch.path });
watch.wd = self.inotify.add(path, INotify.Mask{
.create = true,
.modify = true,
.delete_self = true,
.move_self = true,
.moved_to = true,
.moved_from = true,
}) catch |err| switch (err) {
error.FileNotFound => null,
else => return err
};
}
fn isInside(outer_path: []const u8, inner_path: []const u8) bool {
if (inner_path.len == outer_path.len) {
return std.mem.eql(u8, inner_path, outer_path);
} else if (inner_path.len > outer_path.len) {
return std.mem.startsWith(u8, inner_path, outer_path) and inner_path[outer_path.len] == std.fs.path.sep;
} else {
return false;
}
}
fn unregisterWatchesRecursively(self: *FileWatcher, dir: []const u8) !void {
for (self.watches.items) |*watch| {
const wd = watch.wd orelse continue;
if (isInside(dir, watch.path)) {
try self.inotify.remove(wd);
watch.wd = null;
}
}
}
fn registerWatchesRecursively(self: *FileWatcher, dir: []const u8) !void {
for (self.watches.items) |*watch| {
if (watch.wd == null and isInside(dir, watch.path)) {
try self.attemptRegisteringWatch(watch);
}
}
}
fn ensureWatchExists(self: *FileWatcher, gpa: Allocator, path: []const u8) !usize {
var watch_index: usize = undefined;
if (self.findWatchByPath(path)) |found_watch_index| {
watch_index = found_watch_index;
} else {
try self.watches.ensureUnusedCapacity(gpa, 1);
const path_owned = try gpa.dupe(u8, path);
errdefer gpa.free(path_owned);
watch_index = self.watches.items.len;
self.watches.appendAssumeCapacity(Watch{
.wd = null,
.path = path_owned
});
}
try self.attemptRegisteringWatch(&self.watches.items[watch_index]);
return watch_index;
}
fn removeWatch(self: *FileWatcher, gpa: Allocator, index: usize) void {
const watch = self.watches.swapRemove(index);
watch.deinit(gpa);
}
pub fn add(self: *FileWatcher, gpa: Allocator, path: []const u8, debounce: Io.Duration) !void {
if (std.fs.path.isAbsolute(path)) {
return error.PathIsAbsolute;
}
if (self.findFileIndex(path) != null) {
return;
}
var dir_iter: ?[]const u8 = path;
while (dir_iter) |current| {
_ = try self.ensureWatchExists(gpa, current);
dir_iter = std.fs.path.dirname(current);
}
_ = try self.ensureWatchExists(gpa, "");
try self.files.ensureUnusedCapacity(gpa, 1);
const path_owned = try gpa.dupe(u8, path);
errdefer gpa.free(path_owned);
self.files.appendAssumeCapacity(File{
.path = path_owned,
.debounce = debounce,
.last_event_at = null
});
}
pub fn remove(self: *FileWatcher, gpa: Allocator, path: []const u8) void {
const index = self.findFileIndex(path);
if (index == null) {
return;
}
const file = self.files.items[index];
self.files.swapRemove(index);
file.deinit(gpa);
}
fn nextEvents(self: *FileWatcher) !?*File {
while (try self.inotify.next()) |e| {
const watch_index = self.findWatchByDescriptor(e.wd) orelse continue;
var watch = &self.watches.items[watch_index];
if (e.mask.ignored) {
watch.wd = null;
} else if (e.mask.q_overflow) {
return error.QueueOverflow;
} else if (e.mask.delete_self or e.mask.modify) {
if (self.findFile(watch.path)) |file| {
return file;
}
} else if (e.mask.create) {
assert(e.name != null);
var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined;
const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) });
if (self.findWatchByPath(path)) |child_watch_index| {
try self.attemptRegisteringWatch(&self.watches.items[child_watch_index]);
}
if (self.findFile(path)) |file| {
return file;
}
} else if (e.mask.moved_from) {
assert(e.name != null);
var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined;
const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) });
try self.unregisterWatchesRecursively(path);
if (self.findFile(path)) |file| {
return file;
}
} else if (e.mask.moved_to) {
assert(e.name != null);
var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined;
const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) });
try self.registerWatchesRecursively(path);
if (self.findFile(path)) |file| {
return file;
}
}
}
return null;
}
pub fn next(self: *FileWatcher, io: Io) !?[]const u8 {
const now = Io.Clock.real.now(io);
var queue_overflow = false;
while (true) {
const file = (self.nextEvents() catch |err| switch (err) {
error.QueueOverflow => {
queue_overflow = true;
break;
},
else => return err
}) orelse break;
file.last_event_at = now;
}
if (queue_overflow) {
for (self.files.items) |*file| {
file.last_event_at = now;
}
}
for (self.files.items) |*file| {
const last_event_at = file.last_event_at orelse continue;
if (now.nanoseconds > last_event_at.addDuration(file.debounce).nanoseconds) {
file.last_event_at = null;
return file.path;
}
}
return null;
}
pub fn deinit(self: *FileWatcher, gpa: Allocator) void {
gpa.free(self.root_dir);
gpa.free(self.inotify.buf);
self.inotify.deinit();
for (self.files.items) |file| {
file.deinit(gpa);
}
self.files.deinit(gpa);
for (self.watches.items) |watch| {
watch.deinit(gpa);
}
self.watches.deinit(gpa);
}

186
src/inotify.zig Normal file
View File

@ -0,0 +1,186 @@
const std = @import("std");
const Io = std.Io;
const assert = std.debug.assert;
const linux = std.os.linux;
const INotify = @This();
fd: i32,
buf: []u8,
buf_offset: usize,
buf_used: usize,
pub const Mask = packed struct(u32) {
access: bool = false,
modify: bool = false,
attrib: bool = false,
close_write: bool = false,
close_nowrite: bool = false,
open: bool = false,
moved_from: bool = false,
moved_to: bool = false,
create: bool = false,
delete: bool = false,
delete_self: bool = false,
move_self: bool = false,
_unused1: u1 = 0,
unmount: bool = false,
q_overflow: bool = false,
ignored: bool = false,
_unused2: u8 = 0,
onlydir: bool = false,
dont_follow: bool = false,
excl_unlink: bool = false,
_unused3: u1 = 0,
mask_create: bool = false,
mask_add: bool = false,
isdir: bool = false,
oneshot: bool = false,
pub fn format(self: Mask, writer: *Io.Writer) Io.Writer.Error!void {
try writer.writeAll(".{");
var first = true;
inline for (@typeInfo(Mask).@"struct".fields) |field| {
if (@typeInfo(field.type) == .bool and @field(self, field.name)) {
if (!first) {
try writer.writeAll(",");
}
try writer.print(" .{s} = true", .{field.name});
first = false;
}
}
try writer.writeAll(" }");
}
fn testMask(expected: u32, actual: Mask) !void {
try std.testing.expectEqual(expected, @as(u32, @bitCast(actual)));
}
test {
try testMask(linux.IN.ACCESS, Mask{ .access = true });
try testMask(linux.IN.MODIFY, Mask{ .modify = true });
try testMask(linux.IN.ATTRIB, Mask{ .attrib = true });
try testMask(linux.IN.CLOSE_WRITE, Mask{ .close_write = true });
try testMask(linux.IN.CLOSE_NOWRITE, Mask{ .close_nowrite = true });
try testMask(linux.IN.OPEN, Mask{ .open = true });
try testMask(linux.IN.MOVED_FROM, Mask{ .moved_from = true });
try testMask(linux.IN.MOVED_TO, Mask{ .moved_to = true });
try testMask(linux.IN.CREATE, Mask{ .create = true });
try testMask(linux.IN.DELETE, Mask{ .delete = true });
try testMask(linux.IN.DELETE_SELF, Mask{ .delete_self = true });
try testMask(linux.IN.MOVE_SELF, Mask{ .move_self = true });
try testMask(linux.IN.UNMOUNT, Mask{ .unmount = true });
try testMask(linux.IN.Q_OVERFLOW, Mask{ .q_overflow = true });
try testMask(linux.IN.IGNORED, Mask{ .ignored = true });
try testMask(linux.IN.ONLYDIR, Mask{ .onlydir = true });
try testMask(linux.IN.DONT_FOLLOW, Mask{ .dont_follow = true });
try testMask(linux.IN.EXCL_UNLINK, Mask{ .excl_unlink = true });
try testMask(linux.IN.MASK_CREATE, Mask{ .mask_create = true });
try testMask(linux.IN.MASK_ADD, Mask{ .mask_add = true });
try testMask(linux.IN.ISDIR, Mask{ .isdir = true });
try testMask(linux.IN.ONESHOT, Mask{ .oneshot = true });
}
};
pub const Event = struct {
wd: u32,
mask: Mask,
cookie: u32,
name: ?[*:0]const u8,
};
pub const max_event_size = @sizeOf(linux.inotify_event) + linux.NAME_MAX + 1;
pub fn init(buf: []u8) !INotify {
const fd = linux.inotify_init1(linux.IN.NONBLOCK);
if (linux.errno(fd) != .SUCCESS) {
return error.InotifyInit;
}
return INotify{
.fd = @intCast(fd),
.buf = buf,
.buf_offset = 0,
.buf_used = 0,
};
}
pub fn add(self: INotify, path: [*:0]const u8, mask: Mask) !u32 {
const wd = linux.inotify_add_watch(
self.fd,
path,
@bitCast(mask)
);
switch (linux.errno(wd)) {
.SUCCESS => return @intCast(wd),
.ACCES => return error.AccessDenied,
.BADF => unreachable, // The given file descriptor is not valid.
.EXIST => return error.MarkAlreadyExists,
.FAULT => unreachable, // path points outside of the process's accessible address space.
.INVAL => unreachable, // The given event mask contains no valid events;
.NAMETOOLONG => return error.NameTooLong,
.NOENT => return error.FileNotFound,
.NOMEM => return error.SystemResources,
.NOSPC => return error.UserMarkQuotaExceeded,
.NOTDIR => return error.NotDir,
else => |err| return std.posix.unexpectedErrno(err),
}
}
pub fn remove(self: INotify, wd: u32) !void {
const rc = linux.inotify_rm_watch(self.fd, @bitCast(wd));
if (linux.errno(rc) != .SUCCESS) {
return error.InotifyRemoveWatch;
}
}
pub fn next(self: *INotify) !?Event {
if (self.buf_used <= self.buf.len) {
const n = std.posix.read(self.fd, self.buf[self.buf_used..]) catch |err| switch(err) {
error.WouldBlock => 0,
else => return err
};
if (n > 0) {
self.buf_used += n;
}
}
if (self.buf_offset < self.buf_used) {
const event = std.mem.bytesAsValue(
linux.inotify_event,
self.buf[self.buf_offset..][0..@sizeOf(linux.inotify_event)]
);
self.buf_offset += @sizeOf(linux.inotify_event) + event.len;
assert(self.buf_offset <= self.buf_used);
if (self.buf_used == self.buf_offset) {
self.buf_offset = 0;
self.buf_used = 0;
}
var name: ?[*:0]const u8 = null;
if (event.len > 0) {
name = std.mem.span(@as([*:0]const u8, @ptrCast(event)) + @sizeOf(linux.inotify_event));
}
return Event{
.wd = @bitCast(event.wd),
.mask = @bitCast(event.mask),
.cookie = event.cookie,
.name = name
};
}
return null;
}
pub fn deinit(self: INotify) void {
_ = linux.close(self.fd);
}

View File

@ -1,71 +1,95 @@
const std = @import("std");
const CLI = @import("cli.zig");
const build_options = @import("build_options");
const builtin = @import("builtin");
const FileWatcher = @import("file_watcher.zig");
const Io = std.Io;
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const sokol_template_v2 = @import("sokol_template_v2");
// TODO: zig build --watch --color off --summary none
pub fn main(init: std.process.Init) !void {
// Prints to stderr, unbuffered, ignoring potential errors.
std.debug.print("All your {s} are belong to us.\n", .{"codebase"});
const Plugin = @import("plugin_api").Plugin;
// This is appropriate for anything that lives as long as the process.
const arena: std.mem.Allocator = init.arena.allocator();
// Accessing command line arguments:
const args = try init.minimal.args.toSlice(arena);
for (args) |arg| {
std.log.info("arg: {s}", .{arg});
pub fn getStaticPlugin() !Plugin {
if (!build_options.plugin_static) {
return error.NotSupported;
}
// In order to do I/O operations need an `Io` instance.
return @import("plugin").api;
}
const DynamicPlugin = struct {
lib: std.DynLib,
plugin: Plugin,
pub fn init(path: []const u8) !DynamicPlugin {
var lib = try std.DynLib.open(path);
errdefer lib.close();
var plugin: Plugin = undefined;
inline for (@typeInfo(Plugin).@"struct".fields) |field| {
@field(plugin, field.name) = lib.lookup(field.type, field.name) orelse return error.SymbolNotFound;
}
return DynamicPlugin{
.lib = lib,
.plugin = plugin
};
}
pub fn deinit(self: *DynamicPlugin) void {
self.lib.close();
}
};
pub fn main(init: std.process.Init) !void {
// This is appropriate for anything that lives as long as the process.
const arena: std.mem.Allocator = init.arena.allocator();
const gpa = init.gpa;
const io = init.io;
// Stdout is for the actual output of your application, for example if you
// are implementing gzip, then only the compressed bytes should be sent to
// stdout, not any debugging messages.
var stdout_buffer: [1024]u8 = undefined;
var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;
var cli: CLI = undefined;
cli.init(io, init.gpa);
defer cli.deinit();
try sokol_template_v2.printAnotherMessage(stdout_writer);
const opt_plugin_path = try cli.addOption(.{
.kind = .single_argument,
.long_name = "plugin-path"
});
try stdout_writer.flush(); // Don't forget to flush!
const args = try init.minimal.args.toSlice(arena);
var result = try cli.parse(gpa, args);
defer result.deinit();
const plugin_path: ?[]const u8 = result.getOptionArgument(opt_plugin_path);
if (plugin_path != null) {
var foo = try DynamicPlugin.init(plugin_path.?);
defer foo.deinit();
}
var cwd = Io.Dir.cwd();
const root_dir = try cwd.realPathFileAlloc(io, ".", gpa);
defer gpa.free(root_dir);
var file_watcher = try FileWatcher.init(gpa, root_dir);
defer file_watcher.deinit(gpa);
try file_watcher.add(gpa, "test.txt", .fromMilliseconds(150));
try file_watcher.add(gpa, "foo/test.txt", .fromMilliseconds(150));
while (true) {
while (try file_watcher.next(io)) |path| {
std.debug.print("path: {s}\n", .{path});
}
try io.sleep(.fromMilliseconds(100), .real);
}
}
test "simple test" {
const gpa = std.testing.allocator;
var list: std.ArrayList(i32) = .empty;
defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak!
try list.append(gpa, 42);
try std.testing.expectEqual(@as(i32, 42), list.pop());
}
test "fuzz example" {
try std.testing.fuzz({}, testOne, .{});
}
fn testOne(context: void, smith: *std.testing.Smith) !void {
_ = context;
// Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case!
const gpa = std.testing.allocator;
var list: std.ArrayList(u8) = .empty;
defer list.deinit(gpa);
while (!smith.eos()) switch (smith.value(enum { add_data, dup_data })) {
.add_data => {
const slice = try list.addManyAsSlice(gpa, smith.value(u4));
smith.bytes(slice);
},
.dup_data => {
if (list.items.len == 0) continue;
if (list.items.len > std.math.maxInt(u32)) return error.SkipZigTest;
const len = smith.valueRangeAtMost(u32, 1, @min(32, list.items.len));
const off = smith.valueRangeAtMost(u32, 0, @intCast(list.items.len - len));
try list.appendSlice(gpa, list.items[off..][0..len]);
try std.testing.expectEqualSlices(
u8,
list.items[off..][0..len],
list.items[list.items.len - len ..],
);
},
};
test {
std.testing.refAllDecls(@This());
}

11
src/plugin.zig Normal file
View File

@ -0,0 +1,11 @@
const std = @import("std");
const assert = std.debug.assert;
const PluginAPI = @import("plugin_api");
fn hello() callconv(.c) u32 {
return 1;
}
pub const api = PluginAPI.Plugin{
.hello = hello
};

26
src/plugin_api.zig Normal file
View File

@ -0,0 +1,26 @@
const std = @import("std");
const assert = std.debug.assert;
pub const Plugin = struct {
hello: *const fn() callconv(.c) u32,
comptime {
for (@typeInfo(Plugin).@"struct".fields) |field| {
assert(isPointerToCFunction(field.type));
}
}
};
fn isPointerToCFunction(T: type) bool {
if (@typeInfo(T) != .pointer) {
return false;
}
const fnInfo = @typeInfo(@typeInfo(T).pointer.child);
if (fnInfo != .@"fn") {
return false;
}
const calling_convention = std.meta.activeTag(fnInfo.@"fn".calling_convention);
return calling_convention == std.builtin.CallingConvention.c;
}

16
src/plugin_dynamic.zig Normal file
View File

@ -0,0 +1,16 @@
const plugin = @import("plugin");
const PluginAPI = @import("plugin_api");
comptime {
if (@hasField(plugin, "api")) {
@compileError("Missing 'api' field");
}
if (@TypeOf(plugin.api) != PluginAPI.Plugin) {
@compileError("Incorrect 'api' field type");
}
for (@typeInfo(PluginAPI.Plugin).@"struct".fields) |field| {
@export(@field(plugin.api, field.name), .{ .name = field.name });
}
}

View File

@ -1,18 +0,0 @@
//! By convention, root.zig is the root source file when making a package.
const std = @import("std");
const Io = std.Io;
/// This is a documentation comment to explain the `printAnotherMessage` function below.
///
/// Accepting an `Io.Writer` instance is a handy way to write reusable code.
pub fn printAnotherMessage(writer: *Io.Writer) Io.Writer.Error!void {
try writer.print("Run `zig build test` to run the tests.\n", .{});
}
pub fn add(a: i32, b: i32) i32 {
return a + b;
}
test "basic add functionality" {
try std.testing.expect(add(3, 7) == 10);
}