human review: remove formatter.zig. grouping.zig reviewed

This commit is contained in:
Emil Lerch 2026-08-02 09:28:57 -07:00
parent 7853d8aad4
commit 5e46d9e706
Signed by: lobo
GPG key ID: A7B62D657EF764F8
18 changed files with 1027 additions and 1184 deletions

View file

@ -64,17 +64,16 @@ build.zig (workspace root)
| `grouping.zig` | The thousands rule, shared by both display paths |
| `Integer.zig` | A fixed-width integer (file-as-struct): `raw`, `width`, `signedness`, the interpretations, and the notations it renders in |
| `Rational.zig` | Exact rationals over big integers (file-as-struct) |
| `number.zig` | The exact/inexact numeric model (section 2.7). Lowercase: `Number` is a tagged union, which a file-as-struct cannot express |
| `number.zig` | The exact/inexact numeric model (section 2.7), including how a value renders itself at a budget the caller supplies. Lowercase: `Number` is a tagged union, which a file-as-struct cannot express |
| `tokenizer.zig` | Lexer, and `Base` for literals |
| `ast.zig` | AST node definitions |
| `parser.zig` | Pratt parser -> AST |
| `evaluator.zig` | Walk AST, produce results |
| `bitwise.zig` | The fixed-width operators (`& \| xor ~ << >> >>> rol ror`), shared by both modes |
| `programmer.zig` | Programmer-mode evaluation, its `Config`, wrapping arithmetic |
| `formatter.zig` | Display and clipboard strings for floats and exact `Number`s, plus money and the display width |
| `float_interp.zig` | IEEE 754 bit-level interpretation |
| `units.zig` | Unit conversion tables and resolver |
| `financial.zig` | CAGR, TVM, compound interest, amortization |
| `financial.zig` | CAGR, TVM, compound interest, amortization, and money display (cents are a property of money, not of a screen) |
| `message.zig` | Gone: the wording lives in `engine.zig` beside the `Error` union it words |
| `engine.zig` | Public API surface (Zig-native): the module re-exports, the `Error` union, and `phrase` |
| `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers |
@ -466,7 +465,7 @@ exact value.
bit width is the entire point of that mode; exact arithmetic would break it.
- **The IEEE 754 float view** stays binary `f32`/`f64`. It exists to show binary
encodings.
- **The formatter's f64 paths** remain, since the inexact tier still needs them.
- **The f64 rendering paths** remain, since the inexact tier still needs them.
#### 2.7.8 Fallback precision: why f64 and not f128
@ -651,7 +650,7 @@ Three existing items must change because we are invalidating their premise:
1. `tokenizer.zig` test `"parseNumber huge decimal falls back to float"` -
`99999999999999999999999999` becomes exactly representable, so the test still
passes but its intent is now false. Rewrite it.
2. `formatter.zig` `is_integer`, gated on `< 2^53` - that guard is the display
2. `Number.renderInexact`'s 2^53 bound (then `formatter.zig`'s `is_integer`) - that guard is the display
half of the `9007199254740993` bug.
3. `evaluator.zig` `factorial`'s `x > 170` rejection, which is also the source of
the misleading `unknown function` error.

View file

@ -229,7 +229,7 @@ still required (FR-7.7): the mouse never becomes the only way to do something.
- Decimal numbers must use comma grouping for display (e.g., `4,294,967,295`).
- **Inexact (f64) values**: scientific notation only when absolute value > 10^15 / < 10^-15. Never jump to scientific notation for values that fit in a readable decimal. This bound is not a readability preference: 10^15 is where f64 stops distinguishing consecutive integers (2^53 ~ 9.007 x 10^15), so printing a plain integer past it would assert precision the value does not have.
- (An earlier draft also triggered scientific notation past 15 significant digits. That clause was never implemented and was wrong: read literally it renders `0.9999999999999998` as `9.999999999999998e-1`, which is worse.)
- **Exact values**: the 10^15 bound above must NOT apply. It exists because of f64's precision cliff, and an exact value has no such cliff, so applying it would contradict NFR-9.1 - `9007199254740993` is ~9.007 x 10^15 and would render as `9.007199254740993e15`, which is precisely the bug NFR-9.1 forbids. Exact values instead have a readability cap on integer digits (`formatter.max_display_integer_digits`), above which the display abbreviates to scientific notation while the clipboard/`raw` form retains every digit.
- **Exact values**: the 10^15 bound above must NOT apply. It exists because of f64's precision cliff, and an exact value has no such cliff, so applying it would contradict NFR-9.1 - `9007199254740993` is ~9.007 x 10^15 and would render as `9.007199254740993e15`, which is precisely the bug NFR-9.1 forbids. Exact values instead have a readability cap on integer digits, above which the display abbreviates to scientific notation while the clipboard form retains every digit. The cap is not the engine's to choose: it is a field of `Number.FormatOptions`, which every frontend supplies (`max_integer_digits`, with `null` meaning never abbreviate, which is what the clipboard form asks for).
- The cap must exceed the values the exact tier exists to serve: `9007199254740993` (16 digits) and `2^128` (39 digits). It exists at all because without any cap, `factorial(171)` renders 310 digits and `1.5e300 * 10` renders 301: accurate but unreadable.
- **PROVISIONALLY 40 digits, pending review.** Chosen as the smallest round number above `2^128`. Not derived from any measured preference.
- Programmer mode hex values display with space-separated bytes (e.g., `FF FF FF FF`).

View file

@ -813,11 +813,12 @@ instead of "overflow". Two tests updated to match.
`display`/`raw` pair that lived at two offsets inside it. That is not how Zig formats
values, and the buffer had no bounds check: too small a buffer walked off the end.
- A value now produces a view: `int.as(.hex, .{ .endian = .little })` returns a `View`
with a `format` method, so callers print it (`w.print("hex: {f}\n", .{...})`). The
notation is an enum and the decoration is `Options{ separators, prefix, endian }`,
with `Options.plain` as the no-separators, prefixed form.
- `View.render(buf)` exists for callers that need a slice, such as the TUI, which
- A value now produces a formatter: `int.fmt(.hex, .{ .endian = .little })` returns a
`Format` with a `format` method, so callers print it (`w.print("hex: {f}\n", .{...})`).
`Integer` also has a primary `format`, so `{f}` on the value itself renders decimal.
The notation is an enum and the decoration is `FormatOptions{ separators, prefix,
endian }`, with `FormatOptions.plain` as the no-separators, prefixed form.
- `Format.render(buf)` exists for callers that need a slice, such as the TUI, which
measures and positions text before drawing. It is `Writer.fixed` underneath, so a
short buffer is `error.WriteFailed`.
- `std.Io.Writer.printInt` replaced the hand-written digit loops for the prefixed
@ -832,11 +833,64 @@ values, and the buffer had no bounds check: too small a buffer walked off the en
Both frontends got shorter: the CLI prints five rows in one `print` call with no
per-base buffers, and the programmer view asks the value for each row.
NOT DONE, and it will be obvious when you read them: `formatter.zig`'s remaining
functions (`formatFloat`, `formatCompactFloat`, `formatNumber`, `formatAmount`,
`formatMoney`) still take buffers and return `FormattedValue`/`NumberDisplay` pairs.
That conversion reaches every display call site in both frontends, including the
financial forms and the amortization table, so it is deliberately a separate pass.
### Task 5.17: Delete formatter.zig; a value renders itself at the caller's budget
The other half of 5.16, and the answer to "why does `formatter.zig` exist at all".
It held four unrelated jobs, organised by verb rather than by type, and five display
decisions the engine had no business making: `exact_fraction_digits` (20),
`max_display_integer_digits` (40), `scientific_significant_digits` (17), and two
anonymous `1e15`/`1e-15` literals inside `formatFloat`. Android would have inherited
a budget chosen for an 80-column terminal, or reimplemented the rules in Kotlin.
Each job went to the type that owns it:
- **`Number` renders itself.** `render(allocator, FormatOptions) Error!Rendered`,
where `FormatOptions{ fraction_digits, max_integer_digits, significant_digits,
separators }` has NO defaults, so a frontend cannot forget to decide. `main.zig`
and `tui.zig` each declare one; `null` for `max_integer_digits` means never
abbreviate, which is the clipboard form (requirements.md line 237). The old
`NumberDisplay{ display, raw, exact }` triple is gone: display and clipboard are
two option sets, so nothing allocates a clipboard string on every keystroke for a
yank command that does not exist yet.
- **`exact` became `truncated`**, and now describes the text alone: rounded away at
`fraction_digits`, or abbreviated. Whether the VALUE is exact is `n == .exact`,
and the two are independent. The old flag tried to mean both, which is why an
exact 1e50 reported `exact = false` while its own `raw` field held every digit.
A frontend marking a result approximate composes the two facts itself.
- **`financial.zig` got money.** `money(value)` returns a `Money` with `format` and
`render(buf)`. Two decimals and grouping are properties of money, not of a
screen, so there is no budget to pass. The dead `formatAmount` went away with it,
and the null-on-failure contract became `error.WriteFailed`, so the nine CLI
`orelse return unformattableResult()` sites became `catch return`.
- **`Integer.zig` got `displayWidthFor`**, the one thing in the file that formatted
nothing: it returns a `BitWidth`, which is `Integer`'s own type.
Two rules stayed in the engine because they are facts about values rather than
preferences about screens: the 2^53 bound, past which an f64's fixed rendering would
invent digits (so an inexact value goes scientific there no matter what the caller's
budget says), and that a value needing more fractional digits than the budget allows
abbreviates rather than being silently shortened. `fraction_digits` means the same
thing to both tiers: an exact expansion is rounded there, and a float, whose shortest
round-trip form is never rounded, goes scientific when its digits do not fit. That
one rule replaced five thresholds, including `formatCompactFloat`'s separate
`1e-4`/`1e16` pair.
The float arm renders onto a stack buffer sized by `std.fmt.float.bufferSize(.decimal,
f64)` (347 bytes) and allocates once, so a caller can hand it a
`FixedBufferAllocator` sized for the text it asked for. `src/tui/float_view.zig` does
exactly that: an 80-byte stack buffer, no heap in the draw path, "?" if it does not
fit.
Behaviour changed in two bands, both deliberate, both making the tiers agree:
- An inexact value in [1e15, 2^53) now prints in full instead of scientific. Those
digits are real; the old `> 1e15` test was a display preference sitting in front
of the representability bound that actually matters.
- An inexact value whose shortest form fits the fractional budget now prints fixed
instead of scientific, which is what the exact tier already did for the same
magnitude: a 1e-16 result no longer reads differently depending on which tier
produced it. One that does not fit (`pi * 1e-17` needs 33 digits) still
abbreviates, as before.
Also found while checking callers: nothing consumes the `raw` form of an integer
rendering, and there is no clipboard code in the TUI at all. The prefixed form is kept
@ -1134,6 +1188,22 @@ STILL OPEN, in the order I would take them:
12. `engine/src/c_api.zig` has no tests and appears in no coverage report, because
no test target builds the shared library. Deferred until Phase 6 gives it a
caller; recorded here so it is a known gap rather than an oversight.
13. NFR-9.9 ends with "the exact fraction available", and no frontend ever asks for
it. `Rational.toFractionString` works and is tested, but its only callers are
tests: nothing in the TUI or the CLI renders `1/3` as a fraction. Wiring it
needs a display decision that does not exist yet (integers and inexact values
have no useful fraction form, so the frontend has to choose when to show the
line at all). Recorded rather than left as unused layers of forwarding code.
14. Nothing consumes `Rendered.truncated` yet, and no frontend has a yank command,
so the two halves of NFR-9.9's "marked approximate, with the exact fraction
available" and requirements.md line 237's clipboard form are engine-side only.
`Number.render` reports the fact and takes clipboard options; the TUI has yet
to draw an indicator or copy anything.
15. `c_api.zig` renders nothing today, so Phase 6 has to decide how a
`Number.FormatOptions` crosses the FFI boundary. It must be parameters the
caller passes, not a default the C layer invents, or Android inherits a budget
chosen for an 80-column terminal (Task 5.17). Task 6.2 covers the bridge; this
is the display half of it.
---

View file

@ -268,6 +268,30 @@ pub const BitWidth = enum(u8) {
}
};
/// The width to print the hex, octal and binary rows at, for a standard-mode value
/// that has no configured width (FR-1.9).
///
/// The narrowest of the standard widths that holds `value`, so a small number does
/// not come out padded to 64 bits: 1 prints as `01`, and 256 steps up to `01 00`.
/// It rounds up to a width a reader recognises rather than to a bit count, which is
/// what makes the hex row read as whole bytes.
///
/// Unsigned only: it counts significant bits of the pattern, so a negative value's
/// two's complement form would always report the full width. Callers reach this
/// only for non-negative integers, which is also what FR-1.9 promises. Zero has no
/// significant bits and prints at the narrowest width.
///
/// Programmer mode does not use this; there the width is the user's setting.
pub fn displayWidthFor(value: u128) BitWidth {
return switch (128 - @clz(value)) {
0...8 => .bits8,
9...16 => .bits16,
17...32 => .bits32,
33...64 => .bits64,
else => .bits128,
};
}
/// Whether the top bit of a pattern is a sign.
pub const Signedness = enum {
signed,
@ -614,3 +638,34 @@ test "the default format is the number, read by its own signedness" {
try pair.print("{f} and {f}", .{ at(5, .bits8), at(0xFE, .bits8) });
try testing.expectEqualStrings("5 and -2", pair.buffered());
}
// -- displayWidthFor tests --
//
// Moved here with the function, from `formatter.zig`, where it was the one thing
// in the file that formatted nothing: it returns a `BitWidth`, which is this
// file's type.
test "displayWidthFor: the narrowest standard width that holds the value" {
try testing.expectEqual(BitWidth.bits8, displayWidthFor(0));
try testing.expectEqual(BitWidth.bits8, displayWidthFor(255));
try testing.expectEqual(BitWidth.bits16, displayWidthFor(256));
try testing.expectEqual(BitWidth.bits16, displayWidthFor(65535));
try testing.expectEqual(BitWidth.bits32, displayWidthFor(65536));
try testing.expectEqual(BitWidth.bits64, displayWidthFor(0x1_0000_0000));
try testing.expectEqual(BitWidth.bits128, displayWidthFor(0x1_0000_0000_0000_0000));
try testing.expectEqual(BitWidth.bits128, displayWidthFor(std.math.maxInt(u128)));
}
test "displayWidthFor: the chosen width holds the value and sizes the rows" {
// What the width is for: the hex row of a small number is one byte, not eight.
var buf: [256]u8 = undefined;
for ([_]u128{ 0, 1, 255, 256, 0xFFFF, 0x1_0000, std.math.maxInt(u64), std.math.maxInt(u128) }) |value| {
const width = displayWidthFor(value);
try testing.expectEqual(value, value & width.mask());
const int: Integer = .{ .raw = value, .width = width, .signedness = .unsigned };
const hex = try int.fmt(.hex, .{}).render(&buf);
// Two hex digits per byte, plus one space between bytes.
const bytes: usize = width.bits() / 8;
try testing.expectEqual(bytes * 3 - 1, hex.len);
}
}

View file

@ -2082,7 +2082,7 @@ test "toScientificString: a single significant digit rounds correctly" {
test "toScientificString: agrees with f64 for values f64 can hold" {
// The file-level `alloc` is testing.allocator.
// Cross-check against the float formatter's own rendering of the same value,
// Cross-check against std.fmt's own rendering of the same value,
// so the mantissa and exponent are not just self-consistent.
const values = [_]struct { num: i64, den: i64 }{
.{ .num = 1, .den = 8 },

View file

@ -20,7 +20,6 @@ pub const evaluator = @import("evaluator.zig");
pub const bitwise = @import("bitwise.zig");
pub const programmer = @import("programmer.zig");
// Domains and display.
pub const formatter = @import("formatter.zig");
pub const float_interp = @import("float_interp.zig");
pub const units = @import("units.zig");
pub const financial = @import("financial.zig");

View file

@ -1217,8 +1217,8 @@ fn expectExactDecimal(expected: []const u8, source: []const u8) !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, source);
try testing.expect(result.isExact());
const shown = try result.toDecimalString(arena.allocator(), 20);
try testing.expect(result == .exact);
const shown = try result.exact.toDecimalString(arena.allocator(), 20);
try testing.expectEqualStrings(expected, shown.text);
}
@ -1240,14 +1240,14 @@ test "Number API: one third is retained exactly, not as a decimal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, "1/3");
try testing.expect(result.isExact());
try testing.expect(result == .exact);
// The exact form is a fraction, which no float could express.
const frac = (try result.toFractionString(arena.allocator())).?;
const frac = try result.exact.toFractionString(arena.allocator());
try testing.expectEqualStrings("1/3", frac);
// And its decimal rendering is correctly reported as approximate.
const shown = try result.toDecimalString(arena.allocator(), 10);
const shown = try result.exact.toDecimalString(arena.allocator(), 10);
try testing.expect(!shown.exact);
}
@ -1255,9 +1255,9 @@ test "Number API: factorial is exact past the old 170 limit" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, "factorial(171)");
try testing.expect(result.isExact());
try testing.expect(result == .exact);
const shown = try result.toDecimalString(arena.allocator(), 0);
const shown = try result.exact.toDecimalString(arena.allocator(), 0);
// 171! has 310 digits; f64 could only report infinity.
try testing.expectEqual(@as(usize, 310), shown.text.len);
try testing.expect(shown.exact);
@ -1268,24 +1268,24 @@ test "Number API: transcendentals are reported as inexact" {
defer _ = arena.deinit();
const s = try testEvalNumber(&arena, "sin(1)");
try testing.expect(!s.isExact());
try testing.expect(s != .exact);
const p = try testEvalNumber(&arena, "pi");
try testing.expect(!p.isExact());
try testing.expect(p != .exact);
const r = try testEvalNumber(&arena, "sqrt(2)");
try testing.expect(!r.isExact());
try testing.expect(r != .exact);
// But a perfect square stays exact.
const q = try testEvalNumber(&arena, "sqrt(144)");
try testing.expect(q.isExact());
try testing.expect(q == .exact);
}
test "Number API: inexactness is contagious across an expression" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, "0.1 + 0.2 + sin(0)");
try testing.expect(!result.isExact());
try testing.expect(result != .exact);
}
test "Number API: variables keep the exactness of their expression" {
@ -1298,11 +1298,11 @@ test "Number API: variables keep the exactness of their expression" {
defer env.deinit();
const assigned = try evalString(&env, a, "X = 0.1");
try testing.expect(assigned.isExact());
try testing.expect(assigned == .exact);
const sum = try evalString(&env, a, "X + 0.2");
try testing.expect(sum.isExact());
const shown = try sum.toDecimalString(a, 20);
try testing.expect(sum == .exact);
const shown = try sum.exact.toDecimalString(a, 20);
try testing.expectEqualStrings("0.3", shown.text);
try testing.expect(shown.exact);
}
@ -1316,8 +1316,8 @@ test "Number API: Ans keeps exactness between evaluations" {
_ = try evalString(&env, a, "1/3");
const doubled = try evalString(&env, a, "Ans * 3");
try testing.expect(doubled.isExact());
const shown = try doubled.toDecimalString(a, 20);
try testing.expect(doubled == .exact);
const shown = try doubled.exact.toDecimalString(a, 20);
try testing.expectEqualStrings("1", shown.text);
}
@ -1473,7 +1473,7 @@ test "financial: results are inexact, so they do not claim exactness" {
defer env.deinit();
const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)");
try testing.expect(!result.isExact());
try testing.expect(result != .exact);
}
test "financial: bad arguments are domain errors, not wrong answers" {
@ -1610,10 +1610,15 @@ test "a grouped literal past 2^53 is still exact" {
defer env.deinit();
var value = try evalString(&env, testing.allocator, "9,007,199,254,740,993");
defer value.deinit();
try testing.expect(value.isExact());
const shown = try @import("formatter.zig").formatNumber(testing.allocator, value);
try testing.expect(value == .exact);
const shown = try value.render(testing.allocator, .{
.fraction_digits = 20,
.max_integer_digits = null,
.significant_digits = 17,
.separators = false,
});
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("9007199254740993", shown.raw);
try testing.expectEqualStrings("9007199254740993", shown.text);
}
// -- Operands that do not fit a machine word --

View file

@ -27,6 +27,7 @@
const std = @import("std");
const math = std.math;
const grouping = @import("grouping.zig");
/// What the financial calculations can fail with.
///
/// `InsufficientParameters` and `ConvergenceFailure` are theirs alone: no other part
@ -462,6 +463,49 @@ pub fn roundToCents(value: f64) f64 {
return roundToScale(value, 2, .half_even);
}
// -- Money display --
//
// Two decimal places and a grouped integer part is a property of money, not of a
// screen, so unlike `Number.FormatOptions` there is no budget to pass: cents are
// cents on every frontend. This used to live in `formatter.zig`, and before that
// character for character in `src/main.zig` and `src/tui/financial.zig`, both of
// which also reimplemented the thousands grouping.
/// Widest fixed-point rendering an f64 has (about 310 integer digits), plus
/// separators and cents.
const money_text_max = 512;
/// An amount ready to print: grouped integer part, exactly two decimal places.
pub const Money = struct {
value: f64,
pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
// Grouping asserts numeric text, and "inf" is not. A non-finite amount
// prints as itself rather than as a placeholder that reads like data.
if (!math.isFinite(self.value)) {
return w.print("{d}", .{self.value});
}
var plain: [money_text_max]u8 = undefined;
const text = std.fmt.bufPrint(&plain, "{d:.2}", .{self.value}) catch
return error.WriteFailed;
return grouping.print(w, text);
}
/// For a caller that must measure or pad the text, such as a table column.
/// `error.WriteFailed` when `buf` is too small: the amount is reported as a
/// failure rather than truncated or replaced with "?".
pub fn render(self: Money, buf: []u8) std.Io.Writer.Error![]const u8 {
var w = std.Io.Writer.fixed(buf);
try self.format(&w);
return w.buffered();
}
};
/// An amount at two decimal places, the money case.
pub fn money(value: f64) Money {
return .{ .value = value };
}
// -- Amortization --
/// Upper bound on schedule length. 12,000 monthly periods is a thousand years,
@ -1403,3 +1447,84 @@ test "compound interest: the four variables round-trip through each other" {
try testing.expectApproxEqAbs(rate, try compoundRate(pv, fv, years, per_year), 1e-9);
try testing.expectApproxEqAbs(years, try compoundPeriods(pv, fv, rate, per_year), 1e-9);
}
// -- Money display tests --
//
// These moved here with `Money`, from `formatter.zig`. The old version returned
// `?[]const u8` and both frontends had copies returning the string "?", so
// `tally amort 1e40 0.5 3` printed a table of question marks and exited 0.
fn expectMoney(expected: []const u8, value: f64) !void {
var buf: [money_text_max]u8 = undefined;
try testing.expectEqualStrings(expected, try money(value).render(&buf));
}
test "money: grouping, sign and two decimals" {
try expectMoney("0.00", 0);
try expectMoney("199.10", 199.1);
try expectMoney("1,199.10", 1199.1);
try expectMoney("200,000.00", 200000);
try expectMoney("231,677.04", 231677.04);
try expectMoney("1,234,567.89", 1234567.89);
try expectMoney("-1,199.10", -1199.1);
try expectMoney("-0.01", -0.01);
}
test "money: rounds to the cent" {
try expectMoney("1,199.10", 1199.101050305518);
try expectMoney("2.00", 1.995);
}
test "money: a buffer too small is a failure, not a placeholder" {
var tiny: [4]u8 = undefined;
try testing.expectError(error.WriteFailed, money(1234567.89).render(&tiny));
}
test "money: non-finite amounts print as themselves" {
// Not reachable from the solvers, which reject the inputs that would produce
// one, but a formatter that silently emitted "?" here would be worse than one
// that says "inf".
try expectMoney("inf", math.inf(f64));
try expectMoney("-inf", -math.inf(f64));
try expectMoney("nan", math.nan(f64));
}
test "money: very large amounts render or fail cleanly, never partially" {
// 1e40 needs 41 integer digits, 13 separators and cents: 57 bytes.
var buf: [64]u8 = undefined;
const forty = try money(1e40).render(&buf);
try testing.expectEqual(@as(usize, 57), forty.len);
try testing.expect(std.mem.startsWith(u8, forty, "10,000,000,000"));
try testing.expect(std.mem.endsWith(u8, forty, ".00"));
// 1e300 needs 404 bytes, so the same buffer must refuse rather than truncate.
try testing.expectError(error.WriteFailed, money(1e300).render(&buf));
var wide: [money_text_max]u8 = undefined;
const huge = try money(1e300).render(&wide);
try testing.expect(std.mem.endsWith(u8, huge, ".00"));
}
test "money: prints through a writer without a caller buffer" {
var out: [64]u8 = undefined;
var w = std.Io.Writer.fixed(&out);
try w.print("Payment {f} per period", .{money(1199.1)});
try testing.expectEqualStrings("Payment 1,199.10 per period", w.buffered());
}
test "money: groups the same way an ordinary result does" {
// The point of collapsing the copies: an amount and a plain result group
// identically, differing only in the fixed decimal places.
var money_buf: [64]u8 = undefined;
const as_money = try money(231677).render(&money_buf);
var value = @import("number.zig").Number.fromFloat(231677);
const as_value = try value.render(testing.allocator, .{
.fraction_digits = 20,
.max_integer_digits = 40,
.significant_digits = 17,
.separators = true,
});
defer as_value.deinit(testing.allocator);
try testing.expectEqualStrings("231,677.00", as_money);
try testing.expectEqualStrings("231,677", as_value.text);
try testing.expect(std.mem.startsWith(u8, as_money, as_value.text));
}

View file

@ -1,941 +0,0 @@
//! Number display formatter for Tally.
//!
//! Produces two string representations for every value:
//! - `display`: human-readable with separators (commas, underscores, spaces)
//! - `raw`: clipboard-friendly without separators (but with base prefix)
//!
//! Formatting rules per the spec:
//! - Decimal: comma-separated groups of 3 (e.g. "4,294,967,295"). The integer
//! part is grouped whether or not there is a fractional part, so "231,677.04"
//! and "231,677" read consistently. A fractional part is never grouped.
//! - Hex value view: space per byte (e.g. "FF FF FF FF"); the prefix appears only
//! in the clipboard form ("0xFFFFFFFF")
//! - Binary: space per nibble (e.g. "1111 1111")
//! - Octal: space per 3-digit group, zero-padded to the bit width
//! - Scientific notation when |value| is above 10^15 or below 10^-15, and for an
//! exact value whose integer part exceeds `max_display_integer_digits` or whose
//! magnitude is below the fractional budget
const std = @import("std");
const Integer = @import("Integer.zig");
const BitWidth = Integer.BitWidth;
const grouping = @import("grouping.zig");
const Number = @import("number.zig").Number;
/// A formatted value with both display and clipboard representations.
pub const FormattedValue = struct {
display: []const u8,
raw: []const u8,
};
/// Length of the grouped form of `text`, and the writer for it. Private: the TUI
/// groups partially typed input through `grouping` directly, and these are only the
/// buffer-shaped conveniences this file's own buffer-based functions need.
const groupedDecimalLen = grouping.lengthOf;
/// Group `text` into `dest`, which must hold `groupedDecimalLen(text)` bytes.
fn writeGroupedDecimal(dest: []u8, text: []const u8) usize {
var w = std.Io.Writer.fixed(dest);
grouping.print(&w, text) catch @panic("grouping buffer too small; ask groupedDecimalLen first");
return w.end;
}
/// Format a floating-point value for display.
/// Uses comma grouping for integers, avoids scientific notation unless necessary.
///
/// The 2^53 bound is NOT a display preference: past it an f64 no longer
/// distinguishes consecutive integers, so printing one as an exact-looking integer
/// would assert precision the value does not have. Exact values are not subject to
/// this and go through `formatNumber`, which prints them in full.
///
/// In practice the `< 1e15` test on the next line is the stricter of the two, so
/// the 2^53 bound never decides an outcome on its own. It is kept because the two
/// bounds mean different things: one is about representability, the other about how
/// many digits are worth showing, and a change to the display threshold should not
/// silently remove the representability check.
pub fn formatFloat(buf: []u8, value: f64) FormattedValue {
const is_integer = value == @trunc(value) and @abs(value) < 9007199254740992.0; // 2^53
if (is_integer and @abs(value) < 1e15) {
// An integral value: plain digits, then the same digits grouped.
const int_val: i128 = @intFromFloat(value);
var raw_writer = std.Io.Writer.fixed(buf);
raw_writer.printInt(int_val, 10, .lower, .{}) catch
return .{ .display = "ERR", .raw = "ERR" };
const raw = raw_writer.buffered();
var display_writer = std.Io.Writer.fixed(buf[raw.len..]);
grouping.print(&display_writer, raw) catch
return .{ .display = raw, .raw = raw };
return .{ .display = display_writer.buffered(), .raw = raw };
}
// Check if we should use scientific notation
const abs_val = @abs(value);
if (abs_val != 0 and (abs_val > 1e15 or abs_val < 1e-15)) {
// Scientific notation
const raw_len = (std.fmt.bufPrint(buf, "{e}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] };
}
// Regular float formatting. The integer part gets the same comma grouping an
// integer or an exact value gets, so `231677.04` does not read differently
// from `231677`.
const raw_len = (std.fmt.bufPrint(buf, "{d}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
const raw = buf[0..raw_len];
const display_len = groupedDecimalLen(raw);
// Nothing to group, or no room for a second copy: display is the raw text.
if (display_len == raw_len or raw_len + display_len > buf.len) {
return .{ .display = raw, .raw = raw };
}
// Writes into the region after `raw`, so source and destination never
// overlap.
const written = writeGroupedDecimal(buf[raw_len..], raw);
return .{ .display = buf[raw_len..][0..written], .raw = raw };
}
/// Format a float for compact single-line display (used by the float view).
/// Uses shortest round-trip fixed-point for normal magnitudes, and scientific
/// notation for very small or very large magnitudes so rows stay readable
/// (e.g. a subnormal ULP prints as "1.4e-45" instead of 45 decimal digits).
/// Non-finite values render as "inf", "-inf", or "nan".
pub fn formatCompactFloat(buf: []u8, value: f64) []const u8 {
if (std.math.isNan(value)) return "nan";
if (std.math.isPositiveInf(value)) return "inf";
if (std.math.isNegativeInf(value)) return "-inf";
const abs = @abs(value);
const use_scientific = abs != 0 and (abs < 1e-4 or abs >= 1e16);
if (use_scientific) {
return std.fmt.bufPrint(buf, "{e}", .{value}) catch return "ERR";
}
return std.fmt.bufPrint(buf, "{d}", .{value}) catch return "ERR";
}
/// Format a `Number` for display, preserving exactness where it exists.
///
/// The rules, per NFR-9.9:
/// - An exact **integer** prints in full with comma grouping, at ANY magnitude.
/// It deliberately does not switch to scientific notation: printing
/// `9007199254740993` correctly is the entire point of the exact tier, and
/// abbreviating it would throw the result away at the last step.
/// - An exact value with a **terminating** decimal expansion prints exactly.
/// - An exact value with a **repeating** expansion is rounded to
/// `exact_fraction_digits` and reported as approximate.
/// - An **inexact** value uses the float rules (`formatFloat`) and is always
/// reported as approximate, because rounding already happened.
///
/// Caller owns `display` and `raw`.
pub fn formatNumber(allocator: std.mem.Allocator, value: Number) !NumberDisplay {
switch (value) {
.inexact => |f| {
var buf: [512]u8 = undefined;
const formatted = formatFloat(&buf, f);
const display = try allocator.dupe(u8, formatted.display);
errdefer allocator.free(display);
const raw = try allocator.dupe(u8, formatted.raw);
return .{ .display = display, .raw = raw, .exact = false };
},
.exact => |r| {
const rendered = try r.toDecimalString(allocator, exact_fraction_digits);
errdefer allocator.free(rendered.text);
// Very long values are abbreviated for display only. The `raw`
// (clipboard) form always keeps every digit, so the exact value is
// never actually lost, just not shown inline.
//
// Both this and the small-magnitude case below go through
// `toScientificString`. There used to be a second renderer here that
// worked on the already-rendered text; two implementations of the same
// notation is one more than needed, and the rational-based one handles
// both ends of the range.
if (grouping.integerDigitCount(rendered.text) > max_display_integer_digits) {
const display = try r.toScientificString(allocator, scientific_significant_digits);
return .{ .display = display, .raw = rendered.text, .exact = false };
}
// The other end of the same problem: a value smaller than the
// fractional budget renders as all zeros, which destroys it in the
// clipboard as well as on screen (2^-70 printed as 0.00000...).
if (!isZeroText(rendered.text)) {
// Group the integer part for readability; the raw form stays plain.
const display = try groupDecimalText(allocator, rendered.text);
return .{ .display = display, .raw = rendered.text, .exact = rendered.exact };
}
if (r.isZero()) {
const display = try allocator.dupe(u8, rendered.text);
return .{ .display = display, .raw = rendered.text, .exact = true };
}
allocator.free(rendered.text);
const scientific = try r.toScientificString(allocator, scientific_significant_digits);
errdefer allocator.free(scientific);
const raw = try allocator.dupe(u8, scientific);
// Rounded to 17 significant digits, so not exact even though the value
// is: the tag describes the text, not the value behind it.
return .{ .display = scientific, .raw = raw, .exact = false };
},
}
}
/// True when decimal text carries no significant digit, i.e. it is some spelling
/// of zero ("0", "0.00", "-0.000").
fn isZeroText(text: []const u8) bool {
for (text) |ch| {
if (ch >= '1' and ch <= '9') return false;
}
return true;
}
/// Fractional digits produced for an exact value whose decimal expansion does
/// not terminate (1/3, 1/7). Exact arithmetic can justify more digits than f64,
/// so this is above f64's ~17 significant digits.
pub const exact_fraction_digits: usize = 20;
/// Integer digits shown in full before display switches to scientific notation.
///
/// The exact tier exists so values like `9007199254740993` (16 digits) and
/// `2^128` (39 digits) print correctly, so the cap must be comfortably above
/// those. It exists at all because without it `factorial(171)` renders 310
/// digits and `1.5e300 * 10` renders 301, which is accurate but unreadable.
pub const max_display_integer_digits: usize = 40;
/// Significant digits kept when abbreviating to scientific notation.
const scientific_significant_digits: usize = 17;
pub const NumberDisplay = struct {
/// Human-readable form, with comma grouping.
display: []const u8,
/// Clipboard form: no separators.
raw: []const u8,
/// False when the text is a rounded approximation of the true value.
exact: bool,
pub fn deinit(self: NumberDisplay, allocator: std.mem.Allocator) void {
allocator.free(self.display);
allocator.free(self.raw);
}
};
/// Insert comma separators into the integer part of decimal text, leaving any
/// sign and fractional part alone.
fn groupDecimalText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
const len = groupedDecimalLen(text);
if (len == text.len) return allocator.dupe(u8, text);
const out = try allocator.alloc(u8, len);
const written = writeGroupedDecimal(out, text);
std.debug.assert(written == len);
return out;
}
/// Format a value as an amount: grouped integer part, exactly `decimals` places.
///
/// The single implementation of this. It existed three times before: character for
/// character in `src/main.zig` and `src/tui/financial.zig`, both of which also
/// reimplemented the comma grouping that lives a few lines below here.
///
/// Returns null rather than a placeholder when the result does not fit `buf`. The
/// copies returned the string "?", so `tally amort 1e40 0.5 3` printed a full table
/// of question marks and exited 0. A caller that cannot format a number should say
/// so, not render one.
pub fn formatAmount(buf: []u8, value: f64, decimals: u8) ?[]const u8 {
if (!std.math.isFinite(value)) return null;
// Enough for f64's widest fixed-point rendering (about 310 integer digits)
// plus separators and a fractional part.
var plain: [400]u8 = undefined;
const text = switch (decimals) {
0 => std.fmt.bufPrint(&plain, "{d:.0}", .{value}),
1 => std.fmt.bufPrint(&plain, "{d:.1}", .{value}),
2 => std.fmt.bufPrint(&plain, "{d:.2}", .{value}),
else => std.fmt.bufPrint(&plain, "{d:.6}", .{value}),
} catch return null;
const needed = groupedDecimalLen(text);
if (needed > buf.len) return null;
if (needed == text.len) {
@memcpy(buf[0..text.len], text);
return buf[0..text.len];
}
return buf[0..writeGroupedDecimal(buf, text)];
}
/// `formatAmount` at two decimal places, the money case.
pub fn formatMoney(buf: []u8, value: f64) ?[]const u8 {
return formatAmount(buf, value, 2);
}
/// The width to print the hex, octal and binary rows at, for a standard-mode value
/// that has no configured width (FR-1.9).
///
/// The narrowest of the standard widths that holds `value`, so a small number does
/// not come out padded to 64 bits: 1 prints as `01`, and 256 steps up to `01 00`.
/// It rounds up to a width a reader recognises rather than to a bit count, which is
/// what makes the hex row read as whole bytes.
///
/// Unsigned only: it counts significant bits of the pattern, so a negative value's
/// two's complement form would always report the full width. Callers reach this
/// only for non-negative integers, which is also what FR-1.9 promises. Zero has no
/// significant bits and prints at the narrowest width.
///
/// Programmer mode does not use this; there the width is the user's setting.
pub fn displayWidthFor(value: u128) BitWidth {
return switch (128 - @clz(value)) {
0...8 => .bits8,
9...16 => .bits16,
17...32 => .bits32,
33...64 => .bits64,
else => .bits128,
};
}
// -- Tests --
const testing = std.testing;
test "formatFloat: a buffer too small degrades instead of overrunning" {
// Both halves are written through a fixed writer, so a short buffer is an error
// the function handles rather than a walk off the end.
var no_room: [4]u8 = undefined;
const failed = formatFloat(&no_room, 1234567);
try testing.expectEqualStrings("ERR", failed.display);
try testing.expectEqualStrings("ERR", failed.raw);
// Room for the digits but not for a second, grouped copy: display falls back to
// the ungrouped text rather than being truncated.
var tight: [9]u8 = undefined;
const partial = formatFloat(&tight, 1234567);
try testing.expectEqualStrings("1234567", partial.raw);
try testing.expectEqualStrings("1234567", partial.display);
}
test "formatFloat: integer value" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 42.0);
try testing.expectEqualStrings("42", result.display);
try testing.expectEqualStrings("42", result.raw);
}
test "formatFloat: large integer with commas" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 4294967295.0);
try testing.expectEqualStrings("4,294,967,295", result.display);
try testing.expectEqualStrings("4294967295", result.raw);
}
test "formatFloat: negative integer" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, -1234.0);
try testing.expectEqualStrings("-1,234", result.display);
try testing.expectEqualStrings("-1234", result.raw);
}
test "formatFloat: million" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1000000.0);
try testing.expectEqualStrings("1,000,000", result.display);
try testing.expectEqualStrings("1000000", result.raw);
}
test "formatFloat: zero" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 0.0);
try testing.expectEqualStrings("0", result.display);
try testing.expectEqualStrings("0", result.raw);
}
test "displayWidthFor: the narrowest standard width that holds the value" {
const BW = Integer.BitWidth;
try testing.expectEqual(BW.bits8, displayWidthFor(0));
try testing.expectEqual(BW.bits8, displayWidthFor(255));
try testing.expectEqual(BW.bits16, displayWidthFor(256));
try testing.expectEqual(BW.bits16, displayWidthFor(65535));
try testing.expectEqual(BW.bits32, displayWidthFor(65536));
try testing.expectEqual(BW.bits64, displayWidthFor(0x1_0000_0000));
try testing.expectEqual(BW.bits128, displayWidthFor(0x1_0000_0000_0000_0000));
try testing.expectEqual(BW.bits128, displayWidthFor(std.math.maxInt(u128)));
}
test "displayWidthFor: the chosen width holds the value and sizes the rows" {
// What the width is for: the hex row of a small number is one byte, not eight.
var buf: [256]u8 = undefined;
for ([_]u128{ 0, 1, 255, 256, 0xFFFF, 0x1_0000, std.math.maxInt(u64), std.math.maxInt(u128) }) |value| {
const bw = displayWidthFor(value);
try testing.expectEqual(value, value & bw.mask());
const int: Integer = .{ .raw = value, .width = bw, .signedness = .unsigned };
const hex = try int.fmt(.hex, .{}).render(&buf);
// Two hex digits per byte, plus one space between bytes.
const bytes: usize = bw.bits() / 8;
try testing.expectEqual(bytes * 3 - 1, hex.len);
}
}
test "formatFloat: very large number uses scientific notation" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1.5e16);
// Should use scientific notation for values > 1e15
try testing.expect(std.mem.indexOf(u8, result.display, "e") != null or
std.mem.indexOf(u8, result.display, "E") != null);
}
test "formatFloat: very small number uses scientific notation" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1.5e-16);
try testing.expect(std.mem.indexOf(u8, result.display, "e") != null or
std.mem.indexOf(u8, result.display, "E") != null);
}
test "formatFloat: regular float" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 3.14159);
try testing.expect(std.mem.indexOf(u8, result.display, "3.14") != null);
}
fn hasChar(s: []const u8, c: u8) bool {
return std.mem.indexOfScalar(u8, s, c) != null;
}
test "formatCompactFloat: normal magnitudes use fixed-point" {
var buf: [64]u8 = undefined;
try testing.expect(!hasChar(formatCompactFloat(&buf, 1.0), 'e'));
try testing.expect(!hasChar(formatCompactFloat(&buf, 3.14), 'e'));
try testing.expect(!hasChar(formatCompactFloat(&buf, 0.5), 'e'));
try testing.expect(!hasChar(formatCompactFloat(&buf, -2.0), 'e'));
// Just above the small-magnitude threshold stays fixed-point
try testing.expect(!hasChar(formatCompactFloat(&buf, 0.001), 'e'));
}
test "formatCompactFloat: 3.14 renders exactly" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("3.14", formatCompactFloat(&buf, 3.14));
}
test "formatCompactFloat: preserves round-trip precision for normal magnitude" {
var buf: [64]u8 = undefined;
// 0.1 stored as f32 then widened: normal magnitude, so fixed-point and full
const v: f64 = @floatCast(@as(f32, 0.1));
const s = formatCompactFloat(&buf, v);
try testing.expect(!hasChar(s, 'e'));
try testing.expect(std.mem.startsWith(u8, s, "0.100000001"));
}
test "formatCompactFloat: very small magnitudes use scientific" {
var buf: [64]u8 = undefined;
// smallest f32 subnormal ~ 1.4e-45
try testing.expect(hasChar(formatCompactFloat(&buf, std.math.ldexp(@as(f64, 1.0), -149)), 'e'));
// f32 ULP of 1.0 = 2^-23 ~ 1.19e-7
try testing.expect(hasChar(formatCompactFloat(&buf, std.math.ldexp(@as(f64, 1.0), -23)), 'e'));
// f64 ULP of 1.0 = 2^-52 ~ 2.2e-16
try testing.expect(hasChar(formatCompactFloat(&buf, std.math.ldexp(@as(f64, 1.0), -52)), 'e'));
}
test "formatCompactFloat: very large magnitudes use scientific" {
var buf: [64]u8 = undefined;
try testing.expect(hasChar(formatCompactFloat(&buf, 1e20), 'e'));
try testing.expect(hasChar(formatCompactFloat(&buf, -1e18), 'e'));
}
test "formatCompactFloat: zero is fixed-point, not scientific" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("0", formatCompactFloat(&buf, 0.0));
}
test "formatCompactFloat: non-finite values" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("inf", formatCompactFloat(&buf, std.math.inf(f64)));
try testing.expectEqualStrings("-inf", formatCompactFloat(&buf, -std.math.inf(f64)));
try testing.expectEqualStrings("nan", formatCompactFloat(&buf, std.math.nan(f64)));
}
// -- formatNumber (exact display) --
fn expectNumberDisplay(expected_display: []const u8, expected_exact: bool, value: Number) !void {
const shown = try formatNumber(testing.allocator, value);
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings(expected_display, shown.display);
try testing.expectEqual(expected_exact, shown.exact);
}
test "formatNumber: exact small integer" {
var n = try Number.fromInt(testing.allocator, 42);
defer n.deinit();
try expectNumberDisplay("42", true, n);
}
test "formatNumber: exact integer gets comma grouping" {
var n = try Number.fromInt(testing.allocator, 4294967295);
defer n.deinit();
try expectNumberDisplay("4,294,967,295", true, n);
const shown = try formatNumber(testing.allocator, n);
defer shown.deinit(testing.allocator);
// The clipboard form keeps no separators.
try testing.expectEqualStrings("4294967295", shown.raw);
}
test "formatNumber: the integer f64 cannot represent survives intact" {
// The whole point of the exact tier: this must NOT become
// 9.007199254740992e15.
var n = try Number.parse(testing.allocator, "9007199254740993");
defer n.deinit();
try expectNumberDisplay("9,007,199,254,740,993", true, n);
}
test "formatNumber: huge exact integers print in full, never scientific" {
var n = try Number.parse(testing.allocator, "123456789012345678901234567890");
defer n.deinit();
try expectNumberDisplay("123,456,789,012,345,678,901,234,567,890", true, n);
}
test "formatNumber: negative exact integer" {
var n = try Number.fromInt(testing.allocator, -1234567);
defer n.deinit();
try expectNumberDisplay("-1,234,567", true, n);
}
test "formatNumber: exact terminating fraction" {
var n = try Number.parse(testing.allocator, "0.125");
defer n.deinit();
try expectNumberDisplay("0.125", true, n);
}
test "formatNumber: exact terminating fraction with a grouped integer part" {
var n = try Number.parse(testing.allocator, "1234567.25");
defer n.deinit();
try expectNumberDisplay("1,234,567.25", true, n);
}
test "formatNumber: 0.1 + 0.2 renders as 0.3 exactly" {
const alloc = testing.allocator;
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = try Number.parse(alloc, "0.2");
defer b.deinit();
var sum = try Number.add(alloc, a, b);
defer sum.deinit();
try expectNumberDisplay("0.3", true, sum);
}
test "formatNumber: repeating expansion is rounded and flagged approximate" {
const alloc = testing.allocator;
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var three = try Number.fromInt(alloc, 3);
defer three.deinit();
var third = try Number.div(alloc, one, three);
defer third.deinit();
const shown = try formatNumber(alloc, third);
defer shown.deinit(alloc);
try testing.expect(!shown.exact);
try testing.expect(std.mem.startsWith(u8, shown.display, "0.3333333333"));
try testing.expectEqual(exact_fraction_digits + 2, shown.display.len); // "0." + digits
}
test "formatNumber: inexact values are always flagged approximate" {
var n = Number.fromFloat(0.5);
defer n.deinit();
try expectNumberDisplay("0.5", false, n);
var whole = Number.fromFloat(42.0);
defer whole.deinit();
try expectNumberDisplay("42", false, whole);
}
test "formatNumber: negative zero and zero" {
var z = try Number.fromInt(testing.allocator, 0);
defer z.deinit();
try expectNumberDisplay("0", true, z);
}
test "groupDecimalText: boundaries around the grouping threshold" {
const alloc = testing.allocator;
const cases = [_][2][]const u8{
.{ "1", "1" },
.{ "12", "12" },
.{ "123", "123" },
.{ "1234", "1,234" },
.{ "12345", "12,345" },
.{ "123456", "123,456" },
.{ "1234567", "1,234,567" },
.{ "-1234567", "-1,234,567" },
.{ "1234.5678", "1,234.5678" },
.{ "-1234.5", "-1,234.5" },
.{ "0.123456789", "0.123456789" },
};
for (cases) |c| {
const got = try groupDecimalText(alloc, c[0]);
defer alloc.free(got);
try testing.expectEqualStrings(c[1], got);
}
}
// -- Display cap for very long exact values (NFR-7) --
test "formatNumber: exact integers at the cap still print in full" {
// 2^128 is 39 digits, inside the cap, and is a value the exact tier exists
// to serve.
var n = try Number.parse(testing.allocator, "340282366920938463463374607431768211456");
defer n.deinit();
const shown = try formatNumber(testing.allocator, n);
defer shown.deinit(testing.allocator);
try testing.expect(shown.exact);
try testing.expectEqualStrings("340,282,366,920,938,463,463,374,607,431,768,211,456", shown.display);
}
test "formatNumber: past the cap the display abbreviates but raw stays exact" {
const alloc = testing.allocator;
// 1e50: 51 digits, past the cap.
var n = try Number.parse(alloc, "1e50");
defer n.deinit();
const shown = try formatNumber(alloc, n);
defer shown.deinit(alloc);
try testing.expectEqualStrings("1e50", shown.display);
// The exact value is never lost, just not shown inline.
try testing.expectEqual(@as(usize, 51), shown.raw.len);
try testing.expectEqualStrings("1", shown.raw[0..1]);
// The abbreviated text is not the full value, so it is flagged.
try testing.expect(!shown.exact);
}
test "formatNumber: abbreviation rounds the mantissa" {
const alloc = testing.allocator;
// 41 nines: rounds up and carries all the way into a new power of ten.
var n = try Number.parse(alloc, "99999999999999999999999999999999999999999");
defer n.deinit();
const shown = try formatNumber(alloc, n);
defer shown.deinit(alloc);
try testing.expectEqualStrings("1e41", shown.display);
}
test "formatNumber: abbreviation rounds a middle digit without carrying" {
const alloc = testing.allocator;
// 41 digits whose 18th is 8, so the 17th significant digit rounds 7 -> 8
// with no carry propagation.
var n = try Number.parse(alloc, "12345678901234567800000000000000000000000");
defer n.deinit();
const shown = try formatNumber(alloc, n);
defer shown.deinit(alloc);
try testing.expectEqualStrings("1.2345678901234568e40", shown.display);
}
test "formatNumber: abbreviation rounds down when the next digit is below five" {
const alloc = testing.allocator;
var n = try Number.parse(alloc, "12345678901234567400000000000000000000000");
defer n.deinit();
const shown = try formatNumber(alloc, n);
defer shown.deinit(alloc);
try testing.expectEqualStrings("1.2345678901234567e40", shown.display);
}
test "formatNumber: negative values past the cap keep their sign" {
const alloc = testing.allocator;
var n = try Number.parse(alloc, "-1.5e60");
defer n.deinit();
const shown = try formatNumber(alloc, n);
defer shown.deinit(alloc);
try testing.expectEqualStrings("-1.5e60", shown.display);
}
test "formatNumber: 9007199254740993 is above NFR-7's f64 bound but must print in full" {
// This is the case where NFR-7's 10^15 scientific-notation bound would be
// actively wrong: the value exceeds it, but abbreviating would reintroduce
// the exact bug NFR-9.1 forbids.
const alloc = testing.allocator;
var n = try Number.parse(alloc, "9007199254740993");
defer n.deinit();
const shown = try formatNumber(alloc, n);
defer shown.deinit(alloc);
try testing.expect(shown.exact);
try testing.expectEqualStrings("9,007,199,254,740,993", shown.display);
try testing.expectEqualStrings("9007199254740993", shown.raw);
}
test "abbreviated huge values go through the same renderer as tiny ones" {
// This case used to have its own text-based renderer. Both ends of the range
// now use Rational.toScientificString, so this checks the shared path from the
// formatter's side: mantissa trimming, sign, and the exponent.
const alloc = testing.allocator;
const cases = [_][2][]const u8{
// 41 digits, one past max_display_integer_digits.
.{ "10000000000000000000000000000000000000000", "1e40" },
.{ "12000000000000000000000000000000000000000", "1.2e40" },
.{ "-25000000000000000000000000000000000000000", "-2.5e40" },
};
for (cases) |c| {
var value = try Number.parse(alloc, c[0]);
defer value.deinit();
const shown = try formatNumber(alloc, value);
defer shown.deinit(alloc);
try testing.expectEqualStrings(c[1], shown.display);
// The clipboard form still carries every digit.
try testing.expectEqualStrings(c[0], shown.raw);
try testing.expect(!shown.exact);
}
}
test "integerDigitCount ignores sign and fraction" {
try testing.expectEqual(@as(usize, 3), grouping.integerDigitCount("123"));
try testing.expectEqual(@as(usize, 3), grouping.integerDigitCount("-123"));
try testing.expectEqual(@as(usize, 3), grouping.integerDigitCount("123.456"));
try testing.expectEqual(@as(usize, 1), grouping.integerDigitCount("0.5"));
}
// -- Grouping of values with a fractional part --
//
// Previously only whole numbers were grouped, so a financial result read as
// `231677.04` while the same magnitude as an integer read as `231,677`. The
// spec (design.md 2.6) asks for full decimal with commas, and FR-1.8 makes the
// grouped form valid input again, so the integer part is grouped either way.
test "formatFloat: fractional value groups its integer part" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 231677.04);
try testing.expectEqualStrings("231,677.04", result.display);
try testing.expectEqualStrings("231677.04", result.raw);
}
test "formatFloat: raw form never carries separators" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1234567.891);
try testing.expectEqualStrings("1,234,567.891", result.display);
try testing.expectEqualStrings("1234567.891", result.raw);
try testing.expect(std.mem.indexOfScalar(u8, result.raw, ',') == null);
}
test "formatFloat: negative fractional value" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, -9876543.21);
try testing.expectEqualStrings("-9,876,543.21", result.display);
try testing.expectEqualStrings("-9876543.21", result.raw);
}
test "formatFloat: fewer than four integer digits is left alone" {
var buf: [256]u8 = undefined;
// Display and raw are the same slice in this case, which is intentional:
// there is nothing to group, so there is no reason to copy.
const small = formatFloat(&buf, 123.456);
try testing.expectEqualStrings("123.456", small.display);
try testing.expectEqualStrings("123.456", small.raw);
const sub_one = formatFloat(&buf, 0.5);
try testing.expectEqualStrings("0.5", sub_one.display);
const boundary = formatFloat(&buf, 999.99);
try testing.expectEqualStrings("999.99", boundary.display);
}
test "formatFloat: grouping starts at four integer digits" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1000.25);
try testing.expectEqualStrings("1,000.25", result.display);
}
test "formatFloat: scientific notation is not grouped" {
var buf: [256]u8 = undefined;
// Above 1e15 the float path switches to scientific, where commas would only
// corrupt the exponent.
const big = formatFloat(&buf, 1.234e20);
try testing.expect(std.mem.indexOfScalar(u8, big.display, ',') == null);
try testing.expect(std.mem.indexOfAny(u8, big.display, "eE") != null);
const tiny = formatFloat(&buf, 1.5e-20);
try testing.expect(std.mem.indexOfScalar(u8, tiny.display, ',') == null);
}
test "formatFloat: a buffer too small for both forms falls back to the raw text" {
// Just enough for "1234567.891" but not for a grouped second copy.
var buf: [12]u8 = undefined;
const result = formatFloat(&buf, 1234567.891);
try testing.expectEqualStrings("1234567.891", result.raw);
try testing.expectEqualStrings("1234567.891", result.display);
}
test "formatNumber: an inexact fractional result is grouped too" {
var value = Number.fromFloat(231677.04);
defer value.deinit();
const shown = try formatNumber(testing.allocator, value);
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("231,677.04", shown.display);
try testing.expectEqualStrings("231677.04", shown.raw);
try testing.expect(!shown.exact);
}
test "formatNumber: an exact fractional result was already grouped and still is" {
var value = try Number.parse(testing.allocator, "1234567.891");
defer value.deinit();
const shown = try formatNumber(testing.allocator, value);
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("1,234,567.891", shown.display);
try testing.expectEqualStrings("1234567.891", shown.raw);
try testing.expect(shown.exact);
}
test "groupedDecimalLen: agrees with what writeGroupedDecimal writes" {
const cases = [_][]const u8{
"0",
"999",
"1000",
"-1234",
"1234567.891",
"-9876543.21",
"0.5",
"1000000000000",
"1.5e+20",
};
var buf: [64]u8 = undefined;
for (cases) |text| {
const len = groupedDecimalLen(text);
if (len == text.len) continue;
try testing.expectEqual(len, writeGroupedDecimal(&buf, text));
}
}
test "grouped display re-parses to the same value" {
// FR-1.8 accepts commas as digit separators, so the display form is valid
// input. This is what makes grouping safe to apply to results.
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1234567.891);
var reparsed = try Number.parse(testing.allocator, "1234567.891");
defer reparsed.deinit();
try testing.expectEqualStrings("1,234,567.891", result.display);
try testing.expectApproxEqAbs(@as(f64, 1234567.891), reparsed.toFloat(testing.allocator), 1e-9);
}
// -- Exact values too small for the fractional budget --
//
// The exact path renders 20 fractional digits, so anything below 1e-20 came out
// as "0.00000000000000000000" in `display` AND in `raw`. That destroyed the value
// at the last step, in the one tier whose entire purpose is not doing that, and it
// made the exact tier display strictly worse than the inexact one.
test "formatNumber: an exact value below the fractional budget uses scientific notation" {
// 2^-70, exactly representable, equal to 8.470329472543003e-22.
var value = try Number.parse(testing.allocator, "1");
defer value.deinit();
var divisor = try Number.parse(testing.allocator, "1180591620717411303424");
defer divisor.deinit();
var tiny = try Number.div(testing.allocator, value, divisor);
defer tiny.deinit();
const shown = try formatNumber(testing.allocator, tiny);
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("8.4703294725430034e-22", shown.display);
// The clipboard form must not be zero either.
try testing.expectEqualStrings("8.4703294725430034e-22", shown.raw);
// Rounded to 17 significant digits, so the text is not exact even though the
// value is.
try testing.expect(!shown.exact);
}
test "formatNumber: a small exact value keeps its sign" {
var numerator = try Number.parse(testing.allocator, "-1");
defer numerator.deinit();
var divisor = try Number.parse(testing.allocator, "1180591620717411303424");
defer divisor.deinit();
var tiny = try Number.div(testing.allocator, numerator, divisor);
defer tiny.deinit();
const shown = try formatNumber(testing.allocator, tiny);
defer shown.deinit(testing.allocator);
try testing.expect(shown.display[0] == '-');
try testing.expectEqualStrings("-8.4703294725430034e-22", shown.display);
}
test "formatNumber: exact zero is still zero, not scientific" {
var zero = try Number.parse(testing.allocator, "0");
defer zero.deinit();
const shown = try formatNumber(testing.allocator, zero);
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("0", shown.display);
try testing.expectEqualStrings("0", shown.raw);
try testing.expect(shown.exact);
}
test "formatNumber: values just inside the budget still print fixed point" {
// 1e-20 is the last magnitude the 20-digit budget can show.
var value = try Number.parse(testing.allocator, "0.00000000000000000001");
defer value.deinit();
const shown = try formatNumber(testing.allocator, value);
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("0.00000000000000000001", shown.display);
try testing.expect(shown.exact);
}
test "isZeroText: recognises every spelling of zero" {
try testing.expect(isZeroText("0"));
try testing.expect(isZeroText("0.00"));
try testing.expect(isZeroText("-0.00000000000000000000"));
try testing.expect(!isZeroText("0.00000000000000000001"));
try testing.expect(!isZeroText("10.00"));
try testing.expect(!isZeroText("-0.5"));
}
//
// This logic existed three times: character for character in src/main.zig and
// src/tui/financial.zig, each reimplementing the comma grouping that already lived
// in this file. Both copies also returned the string "?" when the buffer was too
// small, so `tally amort 1e40 0.5 3` printed a table of question marks and exited 0.
test "formatMoney: grouping, sign and two decimals" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0).?);
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1).?);
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1).?);
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000).?);
try testing.expectEqualStrings("231,677.04", formatMoney(&buf, 231677.04).?);
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89).?);
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1).?);
try testing.expectEqualStrings("-0.01", formatMoney(&buf, -0.01).?);
}
test "formatMoney: rounds to the cent" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.101050305518).?);
try testing.expectEqualStrings("2.00", formatMoney(&buf, 1.995).?);
}
test "formatMoney: reports failure instead of a placeholder" {
var tiny: [4]u8 = undefined;
try testing.expect(formatMoney(&tiny, 1234567.89) == null);
// Non-finite values have no amount rendering at all.
var buf: [64]u8 = undefined;
try testing.expect(formatMoney(&buf, std.math.inf(f64)) == null);
try testing.expect(formatMoney(&buf, std.math.nan(f64)) == null);
}
test "formatMoney: very large amounts render or fail cleanly, never partially" {
var buf: [64]u8 = undefined;
// 1e40 needs 41 integer digits, 13 separators and cents: 57 bytes, so it fits.
const forty = formatMoney(&buf, 1e40).?;
try testing.expectEqual(@as(usize, 57), forty.len);
try testing.expect(std.mem.startsWith(u8, forty, "10,000,000,000"));
try testing.expect(std.mem.endsWith(u8, forty, ".00"));
// 1e300 needs 404 bytes, so the same buffer must refuse rather than truncate.
try testing.expect(formatMoney(&buf, 1e300) == null);
var wide: [512]u8 = undefined;
const huge = formatMoney(&wide, 1e300).?;
try testing.expect(std.mem.endsWith(u8, huge, ".00"));
}
test "formatAmount: other decimal counts" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("1,000", formatAmount(&buf, 1000.4, 0).?);
try testing.expectEqualStrings("1,000.4", formatAmount(&buf, 1000.44, 1).?);
try testing.expectEqualStrings("1,000.44", formatAmount(&buf, 1000.44, 2).?);
}
test "formatMoney: agrees with the grouping used for ordinary results" {
// The whole point of collapsing these: an amount and a plain result group the
// same way.
var money_buf: [64]u8 = undefined;
var value_buf: [256]u8 = undefined;
const as_money = formatMoney(&money_buf, 231677).?;
const as_value = formatFloat(&value_buf, 231677).display;
try testing.expectEqualStrings("231,677.00", as_money);
try testing.expectEqualStrings("231,677", as_value);
// Same separators, differing only in the fixed decimal places.
try testing.expect(std.mem.startsWith(u8, as_money, as_value));
}

View file

@ -1,7 +1,7 @@
//! The thousands rule, in one place.
//!
//! Both display paths group digits: `Integer` groups the decimal readings of a
//! fixed-width value, and `formatter` groups floats and exact `Number`s. Those two
//! fixed-width value, and `Number` groups floats and exact rationals. Those two
//! each worked out where a sign ended and where the fraction began, and disagreed, so
//! `-1234.56` grouped differently from `1234.56`. This is the shared answer.
//!
@ -77,7 +77,7 @@ pub fn lengthOf(text: []const u8) usize {
return text.len + (parts.int_digits - 1) / 3;
}
/// Digits before the decimal point, ignoring any sign. The formatter uses this to
/// Digits before the decimal point, ignoring any sign. `Number.render` uses this to
/// decide when an exact value is too wide to show in full.
pub fn integerDigitCount(text: []const u8) usize {
std.debug.assert(isNumericText(text));

File diff suppressed because it is too large Load diff

View file

@ -655,9 +655,9 @@ fn convertExactInner(
to: UnitDef,
) number_mod.Error!Number {
// base = value * from.factor + from.offset
var from_factor = Number.fromRational(try Rational.parse(allocator, from.factor_text.?));
var from_factor = Number.fromRational(allocator, try Rational.parse(allocator, from.factor_text.?));
defer from_factor.deinit();
var from_offset = Number.fromRational(try Rational.parse(allocator, from.offset_text.?));
var from_offset = Number.fromRational(allocator, try Rational.parse(allocator, from.offset_text.?));
defer from_offset.deinit();
var scaled = try Number.mul(allocator, value, from_factor);
@ -666,9 +666,9 @@ fn convertExactInner(
defer base.deinit();
// result = (base - to.offset) / to.factor
var to_factor = Number.fromRational(try Rational.parse(allocator, to.factor_text.?));
var to_factor = Number.fromRational(allocator, try Rational.parse(allocator, to.factor_text.?));
defer to_factor.deinit();
var to_offset = Number.fromRational(try Rational.parse(allocator, to.offset_text.?));
var to_offset = Number.fromRational(allocator, try Rational.parse(allocator, to.offset_text.?));
defer to_offset.deinit();
var shifted = try Number.sub(allocator, base, to_offset);
@ -1263,8 +1263,8 @@ fn expectExactConversion(expected: []const u8, value_text: []const u8, from_name
var result = try convertExactUnits(alloc, value, from, to);
defer result.deinit();
try testing.expect(result.isExact());
const shown = try result.toDecimalString(alloc, 30);
try testing.expect(result == .exact);
const shown = try result.exact.toDecimalString(alloc, 30);
defer alloc.free(shown.text);
try testing.expectEqualStrings(expected, shown.text);
try testing.expect(shown.exact);
@ -1339,7 +1339,7 @@ test "exact: pi-based angle units fall back to inexact" {
defer result.deinit();
// Degrees are defined via pi, so no exact rational answer exists.
try testing.expect(!result.isExact());
try testing.expect(result != .exact);
try testing.expectApproxEqAbs(std.math.pi, result.toFloat(alloc), 1e-15);
}
@ -1355,7 +1355,7 @@ test "exact: an inexact input stays inexact even between exact units" {
defer value.deinit();
var result = try convertExactUnits(alloc, value, findUnit("in").?, findUnit("ft").?);
defer result.deinit();
try testing.expect(!result.isExact());
try testing.expect(result != .exact);
}
test "exact: same unit is an exact identity" {
@ -1380,12 +1380,12 @@ test "exact: non-terminating conversions are exact values with rounded display"
defer result.deinit();
// The VALUE is exact even though its decimal expansion does not terminate.
try testing.expect(result.isExact());
const frac = (try result.toFractionString(alloc)).?;
try testing.expect(result == .exact);
const frac = try result.exact.toFractionString(alloc);
defer alloc.free(frac);
try testing.expectEqualStrings("781250/12573", frac);
const shown = try result.toDecimalString(alloc, 9);
const shown = try result.exact.toDecimalString(alloc, 9);
defer alloc.free(shown.text);
try testing.expect(!shown.exact);
try testing.expectEqualStrings("62.137119224", shown.text);
@ -1411,7 +1411,7 @@ test "exact: every unit pair within a category round-trips EXACTLY" {
var back = try convertExactUnits(alloc, forward, b, a);
defer back.deinit();
try testing.expect(back.isExact());
try testing.expect(back == .exact);
if (!try Number.eql(alloc, value, back)) {
std.debug.print("round trip {s} -> {s} -> {s} was not exact\n", .{ a.name, b.name, a.name });
return error.InexactRoundTrip;

View file

@ -15,6 +15,19 @@ pub const CliResult = struct {
/// 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,
.max_integer_digits = 40,
.significant_digits = 17,
.separators = true,
};
/// Parse CLI args and determine the expression and mode.
/// Returns the joined expression and mode, or an error/help output.
pub const ParsedArgs = union(enum) {
@ -393,17 +406,17 @@ fn formatConversionUnits(
};
defer converted.deinit();
const shown_in = engine.formatter.formatNumber(allocator, value) catch {
const shown_in = value.render(allocator, display_format) catch {
return .{ .output = "error: out of memory\n", .is_error = true };
};
defer shown_in.deinit(allocator);
const shown_out = engine.formatter.formatNumber(allocator, converted) catch {
const shown_out = converted.render(allocator, display_format) catch {
return .{ .output = "error: out of memory\n", .is_error = true };
};
defer shown_out.deinit(allocator);
const output = std.fmt.bufPrint(buf, "{s} {s} = {s} {s}", .{
shown_in.display, from.name, shown_out.display, to.name,
shown_in.text, from.name, shown_out.text, to.name,
}) catch {
return .{ .output = "error: buffer overflow\n", .is_error = true };
};
@ -456,21 +469,24 @@ pub fn evaluateWith(
// Enrich with multi-base view when the expression used non-decimal
// literals and the result is a non-negative integer.
if (info.has_nondecimal_literal and isDisplayableInt(info.value.toFloat(allocator))) {
var base_buf: [4096]u8 = undefined;
const decimal = engine.formatter.formatFloat(&base_buf, info.value.toFloat(allocator));
return formatStandardMultiBase(buf, decimal.display, info.value.toFloat(allocator));
const decimal = info.value.render(allocator, display_format) catch {
return .{ .output = "error: out of memory\n", .is_error = true };
};
defer decimal.deinit(allocator);
return formatStandardMultiBase(buf, decimal.text, info.value.toFloat(allocator));
}
const shown = engine.formatter.formatNumber(allocator, info.value) catch {
const shown = info.value.render(allocator, display_format) catch {
return .{ .output = "error: out of memory\n", .is_error = true };
};
defer shown.deinit(allocator);
// Copy into the caller's buffer so the result does not depend on the
// allocator outliving this call.
if (shown.display.len > buf.len) {
if (shown.text.len > buf.len) {
return .{ .output = "error: result too long to display\n", .is_error = true };
}
@memcpy(buf[0..shown.display.len], shown.display);
return .{ .output = buf[0..shown.display.len], .is_error = false };
@memcpy(buf[0..shown.text.len], shown.text);
return .{ .output = buf[0..shown.text.len], .is_error = false };
}
/// True if the f64 is a non-negative integer within u128 range.
@ -484,19 +500,16 @@ fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliRe
const int_val: u128 = @intFromFloat(value);
const int: engine.Integer = .{
.raw = int_val,
.width = engine.formatter.displayWidthFor(int_val),
.width = engine.Integer.displayWidthFor(int_val),
.signedness = .unsigned,
};
// dec_display points into buf, so copy it out before we overwrite buf.
var dec_copy: [128]u8 = undefined;
const dec_len = @min(dec_display.len, dec_copy.len);
@memcpy(dec_copy[0..dec_len], dec_display[0..dec_len]);
// The rows print themselves, so nothing here needs a buffer per base.
// `dec_display` is the caller's allocated text rather than a slice of `buf`,
// so writing into `buf` cannot clobber it and it needs no copy first.
var w = std.Io.Writer.fixed(buf);
w.print("{s}\n hex: {f}\n oct: {f}\n bin: {f}", .{
dec_copy[0..dec_len],
dec_display,
int.fmt(.hex, .{}),
int.fmt(.octal, .{}),
int.fmt(.binary, .{}),
@ -681,17 +694,17 @@ pub fn formatAmortization(
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
var line: [256]u8 = undefined;
var money: [64]u8 = undefined;
var money_buf: [64]u8 = undefined;
const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{
formatMoney(&money, params.principal) orelse return unformattableResult(),
money(params.principal).render(&money_buf) catch return unformattableResult(),
params.rate,
params.periods,
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, header) catch return oomResult();
const payment_line = std.fmt.bufPrint(&line, "Payment {s} per period\n\n", .{
formatMoney(&money, payment) orelse return unformattableResult(),
money(payment).render(&money_buf) catch return unformattableResult(),
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, payment_line) catch return oomResult();
@ -707,10 +720,10 @@ pub fn formatAmortization(
var bal_buf: [64]u8 = undefined;
const row_text = std.fmt.bufPrint(&line, "{d: >6} {s: >12} {s: >12} {s: >12} {s: >12}\n", .{
row.period,
formatMoney(&pay_buf, row.payment) orelse return unformattableResult(),
formatMoney(&int_buf, row.interest) orelse return unformattableResult(),
formatMoney(&prin_buf, row.principal) orelse return unformattableResult(),
formatMoney(&bal_buf, row.balance) orelse return unformattableResult(),
money(row.payment).render(&pay_buf) catch return unformattableResult(),
money(row.interest).render(&int_buf) catch return unformattableResult(),
money(row.principal).render(&prin_buf) catch return unformattableResult(),
money(row.balance).render(&bal_buf) catch return unformattableResult(),
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, row_text) catch return oomResult();
}
@ -728,9 +741,9 @@ pub fn formatAmortization(
"Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}",
.{
totals.periods,
formatMoney(&paid_buf, totals.paid) orelse return unformattableResult(),
formatMoney(&interest_buf, totals.interest) orelse return unformattableResult(),
formatMoney(&principal_buf, totals.principal) orelse return unformattableResult(),
money(totals.paid).render(&paid_buf) catch return unformattableResult(),
money(totals.interest).render(&interest_buf) catch return unformattableResult(),
money(totals.principal).render(&principal_buf) catch return unformattableResult(),
},
) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, summary) catch return oomResult();
@ -739,9 +752,10 @@ pub fn formatAmortization(
return .{ .output = text, .is_error = false };
}
/// The engine formatter, so the CLI, the TUI and the engine all group amounts the
/// same way.
const formatMoney = engine.formatter.formatMoney;
/// 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;
/// 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
@ -1536,23 +1550,6 @@ test "parseArgs: amort rejects incomplete or malformed terms" {
try testing.expect(zero == .output and zero.output.is_error);
}
test "formatMoney: the CLI uses the engine formatter, not its own copy" {
// This lived in main.zig character for character alongside a second copy in
// src/tui/financial.zig. These cases now exercise engine.formatter.formatMoney.
var buf: [48]u8 = undefined;
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0).?);
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1).?);
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1).?);
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000).?);
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89).?);
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1).?);
}
test "formatMoney: an amount that does not fit is reported, not rendered" {
var tiny: [4]u8 = undefined;
try testing.expect(formatMoney(&tiny, 1234567.89) == null);
}
test "formatAmortization: an unrenderable amount is an error, not a table of marks" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();

View file

@ -22,6 +22,33 @@ const Allocator = std.mem.Allocator;
const Mode = enum { standard, programmer, financial, convert };
/// How the TUI renders a number.
///
/// The engine holds no digit budget of its own, so this is the terminal's answer to
/// NFR-9.9: 20 fractional digits, and full digits up to 40 of them before a value
/// abbreviates to scientific notation. The views share it, and a value copied to
/// the clipboard will ask for `clipboard_format` instead.
pub const display_format: engine.Number.FormatOptions = .{
.fraction_digits = 20,
.max_integer_digits = 40,
.significant_digits = 17,
.separators = true,
};
/// A single line with no separators, for a value shown beside other text rather
/// than as the result: the float view's rows and the convert view's factor.
///
/// 17 fractional digits is every digit an f64 actually has, so a value whose
/// shortest form fits shows in full (an f32-rounded 0.1 reads
/// "0.100000001490116"), and one that does not, such as a subnormal ULP, goes
/// scientific instead of spending the row on 45 digits.
pub const compact_format: engine.Number.FormatOptions = .{
.fraction_digits = 17,
.max_integer_digits = 16,
.significant_digits = 17,
.separators = false,
};
/// Which column of the convert view has keyboard focus.
pub const ConvZone = enum { category, from, to };
@ -1034,9 +1061,9 @@ pub const App = struct {
// Exact results render in full, so an exact integer past f64's 2^53
// limit reaches the user intact instead of collapsing to scientific
// notation.
const shown = try engine.formatter.formatNumber(self.allocator, info.value);
const shown = try info.value.render(self.allocator, display_format);
defer shown.deinit(self.allocator);
const result_copy = try self.allocator.dupe(u8, shown.display);
const result_copy = try self.allocator.dupe(u8, shown.text);
var details: ?[3][]const u8 = null;
const as_float = info.value.toFloat(self.allocator);
@ -1047,7 +1074,7 @@ pub const App = struct {
const int_val: u128 = @intFromFloat(as_float);
const int: engine.Integer = .{
.raw = int_val,
.width = engine.formatter.displayWidthFor(int_val),
.width = engine.Integer.displayWidthFor(int_val),
.signedness = .unsigned,
};
details = .{
@ -1106,13 +1133,13 @@ pub const App = struct {
self.conv_value.deinit();
self.conv_value = value;
const shown_in = try engine.formatter.formatNumber(self.allocator, self.conv_value);
const shown_in = try self.conv_value.render(self.allocator, display_format);
defer shown_in.deinit(self.allocator);
const shown_out = try engine.formatter.formatNumber(self.allocator, converted);
const shown_out = try converted.render(self.allocator, display_format);
defer shown_out.deinit(self.allocator);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
shown_in.display, pair.from.name, shown_out.display, pair.to.name,
shown_in.text, pair.from.name, shown_out.text, pair.to.name,
});
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}
@ -1133,9 +1160,9 @@ pub const App = struct {
};
defer converted.deinit();
const shown = try engine.formatter.formatNumber(self.allocator, converted);
const shown = try converted.render(self.allocator, display_format);
defer shown.deinit(self.allocator);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ shown.display, request.to.name });
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ shown.text, request.to.name });
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}

View file

@ -62,29 +62,29 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
app.conv_value.clone() catch return;
defer converted.deinit();
const shown_in = engine.formatter.formatNumber(app.allocator, app.conv_value) catch return;
const shown_in = app.conv_value.render(app.allocator, tui.display_format) catch return;
defer shown_in.deinit(app.allocator);
const shown_out = engine.formatter.formatNumber(app.allocator, converted) catch return;
const shown_out = converted.render(app.allocator, tui.display_format) catch return;
defer shown_out.deinit(app.allocator);
var line_buf: [192]u8 = undefined;
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.display, pair.from.name }) catch "?";
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.text, pair.from.name }) catch "?";
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
row += 1;
var result_buf: [192]u8 = undefined;
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.display, pair.to.name }) catch "?";
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.text, pair.to.name }) catch "?";
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
row += 1;
// -- Conversion factor (linear conversions only) --
if (pair.from.isLinear() and pair.to.isLinear()) {
const factor = pair.from.to_base_factor / pair.to.to_base_factor;
var factor_val_buf: [64]u8 = undefined;
const factor_str = engine.formatter.formatCompactFloat(&factor_val_buf, factor);
const factor_shown = engine.Number.fromFloat(factor).render(app.allocator, tui.compact_format) catch return;
defer factor_shown.deinit(app.allocator);
var factor_buf: [160]u8 = undefined;
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
pair.from.name, factor_str, pair.to.name,
pair.from.name, factor_shown.text, pair.to.name,
}) catch "?";
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
} else {

View file

@ -29,7 +29,6 @@ const test_render = @import("test_render.zig");
const C = draw.C;
const financial = engine.financial;
const formatter = engine.formatter;
const grouping = engine.grouping;
/// Which calculation the form is showing.
@ -869,14 +868,14 @@ fn substitutedFormula(state: *const State, buf: []u8) ?[]const u8 {
};
}
/// Format an amount with grouping and two decimals, via the engine formatter so
/// the CLI table and this view cannot diverge.
/// Format an amount with grouping and two decimals, via the engine so the CLI
/// table and this view cannot diverge.
///
/// Drawing cannot fail, so a value too large to render becomes "(too large)"
/// rather than being dropped. The CLI reports it as an error instead, because a
/// command can exit non-zero and a frame cannot.
fn money(buf: []u8, value: f64) []const u8 {
return formatter.formatMoney(buf, value) orelse "(too large)";
return engine.financial.money(value).render(buf) catch "(too large)";
}
/// Error text for this view.

View file

@ -14,6 +14,19 @@ const C = draw.C;
const fi = engine.float_interp;
/// Render a float for a single row.
///
/// Drawing cannot fail and should not need the heap, so this hands
/// `Number.render` a fixed buffer over the caller's stack: a value too long for
/// the row becomes "?" rather than a partial line. The budget is the TUI's, not
/// the engine's.
fn compact(buf: []u8, value: f64) []const u8 {
var fba: std.heap.FixedBufferAllocator = .init(buf);
const shown = engine.Number.fromFloat(value).render(fba.allocator(), tui.compact_format) catch
return "?";
return shown.text;
}
/// Which IEEE 754 field a given bit belongs to.
const Field = enum { sign, exponent, significand };
@ -76,7 +89,7 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
// Value
var val_buf: [64]u8 = undefined;
const val_str = engine.formatter.formatCompactFloat(&val_buf, info.value);
const val_str = compact(&val_buf, info.value);
draw.writeStr(surface, row, 2, "Value:", .{ .fg = C.cyan });
draw.writeStr(surface, row, 12, val_str, .{ .fg = C.fg, .bold = true });
row += 1;
@ -111,7 +124,7 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
const ulp_str: []const u8 = if (std.math.isNan(info.ulp))
"n/a"
else
engine.formatter.formatCompactFloat(&ulp_buf, info.ulp);
compact(&ulp_buf, info.ulp);
draw.writeStr(surface, row, 12, ulp_str, .{ .fg = C.fg });
row += 1;
row += 1;

View file

@ -208,7 +208,7 @@ fn registerConfigRegions(app: *tui.App, row: u16, col: u16, text: []const u8) vo
/// is exactly one bit). Otherwise a click just moves the cursor to that digit,
/// since "toggling" a multi-bit nibble or octal digit has no single meaning.
///
/// `text` is a formatter `display` string, which is digits and spaces only. This
/// `text` is a grouped display string, which is digits and spaces only. This
/// used to begin by skipping a `0x`/`0o`/`0b` prefix; only the `raw` strings carry
/// one, so that branch never ran.
fn registerDigitRegions(
@ -250,7 +250,7 @@ fn registerDigitRegions(
/// Draw a field's display string with a cursor highlighting the digit at bit_cursor position.
/// `bits_per_digit` is 4 for hex, 3 for oct, 1 for bin.
///
/// `text` is a formatter `display` string: digits and spaces, never a `0x`/`0o`/`0b`
/// `text` is a grouped display string: digits and spaces, never a `0x`/`0o`/`0b`
/// prefix. This used to skip a prefix and draw it unhighlighted, which was dead
/// code in both this function and `registerDigitRegions`.
fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const u8, bit_cursor: u7, bits_per_digit: u8, total_bits: u8, color: vaxis.Cell.Color) void {