From 9217965ec11baac20efee4599148af8ee0059ab7 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Thu, 25 Jun 2026 20:30:43 +0300 Subject: [PATCH] add file watcher --- build.zig | 207 +++++++++------------------ build.zig.zon | 71 +--------- src/cli.zig | 301 +++++++++++++++++++++++++++++++++++++++ src/file_watcher.zig | 312 +++++++++++++++++++++++++++++++++++++++++ src/inotify.zig | 186 ++++++++++++++++++++++++ src/main.zig | 138 ++++++++++-------- src/plugin.zig | 11 ++ src/plugin_api.zig | 26 ++++ src/plugin_dynamic.zig | 16 +++ src/root.zig | 18 --- 10 files changed, 1003 insertions(+), 283 deletions(-) create mode 100644 src/cli.zig create mode 100644 src/file_watcher.zig create mode 100644 src/inotify.zig create mode 100644 src/plugin.zig create mode 100644 src/plugin_api.zig create mode 100644 src/plugin_dynamic.zig delete mode 100644 src/root.zig diff --git a/build.zig b/build.zig index 4a7cc03..925f5f8 100644 --- a/build.zig +++ b/build.zig @@ -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); + } } diff --git a/build.zig.zon b/build.zig.zon index 28e370f..01b4be6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 `, 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 ` 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", }, } diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 0000000..9b164b3 --- /dev/null +++ b/src/cli.zig @@ -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(" ", .{}); + } + 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(" ", .{}); + } + 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; +} diff --git a/src/file_watcher.zig b/src/file_watcher.zig new file mode 100644 index 0000000..820d398 --- /dev/null +++ b/src/file_watcher.zig @@ -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); +} diff --git a/src/inotify.zig b/src/inotify.zig new file mode 100644 index 0000000..cbc4c90 --- /dev/null +++ b/src/inotify.zig @@ -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); +} diff --git a/src/main.zig b/src/main.zig index 55c64c9..612642c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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()); } diff --git a/src/plugin.zig b/src/plugin.zig new file mode 100644 index 0000000..cd03d01 --- /dev/null +++ b/src/plugin.zig @@ -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 +}; diff --git a/src/plugin_api.zig b/src/plugin_api.zig new file mode 100644 index 0000000..623ebb2 --- /dev/null +++ b/src/plugin_api.zig @@ -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; +} diff --git a/src/plugin_dynamic.zig b/src/plugin_dynamic.zig new file mode 100644 index 0000000..cb1e40c --- /dev/null +++ b/src/plugin_dynamic.zig @@ -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 }); + } +} diff --git a/src/root.zig b/src/root.zig deleted file mode 100644 index 5a71250..0000000 --- a/src/root.zig +++ /dev/null @@ -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); -}