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.
This commit is contained in:
Jacob Jonsson 2026-01-04 23:54:26 +01:00
parent 508e084ddf
commit e1e1938359
Signed by: Jassob
GPG key ID: 7E30B9B047F7202E
7 changed files with 694 additions and 285 deletions

116
build.zig
View file

@ -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,
},
),
}));
}

View file

@ -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/<old-word>/<new-word>/`
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");

269
src/buffer.zig Normal file
View file

@ -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());
}

106
src/commands.zig Normal file
View file

@ -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/<old-word>/<new-word>/`
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,
);
}

View file

@ -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);
// }

54
src/parser.zig Normal file
View file

@ -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];
}
};

View file

@ -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");