187 lines
7.3 KiB
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);
|
|
}
|