tally/src/cli/format.zig

1337 lines
56 KiB
Zig

//! Everything the CLI prints.
//!
//! One function per output shape, each returning text plus whether that text is an
//! error, and none of them touching stdout: `main.zig` decides which stream a result
//! goes to and what the exit status is. These used to sit in `main.zig` alongside the
//! argument parsing, which left one file holding both ends of the program.
//!
//! Nothing here is shared with the TUI, and that is deliberate. The two frontends
//! want different things from the same engine results: this writes a full-width table
//! into an allocated buffer, while `src/tui/financial.zig` draws a windowed slice of
//! the same schedule into a surface at narrower widths. What they do share is the
//! engine's `Money` and `Number` rendering, so the figures themselves match.
//!
//! The `format` prefix these functions used to carry is now the file name, so
//! `format.conversion` and `format.amortization` read as what they are.
const std = @import("std");
const engine = @import("engine");
/// A finished piece of output: complete text, ending in a newline, and whether it is
/// an error. `main.zig` turns the flag into a stream and an exit status and prints the
/// text as it stands.
///
/// The newline is part of the text because some outputs are several lines and some
/// are one. It used to be split: errors and multi-line results carried their own,
/// single-line results relied on `main` to add one, and programmer mode did both, so
/// `tally -p 1` ended in a blank line.
pub const Result = struct {
output: []const u8,
is_error: bool,
pub fn ok(output: []const u8) Result {
return .{ .output = output, .is_error = false };
}
/// An engine error, in the engine's words.
pub fn fail(err: engine.Error) Result {
return .{ .output = decoratedError(err), .is_error = true };
}
/// Text that does not fit the buffer it was written into. One message for one
/// condition: there used to be "buffer overflow" in eight places and "result too
/// long to display" in a ninth, and the first reads like a security bug.
const too_long: Result = .{ .output = "error: the result is too long to display\n", .is_error = true };
};
/// Which evaluator a CLI invocation wants.
///
/// The CLI's own concept, not the engine's: the engine has `evalString` and
/// `evalProgrammerString` and no notion of a mode. There are exactly two here
/// because `-p` is the only mode flag; the TUI's four tabs are its own enum.
pub const Mode = enum { standard, programmer };
/// How this frontend renders a number.
///
/// The engine has no default and no digit constants of its own: a terminal, a
/// clipboard and an Android screen want different budgets, so each frontend states
/// its own (NFR-9.9, "display precision is a separate decision from compute
/// precision"). These are the values the engine used to hold.
const display_format: engine.Number.FormatOptions = .{
.fraction_digits = 20,
.scientific_below_exponent = 15,
.max_integer_digits = 40,
.significant_digits = 17,
.separators = true,
};
/// The engine's amount formatter, so the CLI, the TUI and the engine all group
/// amounts the same way. Two decimals and grouping are properties of money rather
/// than of a screen, so unlike `display_format` there is no budget to pass.
const money = engine.financial.money;
// -- Expressions --
/// Evaluate an expression and format the result for a terminal.
///
/// The programmer configuration (width, signedness, byte order) is explicit because
/// the flags that set it are. Standard mode ignores it: it is fixed at 64-bit signed.
/// There used to be a two-argument `evaluate` in front of this that defaulted the
/// configuration, and by the end only the tests called it.
pub fn evaluateWith(
allocator: std.mem.Allocator,
expression: []const u8,
mode: Mode,
config: engine.programmer.Config,
buf: []u8,
) Result {
if (mode == .programmer) {
const result = engine.evalProgrammerString(allocator, expression, config) catch |err| {
return .fail(err);
};
return programmerResult(buf, result, config);
}
var env = engine.Environment.init(allocator);
defer env.deinit();
// A standalone "to" keyword means this is a unit conversion, e.g.
// "32F to C". Anything without it falls through to normal evaluation.
if (engine.units.parseRequest(expression)) |maybe_request| {
if (maybe_request) |request| {
var value = engine.evalString(&env, allocator, request.value_text) catch |err| return .fail(err);
defer value.deinit();
return conversionUnits(allocator, buf, value, request.from, request.to);
}
} else |err| return .fail(err);
var info = engine.evalStringInfo(&env, allocator, expression) catch |err| return .fail(err);
defer info.value.deinit();
const shown = info.value.render(allocator, display_format) catch |err| return .fail(err);
defer shown.deinit(allocator);
// The hex, octal and binary rows, when the expression used a non-decimal literal
// and the answer is a whole number that fits a pattern (FR-1.9).
if (info.basePattern()) |int| return standardMultiBase(buf, shown.text, int);
// Into the caller's buffer, so the result does not depend on the allocator
// outliving this call.
return .ok(std.fmt.bufPrint(buf, "{s}\n", .{shown.text}) catch return .too_long);
}
/// A standard-mode result with its multi-base rows. The rows print themselves, and
/// `dec_display` is the caller's allocated text rather than a slice of `buf`, so
/// writing into `buf` cannot clobber it.
fn standardMultiBase(buf: []u8, dec_display: []const u8, int: engine.Integer) Result {
return .ok(std.fmt.bufPrint(buf, "{s}\n hex: {f}\n oct: {f}\n bin: {f}\n", .{
dec_display,
int.fmt(.hex, .{}),
int.fmt(.octal, .{}),
int.fmt(.binary, .{}),
}) catch return .too_long);
}
fn programmerResult(buf: []u8, result: engine.Integer, config: engine.programmer.Config) Result {
return .ok(std.fmt.bufPrint(buf,
\\ dec(signed): {f}
\\ dec(unsigned): {f}
\\ hex: {f}
\\ oct: {f}
\\ bin: {f}
\\
, .{
result.fmt(.decimal_signed, .{}),
result.fmt(.decimal_unsigned, .{}),
result.fmt(.hex, .{ .endian = config.display_endian }),
result.fmt(.octal, .{}),
result.fmt(.binary, .{}),
}) catch return .too_long);
}
// -- Conversions --
/// Format a unit conversion between two resolved units.
///
/// The value is parsed exactly rather than through f64, so a decimal input like 2.5
/// enters the conversion without being rounded first. The units arrive resolved
/// because `units.parseRequest` resolved them while reading the request, so there is
/// no second lookup here and no second `UnknownUnit` path.
pub fn conversion(
allocator: std.mem.Allocator,
buf: []u8,
value_text: []const u8,
from: engine.UnitDef,
to: engine.UnitDef,
) Result {
// Parse the value exactly rather than through f64, so a decimal input like
// 2.5 enters the conversion without being rounded first.
var value = engine.Number.parse(allocator, value_text) catch |err| return .fail(err);
defer value.deinit();
return conversionUnits(allocator, buf, value, from, to);
}
/// Format a conversion of a value that has already been evaluated, which is what a
/// bare expression such as `2*3 kg to lb` arrives as.
///
/// Uses the exact path so terminating conversions print exactly: `12 in to ft`
/// is `1`, not `0.9999999999999998`.
fn conversionUnits(
allocator: std.mem.Allocator,
buf: []u8,
value: engine.Number,
from: engine.UnitDef,
to: engine.UnitDef,
) Result {
var converted = engine.units.convertExactUnits(allocator, value, from, to) catch |err| return .fail(err);
defer converted.deinit();
const shown_in = value.render(allocator, display_format) catch |err| return .fail(err);
defer shown_in.deinit(allocator);
const shown_out = converted.render(allocator, display_format) catch |err| return .fail(err);
defer shown_out.deinit(allocator);
return .ok(std.fmt.bufPrint(buf, "{s} {s} = {s} {s}\n", .{
shown_in.text, from.name, shown_out.text, to.name,
}) catch return .too_long);
}
// -- Amortization --
/// Render an amortization schedule as a table. The result is allocated because a
/// 360-period schedule does not fit the fixed buffers the other outputs use.
pub fn amortization(
allocator: std.mem.Allocator,
params: engine.financial.AmortizationParams,
summary_only: bool,
) Result {
const payment = engine.financial.amortizationPayment(params) catch |err| return amortizationError(err);
const totals = engine.financial.amortizationTotals(params) catch |err| return amortizationError(err);
const rows = engine.financial.amortizationSchedule(allocator, params) catch |err| return amortizationError(err);
defer allocator.free(rows);
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
writeSchedule(&out.writer, params, payment, rows, totals, summary_only) catch |err| return switch (err) {
error.Unformattable => unformattable,
error.WriteFailed => .fail(error.OutOfMemory),
};
return .ok(out.toOwnedSlice() catch return .fail(error.OutOfMemory));
}
fn writeSchedule(
w: *std.Io.Writer,
params: engine.financial.AmortizationParams,
payment: f64,
rows: []const engine.financial.AmortizationEntry,
totals: engine.financial.AmortizationTotals,
summary_only: bool,
) (std.Io.Writer.Error || error{Unformattable})!void {
var a: [4]Amount = undefined;
try w.print("{s} at {d}% per period over {d} periods\n", .{ try a[0].of(params.principal), params.rate, params.periods });
try w.print("Payment {s} per period\n\n", .{try a[0].of(payment)});
if (!summary_only) {
try w.writeAll("Period Payment Interest Principal Balance\n");
for (rows) |row| {
try w.print("{d: >6} {s: >12} {s: >12} {s: >12} {s: >12}\n", .{
row.period,
try a[0].of(row.payment),
try a[1].of(row.interest),
try a[2].of(row.principal),
try a[3].of(row.balance),
});
}
try w.writeAll("\n");
}
try w.print("Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}\n", .{
totals.periods,
try a[0].of(totals.paid),
try a[1].of(totals.interest),
try a[2].of(totals.principal),
});
}
/// Room for one amount's text, for the columns that pad it. Rendered rather than
/// printed with `{f}` so that an amount too large to render is told apart from a
/// writer that has run out of memory.
const Amount = struct {
buf: [64]u8,
fn of(self: *Amount, value: f64) error{Unformattable}![]const u8 {
return money(value).render(&self.buf) catch error.Unformattable;
}
};
// -- Struct layout --
const struct_layout = engine.struct_layout;
const CStructParser = engine.CStructParser;
/// Members a memory map can tell apart, one letter each: a-z, then A-Z.
const max_keyed_members = 52;
/// Whether a struct layout is drawn in color. Deciding (a terminal, `NO_COLOR`,
/// `--color`) is `main`'s job; this only carries the answer.
///
/// Color is a second channel, never the only one: every member keeps its letter, so
/// the output means the same thing piped, pasted, or read by someone who cannot tell
/// the hues apart. What color adds is that a member's bytes read as one block in the
/// map, its row in the table is the same hue, and padding falls back as faint gaps.
pub const Paint = enum {
plain,
ansi,
/// The terminal's own six hues rather than fixed RGB values, so the user's theme
/// decides what they look like and keeps them readable on its own background.
/// Cycled by member index, so neighbouring members never share one. Each starts
/// with a reset because faint persists until something clears it.
const hues = [_][]const u8{
"\x1b[0;36m", // cyan
"\x1b[0;33m", // yellow
"\x1b[0;35m", // magenta
"\x1b[0;32m", // green
"\x1b[0;34m", // blue
"\x1b[0;31m", // red
};
/// Faint (SGR 2) rather than "bright black", which some themes (Solarized among
/// them) draw in nearly the background color.
const faint = "\x1b[0;2m";
const reset = "\x1b[0m";
fn member(self: Paint, index: usize) []const u8 {
return self.use(hues[index % hues.len]);
}
fn padding(self: Paint) []const u8 {
return self.use(faint);
}
fn end(self: Paint) []const u8 {
return self.use(reset);
}
/// `code` when painting, nothing when plain.
fn use(self: Paint, code: []const u8) []const u8 {
return if (self == .ansi) code else "";
}
};
/// Lay out a C struct and render it: a table of members and padding, a summary line,
/// and a memory map with one letter per byte. Allocated, because a struct's table has
/// no useful upper bound.
pub fn structLayout(
allocator: std.mem.Allocator,
source: []const u8,
abi: *const struct_layout.Abi,
paint: Paint,
) Result {
var diag: CStructParser.Diagnostic = .{};
var parsed = CStructParser.parse(allocator, source, &diag) catch |err| {
return located(allocator, err, source, diag.span);
};
defer parsed.deinit();
const definition = parsed.definition;
var layout_diag: struct_layout.Diagnostic = .{};
var layout = struct_layout.compute(allocator, definition, abi, &layout_diag) catch |err| {
const span = if (layout_diag.member) |i| definition.members[i].span else null;
return located(allocator, err, source, span);
};
defer layout.deinit(allocator);
var scratch: std.heap.ArenaAllocator = .init(allocator);
defer scratch.deinit();
const rows = tableRows(scratch.allocator(), definition, &layout) catch |err| return .fail(err);
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
writeLayout(&out.writer, rows, definition, &layout, paint) catch return .fail(error.OutOfMemory);
const text = out.toOwnedSlice() catch |err| return .fail(err);
return .{ .output = text, .is_error = false };
}
/// An error, and where in the definition it is, when it is anywhere in particular:
///
/// error: unknown type (typedefs and macros from elsewhere are not visible)
/// line 3, column 5: uint33_t
fn located(allocator: std.mem.Allocator, err: engine.Error, source: []const u8, span: ?struct_layout.Span) Result {
// Running out of memory is about the machine, not a place in the input, and the
// parser's diagnostic always holds a span, so this comes first.
if (err == error.OutOfMemory) return .fail(err);
const s = span orelse return .fail(err);
const at = CStructParser.locate(source, s.start);
const text = source[s.start..][0..s.len];
// One line of it, and not much of that: a directive is reported whole.
const line_end = std.mem.indexOfScalar(u8, text, '\n') orelse text.len;
const shown = text[0..@min(line_end, 40)];
const output = std.fmt.allocPrint(allocator, "error: {s}\n line {d}, column {d}{s}{s}\n", .{
engine.phrase(err),
at.line,
at.column,
if (shown.len == 0) ", at the end of the input" else ": ",
shown,
}) catch return .fail(error.OutOfMemory);
return .{ .output = output, .is_error = true };
}
/// The letter a member is drawn with in the map, matching its row in the table.
fn memberKey(index: usize) u8 {
if (index < 26) return @intCast('a' + index);
if (index < max_keyed_members) return @intCast('A' + index - 26);
return ' ';
}
/// A count with thousands separators (NFR-7), printed with `{f}`.
const Grouped = struct {
n: u64,
pub fn format(self: Grouped, w: *std.Io.Writer) std.Io.Writer.Error!void {
var digits: [20]u8 = undefined;
const len = std.fmt.printInt(&digits, self.n, 10, .lower, .{});
try engine.grouping.print(w, digits[0..len]);
}
};
/// A member's type as a person writes it, dimensions included: `char[16]`,
/// `int[2][3]`, `double[]`. Printed with `{f}`.
const TypeName = struct {
member: struct_layout.Member,
pub fn format(self: TypeName, w: *std.Io.Writer) std.Io.Writer.Error!void {
try w.writeAll(self.member.type_name);
if (self.member.flexible) try w.writeAll("[]");
for (self.member.dims) |dim| try w.print("[{d}]", .{dim});
}
};
/// One line of the table, its cells rendered to text once. A column's width is
/// measured from the same text that is printed, so the two cannot disagree.
const Row = struct {
kind: union(enum) { header, member: usize, padding },
offset: []const u8,
size: []const u8,
alignment: []const u8,
type: []const u8,
name: []const u8,
};
/// The table: a header, then one row per segment of the layout, which is the same
/// list the memory map draws. Allocated in `arena`, which the caller releases.
fn tableRows(arena: std.mem.Allocator, def: struct_layout.Definition, layout: *const struct_layout.Layout) ![]const Row {
var rows: std.ArrayList(Row) = .empty;
try rows.append(arena, .{
.kind = .header,
.offset = "Offset",
.size = "Size",
.alignment = "Align",
.type = "Type",
.name = "Name",
});
var it = layout.segments();
while (it.next()) |seg| {
const offset = try std.fmt.allocPrint(arena, "{f}", .{Grouped{ .n = seg.offset }});
const size = try std.fmt.allocPrint(arena, "{f}", .{Grouped{ .n = seg.len }});
try rows.append(arena, switch (seg.kind) {
.member => |i| .{
.kind = .{ .member = i },
.offset = offset,
.size = size,
.alignment = try std.fmt.allocPrint(arena, "{f}", .{Grouped{ .n = layout.placements[i].alignment }}),
.type = try std.fmt.allocPrint(arena, "{f}", .{TypeName{ .member = def.members[i] }}),
.name = def.members[i].name,
},
.padding, .tail => .{
.kind = .padding,
.offset = offset,
.size = size,
.alignment = "",
.type = "",
.name = if (seg.kind == .tail) "(tail padding)" else "(padding)",
},
});
}
return rows.items;
}
/// The widest cell in each column.
const Widths = struct {
offset: usize = 0,
size: usize = 0,
alignment: usize = 0,
type: usize = 0,
fn of(rows: []const Row) Widths {
var widths: Widths = .{};
for (rows) |row| {
widths.offset = @max(widths.offset, row.offset.len);
widths.size = @max(widths.size, row.size.len);
widths.alignment = @max(widths.alignment, row.alignment.len);
widths.type = @max(widths.type, row.type.len);
}
return widths;
}
};
/// A member row colors its key and its name, which is enough to find its bytes in the
/// map without turning the table into a wall of color. A padding row is faint from
/// end to end, and the header is plain.
fn writeRow(w: *std.Io.Writer, row: Row, widths: Widths, paint: Paint) std.Io.Writer.Error!void {
const key: u8, const key_color: []const u8, const key_end: []const u8, const name_color: []const u8 =
switch (row.kind) {
.header => .{ ' ', "", "", "" },
.member => |i| .{ memberKey(i), paint.member(i), paint.end(), paint.member(i) },
.padding => .{ ' ', paint.padding(), "", "" },
};
const line_end = if (row.kind == .header) "" else paint.end();
try w.print(" {[kc]s}{[key]c}{[ke]s} {[offset]s: >[ow]} {[size]s: >[sw]} {[alignment]s: >[aw]}" ++
" {[type]s: <[tw]} {[nc]s}{[name]s}{[end]s}\n", .{
.kc = key_color,
.key = key,
.ke = key_end,
.offset = row.offset,
.ow = widths.offset,
.size = row.size,
.sw = widths.size,
.alignment = row.alignment,
.aw = widths.alignment,
.type = row.type,
.tw = widths.type,
.nc = name_color,
.name = row.name,
.end = line_end,
});
}
fn writeLayout(
w: *std.Io.Writer,
rows: []const Row,
def: struct_layout.Definition,
layout: *const struct_layout.Layout,
paint: Paint,
) std.Io.Writer.Error!void {
if (def.name) |name| switch (def.name_kind) {
.tag => try w.print("struct {s}\n", .{name}),
.typedef => try w.print("{s}\n", .{name}),
} else try w.writeAll("struct (anonymous)\n");
try w.print("ABI {s}: {s}\n\n", .{ layout.abi.id, layout.abi.label });
const widths: Widths = .of(rows);
for (rows) |row| try writeRow(w, row, widths, paint);
// The summary: what a reader came for.
try w.print("\n {f} {s}, alignment {d}, ", .{ Grouped{ .n = layout.size }, bytes(layout.size), layout.alignment });
const padding = layout.padding();
if (padding == 0) {
try w.writeAll("no padding\n");
} else {
try w.print("{f} {s} of padding (", .{ Grouped{ .n = padding }, bytes(padding) });
try writePercent(w, padding, layout.size);
try w.print("); {f} {s} packed\n", .{ Grouped{ .n = layout.packed_size }, bytes(layout.packed_size) });
}
if (layout.size == 0) return;
if (def.members.len > max_keyed_members) {
try w.print("\n (no memory map: more than {d} members to tell apart)\n", .{max_keyed_members});
return;
}
try w.writeAll("\n");
try writeMap(w, layout, paint);
}
fn bytes(n: u64) []const u8 {
return if (n == 1) "byte" else "bytes";
}
/// `part` as a percentage of `whole`, rounded half up to two places, with trailing
/// zeros dropped: 6.25%, 50%, 33.33%. In integer hundredths, so no float rounding
/// decides what is printed.
fn writePercent(w: *std.Io.Writer, part: u64, whole: u64) std.Io.Writer.Error!void {
const hundredths: u128 = (@as(u128, part) * 20000 + whole) / (@as(u128, whole) * 2);
const units = hundredths / 100;
const frac = hundredths % 100;
if (frac == 0) {
try w.print("{d}%", .{units});
} else if (frac % 10 == 0) {
try w.print("{d}.{d}%", .{ units, frac / 10 });
} else {
try w.print("{d}.{d:0>2}%", .{ units, frac });
}
}
/// The segment holding a byte, for bytes visited in increasing order.
const SegmentCursor = struct {
it: struct_layout.SegmentIterator,
current: struct_layout.Segment,
/// How many segments have been passed: which one `current` is.
index: usize = 0,
fn init(layout: *const struct_layout.Layout) SegmentCursor {
var it = layout.segments();
const first = it.next().?;
return .{ .it = it, .current = first };
}
/// Zero-length segments are passed over, since no byte is in them.
fn at(self: *SegmentCursor, offset: u64) struct_layout.Segment {
while (self.current.offset + self.current.len <= offset) {
self.current = self.it.next().?;
self.index += 1;
}
return self.current;
}
};
/// Sixteen bytes a row, each drawn as its member's letter or `.` for padding, in
/// groups of four. A row identical to the one above it is collapsed to `*`, as
/// hexdump does, so `char buf[4096]` costs two lines rather than 256. Rows are found
/// from the segments rather than walked byte by byte, so a struct the size of an
/// address space costs no more than one with the same number of members.
///
/// In color, a code is written only where the color changes, so a member's run of
/// bytes, gaps included, is one span of one hue.
fn writeMap(w: *std.Io.Writer, layout: *const struct_layout.Layout, paint: Paint) std.Io.Writer.Error!void {
const per_row = 16;
const size = layout.size;
const rows = (size + per_row - 1) / per_row;
const last_offset = (rows - 1) * per_row;
var label_digits: usize = 2;
while (label_digits < 16 and last_offset >> @intCast(label_digits * 4) != 0) label_digits += 1;
var cursor: SegmentCursor = .init(layout);
// The segment the last printed row lay wholly inside, if it did.
var previous_whole: ?usize = null;
var starred = false;
var row: u64 = 0;
while (row < rows) {
const start = row * per_row;
const end = @min(start + per_row, size);
const seg = cursor.at(start);
const whole = end - start == per_row and seg.offset + seg.len >= end;
if (whole and previous_whole == cursor.index) {
if (!starred) try w.writeAll(" *\n");
starred = true;
// Straight to the first row this segment does not fill.
row = (seg.offset + seg.len) / per_row;
continue;
}
starred = false;
previous_whole = if (whole) cursor.index else null;
try w.writeAll(" 0x");
try w.printInt(start, 16, .lower, .{ .fill = '0', .width = label_digits });
var bytes_cursor = cursor;
var current: []const u8 = "";
for (start..end) |offset| {
const glyph: u8, const color = switch (bytes_cursor.at(offset).kind) {
.member => |i| .{ memberKey(i), paint.member(i) },
.padding, .tail => .{ '.', paint.padding() },
};
try w.writeAll(if ((offset - start) % 4 == 0) " " else " ");
if (!std.mem.eql(u8, color, current)) {
try w.writeAll(color);
current = color;
}
try w.writeByte(glyph);
}
try w.print("{s}\n", .{paint.end()});
row += 1;
}
}
// -- Errors --
/// Turn an engine error into a CLI line: "error: <phrase>\n".
///
/// The phrases live once, in `engine.phrase`. This adds the prefix and the newline at
/// comptime, so the strings still have static lifetime and there is no second copy of
/// the wording to drift. The CLI and the TUI previously each kept their own switch
/// over the whole error set; the TUI's was already missing three cases and rendered
/// them as "evaluation error".
///
/// `inline else` makes this exhaustive with no fallback branch: an error added
/// anywhere in the engine fails to compile in `engine.phrase` rather than silently
/// reading "evaluation error" here. Reached through `Result.fail`.
fn decoratedError(err: engine.Error) []const u8 {
return switch (err) {
inline else => |e| comptime "error: " ++ engine.phrase(e) ++ "\n",
};
}
/// The loan terms a schedule cannot be built from, in the words of the thing the
/// user actually typed.
fn amortizationError(err: engine.Error) Result {
return switch (err) {
// The realistic causes are all one of these, and a bare "domain error"
// would leave the user guessing which.
engine.Error.DomainError => .{
.output = "error: check the loan terms: principal and periods must be positive, the rate cannot be negative, and the payment must at least cover the first period's interest\n",
.is_error = true,
},
else => .fail(err),
};
}
/// An amount too large to render. Reported rather than printed as a placeholder:
/// the previous local formatter returned "?" for these, so a table of question
/// marks came out with exit status 0.
const unformattable: Result = .{
.output = "error: an amount in this schedule is too large to format\n",
.is_error = true,
};
// -- Tests --
const testing = std.testing;
/// A unit by name, for the tests that call a formatter directly. In the real paths
/// `units.parseRequest` has already resolved both units by the time a formatter sees
/// them, which is why the formatters take `UnitDef` rather than names.
fn unit(name: []const u8) engine.UnitDef {
return engine.units.findUnit(name).?;
}
test "evaluate: standard arithmetic" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "2 + 2", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("4\n", result.output);
}
test "evaluate: large number has commas" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "2^32 - 1", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("4,294,967,295\n", result.output);
}
test "evaluate: function" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "sin(pi/2) + 1", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("2\n", result.output);
}
test "evaluate: explicit mul" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "3*(4+5)", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("27\n", result.output);
}
test "evaluate: programmer mode" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "0xFF & 0x0F", .programmer, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "15") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "00 00 00 00 00 00 00 0F") != null);
}
test "evaluate: division by zero error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "1/0", .standard, .{}, &buf);
try testing.expect(result.is_error);
try testing.expectEqualStrings("error: division by zero\n", result.output);
}
test "evaluate: unknown variable error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "xyz + 1", .standard, .{}, &buf);
try testing.expect(result.is_error);
try testing.expectEqualStrings("error: unknown variable\n", result.output);
}
test "evaluate: an expression written in another base answers in every base" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
// Standard mode, but a hex literal says the user is thinking in bases, so the
// answer carries the other three rather than only decimal.
const hex = evaluateWith(arena.allocator(), "0xFF + 1", .standard, .{}, &buf);
try testing.expect(!hex.is_error);
try testing.expectEqualStrings(
\\256
\\ hex: 01 00
\\ oct: 000 400
\\ bin: 0000 0001 0000 0000
\\
, hex.output);
// The rows are as wide as the value needs and no wider: 10 fits one byte, where
// 256 above needed two. Standard mode has no width setting to consult, so the
// value chooses.
const binary = evaluateWith(arena.allocator(), "0b1010", .standard, .{}, &buf);
try testing.expect(!binary.is_error);
try testing.expectEqualStrings(
\\10
\\ hex: 0A
\\ oct: 012
\\ bin: 0000 1010
\\
, binary.output);
// The decimal line is the ordinary rendering, so it still groups its digits.
const octal = evaluateWith(arena.allocator(), "0o777 * 2", .standard, .{}, &buf);
try testing.expect(!octal.is_error);
try testing.expect(std.mem.startsWith(u8, octal.output, "1,022\n"));
try testing.expect(std.mem.indexOf(u8, octal.output, "hex: 03 FE") != null);
// Past 2^53 the rows agree with the decimal line above them. They came from an
// f64 and read `DE AD BE EF DE AD C0 00` under the right decimal (open item 3).
const wide = evaluateWith(arena.allocator(), "0xDEADBEEFDEADBEEF", .standard, .{}, &buf);
try testing.expectEqualStrings(
\\16,045,690,984,833,335,023
\\ hex: DE AD BE EF DE AD BE EF
\\ oct: 1 572 555 756 773 653 337 357
\\ bin: 1101 1110 1010 1101 1011 1110 1110 1111 1101 1110 1010 1101 1011 1110 1110 1111
\\
, wide.output);
}
test "evaluate: a non-decimal literal with no whole answer stays decimal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
// The rows show a u128 bit pattern, and neither of these has one, so the answer
// is rendered plainly instead of in four bases. Standard mode is not programmer
// mode: it does not wrap a negative into two's complement to have something to
// print.
const negative = evaluateWith(arena.allocator(), "0xFF - 0x100", .standard, .{}, &buf);
try testing.expect(!negative.is_error);
try testing.expectEqualStrings("-1\n", negative.output);
const fractional = evaluateWith(arena.allocator(), "0xFF / 2", .standard, .{}, &buf);
try testing.expect(!fractional.is_error);
try testing.expectEqualStrings("127.5\n", fractional.output);
}
test "evaluate: multi-base rows that outgrow the buffer are refused, not truncated" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
// Enough room for the decimal answer alone, nowhere near enough for the rows.
// Half a table would read as a wrong answer, so nothing is printed.
var small: [8]u8 = undefined;
const result = evaluateWith(arena.allocator(), "0xFF + 1", .standard, .{}, &small);
try testing.expect(result.is_error);
try testing.expectEqualStrings("error: the result is too long to display\n", result.output);
}
test "the programmer config reaches the result" {
var buf: [4096]u8 = undefined;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// 8-bit: 0xFF + 1 wraps to 0.
const narrow = evaluateWith(alloc, "0xFF + 1", .programmer, .{ .width = .bits8 }, &buf);
try testing.expect(!narrow.is_error);
try testing.expect(std.mem.indexOf(u8, narrow.output, "dec(unsigned): 0\n") != null);
// Signedness decides whether >> extends the sign.
const signed = evaluateWith(alloc, "0xFF >> 1", .programmer, .{
.width = .bits8,
.signedness = .signed,
}, &buf);
try testing.expect(std.mem.indexOf(u8, signed.output, "dec(signed): -1") != null);
const unsigned = evaluateWith(alloc, "0xFF >> 1", .programmer, .{
.width = .bits8,
.signedness = .unsigned,
}, &buf);
try testing.expect(std.mem.indexOf(u8, unsigned.output, "dec(unsigned): 127") != null);
// Byte order reverses the hex row and nothing else.
const little = evaluateWith(alloc, "0xDEAD", .programmer, .{
.width = .bits16,
.display_endian = .little,
}, &buf);
try testing.expect(std.mem.indexOf(u8, little.output, "hex: AD DE") != null);
const big = evaluateWith(alloc, "0xDEAD", .programmer, .{
.width = .bits16,
.display_endian = .big,
}, &buf);
try testing.expect(std.mem.indexOf(u8, big.output, "hex: DE AD") != null);
}
test "conversion: km to mi" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = conversion(arena.allocator(), &buf, "100", unit("km"), unit("mi"));
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "km") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "mi") != null);
}
test "conversion: temperature freezing point" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = conversion(arena.allocator(), &buf, "0", unit("C"), unit("F"));
try testing.expect(!result.is_error);
try testing.expectEqualStrings("0 C = 32 F\n", result.output);
}
test "conversion: incompatible units is an error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = conversion(arena.allocator(), &buf, "1", unit("kg"), unit("m"));
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null);
}
test "conversion: alias resolves to canonical name in output" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = conversion(arena.allocator(), &buf, "1", unit("kilometer"), unit("meters"));
try testing.expect(!result.is_error);
// Conversion output now uses the standard comma grouping (NFR-7).
try testing.expectEqualStrings("1 km = 1,000 m\n", result.output);
}
test "conversion: exact conversion prints exactly" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
// The bug this task exists to fix.
const feet = conversion(arena.allocator(), &buf, "12", unit("in"), unit("ft"));
try testing.expect(!feet.is_error);
try testing.expectEqualStrings("12 in = 1 ft\n", feet.output);
// Exact affine conversion.
const celsius = conversion(arena.allocator(), &buf, "98.6", unit("F"), unit("C"));
try testing.expect(!celsius.is_error);
try testing.expectEqualStrings("98.6 F = 37 C\n", celsius.output);
// Exact fractional factor.
const mps = conversion(arena.allocator(), &buf, "3.6", unit("km/h"), unit("m/s"));
try testing.expect(!mps.is_error);
try testing.expectEqualStrings("3.6 km/h = 1 m/s\n", mps.output);
}
test "evaluate: bare conversion expression without subcommand" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "100 km to mi", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null);
}
test "evaluate: glued bare conversion" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "32F to C", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("32 F = 0 C\n", result.output);
}
test "evaluate: conversion value may be an expression" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "2*3 kg to g", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("6 kg = 6,000 g\n", result.output);
}
test "evaluate: bare conversion with unknown unit errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "100 km to smoots", .standard, .{}, &buf);
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null);
}
test "evaluate: bare conversion with incompatible units errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "1 kg to m", .standard, .{}, &buf);
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null);
}
test "evaluate: ordinary expressions are unaffected by conversion detection" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluateWith(arena.allocator(), "2 + 3 * 4", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("14\n", result.output);
}
test "evaluate: expression containing a unit-like name still evaluates" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
// "e" is Euler's number here, not a unit, because there is no "to" keyword.
const result = evaluateWith(arena.allocator(), "e", .standard, .{}, &buf);
try testing.expect(!result.is_error);
try testing.expect(std.mem.startsWith(u8, result.output, "2.718"));
}
test "amortization: an unrenderable amount is an error, not a table of marks" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
// 1e40 used to produce a full report of "?" with exit status 0. It now fits,
// because the engine formatter groups the whole number.
const large = amortization(arena.allocator(), .{
.principal = 1e40,
.rate = 0.5,
.periods = 3,
}, false);
try testing.expect(!large.is_error);
try testing.expect(std.mem.indexOfScalar(u8, large.output, '?') == null);
try testing.expect(std.mem.indexOf(u8, large.output, "10,000,000,000,000,000,000,000,000,000,000,000,000,000.00") != null);
// Past the width of the formatting buffer the report is refused outright, with
// a non-zero exit status, rather than printed with placeholders.
const huge = amortization(arena.allocator(), .{
.principal = 1e60,
.rate = 0.5,
.periods = 3,
}, false);
try testing.expect(huge.is_error);
try testing.expect(std.mem.indexOf(u8, huge.output, "too large to format") != null);
try testing.expect(std.mem.indexOfScalar(u8, huge.output, '?') == null);
}
test "amortization: table has a row per period and correct first row" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = amortization(arena.allocator(), .{
.principal = 200000,
.rate = 0.5,
.periods = 360,
}, false);
try testing.expect(!result.is_error);
// Header, payment, blank, column head, 360 rows, blank, 4 summary lines, each
// ending in a newline like every other result.
try testing.expectEqual(@as(usize, 369), std.mem.count(u8, result.output, "\n"));
try testing.expect(std.mem.endsWith(u8, result.output, "\n"));
try testing.expect(std.mem.indexOf(u8, result.output, "Payment 1,199.10 per period") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "1,000.00") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "199,800.90") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "Total interest 231,677.04") != null);
}
test "amortization: summary only omits the rows" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = amortization(arena.allocator(), .{
.principal = 200000,
.rate = 0.5,
.periods = 360,
}, true);
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "Period Payment") == null);
try testing.expect(std.mem.indexOf(u8, result.output, "Periods paid 360") != null);
}
test "amortization: bad terms explain themselves" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
// A payment that never covers the interest.
const result = amortization(arena.allocator(), .{
.principal = 200000,
.rate = 0.5,
.periods = 360,
.payment = 500,
}, false);
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "loan terms") != null);
}
test "every result is complete text, ending in exactly one newline" {
// `main` prints a result as it stands. This held for some outputs and not others:
// single-line answers relied on `main` adding a newline, and programmer mode,
// which carried its own, got a second one and ended in a blank line.
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
// A buffer each: the results that write into one point into it, so sharing one
// would leave the early results reading the last one's text.
var bufs: [8][4096]u8 = undefined;
for ([_]Result{
evaluateWith(a, "2 + 2", .standard, .{}, &bufs[0]),
evaluateWith(a, "0xFF + 1", .standard, .{}, &bufs[1]),
evaluateWith(a, "0xFF", .programmer, .{}, &bufs[2]),
evaluateWith(a, "100 km to mi", .standard, .{}, &bufs[3]),
evaluateWith(a, "1 / 0", .standard, .{}, &bufs[4]),
evaluateWith(a, "1 +", .programmer, .{}, &bufs[5]),
conversion(a, &bufs[6], "100", engine.units.findUnit("km").?, engine.units.findUnit("mi").?),
conversion(a, &bufs[7], "abc", engine.units.findUnit("km").?, engine.units.findUnit("mi").?),
amortization(a, .{ .principal = 1000, .rate = 1, .periods = 3 }, false),
amortization(a, .{ .principal = 1000, .rate = 1, .periods = 3 }, true),
amortization(a, .{ .principal = -1, .rate = 1, .periods = 3 }, false),
structLayout(a, "struct { int a; char b; }", struct_layout.default_abi, .plain),
structLayout(a, "struct { int a; char b; }", struct_layout.default_abi, .ansi),
structLayout(a, "struct { bad b; }", struct_layout.default_abi, .plain),
}) |result| {
try testing.expect(std.mem.endsWith(u8, result.output, "\n"));
try testing.expect(!std.mem.endsWith(u8, result.output, "\n\n"));
}
}
// -- Struct layout --
/// What `structLayout` prints for `source` under the profile named `abi`, which must
/// lay out. Allocated in `arena`.
fn rendered(arena: std.mem.Allocator, source: []const u8, abi: []const u8, paint: Paint) ![]const u8 {
const result = structLayout(arena, source, struct_layout.findAbi(abi).?, paint);
try testing.expect(!result.is_error);
return result.output;
}
/// A struct with one more member than the map has letters for.
fn manyMembers(arena: std.mem.Allocator) ![]const u8 {
var source: std.ArrayList(u8) = .empty;
try source.appendSlice(arena, "struct { char ");
for (0..max_keyed_members + 1) |i| try source.print(arena, "{s}m{d}", .{ if (i == 0) "" else ", ", i });
try source.appendSlice(arena, "; }");
return source.items;
}
test "structLayout: the design document's example, in full" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectEqualStrings(
\\struct hdr
\\ABI sysv64: 64-bit Linux and macOS (x86-64 SysV, AArch64)
\\
\\ Offset Size Align Type Name
\\ a 0 4 4 uint32_t magic
\\ b 4 1 1 uint8_t ver
\\ 5 1 (padding)
\\ c 6 2 2 uint16_t len
\\ d 8 8 8 uint64_t ts
\\
\\ 16 bytes, alignment 8, 1 byte of padding (6.25%); 15 bytes packed
\\
\\ 0x00 a a a a b . c c d d d d d d d d
\\
, try rendered(arena.allocator(), "struct hdr { uint32_t magic; uint8_t ver; uint16_t len; uint64_t ts; }", "sysv64", .plain));
}
test "structLayout: arrays, a flexible member, and a map that collapses" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectEqualStrings(
\\buf_t
\\ABI sysv64: 64-bit Linux and macOS (x86-64 SysV, AArch64)
\\
\\ Offset Size Align Type Name
\\ a 0 4 4 int len
\\ b 4 4,096 1 char[4096] data
\\ c 4,100 12 2 short[2][3] grid
\\ d 4,112 0 8 double[] flex
\\
\\ 4,112 bytes, alignment 8, no padding
\\
\\ 0x0000 a a a a b b b b b b b b b b b b
\\ 0x0010 b b b b b b b b b b b b b b b b
\\ *
\\ 0x1000 b b b b c c c c c c c c c c c c
\\
, try rendered(arena.allocator(), "typedef struct { int len; char data[4096]; short grid[2][3]; double flex[]; } buf_t;", "sysv64", .plain));
}
test "structLayout: a struct under another ABI says so and lays out by its rules" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const out = try rendered(arena.allocator(), "struct { char c; long l; }", "win64", .plain);
try testing.expect(std.mem.startsWith(u8, out, "struct (anonymous)\nABI win64: 64-bit Windows (x64, ARM64)\n"));
try testing.expect(std.mem.indexOf(u8, out, " b 4 4 4 long l\n") != null);
try testing.expect(std.mem.indexOf(u8, out, "8 bytes, alignment 4, 3 bytes of padding (37.5%); 5 bytes packed") != null);
}
test "structLayout: tail padding gets a row and dots of its own" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expect(std.mem.endsWith(u8, try rendered(arena.allocator(), "struct tail { uint64_t a; uint8_t b; };", "sysv64", .plain),
\\ a 0 8 8 uint64_t a
\\ b 8 1 1 uint8_t b
\\ 9 7 (tail padding)
\\
\\ 16 bytes, alignment 8, 7 bytes of padding (43.75%); 9 bytes packed
\\
\\ 0x00 a a a a a a a a b . . . . . . .
\\
));
}
test "structLayout: errors name the problem and where it is" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const Case = struct { source: []const u8, abi: []const u8 = "sysv64", output: []const u8 };
for ([_]Case{
.{
.source = "struct {\n int a;\n uint33_t b;\n}",
.output = "error: unknown type (typedefs and macros from elsewhere are not visible)\n line 3, column 3: uint33_t\n",
},
.{
// Errors from laying out point at the member they are about.
.source = "struct { char c; __int128 big; }",
.abi = "arm32",
.output = "error: this type does not exist on the selected ABI\n line 1, column 27: big\n",
},
.{
.source = "struct { int a;",
.output = "error: unexpected end of input\n line 1, column 16, at the end of the input\n",
},
.{
// A directive is reported by its first line, and not much of that.
.source = "#include \"a/very/long/path/to/a/header/that/goes/on.h\"\nstruct { int a; }",
.output = "error: only #pragma pack is understood, not other directives\n line 1, column 1: #include \"a/very/long/path/to/a/header/t\n",
},
.{
// An error about the whole struct has nowhere in particular to point.
.source = "struct empty { };",
.output = "error: the struct has no members\n",
},
}) |case| {
const result = structLayout(arena.allocator(), case.source, struct_layout.findAbi(case.abi).?, .plain);
try testing.expect(result.is_error);
try testing.expectEqualStrings(case.output, result.output);
}
}
test "structLayout: more members than letters gets no map, and says why" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const out = try rendered(arena.allocator(), try manyMembers(arena.allocator()), "sysv64", .plain);
try testing.expect(std.mem.indexOf(u8, out, " Z 51 1 1 char m51\n") != null);
try testing.expect(std.mem.indexOf(u8, out, " 52 1 1 char m52\n") != null);
try testing.expect(std.mem.endsWith(u8, out, "(no memory map: more than 52 members to tell apart)\n"));
}
test "structLayout: a map of a huge struct is as short as a small one" {
// Two gigabytes of array: walking it byte by byte would not finish.
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expect(std.mem.endsWith(u8, try rendered(arena.allocator(), "struct { char c; double d[268435456]; }", "sysv64", .plain),
\\ 0x00000000 a . . . . . . . b b b b b b b b
\\ 0x00000010 b b b b b b b b b b b b b b b b
\\ *
\\ 0x80000000 b b b b b b b b
\\
));
}
test "writePercent drops the zeros it does not need" {
var buf: [32]u8 = undefined;
for ([_]struct { part: u64, whole: u64, text: []const u8 }{
.{ .part = 1, .whole = 16, .text = "6.25%" },
.{ .part = 1, .whole = 2, .text = "50%" },
.{ .part = 1, .whole = 3, .text = "33.33%" },
.{ .part = 3, .whole = 8, .text = "37.5%" },
}) |case| {
var w = std.Io.Writer.fixed(&buf);
try writePercent(&w, case.part, case.whole);
try testing.expectEqualStrings(case.text, w.buffered());
}
}
/// `text` with every ANSI escape sequence (ESC, `[`, parameters, a final letter)
/// removed.
fn stripAnsi(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
var out: std.ArrayList(u8) = .empty;
var i: usize = 0;
while (i < text.len) {
if (text[i] == 0x1b and i + 1 < text.len and text[i + 1] == '[') {
i += 2;
while (i < text.len and !std.ascii.isAlphabetic(text[i])) i += 1;
i += 1;
continue;
}
try out.append(allocator, text[i]);
i += 1;
}
return out.items;
}
test "structLayout: color adds escapes and nothing else" {
// The letters are the key and color is a second channel, so taking the color
// away must leave exactly the plain output: same rows, same columns, same map.
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
for ([_][]const u8{
"struct hdr { uint32_t magic; uint8_t ver; uint16_t len; uint64_t ts; }",
"struct tail { uint64_t a; uint8_t b; };",
"typedef struct { int len; char data[4096]; short grid[2][3]; double flex[]; } buf_t;",
"struct { char c; double d[268435456]; }",
"struct { char a; char b; char c; char d; char e; char f; char g; char h; }",
try manyMembers(a),
}) |source| {
const plain = try rendered(a, source, "sysv64", .plain);
const colored = try rendered(a, source, "sysv64", .ansi);
try testing.expect(std.mem.indexOfScalar(u8, plain, 0x1b) == null);
try testing.expect(std.mem.indexOfScalar(u8, colored, 0x1b) != null);
try testing.expectEqualStrings(plain, try stripAnsi(a, colored));
}
}
test "structLayout: a member's row and bytes share its hue, and padding is faint" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const out = try rendered(arena.allocator(), "struct { char c; int i; }", "sysv64", .ansi);
const first = Paint.hues[0];
const second = Paint.hues[1];
const faint = Paint.faint;
const reset = Paint.reset;
// In the table: the key and the name, nothing between them.
try testing.expect(std.mem.indexOf(u8, out, " " ++ first ++ "a" ++ reset ++ " 0 1 1 char " ++ first ++ "c" ++ reset ++ "\n") != null);
try testing.expect(std.mem.indexOf(u8, out, " " ++ second ++ "b" ++ reset ++ " 4 4 4 int " ++ second ++ "i" ++ reset ++ "\n") != null);
// A padding row is faint from its first column to its end.
try testing.expect(std.mem.indexOf(u8, out, " " ++ faint ++ " 1 3 (padding)" ++ reset ++ "\n") != null);
// In the map, a code only where the color changes, and a reset at the end of
// the row so nothing bleeds into what the terminal prints next.
try testing.expect(std.mem.endsWith(u8, out, " 0x00 " ++ first ++ "a " ++ faint ++ ". . . " ++ second ++ "b b b b" ++ reset ++ "\n"));
// The header, column titles and summary stay plain.
try testing.expect(std.mem.startsWith(u8, out, "struct (anonymous)\nABI sysv64: 64-bit Linux and macOS (x86-64 SysV, AArch64)\n\n Offset"));
try testing.expect(std.mem.indexOf(u8, out, "\n 8 bytes, alignment 4, 3 bytes of padding (37.5%); 5 bytes packed\n") != null);
}
test "Paint: neighbouring members never share a hue, and plain is empty" {
for (0..max_keyed_members) |i| {
try testing.expect(!std.mem.eql(u8, Paint.ansi.member(i), Paint.ansi.member(i + 1)));
try testing.expectEqualStrings("", Paint.plain.member(i));
}
try testing.expectEqualStrings("", Paint.plain.padding());
try testing.expectEqualStrings("", Paint.plain.end());
}