zfin/src/padded.zig

187 lines
7.3 KiB
Zig

//! Generic padding wrapper for any type with a `format(self, *Writer)`
//! method.
//!
//! Wraps an inner value plus a target width and an alignment side.
//! Its own `format` method renders the inner value into a stack
//! buffer to measure its length, then writes the padded output to
//! the caller's writer.
//!
//! Comptime instantiation per inner type is essentially free: the
//! compiler emits one body per `Padded(T)` actually used, with the
//! inner `format` call inlined.
//!
//! Used by `Money.padRight`/`padLeft` and `Date.padRight`/`padLeft`,
//! plus any future format-bearing type that needs column alignment.
const std = @import("std");
pub fn Padded(comptime T: type) type {
return struct {
inner: T,
width: usize,
alignment: enum { left, right },
const Self = @This();
pub fn format(self: Self, w: *std.Io.Writer) std.Io.Writer.Error!void {
// Render the inner value into a stack buffer first so
// we can measure its length and pad. 64 bytes covers
// every realistic format-method output in this codebase
// (Money's worst case is ~14 chars; Date is exactly 10).
var tmp: [64]u8 = undefined;
var fixed = std.Io.Writer.fixed(&tmp);
self.inner.format(&fixed) catch unreachable;
const text = fixed.buffered();
const pad = if (text.len >= self.width) 0 else self.width - text.len;
switch (self.alignment) {
.right => {
try w.splatByteAll(' ', pad);
try w.writeAll(text);
},
.left => {
try w.writeAll(text);
try w.splatByteAll(' ', pad);
},
}
}
};
}
// ── Tests ──────────────────────────────────────────────────────
const testing = std.testing;
/// Simple type with a format method, used to verify Padded(T) works
/// for arbitrary inner types (not just Money / Date).
const Tag = struct {
label: []const u8,
pub fn format(self: Tag, w: *std.Io.Writer) std.Io.Writer.Error!void {
try w.writeAll(self.label);
}
};
/// Helper: render `value` via `{f}` and return an owned string. Caller frees.
fn renderAlloc(value: anytype) ![]u8 {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{value});
return aw.toOwnedSlice();
}
test "Padded right-aligns shorter inner" {
const padded: Padded(Tag) = .{ .inner = .{ .label = "x" }, .width = 5, .alignment = .right };
const s = try renderAlloc(padded);
defer testing.allocator.free(s);
try testing.expectEqualStrings(" x", s);
}
test "Padded left-aligns shorter inner" {
const padded: Padded(Tag) = .{ .inner = .{ .label = "x" }, .width = 5, .alignment = .left };
const s = try renderAlloc(padded);
defer testing.allocator.free(s);
try testing.expectEqualStrings("x ", s);
}
test "Padded leaves inner unchanged when wider than width" {
const padded: Padded(Tag) = .{ .inner = .{ .label = "abcdef" }, .width = 3, .alignment = .right };
const s = try renderAlloc(padded);
defer testing.allocator.free(s);
try testing.expectEqualStrings("abcdef", s);
}
test "Padded equal width emits exactly the inner with no padding" {
const padded: Padded(Tag) = .{ .inner = .{ .label = "abc" }, .width = 3, .alignment = .right };
const s = try renderAlloc(padded);
defer testing.allocator.free(s);
try testing.expectEqualStrings("abc", s);
}
test "Padded width=0 emits inner unchanged regardless of alignment" {
const right: Padded(Tag) = .{ .inner = .{ .label = "abc" }, .width = 0, .alignment = .right };
const left: Padded(Tag) = .{ .inner = .{ .label = "abc" }, .width = 0, .alignment = .left };
const sr = try renderAlloc(right);
defer testing.allocator.free(sr);
const sl = try renderAlloc(left);
defer testing.allocator.free(sl);
try testing.expectEqualStrings("abc", sr);
try testing.expectEqualStrings("abc", sl);
}
test "Padded with empty inner emits pure spaces" {
const right: Padded(Tag) = .{ .inner = .{ .label = "" }, .width = 4, .alignment = .right };
const left: Padded(Tag) = .{ .inner = .{ .label = "" }, .width = 4, .alignment = .left };
const sr = try renderAlloc(right);
defer testing.allocator.free(sr);
const sl = try renderAlloc(left);
defer testing.allocator.free(sl);
try testing.expectEqualStrings(" ", sr);
try testing.expectEqualStrings(" ", sl);
}
test "Padded composes inside a larger format string" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
const left: Padded(Tag) = .{ .inner = .{ .label = "id" }, .width = 4, .alignment = .left };
const right: Padded(Tag) = .{ .inner = .{ .label = "42" }, .width = 4, .alignment = .right };
try aw.writer.print("[{f}|{f}]", .{ left, right });
const s = try aw.toOwnedSlice();
defer testing.allocator.free(s);
try testing.expectEqualStrings("[id | 42]", s);
}
test "Padded counts bytes, not codepoints (multi-byte content overflows visually)" {
// "é" is 2 bytes in UTF-8. With width=3 and 2-byte content, only
// 1 byte of padding is added — correct for fixed-width terminal
// output where the user supplies a column count, but worth pinning
// so a future "fix" using grapheme width doesn't silently change
// existing alignment.
const padded: Padded(Tag) = .{ .inner = .{ .label = "é" }, .width = 3, .alignment = .right };
const s = try renderAlloc(padded);
defer testing.allocator.free(s);
try testing.expectEqual(@as(usize, 3), s.len);
try testing.expectEqualStrings(" é", s);
}
test "Padded works with Date.padLeft / Date.padRight constructors" {
const Date = @import("Date.zig");
const d = Date.fromYmd(2024, 1, 5);
const right_s = try renderAlloc(d.padRight(12));
defer testing.allocator.free(right_s);
try testing.expectEqualStrings(" 2024-01-05", right_s);
const left_s = try renderAlloc(d.padLeft(12));
defer testing.allocator.free(left_s);
try testing.expectEqualStrings("2024-01-05 ", left_s);
}
test "Padded works with Money.padLeft / Money.padRight constructors" {
const Money = @import("Money.zig");
const m = Money.from(1234.5);
const right_s = try renderAlloc(m.padRight(12));
defer testing.allocator.free(right_s);
try testing.expectEqualStrings(" $1,234.50", right_s);
const left_s = try renderAlloc(m.padLeft(12));
defer testing.allocator.free(left_s);
try testing.expectEqualStrings("$1,234.50 ", left_s);
}
test "Padded composes with Money variants (whole / trim / signed)" {
const Money = @import("Money.zig");
const whole = try renderAlloc(Money.from(1234.0).whole().padRight(10));
defer testing.allocator.free(whole);
try testing.expectEqualStrings(" $1,234", whole);
const trim = try renderAlloc(Money.from(1234.0).trim().padRight(10));
defer testing.allocator.free(trim);
try testing.expectEqualStrings(" $1,234", trim);
const signed_pos = try renderAlloc(Money.from(50.0).signed().padRight(10));
defer testing.allocator.free(signed_pos);
try testing.expectEqualStrings(" +$50.00", signed_pos);
}