diff --git a/README.md b/README.md new file mode 100644 index 0000000..83efa99 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# zigeru + +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. + +## Getting started + +To enter into a development shell with all the tools needed for +interacting with this project, run the following command: + +``` +$ nix develop +``` + +To run the tests: `zig build test`. + +To run the binary: `zig build run`. + +To build the binary: `zig build`. diff --git a/flake.lock b/flake.lock index ee94b44..b914351 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,60 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "zls", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1761373498, @@ -16,9 +71,85 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1755704039, + "narHash": "sha256-gKlP0LbyJ3qX0KObfIWcp5nbuHSb5EHwIvU6UcNBg2A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9cb344e96d5b6918e94e1bca2d9f3ea1e9615545", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "zls": "zls" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "nixpkgs": [ + "zls", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755864794, + "narHash": "sha256-hgnov6RLA+DD4Uocs/vCbiH3/3sKvqiJOKHpdhGyVAI=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "5cd601f8760d2383210b7b8c8a45fc79388f3ddf", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + }, + "zls": { + "inputs": { + "gitignore": "gitignore", + "nixpkgs": "nixpkgs_2", + "zig-overlay": "zig-overlay" + }, + "locked": { + "lastModified": 1756048867, + "narHash": "sha256-GFzSHUljcxy7sM1PaabbkQUdUnLwpherekPWJFxXtnk=", + "owner": "zigtools", + "repo": "zls", + "rev": "ce6c8f02c78e622421cfc2405c67c5222819ec03", + "type": "github" + }, + "original": { + "owner": "zigtools", + "ref": "0.15.0", + "repo": "zls", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index dc1c419..17e2e91 100644 --- a/flake.nix +++ b/flake.nix @@ -3,15 +3,17 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + zls.url = "github:zigtools/zls?ref=0.15.0"; # TODO(jsb): Update this when bumping zig version }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, zls }: let pkgs = nixpkgs.legacyPackages.x86_64-linux; in { devShells.x86_64-linux.default = pkgs.mkShell { buildInputs = [ pkgs.zig + zls.packages.x86_64-linux.zls ]; }; }; diff --git a/src/bot.zig b/src/bot.zig new file mode 100644 index 0000000..cf86d8e --- /dev/null +++ b/src/bot.zig @@ -0,0 +1,183 @@ +const std = @import("std"); + +pub const Command = union(enum) { + substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8 }, +}; + +pub const Result = union(enum) { + post_message: struct { content: []const u8 }, +}; + +pub const Error = error{ + OutOfMemory, + NoMessage, +}; + +pub const Message = struct { + timestamp: u32, + author: []const u8, + content: []const u8, +}; + +pub const Bot = struct { + backlog: [1024]?Message, + top: usize, + bottom: usize, + allocator: std.mem.Allocator, + + pub fn new(allocator: std.mem.Allocator) Bot { + return Bot{ + .backlog = .{null} ** 1024, + .top = 0, + .bottom = 0, + .allocator = allocator, + }; + } + + pub fn execute(self: *Bot, cmd: *const Command) Error!Result { + switch (cmd.*) { + .substitute => |command| { + const prev_msg = self.previous_message_by_author(command.author) orelse return Error.NoMessage; + const size = std.mem.replacementSize(u8, prev_msg.content, command.needle, command.replacement); + const output = try self.allocator.alloc(u8, size); + _ = std.mem.replace(u8, prev_msg.content, command.needle, command.replacement, output); + return Result{ .post_message = .{ .content = output } }; + }, + else => unreachable, + } + } + + pub fn hear(self: *Bot, msg: Message) void { + self.backlog[self.top] = msg; + self.top = (self.top + 1) % 1024; + if (self.top == self.bottom) self.bottom = (self.bottom + 1) % self.backlog.len; + } + + 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; + } + + fn previous_message(self: *Bot, comptime pred: *const fn (Message) bool) ?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 (pred(message)) { + return message; + } + if (idx == self.bottom) { + // reached the start of the list + break; + } + } + return null; + } + + fn previous_message_by_author(self: *Bot, author: []const u8) ?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)) { + return message; + } + if (idx == self.bottom) { + // reached the start of the list + break; + } + } + return null; + } +}; + +test "hear_wraps" { + var bot = Bot.new(std.testing.allocator); + const testMessage = Message{ + .author = "Jassob", + .timestamp = 12345, + .content = "All your codebase are belong to us.\n", + }; + + for (0..1025) |_| { + 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); +} + +test "previous_message" { + var bot = Bot.new(std.testing.allocator); + + const callback = struct { + fn callback(_: Message) bool { + return true; + } + }.callback; + const prev = bot.previous_message(callback); + + try std.testing.expect(prev == null); +} + +test "previous_message1" { + var bot = Bot.new(std.testing.allocator); + var contents: [10][]const u8 = .{undefined} ** 10; + + for (0..10) |i| { + contents[i] = try std.fmt.allocPrint(std.testing.allocator, "{d}", .{i}); + const msg = Message{ + .author = "Jassob", + .timestamp = @as(u32, @intCast(i)), + .content = contents[i], + }; + bot.hear(msg); + } + + const callback = struct { + fn callback(msg: Message) bool { + return std.mem.eql(u8, msg.content, "8"); + } + }.callback; + const prev = bot.previous_message(callback); + + for (contents) |str| { + std.testing.allocator.free(str); + } + + try std.testing.expect(prev != null); +} + +test "execute_substitution_no_previous_message" { + var bot = Bot.new(std.testing.allocator); + const cmd = Command{ .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" } }; + try std.testing.expectError(Error.NoMessage, bot.execute(&cmd)); +} + +test "execute_substitution" { + var bot = Bot.new(std.testing.allocator); + bot.hear(Message{ .timestamp = 1234, .author = "jassob", .content = "What" }); + const cmd = Command{ .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" } }; + const result = try bot.execute(&cmd); + switch (result) { + .post_message => |message| { + try std.testing.expectEqualDeep(message.content, "what"); + std.testing.allocator.free(message.content); + }, + } +} diff --git a/src/main.zig b/src/main.zig index cebfe10..931ed37 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,25 +3,26 @@ const zigeru = @import("zigeru"); pub fn main() !void { // Prints to stderr, ignoring potential errors. - std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); - try zigeru.bufferedPrint(); -} - -test "simple test" { - const gpa = std.testing.allocator; - var list: std.ArrayList(i32) = .empty; - defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak! - try list.append(gpa, 42); - try std.testing.expectEqual(@as(i32, 42), list.pop()); -} + const message = zigeru.bot.Message{ + .author = "Jassob", + .timestamp = 12345, + .content = "All your base are belong to us.\n", + }; -test "fuzz example" { - const Context = struct { - fn testOne(context: @This(), input: []const u8) anyerror!void { - _ = context; - // Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case! - try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input)); - } + var gpa = std.heap.DebugAllocator(.{}){}; + var bot = zigeru.bot.Bot.new(gpa.allocator()); + bot.hear(message); + const command = zigeru.bot.Command{ + .substitute = .{ + .author = "Jassob", + .needle = "base", + .replacement = "codebase", + }, }; - try std.testing.fuzz(Context{}, Context.testOne, .{}); + const result = try bot.execute(&command); + switch (result) { + .post_message => |msg| { + std.debug.print("{s}", .{msg.content}); + }, + } } diff --git a/src/root.zig b/src/root.zig index 94c7cd0..2f461df 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,23 +1,3 @@ //! By convention, root.zig is the root source file when making a library. const std = @import("std"); - -pub fn bufferedPrint() !void { - // Stdout is for the actual output of your application, for example if you - // are implementing gzip, then only the compressed bytes should be sent to - // stdout, not any debugging messages. - var stdout_buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); - const stdout = &stdout_writer.interface; - - try stdout.print("Run `zig build test` to run the tests.\n", .{}); - - try stdout.flush(); // Don't forget to flush! -} - -pub fn add(a: i32, b: i32) i32 { - return a + b; -} - -test "basic add functionality" { - try std.testing.expect(add(3, 7) == 10); -} +pub const bot = @import("bot.zig");