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` diff --git a/build.zig b/build.zig index f51357a..cbe6e17 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,69 +118,51 @@ 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 run_mod_tests = b.addRunArtifact(b.addTest(.{ - .root_module = mod, - })); - - const run_bot_tests = b.addRunArtifact(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_parser_tests = b.addRunArtifact(b.addTest(.{ - .root_module = b.addModule("parser", .{ - .target = target, - .root_source_file = b.path("src/parser.zig"), - }), - })); - - const run_buffer_tests = b.addRunArtifact(b.addTest(.{ - .root_module = b.addModule("buffer", .{ - .target = target, - .root_source_file = b.path("src/buffer.zig"), - }), - })); - - // 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 run_exe_tests = b.addRunArtifact(b.addTest(.{ - .root_module = exe.root_module, - })); - // 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_parser_tests.step); - test_step.dependOn(&run_bot_tests.step); - test_step.dependOn(&run_buffer_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 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. + 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. // @@ -190,3 +176,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 a425117..1b57763 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -1,92 +1,25 @@ const std = @import("std"); -const zircon = @import("zircon"); -const Buffer = @import("buffer.zig").Buffer; -const Parser = @import("parser.zig").Parser; - -/// 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."; 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, author: []const u8, content: []const u8, - pub fn init_owned( + pub fn initOwned( allocator: std.mem.Allocator, timestamp: i64, author: []const u8, @@ -109,47 +42,81 @@ 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), - sent_messages: Buffer([]u8, 1024), + outbox: Buffer([]u8, 1024), allocator: std.mem.Allocator, - fn deinit_backlog_slot(allocator: std.mem.Allocator, item: *const Message) void { + // deinit function for backlog messages + fn deinitBacklogSlot(allocator: std.mem.Allocator, item: *const Message) void { item.deinit(allocator); } - fn deinit_sent_message(allocator: std.mem.Allocator, item: []u8) void { + // deinit function for outbox messages. + fn deinitSentMessage(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 { var bot = Bot{ .allocator = allocator, .backlog = undefined, - .sent_messages = undefined, + .outbox = undefined, }; - bot.backlog = .init_with_closure(allocator, &Bot.deinit_backlog_slot); - bot.sent_messages = .init_with_closure(allocator, &Bot.deinit_sent_message); + bot.backlog = .initWithClosure(allocator, &Bot.deinitBacklogSlot); + bot.outbox = .initWithClosure(allocator, &Bot.deinitSentMessage); return bot; } + // deinits self's buffers and their contents. + // + // NOTE: does not deinit self. pub fn deinit(self: *Bot) void { - self.sent_messages.deinit(); + 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 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| { const prev_msg = self.previous_message_by_author( 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, @@ -163,77 +130,67 @@ pub const Bot = struct { "{s}: \"{s}\"", .{ command.author, output }, ); - self.sent_messages.append(quoted_output); - return zircon.Message{ .PRIVMSG = .{ + self.outbox.append(quoted_output); + 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( self.allocator, "heard messages: {}, sent messages: {}", - .{ self.no_messages(), self.sent_messages.items.len }, + .{ 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.insertions() == 0) { + if (self.backlog.len() == 0) { return Error.NoMessage; } - if (backlog.history > self.no_messages()) { + 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}\"", .{ backlog.history, message.author, message.content }, ); - self.sent_messages.append(quoted_output); + 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 } }; }, } } - 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); } - pub fn no_messages(self: *Bot) usize { - return self.backlog.len(); - } - 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)) @@ -245,15 +202,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.initOwned( 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); } @@ -267,17 +228,10 @@ 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", - ); - bot.hear(testMessage); + const testMessage = try newTestMessage(allocator, "test"); + try std.testing.expectEqual(null, bot.hear(testMessage)); try std.testing.expectEqual(0, bot.backlog.top); - try std.testing.expectEqual(1, bot.no_messages()); } test "a few hears and deinit has no leaks" { @@ -285,19 +239,12 @@ 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", - ); - bot.hear(testMessage); + for (0..2) |_| { + const testMessage = try newTestMessage(std.testing.allocator, "test"); + _ = bot.hear(testMessage); } try std.testing.expectEqual(1, bot.backlog.top); - try std.testing.expect(bot.no_messages() == 2); } test "hear wraps" { @@ -305,69 +252,86 @@ 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", - ); - bot.hear(testMessage); + const testMessage = try newTestMessage(std.testing.allocator, "test"); + _ = bot.hear(testMessage); } try std.testing.expectEqual(0, bot.backlog.top); try std.testing.expectEqual(1, bot.backlog.bottom()); - try std.testing.expectEqual(1024, bot.no_messages()); + 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 = Command{ .substitute = .{ - .author = "jassob", - .needle = "What", - .replacement = "what", - } }; - try std.testing.expectError(Error.NoMessage, bot.execute( - &cmd, - null, - "#test", - )); + + const substitution = try newTestMessage(std.testing.allocator, "s/What/what/"); + try std.testing.expectError(Error.NoMessage, bot.hear(substitution).?); } -test "execute_substitution" { - var bot = try Bot.init(std.testing.allocator); +test "execute substitution" { + 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", - ); - bot.hear(msg); + const msg = try newTestMessage(allocator, "What"); + try std.testing.expectEqual(null, bot.hear(msg)); // execute substitution - const cmd = Command{ - .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" }, - }; - const response = try bot.execute(&cmd, null, "#test"); + const sub = try newTestMessage(allocator, "s/What/what/"); + const response = try bot.hear(sub).?; // expect response matching the correct message switch (response) { - .PRIVMSG => |message| { + .privmsg => |message| { try std.testing.expectEqualDeep(message.text, "jassob: \"what\""); }, else => unreachable, } } -test "parse_botcommands" { - const cmd = AdminCommand.parse("!join badchannel") orelse unreachable; - try std.testing.expectEqual( - AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } }, - cmd, - ); +test "execute substitution with no matching needle" { + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); + defer bot.deinit(); + + // hear original message + const msg = try newTestMessage(allocator, "original"); + try std.testing.expectEqual(null, bot.hear(msg)); + + // execute substitution + 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" { + const allocator = std.testing.allocator; + var bot = try Bot.init(allocator); + defer bot.deinit(); + + // hear original message + const msg = try newTestMessage(allocator, "original"); + try std.testing.expectEqual(null, bot.hear(msg)); + + // execute substitution + 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); + }, + else => unreachable, + } + + // execute second substitution + 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/buffer.zig b/src/buffer.zig index c228d1e..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, @@ -28,7 +23,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 +33,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 +66,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; @@ -91,14 +86,14 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { pub fn get(self: *const Buffer(T, length), index: usize) ?T { const idx = (self.bottom() + index) % self.items.len; - if (index > self.insertions()) { + 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()) { + pub fn getBackwards(self: *const Buffer(T, length), offset: usize) ?T { + if (offset >= self.insertions) { return null; } return self.items[(self.top - offset) % self.items.len]; @@ -112,7 +107,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 +116,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 +232,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 +248,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/commands.zig b/src/commands.zig new file mode 100644 index 0000000..f782830 --- /dev/null +++ b/src/commands.zig @@ -0,0 +1,144 @@ +const std = @import("std"); + +const Parser = @import("root.zig").parser.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 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")) |_| { + return .help; + } + + if (original.consume_char('s')) |substitute| { + const log_prefix = "parsing substitute command"; + var parser = substitute; + parser, const delim = parser.take_char(); + if (std.ascii.isAlphanumeric(delim)) { + std.log.debug("{s}: delimiter cannot be a whitespace: \"{s}\"", .{ log_prefix, text }); + return null; + } + 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 .init_substitute(nick, typo, correction, false); + } + 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 "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. +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")) |join| { + if (join.consume_space()) |channel| { + if (channel.rest[0] != '#') { + return .{ .err = .{ .message = "channels must start with \"#\"" } }; + } + return .{ .join = .{ .channel = join.rest } }; + } + } + if (command.consume_str("backlog")) |backlog| { + 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}); + } + return null; + } +}; + +test "parse admin commands" { + const cmd = AdminCommand.parse("!join badchannel") orelse unreachable; + try std.testing.expectEqual( + AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } }, + 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 345f14b..a91b336 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,11 +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 BotCommand = zigeru.bot.Command; -const AdminCommand = zigeru.bot.AdminCommand; -const BotMessage = zigeru.bot.Message; +const UserCommand = zigeru.commands.UserCommand; +const AdminCommand = zigeru.commands.AdminCommand; const zircon = @import("zircon"); var debug_allocator = std.heap.DebugAllocator(.{}).init; @@ -43,11 +42,16 @@ 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. +/// +/// 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, @@ -63,29 +67,48 @@ pub const BotAdapter = struct { self.bot.deinit(); } - pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { + /// 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. + /// + /// 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| { - 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 (BotCommand.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| { - return self.bot.execute_admin(&cmd, msg.prefix, "#eru-admin") catch |err| return report_error(err); - } - const bot_msg = BotMessage.init_owned( + const nick = nickFromPrefix(msg.prefix); + + // create message + const bot_message = bot.Message.initOwned( self.allocator, std.time.timestamp(), nick, msg.targets, msg.text, - ) catch |err| return report_error(err); - self.bot.hear(&bot_msg); - return null; + ) catch |err| return reportError(err); + + // send message to bot + const response = self.bot.hear(bot_message) orelse { + return null; + } catch |err| { + return reportError(err); + }; + return toIRC(response); }, .JOIN => |msg| { std.log.debug("received join message: channels {s}", .{msg.channels}); @@ -98,50 +121,77 @@ pub const BotAdapter = struct { } } - fn report_error(err: Error) zircon.Message { + /// report errors as private message to admin channel. + 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 } }; } - 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, }; } }; -// 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); -// } +/// 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: bot.Response) 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 }, + }, + } +} + +fn priv_msg(text: []const u8) zircon.Message { + return .{ .PRIVMSG = .{ + .prefix = .{ .nick = "jassob", .host = "localhost", .user = "jassob" }, + .targets = "#test", + .text = text, + } }; +} + +test "substitute" { + var adapter = try BotAdapter.init(std.testing.allocator); + defer adapter.deinit(); + if (BotAdapter.callback(&adapter, priv_msg("hello world"))) |_| { + @panic("unexpected response"); + } + 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, + ); +} + +test "get empty backlog message" { + var bot_adapter = try BotAdapter.init(std.testing.allocator); + defer bot_adapter.deinit(); + const msg = zircon.Message{ + .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = "!backlog 0" }, + }; + + try std.testing.expectEqualDeep( + "no matching message", + BotAdapter.callback(&bot_adapter, msg).?.PRIVMSG.text, + ); +} diff --git a/src/parser.zig b/src/parser.zig index 9ee7780..080c708 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1,14 +1,15 @@ 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 }; - } - + // Initializes a Parser for s. pub fn init(s: []const u8) Parser { return .{ .original = s, @@ -17,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; @@ -24,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) { @@ -35,16 +56,34 @@ pub const Parser = struct { 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] }; + // 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 } { + 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. 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()); +} diff --git a/src/root.zig b/src/root.zig index 474a0a3..e106d68 100644 --- a/src/root.zig +++ b/src/root.zig @@ -2,3 +2,5 @@ pub const bot = @import("bot.zig"); pub const buffer = @import("buffer.zig"); +pub const parser = @import("parser.zig"); +pub const commands = @import("commands.zig");