diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index be00c57..76e792d 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -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. diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index 77478bf..3f75a31 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -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`). diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 7542ed1..928810d 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -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. --- diff --git a/engine/src/Integer.zig b/engine/src/Integer.zig index d919815..f1fb220 100644 --- a/engine/src/Integer.zig +++ b/engine/src/Integer.zig @@ -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); + } +} diff --git a/engine/src/Rational.zig b/engine/src/Rational.zig index a73446e..a49c886 100644 --- a/engine/src/Rational.zig +++ b/engine/src/Rational.zig @@ -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 }, diff --git a/engine/src/engine.zig b/engine/src/engine.zig index b7f68ac..7f16c0b 100644 --- a/engine/src/engine.zig +++ b/engine/src/engine.zig @@ -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"); diff --git a/engine/src/evaluator.zig b/engine/src/evaluator.zig index 26303ae..fecddb7 100644 --- a/engine/src/evaluator.zig +++ b/engine/src/evaluator.zig @@ -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 -- diff --git a/engine/src/financial.zig b/engine/src/financial.zig index b193b0c..3069963 100644 --- a/engine/src/financial.zig +++ b/engine/src/financial.zig @@ -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)); +} diff --git a/engine/src/formatter.zig b/engine/src/formatter.zig deleted file mode 100644 index 26c5681..0000000 --- a/engine/src/formatter.zig +++ /dev/null @@ -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)); -} diff --git a/engine/src/grouping.zig b/engine/src/grouping.zig index 2982288..7002e57 100644 --- a/engine/src/grouping.zig +++ b/engine/src/grouping.zig @@ -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)); diff --git a/engine/src/number.zig b/engine/src/number.zig index 6984b18..f992f8d 100644 --- a/engine/src/number.zig +++ b/engine/src/number.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const Rational = @import("Rational.zig"); +const grouping = @import("grouping.zig"); /// The errors arithmetic on `Number` can produce, which are `Rational`'s: this tier /// adds no failure of its own. @@ -44,8 +45,20 @@ pub const Number = union(enum) { // -- Construction -- - pub fn fromRational(value: Rational) Number { - return .{ .exact = value }; + /// Wrap an exact value, applying the growth cap: a rational whose + /// denominator exceeds `max_denominator_bits` is demoted to inexact rather + /// than stored. Takes ownership of `value`. + /// + /// This is the only way to build an exact `Number`, so the cap holds for + /// constructed values and not just for arithmetic results. + pub fn fromRational(allocator: Allocator, value: Rational) Number { + if (value.denBitCount() <= max_denominator_bits) { + return .{ .exact = value }; + } + var v = value; + const f = v.toFloat(allocator); + v.deinit(); + return .{ .inexact = f }; } pub fn fromFloat(value: f64) Number { @@ -85,10 +98,6 @@ pub const Number = union(enum) { // -- Queries -- - pub fn isExact(self: Number) bool { - return self == .exact; - } - /// Collapse to f64 for display or for handing to a float-only operation. pub fn toFloat(self: Number, allocator: Allocator) f64 { return switch (self) { @@ -133,20 +142,6 @@ pub const Number = union(enum) { }; } - // -- Demotion -- - - /// Apply the growth cap: an exact value with an oversized denominator is - /// converted to inexact. Takes ownership of `value`. - fn capped(allocator: Allocator, value: Rational) Number { - if (value.denBitCount() <= max_denominator_bits) { - return .{ .exact = value }; - } - var v = value; - const f = v.toFloat(allocator); - v.deinit(); - return .{ .inexact = f }; - } - // -- Arithmetic (with contagion) -- /// Shape of a binary operation: exact when both operands are exact, @@ -160,11 +155,19 @@ pub const Number = union(enum) { ) Error!Number { if (a == .exact and b == .exact) { const result = try exactOp(allocator, a.exact, b.exact); - return capped(allocator, result); + return fromRational(allocator, result); } return .{ .inexact = floatOp(a.toFloat(allocator), b.toFloat(allocator)) }; } + // The `*Float` wrappers below exist because Zig operators and builtins are + // not first-class values: `x + y` and `@mod(x, y)` cannot be handed to + // `binary` as a `fn (f64, f64) f64`. `std.math` offers no operator + // equivalents, and its `floor`, `ceil`, and `round` are declared + // `pub inline fn (value: anytype)`, which is both generic and inline, so + // they will not coerce to a concrete function type either. A named wrapper + // per operation is the explicit way to name the float side. + fn addFloat(x: f64, y: f64) f64 { return x + y; } @@ -219,7 +222,7 @@ pub const Number = union(enum) { if (exponent.asExactInt(i64)) |e| { if (base.exact.isZero() and e < 0) return Error.DivisionByZero; const result = try Rational.powInt(allocator, base.exact, e); - return capped(allocator, result); + return fromRational(allocator, result); } } return .{ .inexact = std.math.pow(f64, base.toFloat(allocator), exponent.toFloat(allocator)) }; @@ -237,7 +240,7 @@ pub const Number = union(enum) { if (a.isNegative()) return Error.NegativeRoot; if (a == .exact) { if (try Rational.sqrtExact(allocator, a.exact)) |root| { - return capped(allocator, root); + return fromRational(allocator, root); } } return .{ .inexact = @sqrt(a.toFloat(allocator)) }; @@ -251,11 +254,12 @@ pub const Number = union(enum) { comptime floatOp: fn (f64) f64, ) Error!Number { return switch (a) { - .exact => |r| capped(allocator, try exactOp(allocator, r)), + .exact => |r| fromRational(allocator, try exactOp(allocator, r)), .inexact => |f| .{ .inexact = floatOp(f) }, }; } + // Wrapped for the reason given above `addFloat`. fn floorFloat(x: f64) f64 { return @floor(x); } @@ -295,7 +299,7 @@ pub const Number = union(enum) { const n = a.asExactInt(i64) orelse return null; if (n < 0) return null; const result = try Rational.factorial(allocator, @intCast(n)); - return capped(allocator, result); + return fromRational(allocator, result); } /// The larger of two values, preserving exactness when both are exact. @@ -325,40 +329,186 @@ pub const Number = union(enum) { return (try order(allocator, a, b)) == .eq; } - // -- Rendering -- + // -- Display -- + // + // A value renders itself, and the caller supplies the budget. No digit count + // lives in the engine: `FormatOptions` has no defaults, so a frontend must + // state its own rather than inherit one chosen for an 80-column terminal. + // This is NFR-9.9 ("display precision is a separate decision from compute + // precision") taken literally, and it is what lets an Android screen and a + // piped CLI disagree without either of them patching the engine. + // + // The one rule that stays here is `f64_exact_integer_limit`, because it is a + // fact about the value rather than a preference about the screen. - pub const Display = struct { - /// Rendered text. Caller owns the memory. - text: []u8, - /// False when the text is a rounded approximation of the true value, - /// either because the value is inexact or because an exact value has a - /// non-terminating decimal expansion. - exact: bool, + /// How a value is rendered. + /// + /// Deliberately without defaults: a frontend that forgets to decide gets a + /// compile error instead of silently inheriting someone else's screen. + pub const FormatOptions = struct { + /// Fractional digits the text may use. + /// + /// One rule for both tiers: an exact expansion is rounded here, and a + /// float whose shortest round-trip form needs more than this abbreviates + /// to scientific rather than being silently shortened. Exact arithmetic + /// can justify more digits than f64's ~17 significant ones. + fraction_digits: usize, + /// Integer digits shown in full before the text abbreviates to + /// scientific notation. `null` never abbreviates, which is what a + /// clipboard or a file wants (NFR-9.9, requirements.md line 237). + max_integer_digits: ?usize, + /// Significant digits kept in the scientific form. + significant_digits: usize, + /// Thousands separators in the integer part. A fractional part is never + /// grouped. + separators: bool, }; - /// Render for display. Exact values with terminating expansions print - /// exactly; everything else is rounded to `max_digits` and flagged. - pub fn toDecimalString(self: Number, allocator: Allocator, max_digits: usize) Error!Display { - switch (self) { - .exact => |r| { - const d = try r.toDecimalString(allocator, max_digits); - return .{ .text = d.text, .exact = d.exact }; - }, - .inexact => |f| { - const text = std.fmt.allocPrint(allocator, "{d}", .{f}) catch - return Error.OutOfMemory; - return .{ .text = text, .exact = false }; - }, + /// Rendered text, plus whether the budget cost anything. + pub const Rendered = struct { + /// Caller owns the memory. + text: []u8, + /// True when the text is not the whole value: rounded away at + /// `fraction_digits`, or abbreviated to scientific notation. + /// + /// This describes the TEXT, not the value. Whether the value is exact is + /// `number == .exact`, and the two are independent: an exact 1/3 renders + /// truncated, and an inexact 0.5 renders whole. A frontend marking a + /// result as approximate wants both facts, which is why this is not a + /// single `exact` flag trying to mean either. + truncated: bool, + + pub fn deinit(self: Rendered, allocator: Allocator) void { + allocator.free(self.text); } + }; + + /// Past 2^53 an f64 no longer distinguishes consecutive integers, so the + /// trailing digits of a fixed rendering would be invented. Not a display + /// preference, so not the caller's to set: the digits are not there. + /// + /// Exact values have no such cliff and print in full at any magnitude, which + /// is the entire point of the tier (NFR-9.1, requirements.md line 232). + const f64_exact_integer_limit: f64 = 9007199254740992.0; // 2^53 + + /// Render for display. Caller owns `Rendered.text`. + pub fn render(self: Number, allocator: Allocator, options: FormatOptions) Error!Rendered { + return switch (self) { + .exact => |r| renderExact(r, allocator, options), + .inexact => |f| renderInexact(f, allocator, options), + }; } - /// Render an exact value as a fraction, or null when the value is inexact or - /// a whole number (where a fraction adds nothing). - pub fn toFractionString(self: Number, allocator: Allocator) Error!?[]u8 { - return switch (self) { - .exact => |r| if (r.isInteger()) null else try r.toFractionString(allocator), - .inexact => null, + fn renderExact(r: Rational, allocator: Allocator, options: FormatOptions) Error!Rendered { + const decimal = try r.toDecimalString(allocator, options.fraction_digits); + + const abbreviate = blk: { + if (options.max_integer_digits) |limit| { + if (grouping.integerDigitCount(decimal.text) > limit) break :blk true; + } + // The other end of the same problem: a value below the fractional + // budget renders as all zeros, which loses it completely rather than + // merely rounding it (2^-70 as "0.00000000000000000000"). + break :blk isZeroText(decimal.text) and !r.isZero(); }; + + if (abbreviate) { + allocator.free(decimal.text); + return .{ + .text = try r.toScientificString(allocator, options.significant_digits), + .truncated = true, + }; + } + + if (!options.separators) return .{ .text = decimal.text, .truncated = !decimal.exact }; + + defer allocator.free(decimal.text); + return .{ .text = try groupText(allocator, decimal.text), .truncated = !decimal.exact }; + } + + fn renderInexact(f: f64, allocator: Allocator, options: FormatOptions) Error!Rendered { + // An f64's decimal text has a known upper bound, so the conversion runs on + // the stack and only the final text is allocated. That is what keeps a + // caller's `FixedBufferAllocator` sized for the text it asked for rather + // than for the widest thing an f64 can spell (347 bytes, for a subnormal). + var stack: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined; + + // "inf", "-inf" and "nan" are the whole of what the value is. + if (!std.math.isFinite(f)) { + return dupeText(allocator, printFloat(&stack, "{d}", f), false); + } + if (@abs(f) >= f64_exact_integer_limit) { + return dupeText(allocator, printFloat(&stack, "{e}", f), true); + } + + // Shortest round-trip: every digit of `fixed` is a digit the f64 has. + const fixed = printFloat(&stack, "{d}", f); + + const abbreviate = blk: { + if (options.max_integer_digits) |limit| { + if (grouping.integerDigitCount(fixed) > limit) break :blk true; + } + break :blk fractionDigitCount(fixed) > options.fraction_digits; + }; + + if (abbreviate) { + // Reuses `stack`, so `fixed` is dead from here. The scientific form is + // far shorter than the fixed one it replaces, so it still fits. + return dupeText(allocator, printFloat(&stack, "{e}", f), true); + } + + if (!options.separators) return dupeText(allocator, fixed, false); + return .{ .text = try groupText(allocator, fixed), .truncated = false }; + } + + /// Format into a buffer already known to be large enough for any f64. + fn printFloat(buf: []u8, comptime spec: []const u8, f: f64) []const u8 { + return std.fmt.bufPrint(buf, spec, .{f}) catch + @panic("f64 text exceeded std.fmt.float.bufferSize"); + } + + fn dupeText(allocator: Allocator, text: []const u8, truncated: bool) Error!Rendered { + return .{ + .text = allocator.dupe(u8, text) catch return Error.OutOfMemory, + .truncated = truncated, + }; + } + + /// Insert thousands separators into the integer part, leaving any sign and + /// fractional part alone. + fn groupText(allocator: Allocator, text: []const u8) Error![]u8 { + const len = grouping.lengthOf(text); + if (len == text.len) { + return allocator.dupe(u8, text) catch Error.OutOfMemory; + } + const out = try allocator.alloc(u8, len); + var w = std.Io.Writer.fixed(out); + grouping.print(&w, text) catch @panic("grouping.lengthOf disagreed with grouping.print"); + std.debug.assert(w.end == len); + return out; + } + + /// 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 in decimal text, which is the budget the text spends. + /// + /// The float arm's form of the check the exact arm makes with `isZeroText`: + /// both ask "did this value need more fractional digits than the caller + /// allows". The exact arm can ask after the fact because `toDecimalString` + /// rounds at the budget and a value below it rounds to all zeros. A float's + /// shortest round-trip text is never rounded, so its digits are counted + /// instead, and a value needing more than the budget abbreviates rather than + /// being silently shortened. + fn fractionDigitCount(text: []const u8) usize { + const dot = std.mem.indexOfScalar(u8, text, '.') orelse return 0; + return text.len - dot - 1; } }; @@ -367,24 +517,36 @@ pub const Number = union(enum) { const testing = std.testing; const alloc = testing.allocator; +/// Render `n` at a `digits` fractional budget and check the text, plus whether +/// the text is the exact value. +/// +/// That second question is the composition a frontend does: the text is the whole +/// truth only when the value is exact AND the budget did not round it away. +/// `render` reports the two facts separately because they are separate; this +/// helper joins them so the arithmetic tests below can state one expectation. fn expectDecimal(expected: []const u8, expected_exact: bool, n: Number, digits: usize) !void { - const d = try n.toDecimalString(alloc, digits); - defer alloc.free(d.text); - try testing.expectEqualStrings(expected, d.text); - try testing.expectEqual(expected_exact, d.exact); + const shown = try n.render(alloc, .{ + .fraction_digits = digits, + .max_integer_digits = null, + .significant_digits = 17, + .separators = false, + }); + defer shown.deinit(alloc); + try testing.expectEqualStrings(expected, shown.text); + try testing.expectEqual(expected_exact, n == .exact and !shown.truncated); } test "parse produces an exact value" { var n = try Number.parse(alloc, "0.1"); defer n.deinit(); - try testing.expect(n.isExact()); + try testing.expect(n == .exact); try expectDecimal("0.1", true, n, 20); } test "fromFloat produces an inexact value" { var n = Number.fromFloat(0.5); defer n.deinit(); - try testing.expect(!n.isExact()); + try testing.expect(n != .exact); } test "exact + exact stays exact: the 0.1 + 0.2 case" { @@ -394,7 +556,7 @@ test "exact + exact stays exact: the 0.1 + 0.2 case" { defer b.deinit(); var sum = try Number.add(alloc, a, b); defer sum.deinit(); - try testing.expect(sum.isExact()); + try testing.expect(sum == .exact); try expectDecimal("0.3", true, sum, 20); } @@ -406,11 +568,11 @@ test "contagion: inexact operand makes the result inexact" { var sum = try Number.add(alloc, a, b); defer sum.deinit(); - try testing.expect(!sum.isExact()); + try testing.expect(sum != .exact); var product = try Number.mul(alloc, b, a); defer product.deinit(); - try testing.expect(!product.isExact()); + try testing.expect(product != .exact); } test "contagion: an inexact value is never re-exactified" { @@ -422,7 +584,7 @@ test "contagion: an inexact value is never re-exactified" { defer two.deinit(); var product = try Number.mul(alloc, a, two); defer product.deinit(); - try testing.expect(!product.isExact()); + try testing.expect(product != .exact); try testing.expectEqual(@as(f64, 1.0), product.toFloat(alloc)); try testing.expect(!product.isExactInteger()); } @@ -439,7 +601,7 @@ test "contagion propagates through a chain" { defer step2.deinit(); var step3 = try Number.sub(alloc, step2, exact); defer step3.deinit(); - try testing.expect(!step3.isExact()); + try testing.expect(step3 != .exact); } test "sub and mul stay exact" { @@ -464,11 +626,11 @@ test "div: exact thirds and the round trip back to one" { var third = try Number.div(alloc, one, three); defer third.deinit(); - try testing.expect(third.isExact()); + try testing.expect(third == .exact); var back = try Number.mul(alloc, third, three); defer back.deinit(); - try testing.expect(back.isExact()); + try testing.expect(back == .exact); try expectDecimal("1", true, back, 20); } @@ -495,12 +657,12 @@ test "negate and abs preserve exactness" { defer a.deinit(); var n = try Number.negate(alloc, a); defer n.deinit(); - try testing.expect(n.isExact()); + try testing.expect(n == .exact); try expectDecimal("-0.25", true, n, 20); var b = try Number.abs(alloc, n); defer b.deinit(); - try testing.expect(b.isExact()); + try testing.expect(b == .exact); try expectDecimal("0.25", true, b, 20); } @@ -509,7 +671,7 @@ test "negate and abs preserve inexactness" { defer a.deinit(); var b = try Number.abs(alloc, a); defer b.deinit(); - try testing.expect(!b.isExact()); + try testing.expect(b != .exact); try testing.expectEqual(@as(f64, 1.5), b.toFloat(alloc)); } @@ -520,7 +682,7 @@ test "pow: integer exponent stays exact" { defer ten.deinit(); var p = try Number.pow(alloc, two, ten); defer p.deinit(); - try testing.expect(p.isExact()); + try testing.expect(p == .exact); try expectDecimal("1024", true, p, 20); } @@ -546,7 +708,7 @@ test "pow: fractional exponent falls back to inexact" { defer half.deinit(); var p = try Number.pow(alloc, two, half); defer p.deinit(); - try testing.expect(!p.isExact()); + try testing.expect(p != .exact); try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), p.toFloat(alloc), 1e-15); } @@ -557,7 +719,7 @@ test "pow: negative integer exponent stays exact" { defer neg.deinit(); var p = try Number.pow(alloc, two, neg); defer p.deinit(); - try testing.expect(p.isExact()); + try testing.expect(p == .exact); try expectDecimal("0.125", true, p, 20); } @@ -574,14 +736,14 @@ test "sqrt: perfect squares stay exact, others fall back" { defer four.deinit(); var r = try Number.sqrt(alloc, four); defer r.deinit(); - try testing.expect(r.isExact()); + try testing.expect(r == .exact); try expectDecimal("2", true, r, 20); var two = try Number.fromInt(alloc, 2); defer two.deinit(); var r2 = try Number.sqrt(alloc, two); defer r2.deinit(); - try testing.expect(!r2.isExact()); + try testing.expect(r2 != .exact); try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), r2.toFloat(alloc), 1e-15); } @@ -602,7 +764,7 @@ test "sqrt: a negative input is a domain error, not a silent NaN" { defer zero.deinit(); var root_zero = try Number.sqrt(alloc, zero); defer root_zero.deinit(); - try testing.expect(root_zero.isExact()); + try testing.expect(root_zero == .exact); } test "sqrt: a negative input surfaces as NegativeRoot, not a vaguer error" { @@ -660,10 +822,10 @@ test "demotion: an oversized denominator degrades to inexact" { var next = try Number.div(alloc, value, two); value.deinit(); value = next; - if (!value.isExact()) break; + if (value != .exact) break; _ = &next; } - try testing.expect(!value.isExact()); + try testing.expect(value != .exact); } test "demotion: normal values stay well under the cap" { @@ -673,19 +835,16 @@ test "demotion: normal values stay well under the cap" { defer b.deinit(); var product = try Number.mul(alloc, a, b); defer product.deinit(); - try testing.expect(product.isExact()); + try testing.expect(product == .exact); } -test "toDecimalString: inexact values are always flagged approximate" { +test "rendering: inexact values are always flagged approximate" { var a = Number.fromFloat(0.5); defer a.deinit(); - const d = try a.toDecimalString(alloc, 20); - defer alloc.free(d.text); - try testing.expect(!d.exact); - try testing.expectEqualStrings("0.5", d.text); + try expectDecimal("0.5", false, a, 20); } -test "toDecimalString: exact non-terminating values are flagged approximate" { +test "rendering: exact non-terminating values are flagged approximate" { var one = try Number.fromInt(alloc, 1); defer one.deinit(); var three = try Number.fromInt(alloc, 3); @@ -695,25 +854,6 @@ test "toDecimalString: exact non-terminating values are flagged approximate" { try expectDecimal("0.333", false, third, 3); } -test "toFractionString: exact non-integers only" { - 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 frac = (try third.toFractionString(alloc)).?; - defer alloc.free(frac); - try testing.expectEqualStrings("1/3", frac); - - // Integers and inexact values have no useful fraction form. - try testing.expect((try one.toFractionString(alloc)) == null); - var inexact = Number.fromFloat(0.25); - defer inexact.deinit(); - try testing.expect((try inexact.toFractionString(alloc)) == null); -} - test "toFloat: exact to float uses a single correct rounding" { var a = try Number.parse(alloc, "0.1"); defer a.deinit(); @@ -729,14 +869,14 @@ test "clone preserves the tier" { defer a.deinit(); var b = try a.clone(); defer b.deinit(); - try testing.expect(b.isExact()); + try testing.expect(b == .exact); try testing.expect(try Number.eql(alloc, a, b)); var c = Number.fromFloat(1.25); defer c.deinit(); var d = try c.clone(); defer d.deinit(); - try testing.expect(!d.isExact()); + try testing.expect(d != .exact); } test "floor/ceil/round preserve exactness" { @@ -745,17 +885,17 @@ test "floor/ceil/round preserve exactness" { var f = try Number.floor(alloc, a); defer f.deinit(); - try testing.expect(f.isExact()); + try testing.expect(f == .exact); try expectDecimal("3", true, f, 20); var c = try Number.ceil(alloc, a); defer c.deinit(); - try testing.expect(c.isExact()); + try testing.expect(c == .exact); try expectDecimal("4", true, c, 20); var r = try Number.round(alloc, a); defer r.deinit(); - try testing.expect(r.isExact()); + try testing.expect(r == .exact); try expectDecimal("4", true, r, 20); } @@ -765,17 +905,17 @@ test "floor/ceil/round on inexact stay inexact" { var f = try Number.floor(alloc, a); defer f.deinit(); - try testing.expect(!f.isExact()); + try testing.expect(f != .exact); try testing.expectEqual(@as(f64, 3.0), f.toFloat(alloc)); var c = try Number.ceil(alloc, a); defer c.deinit(); - try testing.expect(!c.isExact()); + try testing.expect(c != .exact); try testing.expectEqual(@as(f64, 4.0), c.toFloat(alloc)); var r = try Number.round(alloc, a); defer r.deinit(); - try testing.expect(!r.isExact()); + try testing.expect(r != .exact); try testing.expectEqual(@as(f64, 4.0), r.toFloat(alloc)); } @@ -787,7 +927,7 @@ test "mod with an inexact operand stays inexact" { var m = try Number.mod(alloc, a, b); defer m.deinit(); - try testing.expect(!m.isExact()); + try testing.expect(m != .exact); try testing.expectEqual(@as(f64, 1.0), m.toFloat(alloc)); // And with the inexact value on the right. @@ -797,7 +937,7 @@ test "mod with an inexact operand stays inexact" { defer d.deinit(); var m2 = try Number.mod(alloc, c, d); defer m2.deinit(); - try testing.expect(!m2.isExact()); + try testing.expect(m2 != .exact); try testing.expectEqual(@as(f64, 1.0), m2.toFloat(alloc)); } @@ -808,7 +948,7 @@ test "mod preserves exactness and rejects a zero divisor" { defer b.deinit(); var m = try Number.mod(alloc, a, b); defer m.deinit(); - try testing.expect(m.isExact()); + try testing.expect(m == .exact); try expectDecimal("1", true, m, 20); var zero = try Number.fromInt(alloc, 0); @@ -825,7 +965,7 @@ test "factorial is exact and unbounded" { defer five.deinit(); var f = (try Number.factorial(alloc, five)).?; defer f.deinit(); - try testing.expect(f.isExact()); + try testing.expect(f == .exact); try expectDecimal("120", true, f, 20); // 171! is beyond f64 but fine here. @@ -833,7 +973,7 @@ test "factorial is exact and unbounded" { defer big.deinit(); var bf = (try Number.factorial(alloc, big)).?; defer bf.deinit(); - try testing.expect(bf.isExact()); + try testing.expect(bf == .exact); } test "factorial rejects non-integers and negatives" { @@ -858,12 +998,12 @@ test "max and min preserve exactness" { var hi = try Number.max(alloc, a, b); defer hi.deinit(); - try testing.expect(hi.isExact()); + try testing.expect(hi == .exact); try expectDecimal("0.2", true, hi, 20); var lo = try Number.min(alloc, a, b); defer lo.deinit(); - try testing.expect(lo.isExact()); + try testing.expect(lo == .exact); try expectDecimal("0.1", true, lo, 20); } @@ -874,7 +1014,7 @@ test "max and min with an inexact operand return that operand as-is" { defer b.deinit(); var hi = try Number.max(alloc, a, b); defer hi.deinit(); - try testing.expect(!hi.isExact()); + try testing.expect(hi != .exact); try testing.expectEqual(@as(f64, 2.0), hi.toFloat(alloc)); } @@ -965,10 +1105,27 @@ fn bodyRendering(a: Allocator) anyerror!void { var third = try Number.div(a, x, three); defer third.deinit(); - const d = try third.toDecimalString(a, 12); + // 1/10 / 3 is 1/30, always exact, so the exact renderers are the ones + // under allocation pressure here. + const d = try third.exact.toDecimalString(a, 12); a.free(d.text); - if (try third.toFractionString(a)) |frac| a.free(frac); + const frac = try third.exact.toFractionString(a); + a.free(frac); _ = third.toFloat(a); + + // Every branch of `render` allocates, including the abbreviating ones. + for ([_]Number.FormatOptions{ display_budget, clipboard_budget, compact_budget }) |options| { + const shown = try third.render(a, options); + shown.deinit(a); + const grouped = try x.render(a, options); + grouped.deinit(a); + const inexact = try Number.fromFloat(231677.04).render(a, options); + inexact.deinit(a); + var tiny = try Number.parse(a, "0.00000000000000000000001"); + defer tiny.deinit(); + const abbreviated = try tiny.render(a, options); + abbreviated.deinit(a); + } } test "OOM safety: exact arithmetic" { @@ -986,3 +1143,341 @@ test "OOM safety: rounding, mod, max/min and clone" { test "OOM safety: rendering" { try oomSweep(bodyRendering); } + +// -- Display tests -- +// +// These moved here with the renderer, from `formatter.zig`, where the digit +// budgets were engine constants and every case had to be phrased in terms of +// whichever of five thresholds applied. A test now states its budget the way a +// frontend does. + +/// What a terminal asks for: grouped, 20 fractional digits, abbreviating past 40 +/// integer digits. `src/main.zig` and `src/tui.zig` declare the same thing. +const display_budget: Number.FormatOptions = .{ + .fraction_digits = 20, + .max_integer_digits = 40, + .significant_digits = 17, + .separators = false, +}; + +/// The same, grouped, which is the form that reaches a screen. +const grouped_budget: Number.FormatOptions = .{ + .fraction_digits = 20, + .max_integer_digits = 40, + .significant_digits = 17, + .separators = true, +}; + +/// What a clipboard asks for: every digit, no separators, never abbreviated +/// (requirements.md line 237). +const clipboard_budget: Number.FormatOptions = .{ + .fraction_digits = 20, + .max_integer_digits = null, + .significant_digits = 17, + .separators = false, +}; + +/// What the float view asks for: single line, no separators, and 17 fractional +/// digits, which is every digit an f64 has. A value needing more than that is one +/// no row wants to show in full, such as a subnormal ULP. +const compact_budget: Number.FormatOptions = .{ + .fraction_digits = 17, + .max_integer_digits = 16, + .significant_digits = 17, + .separators = false, +}; + +fn expectRender( + expected: []const u8, + expected_truncated: bool, + value: Number, + options: Number.FormatOptions, +) !void { + const shown = try value.render(alloc, options); + defer shown.deinit(alloc); + try testing.expectEqualStrings(expected, shown.text); + try testing.expectEqual(expected_truncated, shown.truncated); +} + +fn expectRenderExact(expected: []const u8, text: []const u8, options: Number.FormatOptions) !void { + var value = try Number.parse(alloc, text); + defer value.deinit(); + try expectRender(expected, false, value, options); +} + +fn hasChar(s: []const u8, c: u8) bool { + return std.mem.indexOfScalar(u8, s, c) != null; +} + +// -- Exact values -- + +test "render: exact integers group and keep every digit" { + try expectRenderExact("42", "42", grouped_budget); + try expectRenderExact("4,294,967,295", "4294967295", grouped_budget); + try expectRenderExact("-1,234,567", "-1234567", grouped_budget); + try expectRenderExact("0", "0", grouped_budget); + // The whole point of the exact tier: NOT 9.007199254740992e15. + try expectRenderExact("9,007,199,254,740,993", "9007199254740993", grouped_budget); + // 30 digits, still inside a 40-digit budget. + try expectRenderExact( + "123,456,789,012,345,678,901,234,567,890", + "123456789012345678901234567890", + grouped_budget, + ); +} + +test "render: the clipboard budget drops the separators, not the digits" { + try expectRenderExact("4294967295", "4294967295", clipboard_budget); + try expectRenderExact("9007199254740993", "9007199254740993", clipboard_budget); +} + +test "render: exact terminating fractions print in full" { + try expectRenderExact("0.125", "0.125", grouped_budget); + try expectRenderExact("1,234,567.25", "1234567.25", grouped_budget); + try expectRenderExact("1,234,567.891", "1234567.891", grouped_budget); + // The last magnitude a 20-digit budget can show. + try expectRenderExact("0.00000000000000000001", "0.00000000000000000001", grouped_budget); +} + +test "render: 0.1 + 0.2 renders as 0.3" { + 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 expectRender("0.3", false, sum, grouped_budget); +} + +test "render: a repeating expansion is rounded at the budget and flagged" { + 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 third.render(alloc, grouped_budget); + defer shown.deinit(alloc); + try testing.expect(shown.truncated); + try testing.expect(std.mem.startsWith(u8, shown.text, "0.3333333333")); + // "0." plus the budget. + try testing.expectEqual(grouped_budget.fraction_digits + 2, shown.text.len); + + // A smaller budget rounds sooner. Same value, different frontend. + try expectRender("0.33333333333333333", true, third, compact_budget); +} + +// -- Abbreviation past the integer budget -- + +test "render: at the budget a value still prints in full" { + // 2^128 is 39 digits, inside the 40-digit budget, and is a value the exact + // tier exists to serve. + try expectRenderExact( + "340,282,366,920,938,463,463,374,607,431,768,211,456", + "340282366920938463463374607431768211456", + grouped_budget, + ); +} + +test "render: past the budget the text abbreviates and says so" { + var n = try Number.parse(alloc, "1e50"); + defer n.deinit(); + try expectRender("1e50", true, n, grouped_budget); + // A clipboard sets no cap, so the same value keeps all 51 digits. + const raw = try n.render(alloc, clipboard_budget); + defer raw.deinit(alloc); + try testing.expectEqual(@as(usize, 51), raw.text.len); + try testing.expect(!raw.truncated); +} + +test "render: abbreviation rounds the mantissa" { + // 41 nines: rounds up and carries into a new power of ten. + var carry = try Number.parse(alloc, "99999999999999999999999999999999999999999"); + defer carry.deinit(); + try expectRender("1e41", true, carry, grouped_budget); + + // 41 digits whose 18th is 8, so the 17th significant digit rounds 7 -> 8 + // with no carry propagation. + var middle = try Number.parse(alloc, "12345678901234567800000000000000000000000"); + defer middle.deinit(); + try expectRender("1.2345678901234568e40", true, middle, grouped_budget); + + // And rounds down when the next digit is below five. + var down = try Number.parse(alloc, "12345678901234567400000000000000000000000"); + defer down.deinit(); + try expectRender("1.2345678901234567e40", true, down, grouped_budget); +} + +test "render: abbreviated values keep their sign and are never grouped" { + var n = try Number.parse(alloc, "-1.5e60"); + defer n.deinit(); + const shown = try n.render(alloc, grouped_budget); + defer shown.deinit(alloc); + try testing.expectEqualStrings("-1.5e60", shown.text); + // Commas in an exponent would corrupt it. + try testing.expect(!hasChar(shown.text, ',')); +} + +test "render: both ends of the range go through one scientific renderer" { + const cases = [_][2][]const u8{ + // 41 digits, one past the budget. + .{ "10000000000000000000000000000000000000000", "1e40" }, + .{ "12000000000000000000000000000000000000000", "1.2e40" }, + .{ "-25000000000000000000000000000000000000000", "-2.5e40" }, + }; + for (cases) |c| { + var value = try Number.parse(alloc, c[0]); + defer value.deinit(); + try expectRender(c[1], true, value, grouped_budget); + // The clipboard form still carries every digit. + try expectRender(c[0], false, value, clipboard_budget); + } +} + +// -- Exact values below the fractional budget -- +// +// The 20-digit budget renders anything smaller as "0.00000000000000000000", +// which destroys the value at the last step, in the one tier whose entire +// purpose is not doing that. Scientific notation is the honest form, and unlike +// the integer case the clipboard cannot be spared: there is no fixed text to +// give it. + +test "render: a value below the fractional budget uses scientific notation" { + // 2^-70, exactly representable, equal to 8.470329472543003e-22. + var one = try Number.parse(alloc, "1"); + defer one.deinit(); + var divisor = try Number.parse(alloc, "1180591620717411303424"); + defer divisor.deinit(); + var tiny = try Number.div(alloc, one, divisor); + defer tiny.deinit(); + try expectRender("8.4703294725430034e-22", true, tiny, grouped_budget); + try expectRender("8.4703294725430034e-22", true, tiny, clipboard_budget); + + var negative = try Number.fromInt(alloc, -1); + defer negative.deinit(); + var negative_tiny = try Number.div(alloc, negative, divisor); + defer negative_tiny.deinit(); + try expectRender("-8.4703294725430034e-22", true, negative_tiny, grouped_budget); +} + +test "render: exact zero is zero, not scientific" { + try expectRenderExact("0", "0", grouped_budget); + try expectRenderExact("0", "0", clipboard_budget); +} + +// -- Inexact values -- + +test "render: inexact integers group like exact ones" { + try expectRender("42", false, Number.fromFloat(42.0), grouped_budget); + try expectRender("4,294,967,295", false, Number.fromFloat(4294967295.0), grouped_budget); + try expectRender("-1,234", false, Number.fromFloat(-1234.0), grouped_budget); + try expectRender("1,000,000", false, Number.fromFloat(1000000.0), grouped_budget); + try expectRender("0", false, Number.fromFloat(0.0), grouped_budget); +} + +test "render: an inexact fractional value groups its integer part only" { + try expectRender("231,677.04", false, Number.fromFloat(231677.04), grouped_budget); + try expectRender("231677.04", false, Number.fromFloat(231677.04), clipboard_budget); + try expectRender("-9,876,543.21", false, Number.fromFloat(-9876543.21), grouped_budget); + // Fewer than four integer digits has nothing to group. + try expectRender("123.456", false, Number.fromFloat(123.456), grouped_budget); + try expectRender("999.99", false, Number.fromFloat(999.99), grouped_budget); + // Grouping starts at four. + try expectRender("1,000.25", false, Number.fromFloat(1000.25), grouped_budget); + try expectRender("3.14159", false, Number.fromFloat(3.14159), grouped_budget); +} + +test "render: the grouped form of a float re-parses to the same value" { + // FR-1.8 accepts commas as digit separators, so the display form is valid + // input. That is what makes grouping safe to apply to results. + var value = Number.fromFloat(1234567.891); + const shown = try value.render(alloc, grouped_budget); + defer shown.deinit(alloc); + try testing.expectEqualStrings("1,234,567.891", shown.text); + var reparsed = try Number.parse(alloc, "1234567.891"); + defer reparsed.deinit(); + try testing.expectApproxEqAbs(@as(f64, 1234567.891), reparsed.toFloat(alloc), 1e-9); +} + +test "render: past 2^53 an inexact value goes scientific whatever the budget" { + // Not the caller's decision: consecutive integers are no longer distinct up + // there, so the trailing digits of a fixed rendering would be invented. + try expectRender("1.5e16", true, Number.fromFloat(1.5e16), grouped_budget); + try expectRender("1.5e16", true, Number.fromFloat(1.5e16), clipboard_budget); + // Just below the limit it still prints in full: those digits are real. + try expectRender("9,007,199,254,740,990", false, Number.fromFloat(9007199254740992.0 - 2.0), grouped_budget); +} + +test "render: an inexact value past the integer budget abbreviates" { + try expectRender("1e20", true, Number.fromFloat(1e20), grouped_budget); + try expectRender("-1e18", true, Number.fromFloat(-1e18), compact_budget); +} + +test "render: an inexact value needing more digits than the budget goes scientific" { + // One rule for both arms: the text may spend at most `fraction_digits` on a + // fraction. An exact 1/3 is rounded there; a float, whose shortest form is + // never rounded, abbreviates instead of being silently shortened. + try expectRender("1.5e-21", true, Number.fromFloat(1.5e-21), grouped_budget); + // 33 fractional digits, past a 20-digit budget. + try expectRender("3.1415926535897935e-17", true, Number.fromFloat(std.math.pi * 1e-17), grouped_budget); + // 17, inside it, and the same text the exact tier gives for this magnitude. + try expectRender("0.00000000000000015", false, Number.fromFloat(1.5e-16), grouped_budget); + try expectRender("0.001", false, Number.fromFloat(0.001), grouped_budget); +} + +test "render: scientific text is never grouped" { + const big = try Number.fromFloat(1.234e20).render(alloc, grouped_budget); + defer big.deinit(alloc); + try testing.expect(!hasChar(big.text, ',')); + try testing.expect(hasChar(big.text, 'e')); + + const tiny = try Number.fromFloat(1.5e-25).render(alloc, grouped_budget); + defer tiny.deinit(alloc); + try testing.expect(!hasChar(tiny.text, ',')); +} + +test "render: non-finite values render as themselves" { + try expectRender("inf", false, Number.fromFloat(std.math.inf(f64)), grouped_budget); + try expectRender("-inf", false, Number.fromFloat(-std.math.inf(f64)), grouped_budget); + try expectRender("nan", false, Number.fromFloat(std.math.nan(f64)), grouped_budget); +} + +// -- The compact budget, as the float view uses it -- + +test "render: the compact budget keeps normal magnitudes fixed" { + try expectRender("1", false, Number.fromFloat(1.0), compact_budget); + try expectRender("3.14", false, Number.fromFloat(3.14), compact_budget); + try expectRender("0.5", false, Number.fromFloat(0.5), compact_budget); + try expectRender("-2", false, Number.fromFloat(-2.0), compact_budget); + // Just above the small-magnitude threshold. + try expectRender("0.001", false, Number.fromFloat(0.001), compact_budget); + try expectRender("0", false, Number.fromFloat(0.0), compact_budget); +} + +test "render: the compact budget keeps every digit a float actually has" { + // 0.1 stored as f32 then widened. A short budget decides when to abbreviate, + // not how many of a float's own digits to show: all 17 are real. + const v: f64 = @floatCast(@as(f32, 0.1)); + const shown = try Number.fromFloat(v).render(alloc, compact_budget); + defer shown.deinit(alloc); + try testing.expect(!hasChar(shown.text, 'e')); + try testing.expect(std.mem.startsWith(u8, shown.text, "0.100000001")); +} + +test "render: the compact budget sends values needing more than 17 digits to scientific" { + // Smallest f32 subnormal, about 1.4e-45. + const subnormal = try Number.fromFloat(std.math.ldexp(@as(f64, 1.0), -149)).render(alloc, compact_budget); + defer subnormal.deinit(alloc); + try testing.expect(hasChar(subnormal.text, 'e')); + + // f32 ULP of 1.0, 2^-23, about 1.19e-7. + const f32_ulp = try Number.fromFloat(std.math.ldexp(@as(f64, 1.0), -23)).render(alloc, compact_budget); + defer f32_ulp.deinit(alloc); + try testing.expect(hasChar(f32_ulp.text, 'e')); + + // f64 ULP of 1.0, 2^-52, about 2.2e-16. + const f64_ulp = try Number.fromFloat(std.math.ldexp(@as(f64, 1.0), -52)).render(alloc, compact_budget); + defer f64_ulp.deinit(alloc); + try testing.expect(hasChar(f64_ulp.text, 'e')); +} diff --git a/engine/src/units.zig b/engine/src/units.zig index 40b1541..c4f66db 100644 --- a/engine/src/units.zig +++ b/engine/src/units.zig @@ -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; diff --git a/src/main.zig b/src/main.zig index 056bbee..f3a7eae 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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(); diff --git a/src/tui.zig b/src/tui.zig index b41527b..08ccd73 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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 }); } diff --git a/src/tui/convert.zig b/src/tui/convert.zig index 0e42524..a006c5c 100644 --- a/src/tui/convert.zig +++ b/src/tui/convert.zig @@ -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 { diff --git a/src/tui/financial.zig b/src/tui/financial.zig index 2e89ddd..03b5b0f 100644 --- a/src/tui/financial.zig +++ b/src/tui/financial.zig @@ -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. diff --git a/src/tui/float_view.zig b/src/tui/float_view.zig index e243586..4c40e1f 100644 --- a/src/tui/float_view.zig +++ b/src/tui/float_view.zig @@ -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; diff --git a/src/tui/programmer.zig b/src/tui/programmer.zig index a5ad909..fd070dd 100644 --- a/src/tui/programmer.zig +++ b/src/tui/programmer.zig @@ -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 {