diff --git a/build.zig b/build.zig index 4e2fbe0..242a70c 100644 --- a/build.zig +++ b/build.zig @@ -14,6 +14,19 @@ pub fn build(b: *std.Build) void { b.installArtifact(exe); + const tests = b.addTest(.{ + .name = "tpb", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const test_step = b.step("test", "Run tests"); + const run_tests = b.addRunArtifact(tests); + test_step.dependOn(&run_tests.step); + const run_step = b.step("run", "Run the app"); const run_cmd = b.addRunArtifact(exe); diff --git a/src/Clip.zig b/src/Clip.zig index 7d2f6db..8d264c8 100644 --- a/src/Clip.zig +++ b/src/Clip.zig @@ -1 +1,30 @@ pub const OSC52 = @import("Clip/OSC52.zig"); + +// I am not sure if this is super idiomatic Zig or if this is bad practice, but +// by ensuring that all Clip backends implement the same methods, we are able +// to use the `anytype` type and we are able to have an interface that is able +// to dispatch statically + +/// Check if a function with the given name and type exists. +/// `@compileError` if not +fn assertFn(comptime T: type, comptime name: []const u8, comptime Sig: type) void { + if (!@import("std").meta.hasFn(T, name)) + @compileError(@typeName(T) ++ " missing fn " ++ name); + if (@TypeOf(@field(T, name)) != Sig) + @compileError(@typeName(T) ++ "." ++ name ++ " must be " ++ @typeName(Sig)); +} + +test { + inline for (@typeInfo(@This()).@"struct".decls) |decl| { + // Only grab types of structs + const T = @field(@This(), decl.name); + if (@TypeOf(T) != type) continue; + if (@typeInfo(T) != .@"struct") continue; + + // ============================== + // === Interface Requirements === + // ============================== + assertFn(T, "writeCopyBuffer", fn (*T, []const u8) anyerror!void); + assertFn(T, "writePasteboard", fn (*T) anyerror!void); + } +} diff --git a/src/Clip/OSC52.zig b/src/Clip/OSC52.zig index 44d92bf..d95fca1 100644 --- a/src/Clip/OSC52.zig +++ b/src/Clip/OSC52.zig @@ -13,7 +13,7 @@ base64buffer: std.ArrayList(u8), leftover_bytes: [3]u8, leftover_count: u8, -const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); +const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, '='); pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { return .{ @@ -25,31 +25,34 @@ pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { }; } -pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { +pub fn free(self: *OSC52) void { + self.base64buffer.clearAndFree(self.alloc); +} + +pub fn writeCopyBuffer(self: *OSC52, buf: []const u8) anyerror!void { if (buf.len == 0) return; // Start of buffer after dealing with leftover bytes const buf_idx = 3 - self.leftover_count; - if (buf.len + self.leftover_count < 3) { - const len = buf.len + self.leftover_count; - @memcpy(self.leftover_bytes[self.leftover_count..len], buf[0..buf.len]); - self.leftover_count = @intCast(len); + // When we dont fill the leftover buffer, just copy data + if (buf_idx > buf.len) { + const end = self.leftover_count + buf.len; + @memcpy(self.leftover_bytes[self.leftover_count..end], buf[0..buf.len]); + self.leftover_count = @intCast(end); return; } - if (self.leftover_count != 0) { - @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); - try self.base64buffer.appendSlice(self.alloc, s); - } + @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); + var temp: [5]u8 = undefined; + var s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); + try self.base64buffer.appendSlice(self.alloc, s); const chunkable_length = buf.len - ((buf.len - buf_idx) % 3); - var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + var chunker = std.mem.window(u8, buf[buf_idx..chunkable_length], 3, 3); while (chunker.next()) |chunk| { - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, chunk); + // var temp: [5]u8 = undefined; + s = OSC52.base64encoder.encode(&temp, chunk); try self.base64buffer.appendSlice(self.alloc, s); } @@ -59,7 +62,14 @@ pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { } } -pub fn writePasteboard(self: *OSC52) !void { +pub fn encodeRemaining(self: *OSC52) anyerror!void { + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); + try self.base64buffer.appendSlice(self.alloc, s); +} + +pub fn writePasteboard(self: *OSC52) anyerror!void { + try self.encodeRemaining(); // https://ghostty.org/docs/vt/osc/52 const osc_header = &[_]u8{ @@ -76,14 +86,51 @@ pub fn writePasteboard(self: *OSC52) !void { 0x5c, // '\' }; - // Write leftover bytes - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); - try self.base64buffer.appendSlice(self.alloc, s); - // Write data to stdout const stdout = Io.File.stdout(); try stdout.writeStreamingAll(self.io, osc_header); try stdout.writeStreamingAll(self.io, self.base64buffer.items); try stdout.writeStreamingAll(self.io, osc_footer); } + +test "basic base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDE=", osc.base64buffer.items); +} + +test "multi part base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.writeCopyBuffer("part2"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0Mg==", osc.base64buffer.items); +} + +test "tiny buffers base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.writeCopyBuffer("part2"); + try osc.writeCopyBuffer("1"); + try osc.writeCopyBuffer("2"); + try osc.writeCopyBuffer("3"); + try osc.writeCopyBuffer("45"); + try osc.writeCopyBuffer("6"); + try osc.writeCopyBuffer("78"); + try osc.writeCopyBuffer("90fin"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0MjEyMzQ1Njc4OTBmaW4=", osc.base64buffer.items); +} + +test "big buffers base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1part1part1part1part1part1part1part1part1|"); + try osc.writeCopyBuffer("part2part2part2part2part2part2part2part2part2"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0MXBhcnQxcGFydDFwYXJ0MXBhcnQxcGFydDFwYXJ0MXBhcnQxfHBhcnQycGFydDJwYXJ0MnBhcnQycGFydDJwYXJ0MnBhcnQycGFydDJwYXJ0Mg==", osc.base64buffer.items); +} diff --git a/src/main.zig b/src/main.zig index 5c1a339..ea234e5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,11 +11,16 @@ pub fn main(init: std.process.Init) !void { std.process.exit(1); } - std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); - // We want to avoid doing as many allocations as possible so having raw // pages with it being larger should be totally fine var clip = try Clip.OSC52.init(std.heap.page_allocator, init.io, std.heap.pageSize()); + defer clip.free(); + + run(io, clip); +} + +pub fn run(io: *std.Io, clip: anytype) !void { + std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); var buf: [4096]u8 = undefined; while (true) { @@ -38,3 +43,7 @@ pub fn main(init: std.process.Init) !void { std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); } + +test { + _ = @import("Clip.zig"); +}