diff --git a/README.md b/README.md index e9a337f..ca45b49 100644 --- a/README.md +++ b/README.md @@ -5,31 +5,6 @@ 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 @@ -45,4 +20,7 @@ To run the binary: `zig build run`. To build the binary: `zig build`. -To build the binary statically: `zig build -Dtarget=x86_64-linux-musl` +## TO DO + +- [ ] Add IRC support +- [ ] Add TLS support diff --git a/build.zig b/build.zig index cbe6e17..f51357a 100644 --- a/build.zig +++ b/build.zig @@ -1,16 +1,12 @@ 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 @@ -110,7 +106,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 runStep = b.step("run", "Run the app"); + const run_step = b.step("run", "Run the app"); // This creates a RunArtifact step in the build graph. A RunArtifact step // invokes an executable compiled by Zig. Steps will only be executed by the @@ -118,51 +114,69 @@ 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 runCmd = b.addRunArtifact(exe); - runStep.dependOn(&runCmd.step); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); // By making the run step depend on the default step, it will be run from the // installation directory rather than directly from within the cache directory. - runCmd.step.dependOn(b.getInstallStep()); + run_cmd.step.dependOn(b.getInstallStep()); // This allows the user to pass arguments to the application in the build // command itself, like this: `zig build run -- arg1 arg2 etc` if (b.args) |args| { - runCmd.addArgs(args); + run_cmd.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 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); - } + 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); // Just like flags, top level steps are also listed in the `--help` menu. // @@ -176,30 +190,3 @@ 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 1b57763..a425117 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -1,25 +1,92 @@ const std = @import("std"); +const zircon = @import("zircon"); -const zigeru = @import("root.zig"); -const Buffer = zigeru.buffer.Buffer; -const UserCommand = zigeru.commands.UserCommand; -const AdminCommand = zigeru.commands.AdminCommand; +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 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 initOwned( + pub fn init_owned( allocator: std.mem.Allocator, timestamp: i64, author: []const u8, @@ -42,81 +109,47 @@ 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), + sent_messages: Buffer([]u8, 1024), allocator: std.mem.Allocator, - // deinit function for backlog messages - fn deinitBacklogSlot(allocator: std.mem.Allocator, item: *const Message) void { + fn deinit_backlog_slot(allocator: std.mem.Allocator, item: *const Message) void { item.deinit(allocator); } - // deinit function for outbox messages. - fn deinitSentMessage(allocator: std.mem.Allocator, item: []u8) void { + 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 { var bot = Bot{ .allocator = allocator, .backlog = undefined, - .outbox = undefined, + .sent_messages = undefined, }; - bot.backlog = .initWithClosure(allocator, &Bot.deinitBacklogSlot); - bot.outbox = .initWithClosure(allocator, &Bot.deinitSentMessage); + bot.backlog = .init_with_closure(allocator, &Bot.deinit_backlog_slot); + bot.sent_messages = .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 { - self.outbox.deinit(); + self.sent_messages.deinit(); 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 { + pub fn execute( + self: *Bot, + cmd: *const Command, + 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; - if (std.mem.count(u8, prev_msg.content, command.needle) == 0) { - return Error.NoMessage; - } const output = try std.mem.replaceOwned( u8, self.allocator, @@ -130,67 +163,77 @@ pub const Bot = struct { "{s}: \"{s}\"", .{ command.author, output }, ); - self.outbox.append(quoted_output); - return .{ .privmsg = .{ + self.sent_messages.append(quoted_output); + return zircon.Message{ .PRIVMSG = .{ .targets = targets, + .prefix = prefix, .text = quoted_output, } }; }, .help => { - return .{ .privmsg = .{ + return zircon.Message{ .PRIVMSG = .{ .targets = targets, + .prefix = prefix, .text = HELP_MESSAGE, } }; }, } } - pub fn execute_admin(self: *Bot, cmd: *const AdminCommand, targets: []const u8) Error!Response { + 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: {}", - .{ self.backlog.len(), self.outbox.len() }, + .{ self.no_messages(), self.sent_messages.items.len }, ); - return .{ .privmsg = .{ .targets = targets, .text = msg } }; + return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = msg } }; }, .join => |msg| { std.log.debug("received join request: channel \"{s}\"", .{msg.channel}); - return .{ .join = .{ .channels = msg.channel } }; + return .{ .JOIN = .{ .prefix = prefix, .channels = msg.channel } }; }, .backlog => |backlog| { - if (self.backlog.len() == 0) { + if (self.backlog.insertions() == 0) { return Error.NoMessage; } - if (backlog.history >= self.backlog.len()) { + if (backlog.history > self.no_messages()) { return Error.NoMessage; } - if (self.backlog.getBackwards(backlog.history)) |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 }, ); - self.outbox.append(quoted_output); + self.sent_messages.append(quoted_output); return .{ - .privmsg = .{ .targets = targets, .text = quoted_output }, + .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output }, }; } else return Error.NoMessage; }, .err => |err| { - return .{ .privmsg = .{ .targets = targets, .text = err.message } }; + return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = err.message } }; }, } } - // store a message in backlog, potentially overwriting oldest message. - pub fn store(self: *Bot, msg: *const Message) void { + pub fn hear(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.iterateReverse(); + 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)) @@ -202,19 +245,15 @@ pub const Bot = struct { } }; -fn newTestMessage(allocator: std.mem.Allocator, content: []const u8) !*Message { - return try Message.initOwned( +test "deiniting an owned Message leaks no memory" { + const allocator = std.testing.allocator; + const testMessage = try Message.init_owned( allocator, 12345, "jassob", "#test", - content, + "All your codebase are belong to us.\n", ); -} - -test "deiniting an owned Message leaks no memory" { - const allocator = std.testing.allocator; - const testMessage = try newTestMessage(allocator, "test"); testMessage.deinit(allocator); } @@ -228,10 +267,17 @@ test "hear and deinit has no leaks" { var bot = try Bot.init(allocator); defer bot.deinit(); - const testMessage = try newTestMessage(allocator, "test"); - try std.testing.expectEqual(null, bot.hear(testMessage)); + 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); + try std.testing.expectEqual(1, bot.no_messages()); } test "a few hears and deinit has no leaks" { @@ -239,12 +285,19 @@ test "a few hears and deinit has no leaks" { var bot = try Bot.init(allocator); defer bot.deinit(); - for (0..2) |_| { - const testMessage = try newTestMessage(std.testing.allocator, "test"); - _ = bot.hear(testMessage); + 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); + try std.testing.expect(bot.no_messages() == 2); } test "hear wraps" { @@ -252,86 +305,69 @@ test "hear wraps" { defer bot.deinit(); for (0..1025) |_| { - const testMessage = try newTestMessage(std.testing.allocator, "test"); - _ = bot.hear(testMessage); + const testMessage = try Message.init_owned( + std.testing.allocator, + 12345, + "jassob", + "#test", + "All your codebase are belong to us.\n", + ); + 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.backlog.len()); + try std.testing.expectEqual(1024, bot.no_messages()); } -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", + )); +} + +test "execute_substitution" { var bot = try Bot.init(std.testing.allocator); defer bot.deinit(); - const substitution = try newTestMessage(std.testing.allocator, "s/What/what/"); - try std.testing.expectError(Error.NoMessage, bot.hear(substitution).?); -} - -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 newTestMessage(allocator, "What"); - try std.testing.expectEqual(null, bot.hear(msg)); + const msg = try Message.init_owned( + std.testing.allocator, + 1234, + "jassob", + "#test", + "What", + ); + bot.hear(msg); // execute substitution - const sub = try newTestMessage(allocator, "s/What/what/"); - const response = try bot.hear(sub).?; + const cmd = Command{ + .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" }, + }; + const response = try bot.execute(&cmd, null, "#test"); // expect response matching the correct message switch (response) { - .privmsg => |message| { + .PRIVMSG => |message| { try std.testing.expectEqualDeep(message.text, "jassob: \"what\""); }, else => unreachable, } } -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|", +test "parse_botcommands" { + const cmd = AdminCommand.parse("!join badchannel") orelse unreachable; + try std.testing.expectEqual( + AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } }, + cmd, ); - 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 0210e1d..c228d1e 100644 --- a/src/buffer.zig +++ b/src/buffer.zig @@ -6,6 +6,11 @@ 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, @@ -23,7 +28,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { }; } - pub fn initWithClosure( + pub fn init_with_closure( allocator: std.mem.Allocator, deinit_func: *const fn (allocator: std.mem.Allocator, item: T) void, ) Buffer(T, length) { @@ -33,12 +38,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { return buf; } - fn canDeinit(self: *const Buffer(T, length)) bool { + fn can_deinit(self: *const Buffer(T, length)) bool { return self.allocator != null and self.deinit_func != null; } - fn deinitItem(self: *Buffer(T, length), item: T) void { - if (!self.canDeinit()) { + fn deinit_item(self: *Buffer(T, length), item: T) void { + if (!self.can_deinit()) { // Nothing to do return; } @@ -66,7 +71,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { } if (self.insertions >= length) { // free old bottom item - self.deinitItem(self.items[self.top].?); + self.deinit_item(self.items[self.top].?); self.items[self.top] = null; } self.items[self.top] = item; @@ -86,14 +91,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 getBackwards(self: *const Buffer(T, length), offset: usize) ?T { - if (offset >= self.insertions) { + 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]; @@ -107,7 +112,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { }; } - pub fn iterateReverse(self: *const Buffer(T, length)) BufferIterator(T, length) { + pub fn iterate_reverse(self: *const Buffer(T, length)) BufferIterator(T, length) { return .{ .buffer = self, .index = self.top, @@ -116,12 +121,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type { } pub fn deinit(self: *Buffer(T, length)) void { - if (!self.canDeinit()) { + if (!self.can_deinit()) { return; } for (self.items, 0..) |item, idx| { if (item != null) { - self.deinitItem(item.?); + self.deinit_item(item.?); self.items[idx] = null; } } @@ -232,7 +237,7 @@ test "deiniting allocating buffer does not leak" { const allocator = std.testing.allocator; // create buffer with closure - var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice); + var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice); defer buffer.deinit(); // add less elements than length @@ -248,7 +253,7 @@ test "wrapping allocating buffer does not leak" { const allocator = std.testing.allocator; // create buffer with closure - var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice); + var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice); defer buffer.deinit(); // add an element more than length diff --git a/src/commands.zig b/src/commands.zig deleted file mode 100644 index f782830..0000000 --- a/src/commands.zig +++ /dev/null @@ -1,144 +0,0 @@ -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 a91b336..345f14b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,10 +1,11 @@ const std = @import("std"); const zigeru = @import("zigeru"); -const bot = zigeru.bot; const Bot = zigeru.bot.Bot; -const UserCommand = zigeru.commands.UserCommand; -const AdminCommand = zigeru.commands.AdminCommand; +const Error = zigeru.bot.Error; +const BotCommand = zigeru.bot.Command; +const AdminCommand = zigeru.bot.AdminCommand; +const BotMessage = zigeru.bot.Message; const zircon = @import("zircon"); var debug_allocator = std.heap.DebugAllocator(.{}).init; @@ -42,16 +43,11 @@ pub fn main() !void { }; } -/// 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 Adapter = struct { + ptr: *anyopaque, + callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message, +}; + pub const BotAdapter = struct { bot: Bot, allocator: std.mem.Allocator, @@ -67,48 +63,29 @@ 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. - /// - /// 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)); - + pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { switch (message) { .PRIVMSG => |msg| { - const nick = nickFromPrefix(msg.prefix); - - // create message - const bot_message = bot.Message.initOwned( + 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( self.allocator, std.time.timestamp(), nick, msg.targets, msg.text, - ) 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); + ) catch |err| return report_error(err); + self.bot.hear(&bot_msg); + return null; }, .JOIN => |msg| { std.log.debug("received join message: channels {s}", .{msg.channels}); @@ -121,77 +98,50 @@ pub const BotAdapter = struct { } } - /// report errors as private message to admin channel. - fn reportError(err: bot.Error) zircon.Message { + fn report_error(err: Error) zircon.Message { const err_msg = switch (err) { - bot.Error.NoMessage => "no matching message", - bot.Error.OutOfMemory => "out of memory", - bot.Error.WriteFailed => "write failed", + Error.NoMessage => "no matching message", + Error.OutOfMemory => "out of memory", + 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.callback, + .callbackFn = BotAdapter.erased_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: 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, - ); -} +// 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 index 080c708..9ee7780 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1,15 +1,14 @@ 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, - // Initializes a Parser for s. + 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, @@ -18,25 +17,6 @@ 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; @@ -44,7 +24,6 @@ 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) { @@ -56,34 +35,16 @@ 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 } { - if (std.mem.indexOfScalar(u8, self.rest, c)) |idx| { - return .{ self.seek(idx), self.rest[0..idx] }; - } - return null; + 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()); -} diff --git a/src/root.zig b/src/root.zig index e106d68..474a0a3 100644 --- a/src/root.zig +++ b/src/root.zig @@ -2,5 +2,3 @@ pub const bot = @import("bot.zig"); pub const buffer = @import("buffer.zig"); -pub const parser = @import("parser.zig"); -pub const commands = @import("commands.zig");