From e1e19383595738a6b731a5ad54b248e234b30998 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 4 Jan 2026 23:54:26 +0100 Subject: [PATCH 01/18] refactor: extract modules from bot.zig This commit creates a bunch of new modules that contain code and tests for various concepts/implementations that used to exist inside bot.zig. Notable amongst these are: - buffer.zig, which contain the circular buffer containing both backlog and outbox messages. - parser.zig, which contain the parser used to parse commands from IRC messages. --- build.zig | 116 +++++++++------ src/bot.zig | 372 ++++++++++++++++++++--------------------------- src/buffer.zig | 269 ++++++++++++++++++++++++++++++++++ src/commands.zig | 106 ++++++++++++++ src/main.zig | 59 ++++---- src/parser.zig | 54 +++++++ src/root.zig | 3 + 7 files changed, 694 insertions(+), 285 deletions(-) create mode 100644 src/buffer.zig create mode 100644 src/commands.zig create mode 100644 src/parser.zig diff --git a/build.zig b/build.zig index ad0243c..789e0fc 100644 --- a/build.zig +++ b/build.zig @@ -1,12 +1,16 @@ const std = @import("std"); +const Import = std.Build.Module.Import; +const ResolvedTarget = std.Build.ResolvedTarget; +const Run = std.Build.Step.Run; + // 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 { +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 @@ -106,7 +110,7 @@ pub fn build(b: *std.Build) void { // 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"); + const runStep = 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 @@ -114,61 +118,56 @@ pub fn build(b: *std.Build) void { // 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); + const runCmd = b.addRunArtifact(exe); + runStep.dependOn(&runCmd.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()); + runCmd.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); + runCmd.addArgs(args); } - // 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, - }); - - // A run step that will run the test executable. - const run_mod_tests = b.addRunArtifact(mod_tests); - - const bot_tests = b.addTest(.{ - .root_module = b.addModule("bot", .{ - .target = target, - .root_source_file = b.path("src/bot.zig"), - .imports = &.{ - .{ - .name = "zircon", - .module = zircon.module("zircon"), - }, - }, - }), - }); - - const run_bot_tests = b.addRunArtifact(bot_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, - }); - - // A run step that will run the second test executable. - const run_exe_tests = b.addRunArtifact(exe_tests); - // 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); - test_step.dependOn(&run_bot_tests.step); + const testStep = b.step("test", "Run tests"); + + const testRunArtifacts: [6]*Run = .{ + // 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. + b.addRunArtifact(b.addTest(.{ + .root_module = mod, + })), + + // 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. + b.addRunArtifact(b.addTest(.{ + .root_module = exe.root_module, + })), + + // Our bot tests needs zircon module import, + try testRunWithImports(b, target, "bot", &.{ + .{ + .name = "zircon", + .module = zircon.module("zircon"), + }, + }), + // Our module tests for each module we want to add. If + // breaking out new functionality to a module, remember to + // bump the length of the array above. + try testRun(b, target, "buffer"), + try testRun(b, target, "parser"), + try testRun(b, target, "commands"), + }; + for (testRunArtifacts) |test_run_artifact| { + testStep.dependOn(&test_run_artifact.step); + } // Just like flags, top level steps are also listed in the `--help` menu. // @@ -182,3 +181,30 @@ pub fn build(b: *std.Build) void { // Lastly, the Zig build system is relatively simple and self-contained, // and reading its source code will allow you to master it. } + +// Creates a Run reference that can be depended on when creating a +// test step. +// +// Assumes there exists a file called "src/$name.zig" that contains +// tests. +fn testRun(b: *std.Build, target: ?ResolvedTarget, name: []const u8) error{OutOfMemory}!*Run { + return testRunWithImports(b, target, name, &.{}); +} + +// Creates a Run reference that can be depended on when creating a +// test step, for modules with external imports. +// +// Assumes there exists a file called "src/$name.zig" that contains +// tests. +fn testRunWithImports(b: *std.Build, target: ?ResolvedTarget, name: []const u8, imports: []const Import) error{OutOfMemory}!*Run { + return b.addRunArtifact(b.addTest(.{ + .root_module = b.addModule( + name, + .{ + .target = target, + .root_source_file = b.path(try std.mem.concat(b.allocator, u8, &.{ "src/", name, ".zig" })), + .imports = imports, + }, + ), + })); +} diff --git a/src/bot.zig b/src/bot.zig index 658e6b6..a0ed0c2 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -1,126 +1,10 @@ const std = @import("std"); const zircon = @import("zircon"); -pub const Parser = struct { - original: []const u8, - rest: []const u8, - end_idx: usize, - - fn seek(self: *const Parser, skip: usize) Parser { - return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip }; - } - - fn init(s: []const u8) Parser { - return .{ - .original = s, - .end_idx = 0, - .rest = s, - }; - } - - fn consume_char(self: *const Parser, c: u8) ?Parser { - if (self.rest[0] != c) { - return null; - } - return self.seek(1); - } - - fn consume_str(self: *const Parser, s: []const u8) ?Parser { - const len = s.len; - if (self.rest.len < len) { - return null; - } - if (!std.mem.eql(u8, self.rest[0..len], s)) { - return null; - } - return self.seek(len); - } - - fn take_until_char(self: *const Parser, c: u8) struct { Parser, []const u8 } { - const idx = std.mem.indexOfScalar(u8, self.rest, c) orelse unreachable; - return .{ self.seek(idx), self.rest[0..idx] }; - } - - fn take_char(self: *const Parser) struct { Parser, u8 } { - return .{ self.seek(1), self.rest[0] }; - } - - fn parsed(self: *const Parser) []const u8 { - return self.original[0..self.end_idx]; - } -}; - -/// AdminCommand are commands useful for debugging zigeru, since they -/// are more spammy than others they are separated and only sent to -/// #eru-admin. -pub const AdminCommand = union(enum) { - backlog: struct { history: u16 }, - status: void, - join: struct { channel: []const u8 }, - err: struct { message: []const u8 }, - - pub fn parse(text: []const u8) ?AdminCommand { - const original = Parser.init(text); - if (original.consume_char('!')) |command| { - if (command.consume_str("status")) |_| { - return .status; - } - if (command.consume_str("join").?.consume_char(' ')) |join| { - if (join.rest[0] != '#') { - return .{ .err = .{ .message = "channels must start with \"#\"" } }; - } - return .{ .join = .{ .channel = join.rest } }; - } - if (command.consume_str("backlog")) |backlog| { - const history = std.fmt.parseInt(u16, backlog.rest, 10) catch |err| { - std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ backlog.rest, err }); - return null; - }; - return .{ .backlog = .{ .history = history } }; - } - std.log.debug("unknown command: \"{s}\"", .{command.rest}); - } - return null; - } -}; - -/// Command represents the commands that ordinary IRC users can use. -pub const Command = union(enum) { - /// `s///` - substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8, all: bool = false }, - /// !help - help: void, - - pub fn parse(nick: []const u8, text: []const u8) ?Command { - const original = Parser.init(text); - if (original.consume_str("!help")) |_| { - return .help; - } - - if (original.consume_char('s')) |substitute| { - const delim_parser, const delim = substitute.take_char(); - if (std.ascii.isAlphanumeric(delim)) { - std.log.debug("parsing substitute command: delimiter cannot be a whitespace: \"{s}\"", .{text}); - return null; - } - const typo_parser, const typo = delim_parser.take_until_char(delim); - const correction_parser, const correction = typo_parser.consume_char(delim).?.take_until_char(delim); - if (correction_parser.consume_char(delim) == null) { - std.log.debug("parsing substitute command: missing an ending '/' in \"{s}\"", .{text}); - return null; - } - return .{ - .substitute = .{ - .author = nick, - .needle = typo, - .replacement = correction, - .all = false, - }, - }; - } - return null; - } -}; +const zigeru = @import("root.zig"); +const Buffer = zigeru.buffer.Buffer; +const UserCommand = zigeru.commands.UserCommand; +const AdminCommand = zigeru.commands.AdminCommand; const HELP_MESSAGE: []const u8 = "Send `s/TYPO/CORRECTION/` to replace TYPO with CORRECTION in your last message."; @@ -132,59 +16,80 @@ pub const Message = struct { author: []const u8, content: []const u8, - pub fn init_owned(allocator: std.mem.Allocator, timestamp: i64, author: []const u8, targets: []const u8, content: []const u8) Error!Message { - return .{ - .timestamp = timestamp, - .targets = try allocator.dupe(u8, targets), - .author = try allocator.dupe(u8, author), - .content = try allocator.dupe(u8, content), - }; + pub fn init_owned( + allocator: std.mem.Allocator, + timestamp: i64, + author: []const u8, + targets: []const u8, + content: []const u8, + ) Error!*Message { + const message = try allocator.create(Message); + message.timestamp = timestamp; + message.targets = try allocator.dupe(u8, targets); + message.author = try allocator.dupe(u8, author); + message.content = try allocator.dupe(u8, content); + return message; } pub fn deinit(self: *const Message, allocator: std.mem.Allocator) void { allocator.free(self.author); allocator.free(self.content); allocator.free(self.targets); + allocator.destroy(self); } }; pub const Bot = struct { - backlog: [1024]?*const Message, - sent_messages: std.ArrayList([]u8), - top: usize, - bottom: usize, + backlog: Buffer(*const Message, 1024), + outbox: Buffer([]u8, 1024), allocator: std.mem.Allocator, + // deinit function for backlog messages + fn deinit_backlog_slot(allocator: std.mem.Allocator, item: *const Message) void { + item.deinit(allocator); + } + + // deinit function for outbox messages. + fn deinit_sent_message(allocator: std.mem.Allocator, item: []u8) void { + allocator.free(item); + } + + // init initializes a Bot with fixed size backlog and outbox + // buffers. + // + // Both buffers own their contents and deinits any slots that gets + // overwritten. pub fn init(allocator: std.mem.Allocator) Error!Bot { - return Bot{ - .backlog = .{null} ** 1024, - .sent_messages = try std.ArrayList([]u8).initCapacity(allocator, 10), - .top = 0, - .bottom = 0, + var bot = Bot{ .allocator = allocator, + .backlog = undefined, + .outbox = undefined, }; + bot.backlog = .init_with_closure(allocator, &Bot.deinit_backlog_slot); + bot.outbox = .init_with_closure(allocator, &Bot.deinit_sent_message); + return bot; } + // deinits self's buffers and their contents. + // + // NOTE: does not deinit self. pub fn deinit(self: *Bot) void { - for (self.sent_messages.items) |item| { - self.allocator.free(item); - } - self.sent_messages.deinit(self.allocator); - - var idx = self.previous_idx(self.top); - while (idx != self.bottom) : (idx = self.previous_idx(idx)) { - if (self.backlog[idx]) |message| { - message.deinit(self.allocator); - } else { - break; - } - } + self.outbox.deinit(); + self.backlog.deinit(); } - pub fn execute(self: *Bot, cmd: *const Command, prefix: ?zircon.Prefix, targets: []const u8) Error!zircon.Message { + pub fn execute( + self: *Bot, + cmd: *const UserCommand, + prefix: ?zircon.Prefix, + targets: []const u8, + ) Error!zircon.Message { switch (cmd.*) { .substitute => |command| { - const prev_msg = self.previous_message_by_author(command.author, targets) orelse return Error.NoMessage; + const prev_msg = self.previous_message_by_author( + command.author, + targets, + ) orelse return Error.NoMessage; const output = try std.mem.replaceOwned( u8, self.allocator, @@ -193,27 +98,40 @@ pub const Bot = struct { command.replacement, ); defer self.allocator.free(output); - const quoted_output = try std.fmt.allocPrint(self.allocator, "{s}: \"{s}\"", .{ command.author, output }); - try self.sent_messages.append(self.allocator, quoted_output); - return zircon.Message{ - .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output }, - }; + const quoted_output = try std.fmt.allocPrint( + self.allocator, + "{s}: \"{s}\"", + .{ command.author, output }, + ); + self.outbox.append(quoted_output); + return zircon.Message{ .PRIVMSG = .{ + .targets = targets, + .prefix = prefix, + .text = quoted_output, + } }; }, .help => { - return zircon.Message{ - .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = HELP_MESSAGE }, - }; + return zircon.Message{ .PRIVMSG = .{ + .targets = targets, + .prefix = prefix, + .text = HELP_MESSAGE, + } }; }, } } - pub fn execute_admin(self: *Bot, cmd: *const AdminCommand, prefix: ?zircon.Prefix, targets: []const u8) Error!zircon.Message { + pub fn execute_admin( + self: *Bot, + cmd: *const AdminCommand, + prefix: ?zircon.Prefix, + targets: []const u8, + ) Error!zircon.Message { switch (cmd.*) { .status => { const msg = try std.fmt.allocPrint( self.allocator, - "heard messages: {}, sent messages: {}, top: {}, bottom: {}", - .{ self.no_messages(), self.sent_messages.items.len, self.top, self.bottom }, + "heard messages: {}, sent messages: {}", + .{ self.backlog.len(), self.outbox.len() }, ); return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = msg } }; }, @@ -222,20 +140,19 @@ pub const Bot = struct { return .{ .JOIN = .{ .prefix = prefix, .channels = msg.channel } }; }, .backlog => |backlog| { - if (self.top == self.bottom) { + if (self.backlog.insertions == 0) { return Error.NoMessage; } - if (backlog.history > self.no_messages()) { + if (backlog.history > self.backlog.len()) { return Error.NoMessage; } - const idx = self.previous_idx(self.top - backlog.history); - if (self.backlog[idx]) |message| { + if (self.backlog.get_backwards(backlog.history)) |message| { const quoted_output = try std.fmt.allocPrint( self.allocator, "backlog {}: author: \"{s}\", content: \"{s}\"", .{ backlog.history, message.author, message.content }, ); - try self.sent_messages.append(self.allocator, quoted_output); + self.outbox.append(quoted_output); return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output }, }; @@ -247,53 +164,78 @@ pub const Bot = struct { } } + // hear a message and store it in backlog, potentially overwriting oldest message. pub fn hear(self: *Bot, msg: *const Message) void { - self.backlog[self.top] = msg; - self.top = (self.top + 1) % self.backlog.len; - if (self.top == self.bottom) { - self.bottom = (self.bottom + 1) % self.backlog.len; - // free old message - self.allocator.free(self.backlog[self.top].?.author); - self.allocator.free(self.backlog[self.top].?.content); - self.backlog[self.top] = null; - } - } - - pub fn no_messages(self: *Bot) usize { - if (self.top < self.bottom) { - // we've bounded around, the backlog is full. - return self.backlog.len; - } - return self.top - self.bottom; - } - - fn previous_idx(self: *Bot, idx: usize) usize { - if (idx == 0) { - return self.backlog.len - 1; - } - return (idx - 1) % self.backlog.len; + self.backlog.append(msg); } fn previous_message_by_author(self: *Bot, author: []const u8, targets: []const u8) ?*const Message { - var idx = self.previous_idx(self.top); - while (true) : (idx = self.previous_idx(idx)) { - if (self.backlog[idx] == null) { - return null; - } - const message = self.backlog[idx] orelse unreachable; - if (std.mem.eql(u8, message.author, author) and std.mem.eql(u8, message.targets, targets)) { + var iter = self.backlog.iterate_reverse(); + while (iter.prev()) |message| { + if (std.mem.eql(u8, message.author, author) and + std.mem.eql(u8, message.targets, targets)) + { return message; } - if (idx == self.bottom) { - // reached the start of the list - break; - } } return null; } }; -test "hear_wraps" { +test "deiniting an owned Message leaks no memory" { + const allocator = std.testing.allocator; + const testMessage = try Message.init_owned( + allocator, + 12345, + "jassob", + "#test", + "All your codebase are belong to us.\n", + ); + testMessage.deinit(allocator); +} + +test "deiniting an inited Bot leaks no memory" { + var bot = try Bot.init(std.testing.allocator); + bot.deinit(); +} + +test "hear and deinit has no leaks" { + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); + defer bot.deinit(); + + const testMessage = try Message.init_owned( + allocator, + 12345, + "jassob", + "#test", + "All your codebase are belong to us.\n", + ); + bot.hear(testMessage); + + try std.testing.expectEqual(0, bot.backlog.top); +} + +test "a few hears and deinit has no leaks" { + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); + defer bot.deinit(); + + for (0..2) |i| { + const testMessage = try Message.init_owned( + std.testing.allocator, + @intCast(i), + "jassob", + "#test", + "All your codebase are belong to us.\n", + ); + bot.hear(testMessage); + } + + try std.testing.expectEqual(1, bot.backlog.top); +} + +test "hear wraps" { var bot = try Bot.init(std.testing.allocator); defer bot.deinit(); @@ -305,19 +247,27 @@ test "hear_wraps" { "#test", "All your codebase are belong to us.\n", ); - bot.hear(&testMessage); + bot.hear(testMessage); } - try std.testing.expect(bot.top == 1); - try std.testing.expect(bot.bottom == 2); - try std.testing.expect(bot.no_messages() == 1024); + try std.testing.expectEqual(0, bot.backlog.top); + try std.testing.expectEqual(1, bot.backlog.bottom()); + try std.testing.expectEqual(1024, bot.backlog.len()); } test "execute_substitution_no_previous_message" { var bot = try Bot.init(std.testing.allocator); defer bot.deinit(); - const cmd = Command{ .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" } }; - try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, null, "#test")); + const cmd = UserCommand{ .substitute = .{ + .author = "jassob", + .needle = "What", + .replacement = "what", + } }; + try std.testing.expectError(Error.NoMessage, bot.execute( + &cmd, + null, + "#test", + )); } test "execute_substitution" { @@ -332,10 +282,10 @@ test "execute_substitution" { "#test", "What", ); - bot.hear(&msg); + bot.hear(msg); // execute substitution - const cmd = Command{ + const cmd = UserCommand{ .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" }, }; const response = try bot.execute(&cmd, null, "#test"); diff --git a/src/buffer.zig b/src/buffer.zig new file mode 100644 index 0000000..31898e1 --- /dev/null +++ b/src/buffer.zig @@ -0,0 +1,269 @@ +const std = @import("std"); + +// Buffer represents a non-allocating, circular buffer of a fixed size. +// +// It is possible to store owned data in Buffer and have it +// automatically deinited when it is overwritten by supplying a custom +// ItemDeinitClosure, using .init_with_closure. +pub fn Buffer(comptime T: type, comptime length: usize) type { + // TODO(jassob): Refactor buffer to use a top and len instead of + // top and bottom. + // + // Idea: bottom is always 0 if len != length and otherwise it is + // always top + 1. + return struct { + items: [length]?T, + top: usize, + insertions: usize, + deinit_func: ?*const fn (allocator: std.mem.Allocator, item: T) void, + allocator: ?std.mem.Allocator, + + pub fn init() Buffer(T, length) { + return .{ + .items = .{null} ** length, + .top = 0, + .insertions = 0, + .deinit_func = null, + .allocator = null, + }; + } + + pub fn init_with_closure( + allocator: std.mem.Allocator, + deinit_func: *const fn (allocator: std.mem.Allocator, item: T) void, + ) Buffer(T, length) { + var buf = Buffer(T, length).init(); + buf.allocator = allocator; + buf.deinit_func = deinit_func; + return buf; + } + + fn can_deinit(self: *const Buffer(T, length)) bool { + return self.allocator != null and self.deinit_func != null; + } + + fn deinit_item(self: *Buffer(T, length), item: T) void { + if (!self.can_deinit()) { + // Nothing to do + return; + } + self.deinit_func.?(self.allocator.?, item); + } + + fn next(self: *const Buffer(T, length)) usize { + return (self.top + 1) % length; + } + + fn prev(self: *const Buffer(T, length)) usize { + if (self.top == 0) { + return length - 1; + } + return self.top - 1; + } + + fn reached_end(self: *const Buffer(T, length)) bool { + return self.insertions >= length; + } + + pub fn append(self: *Buffer(T, length), item: T) void { + if (self.insertions > 0) { + self.top = self.next(); + } + if (self.insertions >= length) { + // free old bottom item + self.deinit_item(self.items[self.top].?); + self.items[self.top] = null; + } + self.items[self.top] = item; + self.insertions = self.insertions + 1; + } + + pub fn len(self: *const Buffer(T, length)) usize { + return @min(self.insertions, length); + } + + pub fn bottom(self: *const Buffer(T, length)) usize { + if (self.reached_end()) { + return self.next(); + } + return 0; + } + + pub fn get(self: *const Buffer(T, length), index: usize) ?T { + const idx = (self.bottom() + index) % self.items.len; + if (index > self.insertions) { + return null; + } + return self.items[idx]; + } + + pub fn get_backwards(self: *const Buffer(T, length), offset: usize) ?T { + if (offset >= self.insertions) { + return null; + } + return self.items[(self.top - offset) % self.items.len]; + } + + pub fn iterate(self: *const Buffer(T, length)) BufferIterator(T, length) { + return .{ + .buffer = self, + .index = self.bottom(), + .seen_first_item = false, + }; + } + + pub fn iterate_reverse(self: *const Buffer(T, length)) BufferIterator(T, length) { + return .{ + .buffer = self, + .index = self.top, + .seen_first_item = false, + }; + } + + pub fn deinit(self: *Buffer(T, length)) void { + if (!self.can_deinit()) { + return; + } + for (self.items, 0..) |item, idx| { + if (item != null) { + self.deinit_item(item.?); + self.items[idx] = null; + } + } + } + }; +} + +pub fn BufferIterator(comptime T: type, comptime length: usize) type { + return struct { + buffer: *const Buffer(T, length), + index: usize, + seen_first_item: bool, + + pub fn next(self: *BufferIterator(T, length)) ?T { + if (self.seen_first_item and self.index == self.buffer.bottom()) { + // We've reached the top, return null. + return null; + } + const item = self.buffer.items[self.index]; + self.index = (self.index + 1) % length; + if (!self.seen_first_item) { + self.seen_first_item = true; + } + return item; + } + + pub fn prev(self: *BufferIterator(T, length)) ?T { + if (self.seen_first_item and self.index == self.buffer.top) { + // We've reached the top, return null. + return null; + } + const item = self.buffer.items[self.index]; + if (self.index == 0) { + self.index = length - 1; + } else { + self.index = self.index - 1; + } + if (!self.seen_first_item) { + self.seen_first_item = true; + } + return item; + } + }; +} + +test "init is empty" { + const buffer = Buffer(u8, 10).init(); + // len is 0 in initial buffer + try std.testing.expectEqual(0, buffer.len()); + var iter = buffer.iterate(); + if (iter.next()) |_| { + @panic("unexpected message"); + } +} + +test "reached_end is true after we've inserted length items" { + var buffer = Buffer(u8, 2).init(); + buffer.append(1); + try std.testing.expect(!buffer.reached_end()); + buffer.append(2); + try std.testing.expectEqual(2, buffer.insertions); + try std.testing.expect(buffer.reached_end()); +} + +test "append adds item" { + var buffer = Buffer(u8, 10).init(); + buffer.append(10); + + try std.testing.expectEqual(1, buffer.len()); + var iter = buffer.iterate(); + try std.testing.expectEqual(10, iter.next().?); + try std.testing.expectEqual(null, iter.next()); +} + +test "append bounds" { + var buffer = Buffer(usize, 10).init(); + + // append 0 - 9 to buffer + for (0..10) |i| { + buffer.append(i); + } + // we expect 0 - 9 to exist inside buffer + var iter = buffer.iterate(); + for (0..10) |i| { + try std.testing.expectEqual(i, iter.next().?); + } + + // append 11, will overwrite 0. + buffer.append(11); + + // reset iterator + iter = buffer.iterate(); + + // values 1-9 are present + for (1..10) |i| { + try std.testing.expectEqual(i, iter.next().?); + } + // and so is value 11 + try std.testing.expectEqual(11, iter.next().?); +} + +fn deinit_byte_slice(allocator: std.mem.Allocator, item: []u8) void { + allocator.free(item); +} + +test "deiniting allocating buffer does not leak" { + // create closure + const allocator = std.testing.allocator; + + // create buffer with closure + var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice); + defer buffer.deinit(); + + // add less elements than length + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{1})); + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{2})); + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{3})); + + try std.testing.expectEqual(3, buffer.len()); +} + +test "wrapping allocating buffer does not leak" { + // create closure + const allocator = std.testing.allocator; + + // create buffer with closure + var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice); + defer buffer.deinit(); + + // add an element more than length + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{1})); + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{2})); + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{3})); + buffer.append(try std.fmt.allocPrint(allocator, "item {}", .{4})); + + // length is still 3 because we have removed the first element + try std.testing.expectEqual(3, buffer.len()); + try std.testing.expectEqual(4, buffer.insertions); + try std.testing.expect(buffer.reached_end()); +} diff --git a/src/commands.zig b/src/commands.zig new file mode 100644 index 0000000..3f2dfb3 --- /dev/null +++ b/src/commands.zig @@ -0,0 +1,106 @@ +const std = @import("std"); + +const parser = @import("root.zig").parser; + +/// UserCommand represents the commands that ordinary IRC users can use. +pub const UserCommand = union(enum) { + /// `s///` + substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8, all: bool = false }, + /// !help + help: void, + + pub fn parse(nick: []const u8, text: []const u8) ?UserCommand { + const original = parser.init(text); + if (original.consume_str("!help")) |_| { + return .help; + } + + if (original.consume_char('s')) |substitute| { + const delim_parser, const delim = substitute.take_char(); + if (std.ascii.isAlphanumeric(delim)) { + std.log.debug("parsing substitute command: delimiter cannot be a whitespace: \"{s}\"", .{text}); + return null; + } + const typo_parser, const typo = delim_parser.take_until_char(delim); + const correction_parser, const correction = typo_parser.consume_char(delim).?.take_until_char(delim); + if (correction_parser.consume_char(delim) == null) { + std.log.debug("parsing substitute command: missing an ending '/' in \"{s}\"", .{text}); + return null; + } + return .{ + .substitute = .{ + .author = nick, + .needle = typo, + .replacement = correction, + .all = false, + }, + }; + } + return null; + } +}; + +/// AdminCommand are commands useful for debugging zigeru, since they +/// are more spammy than others they are separated and only sent to +/// #eru-admin. +pub const AdminCommand = union(enum) { + backlog: struct { history: u16 }, + status: void, + join: struct { channel: []const u8 }, + err: struct { message: []const u8 }, + + pub fn parse(text: []const u8) ?AdminCommand { + const original = parser.init(text); + if (original.consume_char('!')) |command| { + if (command.consume_str("status")) |_| { + return .status; + } + if (command.consume_str("join").?.consume_char(' ')) |join| { + if (join.rest[0] != '#') { + return .{ .err = .{ .message = "channels must start with \"#\"" } }; + } + return .{ .join = .{ .channel = join.rest } }; + } + if (command.consume_str("backlog")) |backlog| { + const history = std.fmt.parseInt(u16, backlog.rest, 10) catch |err| { + std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ backlog.rest, err }); + return null; + }; + return .{ .backlog = .{ .history = history } }; + } + std.log.debug("unknown command: \"{s}\"", .{command.rest}); + } + return null; + } +}; + +test "can parse !help successful" { + try std.testing.expectEqual( + UserCommand.help, + UserCommand.parse("jassob", "!help"), + ); +} + +test "can parse s/hello/world/ successful" { + try std.testing.expectEqualDeep( + UserCommand{ .substitute = .{ + .author = "jassob", + .needle = "hello", + .replacement = "world", + .all = false, + } }, + UserCommand.parse("jassob", "s/hello/world/"), + ); +} + +test "correctly ignores non-messages when trying to parse" { + try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "Hello, world")); +} + +test "parse admin commands" { + const cmd = AdminCommand.parse("!join badchannel") orelse unreachable; + try std.testing.expectEqual( + AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } }, + cmd, + ); +} diff --git a/src/main.zig b/src/main.zig index 211d537..e200fb8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,13 +1,12 @@ const std = @import("std"); -const zircon = @import("zircon"); const zigeru = @import("zigeru"); - const Bot = zigeru.bot.Bot; const Error = zigeru.bot.Error; -const BotCommand = zigeru.bot.Command; -const AdminCommand = zigeru.bot.AdminCommand; +const UserCommand = zigeru.commands.UserCommand; +const AdminCommand = zigeru.commands.AdminCommand; const BotMessage = zigeru.bot.Message; +const zircon = @import("zircon"); var debug_allocator = std.heap.DebugAllocator(.{}).init; @@ -72,7 +71,7 @@ pub const BotAdapter = struct { .{ msg.prefix.?.nick, msg.prefix.?.user, msg.prefix.?.host, msg.targets, msg.text }, ); const nick = if (msg.prefix) |prefix| if (prefix.nick) |nick| nick else "unknown" else "unknown"; - if (BotCommand.parse(nick, msg.text)) |cmd| { + if (UserCommand.parse(nick, msg.text)) |cmd| { return self.bot.execute(&cmd, msg.prefix, msg.targets) catch |err| return report_error(err); } if (AdminCommand.parse(msg.text)) |cmd| { @@ -85,7 +84,7 @@ pub const BotAdapter = struct { msg.targets, msg.text, ) catch |err| return report_error(err); - self.bot.hear(&bot_msg); + self.bot.hear(bot_msg); return null; }, .JOIN => |msg| { @@ -121,26 +120,28 @@ pub const BotAdapter = struct { } }; -test "substitute" { - var bot_adapter = try BotAdapter.init(std.testing.allocator); - defer bot_adapter.deinit(); - const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; - const msg = zircon.Message{ - .PRIVMSG = .{ - .prefix = prefix, - .targets = "#test", - .text = "hello world", - }, - }; - _ = bot_adapter.callback(msg); - const cmd_msg = zircon.Message{ - .PRIVMSG = .{ - .prefix = prefix, - .targets = "#test", - .text = "s/world/zig/", - }, - }; - const response = bot_adapter.callback(cmd_msg); - try std.testing.expect(response != null); - try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text); -} +// test "substitute" { +// var bot_adapter = try BotAdapter.init(std.testing.allocator); +// defer bot_adapter.deinit(); +// const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; +// const msg = zircon.Message{ +// .PRIVMSG = .{ +// .prefix = prefix, +// .targets = "#test", +// .text = "hello world", +// }, +// }; +// if (bot_adapter.callback(msg)) |_| { +// @panic("unexpected response"); +// } +// const cmd_msg = zircon.Message{ +// .PRIVMSG = .{ +// .prefix = prefix, +// .targets = "#test", +// .text = "s/world/zig/", +// }, +// }; +// const response = bot_adapter.callback(cmd_msg); +// try std.testing.expect(response != null); +// try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text); +// } diff --git a/src/parser.zig b/src/parser.zig new file mode 100644 index 0000000..98053e3 --- /dev/null +++ b/src/parser.zig @@ -0,0 +1,54 @@ +const std = @import("std"); + +pub fn init(s: []const u8) Parser { + return .init(s); +} + +pub const Parser = struct { + original: []const u8, + rest: []const u8, + end_idx: usize, + + pub fn seek(self: *const Parser, skip: usize) Parser { + return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip }; + } + + pub fn init(s: []const u8) Parser { + return .{ + .original = s, + .end_idx = 0, + .rest = s, + }; + } + + pub fn consume_char(self: *const Parser, c: u8) ?Parser { + if (self.rest[0] != c) { + return null; + } + return self.seek(1); + } + + pub fn consume_str(self: *const Parser, s: []const u8) ?Parser { + const len = s.len; + if (self.rest.len < len) { + return null; + } + if (!std.mem.eql(u8, self.rest[0..len], s)) { + return null; + } + return self.seek(len); + } + + pub fn take_until_char(self: *const Parser, c: u8) struct { Parser, []const u8 } { + const idx = std.mem.indexOfScalar(u8, self.rest, c) orelse unreachable; + return .{ self.seek(idx), self.rest[0..idx] }; + } + + pub fn take_char(self: *const Parser) struct { Parser, u8 } { + return .{ self.seek(1), self.rest[0] }; + } + + pub fn parsed(self: *const Parser) []const u8 { + return self.original[0..self.end_idx]; + } +}; diff --git a/src/root.zig b/src/root.zig index caa6615..e106d68 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,3 +1,6 @@ //! By convention, root.zig is the root source file when making a library. pub const bot = @import("bot.zig"); +pub const buffer = @import("buffer.zig"); +pub const parser = @import("parser.zig"); +pub const commands = @import("commands.zig"); From d237ba9e8a01635be82b0e9a4cc66533dd542b58 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Wed, 11 Mar 2026 00:09:28 +0100 Subject: [PATCH 02/18] docs: update README.md --- README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ca45b49..e9a337f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,31 @@ zigeru is a IRC bot which implements the following commands: - `s/OLD/NEW/` -- posts the previous message by the user, where every occurrence of OLD is replaced by NEW. +``` +00:04 hello, world +00:05 s/world/IRC/ +00:05 <@eru> jassob: "hello, IRC" +``` + +- `!help` -- post a usage string as a response. + +``` +00:05 !help +00:05 <@eru> Send `s/TYPO/CORRECTION/` to replace TYPO with CORRECTION in your last message. +``` + +- `!join #CHANNEL` -- make zigeru join #CHANNEL. + +``` +# in #eru-test2 +00:06 !join #eru-test3 + +# in #eru-test3 +00:06 --> jassob (~u@6wh6sdzhnfjx4.dtek.se) has joined #eru-test3 +00:06 -- Channel #eru-test3: 2 nicks (0 owners, 0 admins, 1 op, 0 halfops, 0 voiced, 1 regular) +00:06 -- Channel created on ons, 11 mar 2026 00:06:44 +``` + ## Getting started To enter into a development shell with all the tools needed for @@ -20,7 +45,4 @@ To run the binary: `zig build run`. To build the binary: `zig build`. -## TO DO - -- [ ] Add IRC support -- [ ] Add TLS support +To build the binary statically: `zig build -Dtarget=x86_64-linux-musl` From 4e11cc9ea10ec70a5e53ee2b24eb495db659e955 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Wed, 11 Mar 2026 00:48:02 +0100 Subject: [PATCH 03/18] fix: make admin command parsing more robust There was a bug in how we parsed admin commands, apparently we never tested if we could parse `!backlog X` or unknown admin commands. This commit also make updates to the backlog command construction to make sure that we don't try to access messages in the backlog that don't exist. --- src/bot.zig | 4 ++-- src/commands.zig | 35 ++++++++++++++++++++-------- src/main.zig | 60 ++++++++++++++++++++++++++++-------------------- src/parser.zig | 41 +++++++++++++++++++++++++++++---- 4 files changed, 100 insertions(+), 40 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index a0ed0c2..a6da684 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -140,10 +140,10 @@ pub const Bot = struct { return .{ .JOIN = .{ .prefix = prefix, .channels = msg.channel } }; }, .backlog => |backlog| { - if (self.backlog.insertions == 0) { + if (self.backlog.len() == 0) { return Error.NoMessage; } - if (backlog.history > self.backlog.len()) { + if (backlog.history >= self.backlog.len()) { return Error.NoMessage; } if (self.backlog.get_backwards(backlog.history)) |message| { diff --git a/src/commands.zig b/src/commands.zig index 3f2dfb3..6a57a73 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -55,18 +55,22 @@ pub const AdminCommand = union(enum) { if (command.consume_str("status")) |_| { return .status; } - if (command.consume_str("join").?.consume_char(' ')) |join| { - if (join.rest[0] != '#') { - return .{ .err = .{ .message = "channels must start with \"#\"" } }; + if (command.consume_str("join")) |join| { + if (join.consume_space()) |channel| { + if (channel.rest[0] != '#') { + return .{ .err = .{ .message = "channels must start with \"#\"" } }; + } + return .{ .join = .{ .channel = join.rest } }; } - return .{ .join = .{ .channel = join.rest } }; } if (command.consume_str("backlog")) |backlog| { - const history = std.fmt.parseInt(u16, backlog.rest, 10) catch |err| { - std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ backlog.rest, err }); - return null; - }; - return .{ .backlog = .{ .history = history } }; + if (backlog.consume_space()) |history| { + const historyOffset = std.fmt.parseInt(u16, history.rest, 10) catch |err| { + std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ history.rest, err }); + return null; + }; + return .{ .backlog = .{ .history = historyOffset } }; + } } std.log.debug("unknown command: \"{s}\"", .{command.rest}); } @@ -104,3 +108,16 @@ test "parse admin commands" { cmd, ); } + +test "parse backlog admin commands" { + const cmd = AdminCommand.parse("!backlog 1") orelse unreachable; + try std.testing.expectEqual( + AdminCommand{ .backlog = .{ .history = 1 } }, + cmd, + ); +} + +test "parse unknown admin commands" { + const cmd = AdminCommand.parse("!history 1"); + try std.testing.expectEqual(null, cmd); +} diff --git a/src/main.zig b/src/main.zig index e200fb8..f484f2e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -120,28 +120,38 @@ pub const BotAdapter = struct { } }; -// test "substitute" { -// var bot_adapter = try BotAdapter.init(std.testing.allocator); -// defer bot_adapter.deinit(); -// const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; -// const msg = zircon.Message{ -// .PRIVMSG = .{ -// .prefix = prefix, -// .targets = "#test", -// .text = "hello world", -// }, -// }; -// if (bot_adapter.callback(msg)) |_| { -// @panic("unexpected response"); -// } -// const cmd_msg = zircon.Message{ -// .PRIVMSG = .{ -// .prefix = prefix, -// .targets = "#test", -// .text = "s/world/zig/", -// }, -// }; -// const response = bot_adapter.callback(cmd_msg); -// try std.testing.expect(response != null); -// try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text); -// } +test "substitute" { + var bot_adapter = try BotAdapter.init(std.testing.allocator); + defer bot_adapter.deinit(); + const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; + const msg = zircon.Message{ + .PRIVMSG = .{ + .prefix = prefix, + .targets = "#test", + .text = "hello world", + }, + }; + if (bot_adapter.callback(msg)) |_| { + @panic("unexpected response"); + } + const cmd_msg = zircon.Message{ + .PRIVMSG = .{ + .prefix = prefix, + .targets = "#test", + .text = "s/world/zig/", + }, + }; + const response = bot_adapter.callback(cmd_msg); + try std.testing.expect(response != null); + try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text); +} + +test "get empty backlog message" { + var bot_adapter = try BotAdapter.init(std.testing.allocator); + defer bot_adapter.deinit(); + const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; + const msg = zircon.Message{ + .PRIVMSG = .{ .prefix = prefix, .targets = "#eru-admin", .text = "!backlog 0" }, + }; + try std.testing.expectEqualDeep("no matching message", bot_adapter.callback(msg).?.PRIVMSG.text); +} diff --git a/src/parser.zig b/src/parser.zig index 98053e3..13acaea 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -9,10 +9,7 @@ pub const Parser = struct { rest: []const u8, end_idx: usize, - pub fn seek(self: *const Parser, skip: usize) Parser { - return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip }; - } - + // Initializes a Parser for s. pub fn init(s: []const u8) Parser { return .{ .original = s, @@ -21,6 +18,25 @@ pub const Parser = struct { }; } + // Seek the parser window of the text forward skip bytes and return a new Parser. + pub fn seek(self: *const Parser, skip: usize) Parser { + return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip }; + } + + // Attempts to consume at least one whitespace character from the input text. + pub fn consume_space(self: *const Parser) ?Parser { + if (!std.ascii.isWhitespace(self.rest[0])) { + return null; + } + for (self.rest[1..], 1..) |c, idx| { + if (!std.ascii.isWhitespace(c)) { + return self.seek(idx); + } + } + return self.seek(self.rest.len); + } + + // Attempts to consume a character c. pub fn consume_char(self: *const Parser, c: u8) ?Parser { if (self.rest[0] != c) { return null; @@ -28,6 +44,7 @@ pub const Parser = struct { return self.seek(1); } + // Attempts to consume a string s. pub fn consume_str(self: *const Parser, s: []const u8) ?Parser { const len = s.len; if (self.rest.len < len) { @@ -39,16 +56,32 @@ pub const Parser = struct { return self.seek(len); } + // Finds the next occurrence of c (idx) in the current parser + // window and extracts it. + // + // Returns a new parser window that starts after idx and the + // extracted byte slice. pub fn take_until_char(self: *const Parser, c: u8) struct { Parser, []const u8 } { const idx = std.mem.indexOfScalar(u8, self.rest, c) orelse unreachable; return .{ self.seek(idx), self.rest[0..idx] }; } + // Take the current character and advance the parser one step. pub fn take_char(self: *const Parser) struct { Parser, u8 } { return .{ self.seek(1), self.rest[0] }; } + // Return the currently accepted text. pub fn parsed(self: *const Parser) []const u8 { return self.original[0..self.end_idx]; } }; + +test "parser can skip whitespace" { + var parser = init("Hello, World"); + parser = parser.consume_str("Hello,").?; + parser = parser.consume_space().?; + parser = parser.consume_str("World").?; + + try std.testing.expectEqual("Hello, World", parser.parsed()); +} From 8b153981960ebca859d1f0db6d68bc50bccccc01 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Wed, 11 Mar 2026 01:19:33 +0100 Subject: [PATCH 04/18] fix(commands): substitute parsing bug There was a bug in the parsing logic that caused a substitution command like `s/typo/correction` to crash the bot. Correct command is of course `s/typo/correction/`, but now it at least shouldn't crash. --- src/commands.zig | 37 ++++++++++++++++++++++++++++--------- src/parser.zig | 8 +++++--- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/commands.zig b/src/commands.zig index 6a57a73..cff4b98 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const parser = @import("root.zig").parser; +const Parser = @import("root.zig").parser.Parser; /// UserCommand represents the commands that ordinary IRC users can use. pub const UserCommand = union(enum) { @@ -10,23 +10,34 @@ pub const UserCommand = union(enum) { help: void, pub fn parse(nick: []const u8, text: []const u8) ?UserCommand { - const original = parser.init(text); + const original = Parser.init(text); if (original.consume_str("!help")) |_| { return .help; } if (original.consume_char('s')) |substitute| { - const delim_parser, const delim = substitute.take_char(); + const log_prefix = "parsing substitute command"; + var parser = substitute; + parser, const delim = parser.take_char(); if (std.ascii.isAlphanumeric(delim)) { - std.log.debug("parsing substitute command: delimiter cannot be a whitespace: \"{s}\"", .{text}); + std.log.debug("{s}: delimiter cannot be a whitespace: \"{s}\"", .{ log_prefix, text }); return null; } - const typo_parser, const typo = delim_parser.take_until_char(delim); - const correction_parser, const correction = typo_parser.consume_char(delim).?.take_until_char(delim); - if (correction_parser.consume_char(delim) == null) { - std.log.debug("parsing substitute command: missing an ending '/' in \"{s}\"", .{text}); + var result = parser.take_until_char(delim); + if (result == null) { + std.log.debug( + "{s}: cannot find typo, expecting a message on the form 's{}TYPO{}CORRECTION{}', but got {s}", + .{ log_prefix, delim, delim, delim, text }, + ); return null; } + parser, const typo = result.?; + result = parser.consume_char(delim).?.take_until_char(delim); + if (result == null) { + std.log.debug("{s}: missing an ending '/' in \"{s}\"", .{ log_prefix, text }); + return null; + } + parser, const correction = result.?; return .{ .substitute = .{ .author = nick, @@ -50,7 +61,7 @@ pub const AdminCommand = union(enum) { err: struct { message: []const u8 }, pub fn parse(text: []const u8) ?AdminCommand { - const original = parser.init(text); + const original = Parser.init(text); if (original.consume_char('!')) |command| { if (command.consume_str("status")) |_| { return .status; @@ -97,6 +108,14 @@ test "can parse s/hello/world/ successful" { ); } +test "can parse s/hello/world and report failure" { + try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world")); +} + +test "can parse s/hello|world| and report failure" { + try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world")); +} + test "correctly ignores non-messages when trying to parse" { try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "Hello, world")); } diff --git a/src/parser.zig b/src/parser.zig index 13acaea..080c708 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -61,9 +61,11 @@ pub const Parser = struct { // // Returns a new parser window that starts after idx and the // extracted byte slice. - pub fn take_until_char(self: *const Parser, c: u8) struct { Parser, []const u8 } { - const idx = std.mem.indexOfScalar(u8, self.rest, c) orelse unreachable; - return .{ self.seek(idx), self.rest[0..idx] }; + pub fn take_until_char(self: *const Parser, c: u8) ?struct { Parser, []const u8 } { + if (std.mem.indexOfScalar(u8, self.rest, c)) |idx| { + return .{ self.seek(idx), self.rest[0..idx] }; + } + return null; } // Take the current character and advance the parser one step. From 6796a62a5ff56ed5af148f81d9b2008dc2541d8f Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Wed, 11 Mar 2026 01:35:37 +0100 Subject: [PATCH 05/18] fix(bot): don't substitute if there is no typo This commit fixes an issue where a substitution command replays a message because there was no needle to replace. --- src/bot.zig | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index a6da684..aae3b94 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -90,6 +90,9 @@ pub const Bot = struct { command.author, targets, ) orelse return Error.NoMessage; + if (std.mem.count(u8, prev_msg.content, command.needle) == 0) { + return Error.NoMessage; + } const output = try std.mem.replaceOwned( u8, self.allocator, @@ -255,7 +258,7 @@ test "hear wraps" { try std.testing.expectEqual(1024, bot.backlog.len()); } -test "execute_substitution_no_previous_message" { +test "execute substitution no previous message" { var bot = try Bot.init(std.testing.allocator); defer bot.deinit(); const cmd = UserCommand{ .substitute = .{ @@ -270,7 +273,7 @@ test "execute_substitution_no_previous_message" { )); } -test "execute_substitution" { +test "execute substitution" { var bot = try Bot.init(std.testing.allocator); defer bot.deinit(); @@ -298,3 +301,28 @@ test "execute_substitution" { else => unreachable, } } + +test "execute substitution with no matching needle" { + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); + + // hear original message + const msg = try Message.init_owned( + std.testing.allocator, + 1234, + "jassob", + "#test", + "original", + ); + bot.hear(msg); + + // execute substitution + const cmd = UserCommand{ + .substitute = .{ .author = "jassob", .needle = "something else", .replacement = "weird" }, + }; + try std.testing.expectError(Error.NoMessage, bot.execute( + &cmd, + null, + "#test", + )); +} From a05229f72d50854eacc63f0aebbd469c07358abf Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sat, 14 Mar 2026 11:40:47 +0100 Subject: [PATCH 06/18] refactor(bot): add newTestMessage helper This commit reduces the repetitions in the tests when we create new messages. It hardcodes a bunch of fields that are currently not used, but might be eventually. --- src/bot.zig | 57 +++++++++++++++-------------------------------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index aae3b94..1124d3c 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -185,15 +185,19 @@ pub const Bot = struct { } }; -test "deiniting an owned Message leaks no memory" { - const allocator = std.testing.allocator; - const testMessage = try Message.init_owned( +fn newTestMessage(allocator: std.mem.Allocator, content: []const u8) !*Message { + return try Message.init_owned( allocator, 12345, "jassob", "#test", - "All your codebase are belong to us.\n", + content, ); +} + +test "deiniting an owned Message leaks no memory" { + const allocator = std.testing.allocator; + const testMessage = try newTestMessage(allocator, "test"); testMessage.deinit(allocator); } @@ -207,13 +211,7 @@ test "hear and deinit has no leaks" { var bot = try Bot.init(allocator); defer bot.deinit(); - const testMessage = try Message.init_owned( - allocator, - 12345, - "jassob", - "#test", - "All your codebase are belong to us.\n", - ); + const testMessage = try newTestMessage(allocator, "test"); bot.hear(testMessage); try std.testing.expectEqual(0, bot.backlog.top); @@ -224,14 +222,8 @@ test "a few hears and deinit has no leaks" { var bot = try Bot.init(allocator); defer bot.deinit(); - for (0..2) |i| { - const testMessage = try Message.init_owned( - std.testing.allocator, - @intCast(i), - "jassob", - "#test", - "All your codebase are belong to us.\n", - ); + for (0..2) |_| { + const testMessage = try newTestMessage(std.testing.allocator, "test"); bot.hear(testMessage); } @@ -243,13 +235,7 @@ test "hear wraps" { defer bot.deinit(); for (0..1025) |_| { - const testMessage = try Message.init_owned( - std.testing.allocator, - 12345, - "jassob", - "#test", - "All your codebase are belong to us.\n", - ); + const testMessage = try newTestMessage(std.testing.allocator, "test"); bot.hear(testMessage); } @@ -274,17 +260,12 @@ test "execute substitution no previous message" { } test "execute substitution" { - var bot = try Bot.init(std.testing.allocator); + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); defer bot.deinit(); // hear original message with typo - const msg = try Message.init_owned( - std.testing.allocator, - 1234, - "jassob", - "#test", - "What", - ); + const msg = try newTestMessage(allocator, "What"); bot.hear(msg); // execute substitution @@ -307,13 +288,7 @@ test "execute substitution with no matching needle" { defer bot.deinit(); // hear original message - const msg = try Message.init_owned( - std.testing.allocator, - 1234, - "jassob", - "#test", - "original", - ); + const msg = try newTestMessage(std.testing.allocator, "original"); bot.hear(msg); // execute substitution From b0f0daa19d69aa59374326d6cb3939bd57646ac4 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sat, 14 Mar 2026 11:43:26 +0100 Subject: [PATCH 07/18] refactor(commands): add a substitute constructor This commit recuces some repetitiveness from creating substitution literals in tests. --- src/bot.zig | 8 ++------ src/commands.zig | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index 1124d3c..0694738 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -269,9 +269,7 @@ test "execute substitution" { bot.hear(msg); // execute substitution - const cmd = UserCommand{ - .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" }, - }; + const cmd = UserCommand.init_substitute("jassob", "What", "what", false); const response = try bot.execute(&cmd, null, "#test"); // expect response matching the correct message @@ -292,9 +290,7 @@ test "execute substitution with no matching needle" { bot.hear(msg); // execute substitution - const cmd = UserCommand{ - .substitute = .{ .author = "jassob", .needle = "something else", .replacement = "weird" }, - }; + const cmd = UserCommand.init_substitute("jassob", "something else", "weird", false); try std.testing.expectError(Error.NoMessage, bot.execute( &cmd, null, diff --git a/src/commands.zig b/src/commands.zig index cff4b98..b16f3ef 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -9,6 +9,15 @@ pub const UserCommand = union(enum) { /// !help help: void, + pub fn init_substitute(author: []const u8, needle: []const u8, replacement: []const u8, all: bool) UserCommand { + return .{ .substitute = .{ + .author = author, + .needle = needle, + .replacement = replacement, + .all = all, + } }; + } + pub fn parse(nick: []const u8, text: []const u8) ?UserCommand { const original = Parser.init(text); if (original.consume_str("!help")) |_| { @@ -38,14 +47,7 @@ pub const UserCommand = union(enum) { return null; } parser, const correction = result.?; - return .{ - .substitute = .{ - .author = nick, - .needle = typo, - .replacement = correction, - .all = false, - }, - }; + return .init_substitute(nick, typo, correction, false); } return null; } From 4f2b9cbce2d48b96004de591d0f236bc02f406a3 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 12:38:23 +0100 Subject: [PATCH 08/18] test(bot): record recursive substitution test case Found while the IRC users tried to find bugs in the code base. Currently the bot does not "hear" the commands of users, which means that of the following conversation: ``` helo, world! s/helo/hello/ ``` the only heard messages in the backlog would be the first ("helo, world!"), the substitution would be parsed and executed, but not added to the backlog and hence not possible to update. --- src/bot.zig | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/bot.zig b/src/bot.zig index 0694738..f8c9d9e 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -297,3 +297,33 @@ test "execute substitution with no matching needle" { "#test", )); } + +test "recursive substitutions does not cause issues" { + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); + + // hear original message + const msg = try newTestMessage(std.testing.allocator, "original"); + bot.hear(msg); + + // execute substitution + const cmd = UserCommand.init_substitute("jassob", "original", "something else", false); + switch (try bot.execute( + &cmd, + null, + "#test", + )) { + .PRIVMSG => |message| { + try std.testing.expectEqualDeep("jassob: \"something else\"", message.text); + }, + else => unreachable, + } + + // execute second substitution + const cmd2 = UserCommand.init_substitute("jassob", "s/original/something else/", "something else", false); + try std.testing.expectError(Error.NoMessage, bot.execute( + &cmd2, + null, + "#test", + )); +} From 4d1f22194c2e23cca925c7a24c64142b9fe18ef2 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 13:47:57 +0100 Subject: [PATCH 09/18] refactor(bot): introduce response type The response type holds represents the way a bot can respond to a message it executes and allows us to move the IRC dependency out of bot and into only the main module. --- build.zig | 7 +---- src/bot.zig | 80 ++++++++++++++++++++++------------------------------ src/main.zig | 17 +++++++++-- 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/build.zig b/build.zig index 789e0fc..cbe6e17 100644 --- a/build.zig +++ b/build.zig @@ -152,12 +152,7 @@ pub fn build(b: *std.Build) !void { })), // Our bot tests needs zircon module import, - try testRunWithImports(b, target, "bot", &.{ - .{ - .name = "zircon", - .module = zircon.module("zircon"), - }, - }), + try testRun(b, target, "bot"), // Our module tests for each module we want to add. If // breaking out new functionality to a module, remember to // bump the length of the array above. diff --git a/src/bot.zig b/src/bot.zig index f8c9d9e..ee3ab49 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const zircon = @import("zircon"); const zigeru = @import("root.zig"); const Buffer = zigeru.buffer.Buffer; @@ -10,6 +9,10 @@ const HELP_MESSAGE: []const u8 = "Send `s/TYPO/CORRECTION/` to replace TYPO with pub const Error = error{ OutOfMemory, NoMessage, WriteFailed }; +/// Message represents a message received by the bot. +/// +/// It is what we store in the backlog and what the substitutions are +/// run on. pub const Message = struct { timestamp: i64, targets: []const u8, @@ -39,6 +42,19 @@ pub const Message = struct { } }; +/// Responses from hearing a Message. +/// +/// Response represents the kind of responses the bot can make when +/// hearing a message. +/// +/// Responses can be both administrative (i.e. a join-request for a +/// channel), or actual responses to a message (e.g. the result of a +/// substitution). +pub const Response = union(enum) { + join: struct { channels: []const u8 }, + privmsg: struct { targets: []const u8, text: []const u8 }, +}; + pub const Bot = struct { backlog: Buffer(*const Message, 1024), outbox: Buffer([]u8, 1024), @@ -78,12 +94,7 @@ pub const Bot = struct { self.backlog.deinit(); } - pub fn execute( - self: *Bot, - cmd: *const UserCommand, - prefix: ?zircon.Prefix, - targets: []const u8, - ) Error!zircon.Message { + pub fn execute(self: *Bot, cmd: *const UserCommand, targets: []const u8) Error!Response { switch (cmd.*) { .substitute => |command| { const prev_msg = self.previous_message_by_author( @@ -107,28 +118,21 @@ pub const Bot = struct { .{ command.author, output }, ); self.outbox.append(quoted_output); - return zircon.Message{ .PRIVMSG = .{ + return .{ .privmsg = .{ .targets = targets, - .prefix = prefix, .text = quoted_output, } }; }, .help => { - return zircon.Message{ .PRIVMSG = .{ + return .{ .privmsg = .{ .targets = targets, - .prefix = prefix, .text = HELP_MESSAGE, } }; }, } } - pub fn execute_admin( - self: *Bot, - cmd: *const AdminCommand, - prefix: ?zircon.Prefix, - targets: []const u8, - ) Error!zircon.Message { + pub fn execute_admin(self: *Bot, cmd: *const AdminCommand, targets: []const u8) Error!Response { switch (cmd.*) { .status => { const msg = try std.fmt.allocPrint( @@ -136,11 +140,11 @@ pub const Bot = struct { "heard messages: {}, sent messages: {}", .{ self.backlog.len(), self.outbox.len() }, ); - return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = msg } }; + return .{ .privmsg = .{ .targets = targets, .text = msg } }; }, .join => |msg| { std.log.debug("received join request: channel \"{s}\"", .{msg.channel}); - return .{ .JOIN = .{ .prefix = prefix, .channels = msg.channel } }; + return .{ .join = .{ .channels = msg.channel } }; }, .backlog => |backlog| { if (self.backlog.len() == 0) { @@ -157,18 +161,18 @@ pub const Bot = struct { ); self.outbox.append(quoted_output); return .{ - .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output }, + .privmsg = .{ .targets = targets, .text = quoted_output }, }; } else return Error.NoMessage; }, .err => |err| { - return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = err.message } }; + return .{ .privmsg = .{ .targets = targets, .text = err.message } }; }, } } - // hear a message and store it in backlog, potentially overwriting oldest message. - pub fn hear(self: *Bot, msg: *const Message) void { + // store a message in backlog, potentially overwriting oldest message. + pub fn store(self: *Bot, msg: *const Message) void { self.backlog.append(msg); } @@ -252,11 +256,7 @@ test "execute substitution no previous message" { .needle = "What", .replacement = "what", } }; - try std.testing.expectError(Error.NoMessage, bot.execute( - &cmd, - null, - "#test", - )); + try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, "#test")); } test "execute substitution" { @@ -270,11 +270,11 @@ test "execute substitution" { // execute substitution const cmd = UserCommand.init_substitute("jassob", "What", "what", false); - const response = try bot.execute(&cmd, null, "#test"); + const response = try bot.execute(&cmd, "#test"); // expect response matching the correct message switch (response) { - .PRIVMSG => |message| { + .privmsg => |message| { try std.testing.expectEqualDeep(message.text, "jassob: \"what\""); }, else => unreachable, @@ -291,11 +291,7 @@ test "execute substitution with no matching needle" { // execute substitution const cmd = UserCommand.init_substitute("jassob", "something else", "weird", false); - try std.testing.expectError(Error.NoMessage, bot.execute( - &cmd, - null, - "#test", - )); + try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, "#test")); } test "recursive substitutions does not cause issues" { @@ -308,12 +304,8 @@ test "recursive substitutions does not cause issues" { // execute substitution const cmd = UserCommand.init_substitute("jassob", "original", "something else", false); - switch (try bot.execute( - &cmd, - null, - "#test", - )) { - .PRIVMSG => |message| { + switch (try bot.execute(&cmd, "#test")) { + .privmsg => |message| { try std.testing.expectEqualDeep("jassob: \"something else\"", message.text); }, else => unreachable, @@ -321,9 +313,5 @@ test "recursive substitutions does not cause issues" { // execute second substitution const cmd2 = UserCommand.init_substitute("jassob", "s/original/something else/", "something else", false); - try std.testing.expectError(Error.NoMessage, bot.execute( - &cmd2, - null, - "#test", - )); + try std.testing.expectError(Error.NoMessage, bot.execute(&cmd2, "#test")); } diff --git a/src/main.zig b/src/main.zig index f484f2e..2ef1980 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,6 +6,7 @@ const Error = zigeru.bot.Error; const UserCommand = zigeru.commands.UserCommand; const AdminCommand = zigeru.commands.AdminCommand; const BotMessage = zigeru.bot.Message; +const BotResponse = zigeru.bot.Response; const zircon = @import("zircon"); var debug_allocator = std.heap.DebugAllocator(.{}).init; @@ -72,10 +73,10 @@ pub const BotAdapter = struct { ); const nick = if (msg.prefix) |prefix| if (prefix.nick) |nick| nick else "unknown" else "unknown"; if (UserCommand.parse(nick, msg.text)) |cmd| { - return self.bot.execute(&cmd, msg.prefix, msg.targets) catch |err| return report_error(err); + return toIRC(self.bot.execute(&cmd, msg.targets) catch |err| return report_error(err)); } if (AdminCommand.parse(msg.text)) |cmd| { - return self.bot.execute_admin(&cmd, msg.prefix, "#eru-admin") catch |err| return report_error(err); + return toIRC(self.bot.execute_admin(&cmd, "#eru-admin") catch |err| return report_error(err)); } const bot_msg = BotMessage.init_owned( self.allocator, @@ -120,6 +121,18 @@ pub const BotAdapter = struct { } }; +/// toIRC converts a bot response and converts it to a IRC message. +fn toIRC(response: BotResponse) zircon.Message { + switch (response) { + .join => |join| return .{ + .JOIN = .{ .prefix = null, .channels = join.channels }, + }, + .privmsg => |msg| return .{ + .PRIVMSG = .{ .prefix = null, .targets = msg.targets, .text = msg.text }, + }, + } +} + test "substitute" { var bot_adapter = try BotAdapter.init(std.testing.allocator); defer bot_adapter.deinit(); From 958c00ce6fd0f762e6d624cacb2ccbc9bc1e5dc9 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 13:49:07 +0100 Subject: [PATCH 10/18] refactor(bot): rename bot.hear to bot.store Clearer what it actually does (i.e. record a message in the backlog) and also unlocks that name for a dispatch function where we hear any message and decide what to do with it. Currently that dispatch logic lives inside BotAdapter in main.zig and that is not ideal. --- src/bot.zig | 12 ++++++------ src/main.zig | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index ee3ab49..26d1ea6 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -216,7 +216,7 @@ test "hear and deinit has no leaks" { defer bot.deinit(); const testMessage = try newTestMessage(allocator, "test"); - bot.hear(testMessage); + bot.store(testMessage); try std.testing.expectEqual(0, bot.backlog.top); } @@ -228,7 +228,7 @@ test "a few hears and deinit has no leaks" { for (0..2) |_| { const testMessage = try newTestMessage(std.testing.allocator, "test"); - bot.hear(testMessage); + bot.store(testMessage); } try std.testing.expectEqual(1, bot.backlog.top); @@ -240,7 +240,7 @@ test "hear wraps" { for (0..1025) |_| { const testMessage = try newTestMessage(std.testing.allocator, "test"); - bot.hear(testMessage); + bot.store(testMessage); } try std.testing.expectEqual(0, bot.backlog.top); @@ -266,7 +266,7 @@ test "execute substitution" { // hear original message with typo const msg = try newTestMessage(allocator, "What"); - bot.hear(msg); + bot.store(msg); // execute substitution const cmd = UserCommand.init_substitute("jassob", "What", "what", false); @@ -287,7 +287,7 @@ test "execute substitution with no matching needle" { // hear original message const msg = try newTestMessage(std.testing.allocator, "original"); - bot.hear(msg); + bot.store(msg); // execute substitution const cmd = UserCommand.init_substitute("jassob", "something else", "weird", false); @@ -300,7 +300,7 @@ test "recursive substitutions does not cause issues" { // hear original message const msg = try newTestMessage(std.testing.allocator, "original"); - bot.hear(msg); + bot.store(msg); // execute substitution const cmd = UserCommand.init_substitute("jassob", "original", "something else", false); diff --git a/src/main.zig b/src/main.zig index 2ef1980..43f2880 100644 --- a/src/main.zig +++ b/src/main.zig @@ -85,7 +85,7 @@ pub const BotAdapter = struct { msg.targets, msg.text, ) catch |err| return report_error(err); - self.bot.hear(bot_msg); + self.bot.store(bot_msg); return null; }, .JOIN => |msg| { From 49a6b79fd92bab1b0efa922df3debe3a611c6368 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 13:51:26 +0100 Subject: [PATCH 11/18] refactor(commands): move user command tests This commit just shuffles the test for user commands closer to the definition of the user commands. --- src/commands.zig | 62 ++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/commands.zig b/src/commands.zig index b16f3ef..f782830 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -53,6 +53,37 @@ pub const UserCommand = union(enum) { } }; +test "can parse !help successful" { + try std.testing.expectEqual( + UserCommand.help, + UserCommand.parse("jassob", "!help"), + ); +} + +test "can parse s/hello/world/ successful" { + try std.testing.expectEqualDeep( + UserCommand{ .substitute = .{ + .author = "jassob", + .needle = "hello", + .replacement = "world", + .all = false, + } }, + UserCommand.parse("jassob", "s/hello/world/"), + ); +} + +test "can parse s/hello/world and report failure" { + try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world")); +} + +test "can parse s/hello|world| and report failure" { + try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world")); +} + +test "correctly ignores non-messages when trying to parse" { + try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "Hello, world")); +} + /// AdminCommand are commands useful for debugging zigeru, since they /// are more spammy than others they are separated and only sent to /// #eru-admin. @@ -91,37 +122,6 @@ pub const AdminCommand = union(enum) { } }; -test "can parse !help successful" { - try std.testing.expectEqual( - UserCommand.help, - UserCommand.parse("jassob", "!help"), - ); -} - -test "can parse s/hello/world/ successful" { - try std.testing.expectEqualDeep( - UserCommand{ .substitute = .{ - .author = "jassob", - .needle = "hello", - .replacement = "world", - .all = false, - } }, - UserCommand.parse("jassob", "s/hello/world/"), - ); -} - -test "can parse s/hello/world and report failure" { - try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world")); -} - -test "can parse s/hello|world| and report failure" { - try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world")); -} - -test "correctly ignores non-messages when trying to parse" { - try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "Hello, world")); -} - test "parse admin commands" { const cmd = AdminCommand.parse("!join badchannel") orelse unreachable; try std.testing.expectEqual( From bd1891521e2089c368251c80f839daa46e8c886b Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 14:35:51 +0100 Subject: [PATCH 12/18] refactor: move message dispatch logic to bot This commit moves the logic that governs what action to take when a message is heard by the bot from the BotAdapter (which should be a layer only responsible for translating IRC messages to our internal representation) to the Bot. This makes it possible to test full conversations in the bot tests. --- src/bot.zig | 68 +++++++++++++++++++++++++++++++++------------------- src/main.zig | 29 ++++++++++------------ 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index 26d1ea6..137813e 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -94,6 +94,19 @@ pub const Bot = struct { self.backlog.deinit(); } + pub fn hear(self: *Bot, message: *const Message) ?Error!Response { + // Store the message to keep track of the allocation + defer self.store(message); + + if (UserCommand.parse(message.author, message.content)) |cmd| { + return self.execute(&cmd, message.targets); + } + if (AdminCommand.parse(message.content)) |cmd| { + return self.execute_admin(&cmd, "#eru-admin"); + } + return null; + } + pub fn execute(self: *Bot, cmd: *const UserCommand, targets: []const u8) Error!Response { switch (cmd.*) { .substitute => |command| { @@ -216,7 +229,7 @@ test "hear and deinit has no leaks" { defer bot.deinit(); const testMessage = try newTestMessage(allocator, "test"); - bot.store(testMessage); + try std.testing.expectEqual(null, bot.hear(testMessage)); try std.testing.expectEqual(0, bot.backlog.top); } @@ -228,7 +241,7 @@ test "a few hears and deinit has no leaks" { for (0..2) |_| { const testMessage = try newTestMessage(std.testing.allocator, "test"); - bot.store(testMessage); + _ = bot.hear(testMessage); } try std.testing.expectEqual(1, bot.backlog.top); @@ -240,7 +253,7 @@ test "hear wraps" { for (0..1025) |_| { const testMessage = try newTestMessage(std.testing.allocator, "test"); - bot.store(testMessage); + _ = bot.hear(testMessage); } try std.testing.expectEqual(0, bot.backlog.top); @@ -251,12 +264,9 @@ test "hear wraps" { test "execute substitution no previous message" { var bot = try Bot.init(std.testing.allocator); defer bot.deinit(); - const cmd = UserCommand{ .substitute = .{ - .author = "jassob", - .needle = "What", - .replacement = "what", - } }; - try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, "#test")); + + const substitution = try newTestMessage(std.testing.allocator, "s/What/what/"); + try std.testing.expectError(Error.NoMessage, bot.hear(substitution).?); } test "execute substitution" { @@ -266,11 +276,11 @@ test "execute substitution" { // hear original message with typo const msg = try newTestMessage(allocator, "What"); - bot.store(msg); + try std.testing.expectEqual(null, bot.hear(msg)); // execute substitution - const cmd = UserCommand.init_substitute("jassob", "What", "what", false); - const response = try bot.execute(&cmd, "#test"); + const sub = try newTestMessage(allocator, "s/What/what/"); + const response = try bot.hear(sub).?; // expect response matching the correct message switch (response) { @@ -282,29 +292,31 @@ test "execute substitution" { } test "execute substitution with no matching needle" { - var bot = try Bot.init(std.testing.allocator); + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); defer bot.deinit(); // hear original message - const msg = try newTestMessage(std.testing.allocator, "original"); - bot.store(msg); + const msg = try newTestMessage(allocator, "original"); + try std.testing.expectEqual(null, bot.hear(msg)); // execute substitution - const cmd = UserCommand.init_substitute("jassob", "something else", "weird", false); - try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, "#test")); + const sub = try newTestMessage(allocator, "s/something else/weird/"); + try std.testing.expectError(Error.NoMessage, bot.hear(sub).?); } test "recursive substitutions does not cause issues" { - var bot = try Bot.init(std.testing.allocator); + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); defer bot.deinit(); // hear original message - const msg = try newTestMessage(std.testing.allocator, "original"); - bot.store(msg); + const msg = try newTestMessage(allocator, "original"); + try std.testing.expectEqual(null, bot.hear(msg)); // execute substitution - const cmd = UserCommand.init_substitute("jassob", "original", "something else", false); - switch (try bot.execute(&cmd, "#test")) { + const sub = try newTestMessage(allocator, "s/original/something else/"); + switch (try bot.hear(sub).?) { .privmsg => |message| { try std.testing.expectEqualDeep("jassob: \"something else\"", message.text); }, @@ -312,6 +324,14 @@ test "recursive substitutions does not cause issues" { } // execute second substitution - const cmd2 = UserCommand.init_substitute("jassob", "s/original/something else/", "something else", false); - try std.testing.expectError(Error.NoMessage, bot.execute(&cmd2, "#test")); + const sub2 = try newTestMessage( + allocator, + "s|s/original/something else/|something else|", + ); + switch (try bot.hear(sub2).?) { + .privmsg => |message| { + try std.testing.expectEqualDeep("jassob: \"something else\"", message.text); + }, + else => unreachable, + } } diff --git a/src/main.zig b/src/main.zig index 43f2880..54e48ea 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,26 +67,24 @@ pub const BotAdapter = struct { pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { switch (message) { .PRIVMSG => |msg| { - std.log.debug( - "received message: nick {?s}, user: {?s}, host: {?s}, targets: {s}, text: {s}", - .{ msg.prefix.?.nick, msg.prefix.?.user, msg.prefix.?.host, msg.targets, msg.text }, - ); - const nick = if (msg.prefix) |prefix| if (prefix.nick) |nick| nick else "unknown" else "unknown"; - if (UserCommand.parse(nick, msg.text)) |cmd| { - return toIRC(self.bot.execute(&cmd, msg.targets) catch |err| return report_error(err)); - } - if (AdminCommand.parse(msg.text)) |cmd| { - return toIRC(self.bot.execute_admin(&cmd, "#eru-admin") catch |err| return report_error(err)); - } - const bot_msg = BotMessage.init_owned( + const nick = if (msg.prefix != null and msg.prefix.?.nick != null) msg.prefix.?.nick.? else "unknown"; + + // create message + const bot_message = BotMessage.init_owned( self.allocator, std.time.timestamp(), nick, msg.targets, msg.text, ) catch |err| return report_error(err); - self.bot.store(bot_msg); - return null; + + // send message to bot + const response = self.bot.hear(bot_message) orelse { + return null; + } catch |err| { + return report_error(err); + }; + return toIRC(response); }, .JOIN => |msg| { std.log.debug("received join message: channels {s}", .{msg.channels}); @@ -162,9 +160,8 @@ test "substitute" { test "get empty backlog message" { var bot_adapter = try BotAdapter.init(std.testing.allocator); defer bot_adapter.deinit(); - const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; const msg = zircon.Message{ - .PRIVMSG = .{ .prefix = prefix, .targets = "#eru-admin", .text = "!backlog 0" }, + .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = "!backlog 0" }, }; try std.testing.expectEqualDeep("no matching message", bot_adapter.callback(msg).?.PRIVMSG.text); } From 1d44645451179f86dc5a6368d041a136b4b6fcfb Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 15:04:56 +0100 Subject: [PATCH 13/18] chore: rename functions This commit updates the function to match the naming conventions used in Zig, where variables and fields are snake_case, but functions are camelCase and types are PascalCase. --- src/bot.zig | 14 +++++++------- src/buffer.zig | 22 +++++++++++----------- src/main.zig | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index 137813e..eeb4a97 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -19,7 +19,7 @@ pub const Message = struct { author: []const u8, content: []const u8, - pub fn init_owned( + pub fn initOwned( allocator: std.mem.Allocator, timestamp: i64, author: []const u8, @@ -61,12 +61,12 @@ pub const Bot = struct { allocator: std.mem.Allocator, // deinit function for backlog messages - fn deinit_backlog_slot(allocator: std.mem.Allocator, item: *const Message) void { + fn deinitBacklogSlot(allocator: std.mem.Allocator, item: *const Message) void { item.deinit(allocator); } // deinit function for outbox messages. - fn deinit_sent_message(allocator: std.mem.Allocator, item: []u8) void { + fn deinitSentMessage(allocator: std.mem.Allocator, item: []u8) void { allocator.free(item); } @@ -81,8 +81,8 @@ pub const Bot = struct { .backlog = undefined, .outbox = undefined, }; - bot.backlog = .init_with_closure(allocator, &Bot.deinit_backlog_slot); - bot.outbox = .init_with_closure(allocator, &Bot.deinit_sent_message); + bot.backlog = .initWithClosure(allocator, &Bot.deinitBacklogSlot); + bot.outbox = .initWithClosure(allocator, &Bot.deinitSentMessage); return bot; } @@ -166,7 +166,7 @@ pub const Bot = struct { if (backlog.history >= self.backlog.len()) { return Error.NoMessage; } - if (self.backlog.get_backwards(backlog.history)) |message| { + if (self.backlog.getBackwards(backlog.history)) |message| { const quoted_output = try std.fmt.allocPrint( self.allocator, "backlog {}: author: \"{s}\", content: \"{s}\"", @@ -190,7 +190,7 @@ pub const Bot = struct { } fn previous_message_by_author(self: *Bot, author: []const u8, targets: []const u8) ?*const Message { - var iter = self.backlog.iterate_reverse(); + var iter = self.backlog.iterateReverse(); while (iter.prev()) |message| { if (std.mem.eql(u8, message.author, author) and std.mem.eql(u8, message.targets, targets)) diff --git a/src/buffer.zig b/src/buffer.zig index 31898e1..b08bc16 100644 --- a/src/buffer.zig +++ b/src/buffer.zig @@ -28,7 +28,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { }; } - pub fn init_with_closure( + pub fn initWithClosure( allocator: std.mem.Allocator, deinit_func: *const fn (allocator: std.mem.Allocator, item: T) void, ) Buffer(T, length) { @@ -38,12 +38,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { return buf; } - fn can_deinit(self: *const Buffer(T, length)) bool { + fn canDeinit(self: *const Buffer(T, length)) bool { return self.allocator != null and self.deinit_func != null; } - fn deinit_item(self: *Buffer(T, length), item: T) void { - if (!self.can_deinit()) { + fn deinitItem(self: *Buffer(T, length), item: T) void { + if (!self.canDeinit()) { // Nothing to do return; } @@ -71,7 +71,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { } if (self.insertions >= length) { // free old bottom item - self.deinit_item(self.items[self.top].?); + self.deinitItem(self.items[self.top].?); self.items[self.top] = null; } self.items[self.top] = item; @@ -97,7 +97,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { return self.items[idx]; } - pub fn get_backwards(self: *const Buffer(T, length), offset: usize) ?T { + pub fn getBackwards(self: *const Buffer(T, length), offset: usize) ?T { if (offset >= self.insertions) { return null; } @@ -112,7 +112,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { }; } - pub fn iterate_reverse(self: *const Buffer(T, length)) BufferIterator(T, length) { + pub fn iterateReverse(self: *const Buffer(T, length)) BufferIterator(T, length) { return .{ .buffer = self, .index = self.top, @@ -121,12 +121,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { } pub fn deinit(self: *Buffer(T, length)) void { - if (!self.can_deinit()) { + if (!self.canDeinit()) { return; } for (self.items, 0..) |item, idx| { if (item != null) { - self.deinit_item(item.?); + self.deinitItem(item.?); self.items[idx] = null; } } @@ -237,7 +237,7 @@ test "deiniting allocating buffer does not leak" { const allocator = std.testing.allocator; // create buffer with closure - var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice); + var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice); defer buffer.deinit(); // add less elements than length @@ -253,7 +253,7 @@ test "wrapping allocating buffer does not leak" { const allocator = std.testing.allocator; // create buffer with closure - var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice); + var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice); defer buffer.deinit(); // add an element more than length diff --git a/src/main.zig b/src/main.zig index 54e48ea..c41ad19 100644 --- a/src/main.zig +++ b/src/main.zig @@ -76,13 +76,13 @@ pub const BotAdapter = struct { nick, msg.targets, msg.text, - ) catch |err| return report_error(err); + ) catch |err| return reportError(err); // send message to bot const response = self.bot.hear(bot_message) orelse { return null; } catch |err| { - return report_error(err); + return reportError(err); }; return toIRC(response); }, @@ -97,7 +97,7 @@ pub const BotAdapter = struct { } } - fn report_error(err: Error) zircon.Message { + fn reportError(err: Error) zircon.Message { const err_msg = switch (err) { Error.NoMessage => "no matching message", Error.OutOfMemory => "out of memory", From b72d5849965107798c9907e795bc4f48739abdf6 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 15:06:45 +0100 Subject: [PATCH 14/18] fixup! refactor: extract modules from bot.zig This comment was added when this refactoring had already been performed. --- src/buffer.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/buffer.zig b/src/buffer.zig index b08bc16..0210e1d 100644 --- a/src/buffer.zig +++ b/src/buffer.zig @@ -6,11 +6,6 @@ const std = @import("std"); // automatically deinited when it is overwritten by supplying a custom // ItemDeinitClosure, using .init_with_closure. pub fn Buffer(comptime T: type, comptime length: usize) type { - // TODO(jassob): Refactor buffer to use a top and len instead of - // top and bottom. - // - // Idea: bottom is always 0 if len != length and otherwise it is - // always top + 1. return struct { items: [length]?T, top: usize, From 7d444204efc1c2d3544724003844d0e25cea71c6 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 15:13:29 +0100 Subject: [PATCH 15/18] docs(main): add comments to functions and types --- src/main.zig | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main.zig b/src/main.zig index c41ad19..0caba3b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -49,6 +49,16 @@ pub const Adapter = struct { callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message, }; +/// BotAdapter is the closure that we register in zircon as the +/// message callback. +/// +/// Whenever a message is received by the zircon client it will invoke +/// BotAdapter.callback (through some indirection) with the received +/// message. +/// +/// The main responsibility of BotAdapter is to serve as the +/// translation layer between our own internal types and the zircon +/// IRC types. pub const BotAdapter = struct { bot: Bot, allocator: std.mem.Allocator, @@ -64,6 +74,14 @@ pub const BotAdapter = struct { self.bot.deinit(); } + /// callback gets called for every message that we receive. + /// + /// This is where we can extend the bot to support more types of + /// messages if needed. + /// + /// See + /// - https://modern.ircdocs.horse/, for what kinds of messages exists in the IRC protocol documentation, + /// - https://github.com/Jassob/zircon/blob/main/src/message.zig, for zircon documentation. pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { switch (message) { .PRIVMSG => |msg| { @@ -97,6 +115,7 @@ pub const BotAdapter = struct { } } + /// report errors as private message to admin channel. fn reportError(err: Error) zircon.Message { const err_msg = switch (err) { Error.NoMessage => "no matching message", From 2e1416eb216791ed537bac4cf4f77813f6549709 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 15:08:43 +0100 Subject: [PATCH 16/18] refactor(main): remove one layer of indirection This commit removes the BotAdapter.erased_callback function, which served as a generic version of BotAdapter.callback that was legible for zircon.MessageClosure. However, it is clearer to document how that mechanism works. Therefore we remove the BotAdapter.erased_callback and instead makes BotAdapter.callback match the zircon.MessageClosure.callbackFn signature. --- src/main.zig | 81 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/src/main.zig b/src/main.zig index 0caba3b..7d494b7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -44,11 +44,6 @@ pub fn main() !void { }; } -pub const Adapter = struct { - ptr: *anyopaque, - callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message, -}; - /// BotAdapter is the closure that we register in zircon as the /// message callback. /// @@ -82,10 +77,23 @@ pub const BotAdapter = struct { /// See /// - https://modern.ircdocs.horse/, for what kinds of messages exists in the IRC protocol documentation, /// - https://github.com/Jassob/zircon/blob/main/src/message.zig, for zircon documentation. - pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { + /// + /// NOTE: This function does not have a self-parameter, this is + /// because this function is called as a "generic" function + /// that is parameterized in the pointer argument (otherwise + /// zircon library would not be able to be reused). + /// + /// That's why the first thing we do is perform some casting + /// magic to convert our pointer back to a BotAdapter + /// pointer. This is safe as long as we know that we + /// registered a BotAdapter closure and there are no other + /// callbacks registered. + pub fn callback(ptr: *anyopaque, message: zircon.Message) ?zircon.Message { + const self: *@This() = @ptrCast(@alignCast(ptr)); + switch (message) { .PRIVMSG => |msg| { - const nick = if (msg.prefix != null and msg.prefix.?.nick != null) msg.prefix.?.nick.? else "unknown"; + const nick = nickFromPrefix(msg.prefix); // create message const bot_message = BotMessage.init_owned( @@ -125,19 +133,23 @@ pub const BotAdapter = struct { return .{ .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = err_msg } }; } - pub fn erased_callback(self: *anyopaque, message: zircon.Message) ?zircon.Message { - const a: *@This() = @ptrCast(@alignCast(self)); - return a.callback(message); - } - pub fn closure(self: *BotAdapter) zircon.MessageClosure { return .{ .ptr = self, - .callbackFn = BotAdapter.erased_callback, + .callbackFn = BotAdapter.callback, }; } }; +/// nickFromPrefix returns a nick if it exists in the prefix or +/// "unknown" otherwise. +fn nickFromPrefix(prefix: ?zircon.Prefix) []const u8 { + if (prefix == null or prefix.?.nick == null) { + return "unknown"; + } + return prefix.?.nick.?; +} + /// toIRC converts a bot response and converts it to a IRC message. fn toIRC(response: BotResponse) zircon.Message { switch (response) { @@ -150,30 +162,27 @@ fn toIRC(response: BotResponse) zircon.Message { } } +fn priv_msg(text: []const u8) zircon.Message { + return .{ .PRIVMSG = .{ + .prefix = .{ .nick = "jassob", .host = "localhost", .user = "jassob" }, + .targets = "#test", + .text = text, + } }; +} + test "substitute" { - var bot_adapter = try BotAdapter.init(std.testing.allocator); - defer bot_adapter.deinit(); - const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" }; - const msg = zircon.Message{ - .PRIVMSG = .{ - .prefix = prefix, - .targets = "#test", - .text = "hello world", - }, - }; - if (bot_adapter.callback(msg)) |_| { + var adapter = try BotAdapter.init(std.testing.allocator); + defer adapter.deinit(); + if (BotAdapter.callback(&adapter, priv_msg("hello world"))) |_| { @panic("unexpected response"); } - const cmd_msg = zircon.Message{ - .PRIVMSG = .{ - .prefix = prefix, - .targets = "#test", - .text = "s/world/zig/", - }, - }; - const response = bot_adapter.callback(cmd_msg); + const response = BotAdapter.callback(&adapter, priv_msg("s/world/zig/")); + try std.testing.expect(response != null); - try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text); + try std.testing.expectEqualStrings( + "jassob: \"hello zig\"", + response.?.PRIVMSG.text, + ); } test "get empty backlog message" { @@ -182,5 +191,9 @@ test "get empty backlog message" { const msg = zircon.Message{ .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = "!backlog 0" }, }; - try std.testing.expectEqualDeep("no matching message", bot_adapter.callback(msg).?.PRIVMSG.text); + + try std.testing.expectEqualDeep( + "no matching message", + BotAdapter.callback(&bot_adapter, msg).?.PRIVMSG.text, + ); } From 1addcc7ae358515759f8657d2007609f770fa738 Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 15:19:57 +0100 Subject: [PATCH 17/18] fixup! chore: rename functions --- src/bot.zig | 2 +- src/main.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bot.zig b/src/bot.zig index eeb4a97..1b57763 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -203,7 +203,7 @@ pub const Bot = struct { }; fn newTestMessage(allocator: std.mem.Allocator, content: []const u8) !*Message { - return try Message.init_owned( + return try Message.initOwned( allocator, 12345, "jassob", diff --git a/src/main.zig b/src/main.zig index 7d494b7..7f299d6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -96,7 +96,7 @@ pub const BotAdapter = struct { const nick = nickFromPrefix(msg.prefix); // create message - const bot_message = BotMessage.init_owned( + const bot_message = BotMessage.initOwned( self.allocator, std.time.timestamp(), nick, From bd3d7b909a82ff6c3cb5c5aaf1a6a5d51a54d66d Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 15 Mar 2026 15:21:50 +0100 Subject: [PATCH 18/18] chore: unexport some bot imports --- src/main.zig | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main.zig b/src/main.zig index 7f299d6..a91b336 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,12 +1,10 @@ const std = @import("std"); const zigeru = @import("zigeru"); +const bot = zigeru.bot; const Bot = zigeru.bot.Bot; -const Error = zigeru.bot.Error; const UserCommand = zigeru.commands.UserCommand; const AdminCommand = zigeru.commands.AdminCommand; -const BotMessage = zigeru.bot.Message; -const BotResponse = zigeru.bot.Response; const zircon = @import("zircon"); var debug_allocator = std.heap.DebugAllocator(.{}).init; @@ -96,7 +94,7 @@ pub const BotAdapter = struct { const nick = nickFromPrefix(msg.prefix); // create message - const bot_message = BotMessage.initOwned( + const bot_message = bot.Message.initOwned( self.allocator, std.time.timestamp(), nick, @@ -124,11 +122,11 @@ pub const BotAdapter = struct { } /// report errors as private message to admin channel. - fn reportError(err: Error) zircon.Message { + fn reportError(err: bot.Error) zircon.Message { const err_msg = switch (err) { - Error.NoMessage => "no matching message", - Error.OutOfMemory => "out of memory", - Error.WriteFailed => "write failed", + bot.Error.NoMessage => "no matching message", + bot.Error.OutOfMemory => "out of memory", + bot.Error.WriteFailed => "write failed", }; return .{ .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = err_msg } }; } @@ -151,7 +149,7 @@ fn nickFromPrefix(prefix: ?zircon.Prefix) []const u8 { } /// toIRC converts a bot response and converts it to a IRC message. -fn toIRC(response: BotResponse) zircon.Message { +fn toIRC(response: bot.Response) zircon.Message { switch (response) { .join => |join| return .{ .JOIN = .{ .prefix = null, .channels = join.channels },