diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index 183f7c3..fca458e 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -103,6 +103,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm - **FR-5.5**: Display intermediate calculation steps/formula used (transparency). - **FR-5.6**: Produce an amortization schedule for a loan: given principal, rate per period, and number of periods (with an optional payment override), report each period's payment, interest, principal, and remaining balance, plus totals. Figures round to whole cents by default, with the final period absorbing the rounding residue so the balance reaches exactly zero. A payment larger than the level payment retires the loan early; a payment that covers the interest but not the full term ends in a balloon payment; a payment that does not cover the first period's interest is an error rather than an infinite schedule. - **FR-5.7**: Financial calculations must be reachable from a standard-mode expression as functions, not only from a dedicated mode, the same way unit conversion is reachable from a bare expression. Results compose with ordinary arithmetic, so `cagr(10000, 25000, 5) * 100` is a valid expression. The functions are: `cagr(start, end, periods)`, `fv(pv, rate, years[, per_year])`, `pv(fv, rate, years[, per_year])`, `compound_rate(pv, fv, years[, per_year])`, `compound_years(pv, fv, rate[, per_year])`, `apy(nominal_rate, per_year)`, `tvm_pmt`/`tvm_fv`/`tvm_pv`/`tvm_n`/`tvm_rate` (each taking the other four variables), `amort_payment(principal, rate, periods)`, `amort_interest`/`amort_principal`/`amort_balance` (`..., period`), and `amort_total_interest`/`amort_total_paid`. Anything the financial forms can solve must also be reachable this way, so the two surfaces do not drift apart. + - The list a user sees is the engine's, not each frontend's: the signature and one-line summary of every function above live in `financial.expression_functions`, and the CLI help and the TUI help overlay render it. A frontend must not carry its own copy. Both did, and both were wrong in different ways: the CLI named nine of the seventeen and the overlay eleven, with wording that had diverged. An evaluator test asserts every documented name dispatches at the arity its signature advertises, which is what keeps this requirement's list and the code in step. - **FR-5.8**: Financial results are in the inexact tier (FR-4.22's exactness model does not apply): every formula needs a non-integer power or a logarithm, so there is no exact rational to preserve. Money is handled by explicit rounding at the point a figure becomes an amount, banker's rounding (half-even) by default so repeated rounding does not bias totals upward. ### FR-6: CLI Frontend diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 8d2244b..aa349cd 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -914,6 +914,103 @@ plans a `--raw` flag, but today it is exercised only by tests. 100% line coverage, engine 99.44%. CLI output byte-identical across all five rows, both byte orders, ASCII packing and the multi-base standard-mode view. +### Task 5.26: The CLI's output leaves main.zig + +`main.zig` held both ends of the program: which arguments mean what, and how a +finished figure reads on a terminal. One file decided `--bits` spellings and money +column widths. The output half is now `src/cli/format.zig`, a sibling in spirit to +`src/tui/`, and `main.zig` is argument parsing plus the one function that touches a +stream. 913 lines and 669, from 1631. + +What moved: `evaluate`/`evaluateWith`, the conversion and amortization renderers, the +programmer and multi-base result rows, the error decoration, `Mode`, and this +frontend's `display_format`. What stayed: `ParsedArgs`, `parseArgs` and the +subcommand parsers, the usage and help text, and `main`. + +Since the file is named `format.zig`, the prefix those functions carried is gone: +`format.conversion`, `format.amortization`, `format.evaluateWith`. `CliResult` +became `format.Result` for the same reason, and `amortErrorMessage`, +`unformattableResult` and `oomResult` became `amortizationError`, `unformattable` +and `oom`. `decoratedError` is the one output function the argument layer also +needs, because `readConversion` reports engine errors while parsing. + +The two-argument `evaluate` did not survive the move. It defaulted the programmer +configuration and forwarded to `evaluateWith`, and once `main` began passing a +configuration through, only the tests called it - the same shape as the +`evalProgrammer` forwarder deleted in entry 9. Its callers name the default. + +`standardMultiBase` gained the tests it never had. It renders the hex, octal and +binary rows a standard-mode answer carries when the expression was written in another +base, and nothing exercised it: `tally '0xFF + 1'` prints four lines that no test had +ever looked at. There are now assertions on the whole block for a hex, a binary and an +octal expression, on the width following the value rather than a setting (10 gets one +byte, 256 gets two), on a negative and a fractional answer staying decimal because +neither has a bit pattern to show, and on a buffer too small for the rows being an +error rather than half a table. + +None of this is shared with the TUI, deliberately. `src/tui/financial.zig` draws a +windowed slice of the same schedule at narrower widths into a surface; this writes the +whole table into an allocated buffer. What they do share is the engine's `Money` and +`Number` rendering, so the figures agree even though the tables do not. + +- Verify: 929 tests pass, 926 of them the same 926 as before the move (24 moved with + their subjects, 3 are new and none were deleted), fmt and zlint clean. `build.zig`'s + CLI coverage report includes `src/cli`, so the split does not hide a file: + `main.zig` 99.4%, `format.zig` 98.3%. What is left uncovered there is one error path + per output shape - a programmer-mode evaluation error, a result too long for the + buffer, the programmer rows overflowing it, a non-domain amortization error, and + `oom` - none of which any test reaches yet. + +### Task 5.25: The CLI stops reimplementing the engine + +`main.zig` had grown four copies of things the engine already owned. All four are +gone; the file is 105 lines shorter and does less. + +**A second conversion-request parser.** `isSeparatorWord`, `interpretConvertTokens` +and `splitValueAndUnit` reimplemented `units.parseRequest` over argv boundaries: find +`to`/`in`, split the value from a trailing unit, resolve both names. One grammar, two +implementations, and they disagreed - `units.parseRequest` had learned +`Error.AmbiguousUnit` in Task 5.22 while the CLI still picked the first table entry. + +`parseConvertArgs` now joins argv with spaces and reads the result through +`units.parseRequest`. The subcommand allows the separator to be omitted +(`convert 100 km mi`), which a bare expression does not, so the text is read twice: +as written, and with `" to "` inserted before the last token. A `.conversion` from +either reading wins over any error, which is what makes `convert 1 in cm` work - `in` +is the source unit there, not the separator, and the first reading is the one that +sees it. + +`ParsedArgs.conversion` therefore carries a value and two resolved `UnitDef`s rather +than three strings. The value text is duped because the request borrows from the +joined buffer, which is freed on return; the units are values whose names are static. +`formatConversion` takes the resolved units, so its own `findUnit` calls and +`UnknownUnit` path are gone. + +**Two numeric-text predicates.** `looksNumeric` and `isFullyNumeric` decided whether +an argument was a value, with rules of their own for signs, separators and suffixes. +Whether text is a number is `Number.parse`'s answer, and `formatConversion` was going +to ask it anyway, so `readConversion` asks first and the error simply arrives earlier. +`grouping.isNumericText` was the other candidate and is wrong here: it rejects the +FR-1.8 separators `1_000` and `1,000`. + +**A one-line error forwarder.** `errorMessage` called `decoratedError` and nothing +else. Its nine callers call `decoratedError`, whose doc comment absorbed what both +had to say. + +**Its own list of financial functions.** The CLI help named nine of the seventeen +functions in FR-5.7, the TUI overlay named eleven in different words, and neither was +checked against the evaluator. `financial.expression_functions` now holds the name, +signature and one-line summary of each, `financial.help_lines` renders them into an +aligned column at comptime, and both frontends print that. An evaluator test asserts +every documented name resolves to a `Builtin` at the arity its signature advertises, +which is the only place the two can be compared - `Builtin` is private. + +- Verify: 926 tests pass (five new, eight deleted with the helpers they covered), fmt + and zlint clean. `tally --help` lists all seventeen functions; `convert 100 km to + mi`, `convert 100 km mi`, `convert 1 in cm`, `convert 1 in in cm`, `convert 100 mm + in in` and `convert 98.6F in C` all still convert, and `convert abc km mi` and + `convert 1 smoots m` still report the number and the unit respectively. + ### Task 5.24: The engine's surface is what someone imports `engine.zig` re-exported thirteen modules; six had no importer outside the engine. diff --git a/build.zig b/build.zig index b10a31f..2ebec2a 100644 --- a/build.zig +++ b/build.zig @@ -106,8 +106,8 @@ pub fn build(b: *std.Build) void { // One report per source tree, with disjoint include patterns so no file is // accounted for twice. The CLI test binary also compiles tui.zig (main.zig // imports it) but never runs its tests, so the CLI report is narrowed to - // main.zig; without that narrowing the TUI would appear near-uncovered in one - // report and covered in another. + // main.zig and src/cli; without that narrowing the TUI would appear + // near-uncovered in one report and covered in another. { var cov = Coverage.init(b); @@ -127,7 +127,7 @@ pub fn build(b: *std.Build) void { .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }); - _ = cov.addModule(cli_cov, "tally-cli", &.{"src/main.zig"}); + _ = cov.addModule(cli_cov, "tally-cli", &.{ "src/main.zig", "src/cli" }); const tui_cov = b.createModule(.{ .root_source_file = b.path("src/tui.zig"), @@ -138,9 +138,9 @@ pub fn build(b: *std.Build) void { .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }); - // tui.zig plus its view modules. main.zig is deliberately excluded even - // though it is not compiled into this binary, so the two app reports stay - // disjoint by construction rather than by accident. + // tui.zig plus its views. main.zig and src/cli are deliberately excluded + // even though they are not compiled into this binary, so the two app reports + // stay disjoint by construction rather than by accident. _ = cov.addModule(tui_cov, "tally-tui", &.{ "src/tui.zig", "src/tui" }); } diff --git a/engine/src/evaluator.zig b/engine/src/evaluator.zig index 204c6a7..ca662ef 100644 --- a/engine/src/evaluator.zig +++ b/engine/src/evaluator.zig @@ -1637,6 +1637,22 @@ test "every built-in is reachable by name at its own arity" { } } +test "the help table's financial functions dispatch at the arity they advertise" { + // `financial.expression_functions` is what both frontends' help screens render. + // The names and signatures there are only correct if the evaluator agrees, and + // nothing else checks it: `Builtin` is private, so this is the one place the two + // can be compared. + for (financial.expression_functions) |doc| { + const builtin = std.meta.stringToEnum(Builtin, doc.name) orelse { + std.debug.print("help table lists '{s}', which no built-in answers\n", .{doc.name}); + return error.UndocumentedFunction; + }; + const arity = arityOf(builtin); + try testing.expectEqual(arity.min, doc.requiredArgs()); + try testing.expectEqual(arity.max, doc.maxArgs()); + } +} + // -- The AST is not the caller's problem -- // // These use testing.allocator directly rather than testEval's arena, because an diff --git a/engine/src/financial.zig b/engine/src/financial.zig index 8f67e07..a4ed1f8 100644 --- a/engine/src/financial.zig +++ b/engine/src/financial.zig @@ -702,6 +702,96 @@ pub fn amortizationTotals(p: AmortizationParams) Error!AmortizationTotals { return totals; } +// -- Expression function reference -- + +/// One financial function as an expression writes it, for the help screens. +/// +/// The frontends had a list each: nine example lines in the CLI help and eleven +/// compact signatures in the TUI overlay, neither complete and neither checked +/// against the evaluator. Both now render `help_lines` below, and an evaluator test +/// asserts every `name` here dispatches with the arity its `params` advertise. +pub const FunctionDoc = struct { + /// The name the evaluator dispatches on. + name: []const u8, + /// Parameter list as written in a call, parentheses included. A bracketed + /// trailing group is an optional argument: `[, m]`. + params: []const u8, + /// One line, lower case, no trailing period. Kept short: the rendered line has + /// to fit an 80-column terminal beside the signature column. + summary: []const u8, + + /// Arguments the signature shows as required, which is the parameters up to any + /// optional group. Every entry takes at least one. + pub fn requiredArgs(self: FunctionDoc) u8 { + var count: u8 = 1; + for (self.params) |c| switch (c) { + ',' => count += 1, + '[' => break, + else => {}, + }; + return count; + } + + /// Arguments the signature accepts at most: one more than required when it shows + /// an optional trailing group. + pub fn maxArgs(self: FunctionDoc) u8 { + const optional = std.mem.indexOfScalar(u8, self.params, '[') != null; + return self.requiredArgs() + @intFromBool(optional); + } + + /// `name` and `params` joined, as the help screens show it. + pub fn signatureLen(self: FunctionDoc) usize { + return self.name.len + self.params.len; + } +}; + +/// Every financial function reachable from an expression (FR-5.7), in the order the +/// help screens list them: growth, then the TVM family, then amortization. +/// +/// `rate%` marks a rate given as a percentage rather than a fraction, and `m` the +/// compounding periods per year. The TVM entries take the other four variables in a +/// financial calculator's N, I/Y, PV, PMT, FV order, minus the one they solve for. +/// The TVM and amortization rates are per period, not annual: 0.5 there is 6% a year +/// compounded monthly. +pub const expression_functions = [_]FunctionDoc{ + .{ .name = "cagr", .params = "(start, end, periods)", .summary = "growth rate as a fraction" }, + .{ .name = "fv", .params = "(pv, rate%, years[, m])", .summary = "future value" }, + .{ .name = "pv", .params = "(fv, rate%, years[, m])", .summary = "present value" }, + .{ .name = "compound_rate", .params = "(pv, fv, years[, m])", .summary = "nominal annual rate" }, + .{ .name = "compound_years", .params = "(pv, fv, rate%[, m])", .summary = "years to reach the target" }, + .{ .name = "apy", .params = "(rate%, m)", .summary = "nominal rate to effective" }, + .{ .name = "tvm_fv", .params = "(n, rate%, pv, pmt)", .summary = "solve the future value" }, + .{ .name = "tvm_pv", .params = "(n, rate%, pmt, fv)", .summary = "solve the present value" }, + .{ .name = "tvm_pmt", .params = "(n, rate%, pv, fv)", .summary = "solve the payment" }, + .{ .name = "tvm_n", .params = "(rate%, pv, pmt, fv)", .summary = "solve the period count" }, + .{ .name = "tvm_rate", .params = "(n, pv, pmt, fv)", .summary = "solve the periodic rate" }, + .{ .name = "amort_payment", .params = "(principal, rate%, n)", .summary = "level payment" }, + .{ .name = "amort_total_interest", .params = "(principal, rate%, n)", .summary = "interest over the loan" }, + .{ .name = "amort_total_paid", .params = "(principal, rate%, n)", .summary = "principal plus interest" }, + .{ .name = "amort_interest", .params = "(principal, rate%, n, period)", .summary = "interest in one period" }, + .{ .name = "amort_principal", .params = "(principal, rate%, n, period)", .summary = "principal in one period" }, + .{ .name = "amort_balance", .params = "(principal, rate%, n, period)", .summary = "balance after a period" }, +}; + +/// Column the summaries start in, one gutter space past the longest signature. +const summary_column = blk: { + var widest: usize = 0; + for (expression_functions) |doc| widest = @max(widest, doc.signatureLen()); + break :blk widest + 2; +}; + +/// One line per function, `signature`, padding, `summary`, with no leading indent so +/// each frontend can add its own. Rendered at comptime: the help screens hold a +/// slice of this rather than their own copy of the words. +pub const help_lines: [expression_functions.len][]const u8 = blk: { + var rendered: [expression_functions.len][]const u8 = undefined; + for (&rendered, expression_functions) |*line, doc| { + const signature = doc.name ++ doc.params; + line.* = signature ++ " " ** (summary_column - signature.len) ++ doc.summary; + } + break :blk rendered; +}; + // -- Tests -- const testing = std.testing; @@ -1615,3 +1705,41 @@ test "amortization: every figure in a rounded schedule is a whole number of cent } } } + +test "function reference: an optional trailing group is one more argument" { + const optional = FunctionDoc{ + .name = "fv", + .params = "(pv, rate%, years[, m])", + .summary = "future value", + }; + try testing.expectEqual(@as(u8, 3), optional.requiredArgs()); + try testing.expectEqual(@as(u8, 4), optional.maxArgs()); + + const fixed = FunctionDoc{ + .name = "cagr", + .params = "(start, end, periods)", + .summary = "growth rate as a fraction", + }; + try testing.expectEqual(@as(u8, 3), fixed.requiredArgs()); + try testing.expectEqual(@as(u8, 3), fixed.maxArgs()); +} + +test "function reference: rendered lines are aligned and fit an 80-column terminal" { + // The widest indent either frontend adds. The TUI writes help text at column 4. + const indent = 4; + for (help_lines, expression_functions) |line, doc| { + try testing.expect(indent + line.len <= 80); + try testing.expect(std.mem.startsWith(u8, line, doc.name)); + try testing.expect(std.mem.endsWith(u8, line, doc.summary)); + // Every summary starts in the same column, or the list is not a table. + try testing.expectEqual(summary_column, line.len - doc.summary.len); + } +} + +test "function reference: no function is listed twice" { + for (expression_functions, 0..) |doc, i| { + for (expression_functions[i + 1 ..]) |other| { + try testing.expect(!std.mem.eql(u8, doc.name, other.name)); + } + } +} diff --git a/src/cli/format.zig b/src/cli/format.zig new file mode 100644 index 0000000..aa97039 --- /dev/null +++ b/src/cli/format.zig @@ -0,0 +1,733 @@ +//! Everything the CLI prints. +//! +//! One function per output shape, each returning text plus whether that text is an +//! error, and none of them touching stdout: `main.zig` decides which stream a result +//! goes to and what the exit status is. These used to sit in `main.zig` alongside the +//! argument parsing, which left one file holding both ends of the program. +//! +//! Nothing here is shared with the TUI, and that is deliberate. The two frontends +//! want different things from the same engine results: this writes a full-width table +//! into an allocated buffer, while `src/tui/financial.zig` draws a windowed slice of +//! the same schedule into a surface at narrower widths. What they do share is the +//! engine's `Money` and `Number` rendering, so the figures themselves match. +//! +//! The `format` prefix these functions used to carry is now the file name, so +//! `format.conversion` and `format.amortization` read as what they are. + +const std = @import("std"); +const engine = @import("engine"); + +/// A finished piece of output: the text, and whether it is an error. `main.zig` turns +/// the flag into a stream choice and an exit status. +pub const Result = struct { + output: []const u8, + is_error: bool, +}; + +/// Which evaluator a CLI invocation wants. +/// +/// The CLI's own concept, not the engine's: the engine has `evalString` and +/// `evalProgrammerString` and no notion of a mode. There are exactly two here +/// because `-p` is the only mode flag; the TUI's four tabs are its own enum. +pub const Mode = enum { standard, programmer }; + +/// How this frontend renders a number. +/// +/// The engine has no default and no digit constants of its own: a terminal, a +/// clipboard and an Android screen want different budgets, so each frontend states +/// its own (NFR-9.9, "display precision is a separate decision from compute +/// precision"). These are the values the engine used to hold. +const display_format: engine.Number.FormatOptions = .{ + .fraction_digits = 20, + .scientific_below_exponent = 15, + .max_integer_digits = 40, + .significant_digits = 17, + .separators = true, +}; + +/// The engine's amount formatter, so the CLI, the TUI and the engine all group +/// amounts the same way. Two decimals and grouping are properties of money rather +/// than of a screen, so unlike `display_format` there is no budget to pass. +const money = engine.financial.money; + +// -- Expressions -- + +/// Evaluate an expression and format the result for a terminal. +/// +/// The programmer configuration (width, signedness, byte order) is explicit because +/// the flags that set it are. Standard mode ignores it: it is fixed at 64-bit signed. +/// There used to be a two-argument `evaluate` in front of this that defaulted the +/// configuration, and by the end only the tests called it. +pub fn evaluateWith( + allocator: std.mem.Allocator, + expression: []const u8, + mode: Mode, + config: engine.programmer.Config, + buf: []u8, +) Result { + if (mode == .programmer) { + const result = engine.evalProgrammerString(allocator, expression, config) catch |err| { + return .{ .output = decoratedError(err), .is_error = true }; + }; + return programmerResult(buf, result, config); + } + + var env = engine.Environment.init(allocator); + defer env.deinit(); + + // A standalone "to" keyword means this is a unit conversion, e.g. + // "32F to C". Anything without it falls through to normal evaluation. + if (engine.units.parseRequest(expression)) |maybe_request| { + if (maybe_request) |request| { + var value = engine.evalString(&env, allocator, request.value_text) catch |err| { + return .{ .output = decoratedError(err), .is_error = true }; + }; + defer value.deinit(); + return conversionUnits(allocator, buf, value, request.from, request.to); + } + } else |err| { + return .{ .output = decoratedError(err), .is_error = true }; + } + + var info = engine.evalStringInfo(&env, allocator, expression) catch |err| { + return .{ .output = decoratedError(err), .is_error = true }; + }; + defer info.value.deinit(); + + // 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))) { + const decimal = info.value.render(allocator, display_format) catch { + return .{ .output = "error: out of memory\n", .is_error = true }; + }; + defer decimal.deinit(allocator); + return standardMultiBase(buf, decimal.text, info.value.toFloat(allocator)); + } + + 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.text.len > buf.len) { + return .{ .output = "error: result too long to display\n", .is_error = true }; + } + @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. +fn isDisplayableInt(value: f64) bool { + return value >= 0 and value == @trunc(value) and value < 340282366920938463463374607431768211456.0; +} + +/// Format a standard-mode result with an inline multi-base breakdown. +/// The decimal display is already computed; append hex/oct/bin. +fn standardMultiBase(buf: []u8, dec_display: []const u8, value: f64) Result { + const int_val: u128 = @intFromFloat(value); + const int: engine.Integer = .{ + .raw = int_val, + .width = engine.BitWidth.displayFor(int_val), + .signedness = .unsigned, + }; + + // 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_display, + int.fmt(.hex, .{}), + int.fmt(.octal, .{}), + int.fmt(.binary, .{}), + }) catch { + return .{ .output = "error: buffer overflow\n", .is_error = true }; + }; + + return .{ .output = w.buffered(), .is_error = false }; +} + +fn programmerResult(buf: []u8, result: engine.Integer, config: engine.programmer.Config) Result { + var w = std.Io.Writer.fixed(buf); + w.print( + \\ dec(signed): {f} + \\ dec(unsigned): {f} + \\ hex: {f} + \\ oct: {f} + \\ bin: {f} + \\ + , .{ + result.fmt(.decimal_signed, .{}), + result.fmt(.decimal_unsigned, .{}), + result.fmt(.hex, .{ .endian = config.display_endian }), + result.fmt(.octal, .{}), + result.fmt(.binary, .{}), + }) catch { + return .{ .output = "error: buffer overflow\n", .is_error = true }; + }; + + return .{ .output = w.buffered(), .is_error = false }; +} + +// -- Conversions -- + +/// Format a unit conversion between two resolved units. +/// +/// The value is parsed exactly rather than through f64, so a decimal input like 2.5 +/// enters the conversion without being rounded first. The units arrive resolved +/// because `units.parseRequest` resolved them while reading the request, so there is +/// no second lookup here and no second `UnknownUnit` path. +pub fn conversion( + allocator: std.mem.Allocator, + buf: []u8, + value_text: []const u8, + from: engine.UnitDef, + to: engine.UnitDef, +) Result { + // Parse the value exactly rather than through f64, so a decimal input like + // 2.5 enters the conversion without being rounded first. + var value = engine.Number.parse(allocator, value_text) catch { + return .{ .output = "error: invalid number\n", .is_error = true }; + }; + defer value.deinit(); + + return conversionUnits(allocator, buf, value, from, to); +} + +/// Format a conversion of a value that has already been evaluated, which is what a +/// bare expression such as `2*3 kg to lb` arrives as. +/// +/// Uses the exact path so terminating conversions print exactly: `12 in to ft` +/// is `1`, not `0.9999999999999998`. +fn conversionUnits( + allocator: std.mem.Allocator, + buf: []u8, + value: engine.Number, + from: engine.UnitDef, + to: engine.UnitDef, +) Result { + var converted = engine.units.convertExactUnits(allocator, value, from, to) catch |err| { + return .{ .output = decoratedError(err), .is_error = true }; + }; + defer converted.deinit(); + + 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 = 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.text, from.name, shown_out.text, to.name, + }) catch { + return .{ .output = "error: buffer overflow\n", .is_error = true }; + }; + return .{ .output = output, .is_error = false }; +} + +// -- Amortization -- + +/// Render an amortization schedule as a table. The result is allocated because a +/// 360-period schedule does not fit the fixed buffers the other outputs use. +pub fn amortization( + allocator: std.mem.Allocator, + params: engine.financial.AmortizationParams, + summary_only: bool, +) Result { + const payment = engine.financial.amortizationPayment(params) catch |err| { + return .{ .output = amortizationError(err), .is_error = true }; + }; + const rows = engine.financial.amortizationSchedule(allocator, params) catch |err| { + return .{ .output = amortizationError(err), .is_error = true }; + }; + defer allocator.free(rows); + + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + var line: [256]u8 = undefined; + var money_buf: [64]u8 = undefined; + + const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{ + money(params.principal).render(&money_buf) catch return unformattable(), + params.rate, + params.periods, + }) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, header) catch return oom(); + + const payment_line = std.fmt.bufPrint(&line, "Payment {s} per period\n\n", .{ + money(payment).render(&money_buf) catch return unformattable(), + }) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, payment_line) catch return oom(); + + if (!summary_only) { + out.appendSlice( + allocator, + "Period Payment Interest Principal Balance\n", + ) catch return oom(); + for (rows) |row| { + var pay_buf: [64]u8 = undefined; + var int_buf: [64]u8 = undefined; + var prin_buf: [64]u8 = undefined; + 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, + money(row.payment).render(&pay_buf) catch return unformattable(), + money(row.interest).render(&int_buf) catch return unformattable(), + money(row.principal).render(&prin_buf) catch return unformattable(), + money(row.balance).render(&bal_buf) catch return unformattable(), + }) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, row_text) catch return oom(); + } + out.appendSlice(allocator, "\n") catch return oom(); + } + + const totals = engine.financial.amortizationTotals(params) catch |err| { + return .{ .output = amortizationError(err), .is_error = true }; + }; + var paid_buf: [64]u8 = undefined; + var interest_buf: [64]u8 = undefined; + var principal_buf: [64]u8 = undefined; + const summary = std.fmt.bufPrint( + &line, + "Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}", + .{ + totals.periods, + money(totals.paid).render(&paid_buf) catch return unformattable(), + money(totals.interest).render(&interest_buf) catch return unformattable(), + money(totals.principal).render(&principal_buf) catch return unformattable(), + }, + ) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, summary) catch return oom(); + + const text = out.toOwnedSlice(allocator) catch return oom(); + return .{ .output = text, .is_error = false }; +} + +// -- Errors -- + +/// Turn an engine error into a CLI line: "error: \n". +/// +/// The phrases live once, in `engine.phrase`. This adds the prefix and the newline at +/// comptime, so the strings still have static lifetime and there is no second copy of +/// the wording to drift. The CLI and the TUI previously each kept their own switch +/// over the whole error set; the TUI's was already missing three cases and rendered +/// them as "evaluation error". +/// +/// `inline else` makes this exhaustive with no fallback branch: an error added +/// anywhere in the engine fails to compile in `engine.phrase` rather than silently +/// reading "evaluation error" here. +pub fn decoratedError(err: engine.Error) []const u8 { + return switch (err) { + inline else => |e| comptime "error: " ++ engine.phrase(e) ++ "\n", + }; +} + +/// The loan terms a schedule cannot be built from, in the words of the thing the +/// user actually typed. +fn amortizationError(err: engine.Error) []const u8 { + return switch (err) { + engine.Error.DomainError => + // The realistic causes are all one of these, and a bare "domain error" + // would leave the user guessing which. + "error: check the loan terms: principal and periods must be positive, the rate cannot be negative, and the payment must at least cover the first period's interest\n", + else => decoratedError(err), + }; +} + +/// An amount too large to render. Reported rather than printed as a placeholder: +/// the previous local formatter returned "?" for these, so a table of question +/// marks came out with exit status 0. +fn unformattable() Result { + return .{ + .output = "error: an amount in this schedule is too large to format\n", + .is_error = true, + }; +} + +fn oom() Result { + return .{ .output = "error: out of memory\n", .is_error = true }; +} + +// -- Tests -- + +const testing = std.testing; + +/// A unit by name, for the tests that call a formatter directly. In the real paths +/// `units.parseRequest` has already resolved both units by the time a formatter sees +/// them, which is why the formatters take `UnitDef` rather than names. +fn unit(name: []const u8) engine.UnitDef { + return engine.units.findUnit(name).?; +} + +test "evaluate: standard arithmetic" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "2 + 2", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("4", result.output); +} + +test "evaluate: large number has commas" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "2^32 - 1", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("4,294,967,295", result.output); +} + +test "evaluate: function" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "sin(pi/2) + 1", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("2", result.output); +} + +test "evaluate: explicit mul" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "3*(4+5)", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("27", result.output); +} + +test "evaluate: programmer mode" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "0xFF & 0x0F", .programmer, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "15") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "00 00 00 00 00 00 00 0F") != null); +} + +test "evaluate: division by zero error" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "1/0", .standard, .{}, &buf); + try testing.expect(result.is_error); + try testing.expectEqualStrings("error: division by zero\n", result.output); +} + +test "evaluate: unknown variable error" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "xyz + 1", .standard, .{}, &buf); + try testing.expect(result.is_error); + try testing.expectEqualStrings("error: unknown variable\n", result.output); +} + +test "evaluate: an expression written in another base answers in every base" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + + // Standard mode, but a hex literal says the user is thinking in bases, so the + // answer carries the other three rather than only decimal. + const hex = evaluateWith(arena.allocator(), "0xFF + 1", .standard, .{}, &buf); + try testing.expect(!hex.is_error); + try testing.expectEqualStrings( + \\256 + \\ hex: 01 00 + \\ oct: 000 400 + \\ bin: 0000 0001 0000 0000 + , hex.output); + + // The rows are as wide as the value needs and no wider: 10 fits one byte, where + // 256 above needed two. Standard mode has no width setting to consult, so the + // value chooses. + const binary = evaluateWith(arena.allocator(), "0b1010", .standard, .{}, &buf); + try testing.expect(!binary.is_error); + try testing.expectEqualStrings( + \\10 + \\ hex: 0A + \\ oct: 012 + \\ bin: 0000 1010 + , binary.output); + + // The decimal line is the ordinary rendering, so it still groups its digits. + const octal = evaluateWith(arena.allocator(), "0o777 * 2", .standard, .{}, &buf); + try testing.expect(!octal.is_error); + try testing.expect(std.mem.startsWith(u8, octal.output, "1,022\n")); + try testing.expect(std.mem.indexOf(u8, octal.output, "hex: 03 FE") != null); +} + +test "evaluate: a non-decimal literal with no whole answer stays decimal" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + + // The rows show a u128 bit pattern, and neither of these has one, so the answer + // is rendered plainly instead of in four bases. Standard mode is not programmer + // mode: it does not wrap a negative into two's complement to have something to + // print. + const negative = evaluateWith(arena.allocator(), "0xFF - 0x100", .standard, .{}, &buf); + try testing.expect(!negative.is_error); + try testing.expectEqualStrings("-1", negative.output); + + const fractional = evaluateWith(arena.allocator(), "0xFF / 2", .standard, .{}, &buf); + try testing.expect(!fractional.is_error); + try testing.expectEqualStrings("127.5", fractional.output); +} + +test "evaluate: multi-base rows that outgrow the buffer are refused, not truncated" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + // Enough room for the decimal answer alone, nowhere near enough for the rows. + // Half a table would read as a wrong answer, so nothing is printed. + var small: [8]u8 = undefined; + const result = evaluateWith(arena.allocator(), "0xFF + 1", .standard, .{}, &small); + try testing.expect(result.is_error); + try testing.expectEqualStrings("error: buffer overflow\n", result.output); +} + +test "the programmer config reaches the result" { + var buf: [4096]u8 = undefined; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // 8-bit: 0xFF + 1 wraps to 0. + const narrow = evaluateWith(alloc, "0xFF + 1", .programmer, .{ .width = .bits8 }, &buf); + try testing.expect(!narrow.is_error); + try testing.expect(std.mem.indexOf(u8, narrow.output, "dec(unsigned): 0\n") != null); + + // Signedness decides whether >> extends the sign. + const signed = evaluateWith(alloc, "0xFF >> 1", .programmer, .{ + .width = .bits8, + .signedness = .signed, + }, &buf); + try testing.expect(std.mem.indexOf(u8, signed.output, "dec(signed): -1") != null); + + const unsigned = evaluateWith(alloc, "0xFF >> 1", .programmer, .{ + .width = .bits8, + .signedness = .unsigned, + }, &buf); + try testing.expect(std.mem.indexOf(u8, unsigned.output, "dec(unsigned): 127") != null); + + // Byte order reverses the hex row and nothing else. + const little = evaluateWith(alloc, "0xDEAD", .programmer, .{ + .width = .bits16, + .display_endian = .little, + }, &buf); + try testing.expect(std.mem.indexOf(u8, little.output, "hex: AD DE") != null); + const big = evaluateWith(alloc, "0xDEAD", .programmer, .{ + .width = .bits16, + .display_endian = .big, + }, &buf); + try testing.expect(std.mem.indexOf(u8, big.output, "hex: DE AD") != null); +} + +test "conversion: km to mi" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [256]u8 = undefined; + const result = conversion(arena.allocator(), &buf, "100", unit("km"), unit("mi")); + try testing.expect(!result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "km") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "mi") != null); +} + +test "conversion: temperature freezing point" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [256]u8 = undefined; + const result = conversion(arena.allocator(), &buf, "0", unit("C"), unit("F")); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("0 C = 32 F", result.output); +} + +test "conversion: incompatible units is an error" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [256]u8 = undefined; + const result = conversion(arena.allocator(), &buf, "1", unit("kg"), unit("m")); + try testing.expect(result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null); +} + +test "conversion: alias resolves to canonical name in output" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [256]u8 = undefined; + const result = conversion(arena.allocator(), &buf, "1", unit("kilometer"), unit("meters")); + try testing.expect(!result.is_error); + // Conversion output now uses the standard comma grouping (NFR-7). + try testing.expectEqualStrings("1 km = 1,000 m", result.output); +} + +test "conversion: exact conversion prints exactly" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [256]u8 = undefined; + + // The bug this task exists to fix. + const feet = conversion(arena.allocator(), &buf, "12", unit("in"), unit("ft")); + try testing.expect(!feet.is_error); + try testing.expectEqualStrings("12 in = 1 ft", feet.output); + + // Exact affine conversion. + const celsius = conversion(arena.allocator(), &buf, "98.6", unit("F"), unit("C")); + try testing.expect(!celsius.is_error); + try testing.expectEqualStrings("98.6 F = 37 C", celsius.output); + + // Exact fractional factor. + const mps = conversion(arena.allocator(), &buf, "3.6", unit("km/h"), unit("m/s")); + try testing.expect(!mps.is_error); + try testing.expectEqualStrings("3.6 km/h = 1 m/s", mps.output); +} + +test "evaluate: bare conversion expression without subcommand" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "100 km to mi", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null); +} + +test "evaluate: glued bare conversion" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "32F to C", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("32 F = 0 C", result.output); +} + +test "evaluate: conversion value may be an expression" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "2*3 kg to g", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("6 kg = 6,000 g", result.output); +} + +test "evaluate: bare conversion with unknown unit errors" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "100 km to smoots", .standard, .{}, &buf); + try testing.expect(result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null); +} + +test "evaluate: bare conversion with incompatible units errors" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "1 kg to m", .standard, .{}, &buf); + try testing.expect(result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null); +} + +test "evaluate: ordinary expressions are unaffected by conversion detection" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + const result = evaluateWith(arena.allocator(), "2 + 3 * 4", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expectEqualStrings("14", result.output); +} + +test "evaluate: expression containing a unit-like name still evaluates" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + var buf: [4096]u8 = undefined; + // "e" is Euler's number here, not a unit, because there is no "to" keyword. + const result = evaluateWith(arena.allocator(), "e", .standard, .{}, &buf); + try testing.expect(!result.is_error); + try testing.expect(std.mem.startsWith(u8, result.output, "2.718")); +} + +test "amortization: an unrenderable amount is an error, not a table of marks" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + // 1e40 used to produce a full report of "?" with exit status 0. It now fits, + // because the engine formatter groups the whole number. + const large = amortization(arena.allocator(), .{ + .principal = 1e40, + .rate = 0.5, + .periods = 3, + }, false); + try testing.expect(!large.is_error); + try testing.expect(std.mem.indexOfScalar(u8, large.output, '?') == null); + try testing.expect(std.mem.indexOf(u8, large.output, "10,000,000,000,000,000,000,000,000,000,000,000,000,000.00") != null); + + // Past the width of the formatting buffer the report is refused outright, with + // a non-zero exit status, rather than printed with placeholders. + const huge = amortization(arena.allocator(), .{ + .principal = 1e60, + .rate = 0.5, + .periods = 3, + }, false); + try testing.expect(huge.is_error); + try testing.expect(std.mem.indexOf(u8, huge.output, "too large to format") != null); + try testing.expect(std.mem.indexOfScalar(u8, huge.output, '?') == null); +} + +test "amortization: table has a row per period and correct first row" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + const result = amortization(arena.allocator(), .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + }, false); + try testing.expect(!result.is_error); + + // Header, payment, blank, column head, 360 rows, blank, 4 summary lines. + var lines: usize = 0; + var it = std.mem.splitScalar(u8, result.output, '\n'); + while (it.next()) |_| lines += 1; + try testing.expectEqual(@as(usize, 369), lines); + + try testing.expect(std.mem.indexOf(u8, result.output, "Payment 1,199.10 per period") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "1,000.00") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "199,800.90") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "Total interest 231,677.04") != null); +} + +test "amortization: summary only omits the rows" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + const result = amortization(arena.allocator(), .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + }, true); + try testing.expect(!result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "Period Payment") == null); + try testing.expect(std.mem.indexOf(u8, result.output, "Periods paid 360") != null); +} + +test "amortization: bad terms explain themselves" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + // A payment that never covers the interest. + const result = amortization(arena.allocator(), .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = 500, + }, false); + try testing.expect(result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "loan terms") != null); +} diff --git a/src/main.zig b/src/main.zig index 6e6b904..e90e646 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,40 +1,21 @@ +//! The CLI: argv in, one of `cli/format.zig`'s outputs out. +//! +//! Parsing decides what to compute and writes no text of its own beyond usage and +//! help, `format.zig` decides how a result reads, and `main` is the only part that +//! touches a stream or an exit status. The formatters used to live here too, which +//! left one file deciding both `--bits` spellings and money column widths. + const std = @import("std"); const engine = @import("engine"); +const format = @import("cli/format.zig"); const tui = @import("tui.zig"); -/// Result of CLI evaluation - pure data, no I/O. -pub const CliResult = struct { - output: []const u8, - is_error: bool, -}; - -/// Which evaluator a CLI invocation wants. -/// -/// The CLI's own concept, not the engine's: the engine has `evalString` and -/// `evalProgrammerString` and no notion of a mode. There are exactly two here -/// because `-p` is the only mode flag; the TUI's four tabs are its own enum. -pub const Mode = enum { standard, programmer }; - -/// How this frontend renders a number. -/// -/// The engine has no default and no digit constants of its own: a terminal, a -/// clipboard and an Android screen want different budgets, so each frontend states -/// its own (NFR-9.9, "display precision is a separate decision from compute -/// precision"). These are the values the engine used to hold. -const display_format: engine.Number.FormatOptions = .{ - .fraction_digits = 20, - .scientific_below_exponent = 15, - .max_integer_digits = 40, - .significant_digits = 17, - .separators = true, -}; - /// 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) { expression: struct { text: []const u8, - mode: Mode, + mode: format.Mode, /// Width, signedness and byte order for programmer mode. The CLI accepts /// the same settings the TUI has, so a session in one can be reproduced in /// the other. @@ -42,9 +23,11 @@ pub const ParsedArgs = union(enum) { }, conversion: struct { /// Kept as text so it can be parsed exactly rather than through f64. + /// Allocated with the allocator given to `parseArgs`, like `expression.text`, + /// because it is a slice of a joined argument list rather than of argv. value_text: []const u8, - from: []const u8, - to: []const u8, + from: engine.UnitDef, + to: engine.UnitDef, }, amortization: struct { params: engine.financial.AmortizationParams, @@ -58,7 +41,7 @@ pub const ParsedArgs = union(enum) { }; pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedArgs { - var mode: Mode = .standard; + var mode: format.Mode = .standard; var config: engine.programmer.Config = .{}; var expr_parts = std.ArrayList([]const u8).empty; defer expr_parts.deinit(allocator); @@ -66,7 +49,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA // "convert" subcommand: tally convert // Also accepts the natural form: tally convert to if (args.len > 0 and std.mem.eql(u8, args[0], "convert")) { - return parseConvertArgs(args[1..]); + return parseConvertArgs(allocator, args[1..]); } // "amort" subcommand: the one financial output that is a table rather than a @@ -230,17 +213,26 @@ const unknown_flag_usage = /// Parse the arguments following the `convert` subcommand. /// -/// Accepts ` `, either separator word between the units -/// (` to|in `), and the glued form ` to `. +/// The tokens are joined and read by `units.parseRequest`, which is the same reading a +/// bare expression goes through (`tally 100 km to mi`), so the two forms cannot +/// disagree about what a conversion is. That includes the `in`-is-also-inches +/// backtracking, which is why `convert 1 in in cm` and `convert 100 mm in in` both +/// resolve. /// -/// `in` is both a separator and the name for inches, so candidate readings are -/// validated by resolving the unit names, and the first reading that resolves -/// wins. Later separators are tried first, which is what makes `1 in in cm` -/// (inches to centimetres) and `1 acre in ft2` both work. This mirrors the -/// backtracking in `units.parseRequest`, which handles the same ambiguity for -/// bare expressions. -fn parseConvertArgs(args: []const []const u8) ParsedArgs { - const max_tokens = 8; +/// This subcommand additionally allows the separator to be left out +/// (`convert 100 km mi`), which a bare expression cannot, so text with no separator +/// gets a `to` before its last token and is read again. +/// +/// It used to be about ninety lines of its own: `isSeparatorWord`, +/// `interpretConvertTokens`, `looksNumeric`, `isFullyNumeric` and `splitValueAndUnit` +/// split values from units, recognised the separator words and tried readings +/// last-to-first over argv boundaries. All of it was a second implementation of +/// `units.parseRequest`, and the two numeric predicates were a third and fourth +/// grammar for "is this a number", neither matching the real one. +fn parseConvertArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedArgs { + // A conversion is a value, two units and at most one separator. More than that is + // a mistake worth showing the usage for rather than parsing. + const max_tokens = 4; if (wantsHelp(args)) { return .{ .output = .{ .text = convert_usage, .is_error = false } }; } @@ -248,321 +240,56 @@ fn parseConvertArgs(args: []const []const u8) ParsedArgs { return .{ .output = .{ .text = convert_usage, .is_error = true } }; } - // Try dropping each separator word, from the last one backwards, then try - // dropping nothing at all. - var skip = args.len; - while (true) { - const consider = skip == args.len or isSeparatorWord(args[skip]); - if (consider) { - if (interpretConvertTokens(args, skip)) |conversion| { - return .{ .conversion = conversion }; - } - } - if (skip == 0) break; - skip -= 1; - } + const joined = std.mem.join(allocator, " ", args) catch return oomArgs(); + defer allocator.free(joined); - // Nothing resolved. Distinguish a bad number from a bad unit so the message - // is useful. - if (args.len >= 2 and !looksNumeric(args[0])) { - if (splitValueAndUnit(args[0]) == null) { - return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } }; - } + // The bare form requires a separator word and this one does not, so the tokens are + // read twice: as written, and with a `to` supplied before the last one. The second + // reading is what makes `convert 1 in cm` work, where `in` is the source unit -- + // as written, `parseRequest` sees a separator with no unit in front of it. + const last = args[args.len - 1]; + const head = std.mem.join(allocator, " ", args[0 .. args.len - 1]) catch return oomArgs(); + defer allocator.free(head); + const spelled = std.fmt.allocPrint(allocator, "{s} to {s}", .{ head, last }) catch return oomArgs(); + defer allocator.free(spelled); + + var failure: ?ParsedArgs = null; + for ([_][]const u8{ joined, spelled }) |text| { + const reading = readConversion(allocator, text) orelse continue; + if (reading == .conversion) return reading; + // A reading that resolved far enough to name its problem, which is a better + // message than the usage text. The first one is kept: it describes the tokens + // as the user wrote them. + if (failure == null) failure = reading; } + if (failure) |reported| return reported; return .{ .output = .{ .text = convert_usage, .is_error = true } }; } -fn isSeparatorWord(text: []const u8) bool { - return std.ascii.eqlIgnoreCase(text, "to") or std.ascii.eqlIgnoreCase(text, "in"); -} +/// One reading of conversion text: the conversion, an error worth reporting, or null +/// when the text holds no separator for `parseRequest` to work from. +fn readConversion(allocator: std.mem.Allocator, text: []const u8) ?ParsedArgs { + const request = engine.units.parseRequest(text) catch |err| { + return .{ .output = .{ .text = format.decoratedError(err), .is_error = true } }; + } orelse return null; -/// Interpret the argument list with the token at `skip` removed (pass -/// `args.len` to remove nothing). Returns null when the reading does not -/// resolve to two known units plus a numeric value. -fn interpretConvertTokens( - args: []const []const u8, - skip: usize, -) ?@FieldType(ParsedArgs, "conversion") { - var parts: [3][]const u8 = undefined; - var count: usize = 0; - for (args, 0..) |arg, i| { - if (i == skip) continue; - if (count >= parts.len) return null; - parts[count] = arg; - count += 1; - } - - if (count == 3) { - if (!isFullyNumeric(parts[0])) return null; - if (engine.units.findUnit(parts[1]) == null) return null; - if (engine.units.findUnit(parts[2]) == null) return null; - return .{ .value_text = parts[0], .from = parts[1], .to = parts[2] }; - } - - // Glued form: "100km" "mi" -> split the leading number from the unit. - if (count == 2) { - const split = splitValueAndUnit(parts[0]) orelse return null; - if (engine.units.findUnit(split.unit) == null) return null; - if (engine.units.findUnit(parts[1]) == null) return null; - return .{ .value_text = split.number, .from = split.unit, .to = parts[1] }; - } - - return null; -} - -/// Cheap shape check for a numeric literal, so a malformed value is rejected at -/// the argument layer rather than surfacing later from the formatter. -/// -/// Deliberately not a full parse: the value text is handed to the exact rational -/// parser, which is the real authority, and duplicating its grammar here would -/// be a second source of truth. -fn looksNumeric(text: []const u8) bool { - if (text.len == 0) return false; - const first = text[0]; - return (first >= '0' and first <= '9') or first == '.' or first == '-' or first == '+'; -} - -/// True when every character could belong to a numeric literal and at least one -/// digit is present. -/// -/// This has to check the WHOLE token, not just the first character: `98.6F` in -/// `convert 98.6F in C` starts numerically but is really a glued value and unit, -/// and accepting it as a bare value would hand `98.6F` to the number parser and -/// fail. Separator accuracy depends on rejecting that reading so the glued one -/// is tried instead. -fn isFullyNumeric(text: []const u8) bool { - var has_digit = false; - for (text) |c| { - if (c >= '0' and c <= '9') { - has_digit = true; - continue; - } - switch (c) { - '.', '+', '-', 'e', 'E', ',', '_' => {}, - else => return false, - } - } - return has_digit; -} - -/// Split a token like "100km" into its numeric prefix and unit suffix. -fn splitValueAndUnit(token: []const u8) ?struct { number: []const u8, unit: []const u8 } { - var i: usize = 0; - while (i < token.len) : (i += 1) { - const c = token[i]; - const is_numeric = (c >= '0' and c <= '9') or c == '.' or c == '-' or c == '+' or - c == 'e' or c == 'E'; - // Stop at the first character that cannot continue a number. 'e'/'E' - // only continue a number when followed by a digit or sign (exponent), - // otherwise they begin the unit (e.g. the "eV" in "5eV"). - if (c == 'e' or c == 'E') { - if (i + 1 >= token.len) break; - const n = token[i + 1]; - const is_exponent = (n >= '0' and n <= '9') or n == '-' or n == '+'; - if (!is_exponent) break; - continue; - } - if (!is_numeric) break; - } - if (i == 0 or i == token.len) return null; - return .{ .number = token[0..i], .unit = token[i..] }; -} - -/// Format a unit conversion result by unit name. -pub fn formatConversion( - allocator: std.mem.Allocator, - buf: []u8, - value_text: []const u8, - from_name: []const u8, - to_name: []const u8, -) CliResult { - const from = engine.units.findUnit(from_name) orelse - return .{ .output = errorMessage(engine.Error.UnknownUnit), .is_error = true }; - const to = engine.units.findUnit(to_name) orelse - return .{ .output = errorMessage(engine.Error.UnknownUnit), .is_error = true }; - - // Parse the value exactly rather than through f64, so a decimal input like - // 2.5 enters the conversion without being rounded first. - var value = engine.Number.parse(allocator, value_text) catch { - return .{ .output = "error: invalid number\n", .is_error = true }; + // The value has to be a number, and the exact parser is the authority on what one + // is: `formatConversion` parses it the same way, so checking here only moves the + // report to the argument layer. A shape check would be a second grammar. + var probe = engine.Number.parse(allocator, request.value_text) catch { + return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } }; }; - defer value.deinit(); + probe.deinit(); - return formatConversionUnits(allocator, buf, value, from, to); + // `request` borrows from `text`, which the caller frees, so the value text is + // copied out. Unit definitions carry static names and need no copy. + const owned = allocator.dupe(u8, request.value_text) catch return oomArgs(); + return .{ .conversion = .{ .value_text = owned, .from = request.from, .to = request.to } }; } -/// Format a conversion between two already-resolved units. -/// -/// Uses the exact path so terminating conversions print exactly: `12 in to ft` -/// is `1`, not `0.9999999999999998`. -fn formatConversionUnits( - allocator: std.mem.Allocator, - buf: []u8, - value: engine.Number, - from: engine.UnitDef, - to: engine.UnitDef, -) CliResult { - var converted = engine.units.convertExactUnits(allocator, value, from, to) catch |err| { - return .{ .output = errorMessage(err), .is_error = true }; - }; - defer converted.deinit(); - - 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 = 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.text, from.name, shown_out.text, to.name, - }) catch { - return .{ .output = "error: buffer overflow\n", .is_error = true }; - }; - return .{ .output = output, .is_error = false }; -} - -/// Evaluate an expression and format the result as a string. -pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: Mode, buf: []u8) CliResult { - return evaluateWith(allocator, expression, mode, .{}, buf); -} - -/// Evaluate with an explicit programmer configuration (width, signedness, byte -/// order). Standard mode ignores it: it is fixed at 64-bit signed. -pub fn evaluateWith( - allocator: std.mem.Allocator, - expression: []const u8, - mode: Mode, - config: engine.programmer.Config, - buf: []u8, -) CliResult { - if (mode == .programmer) { - const result = engine.evalProgrammerString(allocator, expression, config) catch |err| { - return .{ .output = errorMessage(err), .is_error = true }; - }; - return formatProgrammerResult(buf, result, config); - } - - var env = engine.Environment.init(allocator); - defer env.deinit(); - - // A standalone "to" keyword means this is a unit conversion, e.g. - // "32F to C". Anything without it falls through to normal evaluation. - if (engine.units.parseRequest(expression)) |maybe_request| { - if (maybe_request) |request| { - var value = engine.evalString(&env, allocator, request.value_text) catch |err| { - return .{ .output = errorMessage(err), .is_error = true }; - }; - defer value.deinit(); - return formatConversionUnits(allocator, buf, value, request.from, request.to); - } - } else |err| { - return .{ .output = errorMessage(err), .is_error = true }; - } - - var info = engine.evalStringInfo(&env, allocator, expression) catch |err| { - return .{ .output = errorMessage(err), .is_error = true }; - }; - defer info.value.deinit(); - - // 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))) { - 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 = 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.text.len > buf.len) { - return .{ .output = "error: result too long to display\n", .is_error = true }; - } - @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. -fn isDisplayableInt(value: f64) bool { - return value >= 0 and value == @trunc(value) and value < 340282366920938463463374607431768211456.0; -} - -/// Format a standard-mode result with an inline multi-base breakdown. -/// The decimal display is already computed; append hex/oct/bin. -fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliResult { - const int_val: u128 = @intFromFloat(value); - const int: engine.Integer = .{ - .raw = int_val, - .width = engine.BitWidth.displayFor(int_val), - .signedness = .unsigned, - }; - - // 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_display, - int.fmt(.hex, .{}), - int.fmt(.octal, .{}), - int.fmt(.binary, .{}), - }) catch { - return .{ .output = "error: buffer overflow\n", .is_error = true }; - }; - - return .{ .output = w.buffered(), .is_error = false }; -} - -fn formatProgrammerResult(buf: []u8, result: engine.Integer, config: engine.programmer.Config) CliResult { - var w = std.Io.Writer.fixed(buf); - w.print( - \\ dec(signed): {f} - \\ dec(unsigned): {f} - \\ hex: {f} - \\ oct: {f} - \\ bin: {f} - \\ - , .{ - result.fmt(.decimal_signed, .{}), - result.fmt(.decimal_unsigned, .{}), - result.fmt(.hex, .{ .endian = config.display_endian }), - result.fmt(.octal, .{}), - result.fmt(.binary, .{}), - }) catch { - return .{ .output = "error: buffer overflow\n", .is_error = true }; - }; - - return .{ .output = w.buffered(), .is_error = false }; -} - -/// Turn an engine error into a CLI line. -/// -/// The phrases live once, in `engine.phrase`. This adds the prefix and -/// the newline at comptime, so the strings still have static lifetime and there is -/// no second copy of the wording to drift. The CLI and the TUI previously each kept -/// their own switch over the whole error set; the TUI's was already missing three -/// cases and rendered them as "evaluation error". -fn errorMessage(err: engine.Error) []const u8 { - return decoratedError(err); -} - -/// Comptime-decorated form of every error phrase: "error: \n". -/// -/// `inline else` makes this exhaustive over the error set with no fallback branch: an -/// error added to any engine module fails to compile in `message.phrase` rather than -/// silently reading "evaluation error" here. -fn decoratedError(err: engine.Error) []const u8 { - return switch (err) { - inline else => |e| comptime "error: " ++ engine.phrase(e) ++ "\n", - }; +/// An out-of-memory result at the argument layer. +fn oomArgs() ParsedArgs { + return .{ .output = .{ .text = "error: out of memory\n", .is_error = true } }; } /// True when a subcommand's arguments ask for its usage rather than run it. @@ -676,113 +403,9 @@ fn parseAmortArgs(args: []const []const u8) ParsedArgs { } }; } -/// Render an amount with thousands separators and two decimal places. -/// Render an amortization schedule as a table. The result is allocated because a -/// 360-period schedule does not fit the fixed buffers the other outputs use. -pub fn formatAmortization( - allocator: std.mem.Allocator, - params: engine.financial.AmortizationParams, - summary_only: bool, -) CliResult { - const payment = engine.financial.amortizationPayment(params) catch |err| { - return .{ .output = amortErrorMessage(err), .is_error = true }; - }; - const rows = engine.financial.amortizationSchedule(allocator, params) catch |err| { - return .{ .output = amortErrorMessage(err), .is_error = true }; - }; - defer allocator.free(rows); +const help_text = help_head ++ financial_function_help ++ help_tail; - var out = std.ArrayList(u8).empty; - errdefer out.deinit(allocator); - var line: [256]u8 = undefined; - var money_buf: [64]u8 = undefined; - - const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{ - 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", .{ - 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(); - - if (!summary_only) { - out.appendSlice( - allocator, - "Period Payment Interest Principal Balance\n", - ) catch return oomResult(); - for (rows) |row| { - var pay_buf: [64]u8 = undefined; - var int_buf: [64]u8 = undefined; - var prin_buf: [64]u8 = undefined; - 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, - 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(); - } - out.appendSlice(allocator, "\n") catch return oomResult(); - } - - const totals = engine.financial.amortizationTotals(params) catch |err| { - return .{ .output = amortErrorMessage(err), .is_error = true }; - }; - var paid_buf: [64]u8 = undefined; - var interest_buf: [64]u8 = undefined; - var principal_buf: [64]u8 = undefined; - const summary = std.fmt.bufPrint( - &line, - "Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}", - .{ - totals.periods, - 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(); - - const text = out.toOwnedSlice(allocator) catch return oomResult(); - return .{ .output = text, .is_error = false }; -} - -/// 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 -/// marks came out with exit status 0. -fn unformattableResult() CliResult { - return .{ - .output = "error: an amount in this schedule is too large to format\n", - .is_error = true, - }; -} - -fn oomResult() CliResult { - return .{ .output = "error: out of memory\n", .is_error = true }; -} - -fn amortErrorMessage(err: engine.Error) []const u8 { - return switch (err) { - engine.Error.DomainError => - // The realistic causes are all one of these, and a bare "domain error" - // would leave the user guessing which. - "error: check the loan terms: principal and periods must be positive, the rate cannot be negative, and the payment must at least cover the first period's interest\n", - else => errorMessage(err), - }; -} - -const help_text = +const help_head = \\tally - a cross-platform calculator \\ \\Usage: tally [OPTIONS] @@ -809,16 +432,21 @@ const help_text = \\ tally 32F in C \\ tally '2*3 kg to lb' The value may be an expression \\ - \\Financial functions work in any expression: - \\ tally 'cagr(10000, 25000, 5) * 100' Growth rate as a percent - \\ tally 'fv(1000, 5, 10)' Future value, 5% for 10 years - \\ tally 'fv(1000, 5, 10, 12)' ...compounded monthly - \\ tally 'pv(2000, 7, 10)' Present value - \\ tally 'compound_rate(10000, 25000, 5)' Solve the rate (nominal) - \\ tally 'apy(18, 12)' Nominal rate to effective - \\ tally 'tvm_pmt(360, 0.5, 200000, 0)' Solve a payment (N, I/Y, PV, FV) - \\ tally 'tvm_rate(10, -1000, 0, 2000)' Solve a rate (N, PV, PMT, FV) - \\ tally 'amort_interest(200000, 0.5, 360, 1)' + \\Financial functions work in any expression, for example + \\ tally 'cagr(10000, 25000, 5) * 100' + \\ +; + +/// The financial function list, rendered from the engine's table rather than written +/// out here. The hand-written version named nine of the seventeen functions, in +/// wording of its own that had drifted from the TUI's copy of the same list. +const financial_function_help = blk: { + var text: []const u8 = ""; + for (engine.financial.help_lines) |line| text = text ++ " " ++ line ++ "\n"; + break :blk text; +}; + +const help_tail = \\ \\Subcommands: \\ tally convert 100 km to mi Explicit unit conversion @@ -861,7 +489,7 @@ pub fn main(init: std.process.Init) u8 { }, .expression => |expr| { var buf: [4096]u8 = undefined; - const result = evaluateWith(allocator, expr.text, expr.mode, expr.config, &buf); + const result = format.evaluateWith(allocator, expr.text, expr.mode, expr.config, &buf); const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout(); write(io, file, result.output); if (!result.is_error) write(io, std.Io.File.stdout(), "\n"); @@ -869,14 +497,14 @@ pub fn main(init: std.process.Init) u8 { }, .conversion => |conv| { var buf: [4096]u8 = undefined; - const result = formatConversion(allocator, &buf, conv.value_text, conv.from, conv.to); + const result = format.conversion(allocator, &buf, conv.value_text, conv.from, conv.to); const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout(); write(io, file, result.output); if (!result.is_error) write(io, std.Io.File.stdout(), "\n"); return if (result.is_error) @as(u8, 1) else 0; }, .amortization => |amort| { - const result = formatAmortization(allocator, amort.params, amort.summary_only); + const result = format.amortization(allocator, amort.params, amort.summary_only); const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout(); write(io, file, result.output); if (!result.is_error) write(io, std.Io.File.stdout(), "\n"); @@ -901,7 +529,7 @@ test "parseArgs: simple expression" { switch (parsed) { .expression => |e| { try testing.expectEqualStrings("2+2", e.text); - try testing.expectEqual(Mode.standard, e.mode); + try testing.expectEqual(format.Mode.standard, e.mode); testing.allocator.free(e.text); }, else => unreachable, @@ -923,7 +551,7 @@ test "parseArgs: programmer flag" { const parsed = parseArgs(testing.allocator, &.{ "-p", "0xFF" }); switch (parsed) { .expression => |e| { - try testing.expectEqual(Mode.programmer, e.mode); + try testing.expectEqual(format.Mode.programmer, e.mode); try testing.expectEqualStrings("0xFF", e.text); testing.allocator.free(e.text); }, @@ -935,7 +563,7 @@ test "parseArgs: --programmer long flag" { const parsed = parseArgs(testing.allocator, &.{ "--programmer", "0xF0", "|", "0x0F" }); switch (parsed) { .expression => |e| { - try testing.expectEqual(Mode.programmer, e.mode); + try testing.expectEqual(format.Mode.programmer, e.mode); try testing.expectEqualStrings("0xF0 | 0x0F", e.text); testing.allocator.free(e.text); }, @@ -953,7 +581,7 @@ test "parseArgs: --bits sets the width, in either spelling, and implies -p" { try testing.expectEqual(engine.Integer.BitWidth.bits8, e.config.width); // Asking for a width is asking for programmer mode: standard mode is fixed // at 64-bit signed, so the flag would mean nothing there. - try testing.expectEqual(Mode.programmer, e.mode); + try testing.expectEqual(format.Mode.programmer, e.mode); try testing.expectEqualStrings("0xFF", e.text); } } @@ -978,12 +606,12 @@ test "parseArgs: --signed and --unsigned set the signedness and imply -p" { const signed = parseArgs(testing.allocator, &.{ "--signed", "0xFF" }).expression; defer testing.allocator.free(signed.text); try testing.expectEqual(engine.Integer.Signedness.signed, signed.config.signedness); - try testing.expectEqual(Mode.programmer, signed.mode); + try testing.expectEqual(format.Mode.programmer, signed.mode); const unsigned = parseArgs(testing.allocator, &.{ "--unsigned", "0xFF" }).expression; defer testing.allocator.free(unsigned.text); try testing.expectEqual(engine.Integer.Signedness.unsigned, unsigned.config.signedness); - try testing.expectEqual(Mode.programmer, unsigned.mode); + try testing.expectEqual(format.Mode.programmer, unsigned.mode); } test "parseArgs: --endian sets the byte order and accepts both spellings" { @@ -1049,43 +677,6 @@ test "parseArgs: subcommands answer --help on stdout" { try testing.expect(parseArgs(testing.allocator, &.{"convert"}).output.is_error); } -test "the programmer config reaches the result" { - var buf: [4096]u8 = undefined; - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - // 8-bit: 0xFF + 1 wraps to 0. - const narrow = evaluateWith(alloc, "0xFF + 1", .programmer, .{ .width = .bits8 }, &buf); - try testing.expect(!narrow.is_error); - try testing.expect(std.mem.indexOf(u8, narrow.output, "dec(unsigned): 0\n") != null); - - // Signedness decides whether >> extends the sign. - const signed = evaluateWith(alloc, "0xFF >> 1", .programmer, .{ - .width = .bits8, - .signedness = .signed, - }, &buf); - try testing.expect(std.mem.indexOf(u8, signed.output, "dec(signed): -1") != null); - - const unsigned = evaluateWith(alloc, "0xFF >> 1", .programmer, .{ - .width = .bits8, - .signedness = .unsigned, - }, &buf); - try testing.expect(std.mem.indexOf(u8, unsigned.output, "dec(unsigned): 127") != null); - - // Byte order reverses the hex row and nothing else. - const little = evaluateWith(alloc, "0xDEAD", .programmer, .{ - .width = .bits16, - .display_endian = .little, - }, &buf); - try testing.expect(std.mem.indexOf(u8, little.output, "hex: AD DE") != null); - const big = evaluateWith(alloc, "0xDEAD", .programmer, .{ - .width = .bits16, - .display_endian = .big, - }, &buf); - try testing.expect(std.mem.indexOf(u8, big.output, "hex: DE AD") != null); -} - test "parseArgs: --help" { const parsed = parseArgs(testing.allocator, &.{"--help"}); switch (parsed) { @@ -1119,77 +710,14 @@ test "parseArgs: no expression" { } } -test "evaluate: standard arithmetic" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "2 + 2", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("4", result.output); -} - -test "evaluate: large number has commas" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "2^32 - 1", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("4,294,967,295", result.output); -} - -test "evaluate: function" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "sin(pi/2) + 1", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("2", result.output); -} - -test "evaluate: explicit mul" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "3*(4+5)", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("27", result.output); -} - -test "evaluate: programmer mode" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "0xFF & 0x0F", .programmer, &buf); - try testing.expect(!result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "15") != null); - try testing.expect(std.mem.indexOf(u8, result.output, "00 00 00 00 00 00 00 0F") != null); -} - -test "evaluate: division by zero error" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "1/0", .standard, &buf); - try testing.expect(result.is_error); - try testing.expectEqualStrings("error: division by zero\n", result.output); -} - -test "evaluate: unknown variable error" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "xyz + 1", .standard, &buf); - try testing.expect(result.is_error); - try testing.expectEqualStrings("error: unknown variable\n", result.output); -} - test "parseArgs: convert subcommand three-arg form" { const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "mi" }); switch (parsed) { .conversion => |c| { + defer testing.allocator.free(c.value_text); try testing.expectEqualStrings("100", c.value_text); - try testing.expectEqualStrings("km", c.from); - try testing.expectEqualStrings("mi", c.to); + try testing.expectEqualStrings("km", c.from.name); + try testing.expectEqualStrings("mi", c.to.name); }, else => return error.ExpectedConversion, } @@ -1199,9 +727,10 @@ test "parseArgs: convert subcommand with 'to' separator" { const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "to", "mi" }); switch (parsed) { .conversion => |c| { + defer testing.allocator.free(c.value_text); try testing.expectEqualStrings("100", c.value_text); - try testing.expectEqualStrings("km", c.from); - try testing.expectEqualStrings("mi", c.to); + try testing.expectEqualStrings("km", c.from.name); + try testing.expectEqualStrings("mi", c.to.name); }, else => return error.ExpectedConversion, } @@ -1211,9 +740,10 @@ test "parseArgs: convert glued value and unit" { const parsed = parseArgs(testing.allocator, &.{ "convert", "100km", "to", "mi" }); switch (parsed) { .conversion => |c| { + defer testing.allocator.free(c.value_text); try testing.expectEqualStrings("100", c.value_text); - try testing.expectEqualStrings("km", c.from); - try testing.expectEqualStrings("mi", c.to); + try testing.expectEqualStrings("km", c.from.name); + try testing.expectEqualStrings("mi", c.to.name); }, else => return error.ExpectedConversion, } @@ -1223,9 +753,10 @@ test "parseArgs: convert glued negative and decimal value" { const parsed = parseArgs(testing.allocator, &.{ "convert", "-40.5C", "F" }); switch (parsed) { .conversion => |c| { + defer testing.allocator.free(c.value_text); try testing.expectEqualStrings("-40.5", c.value_text); - try testing.expectEqualStrings("C", c.from); - try testing.expectEqualStrings("F", c.to); + try testing.expectEqualStrings("C", c.from.name); + try testing.expectEqualStrings("F", c.to.name); }, else => return error.ExpectedConversion, } @@ -1255,156 +786,16 @@ test "parseArgs: convert with non-numeric value errors" { } } -test "splitValueAndUnit: basic" { - const r = splitValueAndUnit("100km").?; - try testing.expectEqualStrings("100", r.number); - try testing.expectEqualStrings("km", r.unit); -} - -test "splitValueAndUnit: decimal and negative" { - const a = splitValueAndUnit("-40.5C").?; - try testing.expectEqualStrings("-40.5", a.number); - try testing.expectEqualStrings("C", a.unit); -} - -test "splitValueAndUnit: exponent continues the number" { - const r = splitValueAndUnit("1e3m").?; - try testing.expectEqualStrings("1e3", r.number); - try testing.expectEqualStrings("m", r.unit); -} - -test "splitValueAndUnit: unit starting with e is not eaten as exponent" { - const r = splitValueAndUnit("5eV").?; - try testing.expectEqualStrings("5", r.number); - try testing.expectEqualStrings("eV", r.unit); -} - -test "splitValueAndUnit: rejects pure number or pure unit" { - try testing.expect(splitValueAndUnit("100") == null); - try testing.expect(splitValueAndUnit("km") == null); -} - -test "formatConversion: km to mi" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [256]u8 = undefined; - const result = formatConversion(arena.allocator(), &buf, "100", "km", "mi"); - try testing.expect(!result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null); - try testing.expect(std.mem.indexOf(u8, result.output, "km") != null); - try testing.expect(std.mem.indexOf(u8, result.output, "mi") != null); -} - -test "formatConversion: temperature freezing point" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [256]u8 = undefined; - const result = formatConversion(arena.allocator(), &buf, "0", "C", "F"); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("0 C = 32 F", result.output); -} - -test "formatConversion: unknown unit is an error" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [256]u8 = undefined; - const result = formatConversion(arena.allocator(), &buf, "1", "smoots", "m"); - try testing.expect(result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null); -} - -test "formatConversion: incompatible units is an error" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [256]u8 = undefined; - const result = formatConversion(arena.allocator(), &buf, "1", "kg", "m"); - try testing.expect(result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null); -} - -test "formatConversion: alias resolves to canonical name in output" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [256]u8 = undefined; - const result = formatConversion(arena.allocator(), &buf, "1", "kilometer", "meters"); - try testing.expect(!result.is_error); - // Conversion output now uses the standard comma grouping (NFR-7). - try testing.expectEqualStrings("1 km = 1,000 m", result.output); -} - -test "evaluate: bare conversion expression without subcommand" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "100 km to mi", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null); -} - -test "evaluate: glued bare conversion" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "32F to C", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("32 F = 0 C", result.output); -} - -test "evaluate: conversion value may be an expression" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "2*3 kg to g", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("6 kg = 6,000 g", result.output); -} - -test "evaluate: bare conversion with unknown unit errors" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "100 km to smoots", .standard, &buf); - try testing.expect(result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null); -} - -test "evaluate: bare conversion with incompatible units errors" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "1 kg to m", .standard, &buf); - try testing.expect(result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null); -} - -test "evaluate: ordinary expressions are unaffected by conversion detection" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - const result = evaluate(arena.allocator(), "2 + 3 * 4", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expectEqualStrings("14", result.output); -} - -test "evaluate: expression containing a unit-like name still evaluates" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [4096]u8 = undefined; - // "e" is Euler's number here, not a unit, because there is no "to" keyword. - const result = evaluate(arena.allocator(), "e", .standard, &buf); - try testing.expect(!result.is_error); - try testing.expect(std.mem.startsWith(u8, result.output, "2.718")); -} - // -- convert subcommand separator handling (Task 2.0e) -- fn expectConvertArgs(expected_value: []const u8, expected_from: []const u8, expected_to: []const u8, args: []const []const u8) !void { const parsed = parseArgs(testing.allocator, args); switch (parsed) { .conversion => |c| { + defer testing.allocator.free(c.value_text); try testing.expectEqualStrings(expected_value, c.value_text); - try testing.expectEqualStrings(expected_from, c.from); - try testing.expectEqualStrings(expected_to, c.to); + try testing.expectEqualStrings(expected_from, c.from.name); + try testing.expectEqualStrings(expected_to, c.to.name); }, else => return error.ExpectedConversion, } @@ -1439,49 +830,18 @@ test "parseArgs: convert rejects unknown units rather than guessing" { } } -test "isFullyNumeric: whole-token validation" { - try testing.expect(isFullyNumeric("100")); - try testing.expect(isFullyNumeric("-40.5")); - try testing.expect(isFullyNumeric("1e-3")); - try testing.expect(isFullyNumeric("1,000")); - try testing.expect(isFullyNumeric("1_000")); - // A glued value and unit is NOT a bare number. - try testing.expect(!isFullyNumeric("98.6F")); - try testing.expect(!isFullyNumeric("100km")); - try testing.expect(!isFullyNumeric("abc")); - // Needs at least one digit. - try testing.expect(!isFullyNumeric("-")); - try testing.expect(!isFullyNumeric("")); -} - -test "isSeparatorWord: both words, case-insensitive" { - try testing.expect(isSeparatorWord("to")); - try testing.expect(isSeparatorWord("TO")); - try testing.expect(isSeparatorWord("in")); - try testing.expect(isSeparatorWord("In")); - try testing.expect(!isSeparatorWord("km")); - try testing.expect(!isSeparatorWord("into")); -} - -test "formatConversion: exact conversion prints exactly" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - var buf: [256]u8 = undefined; - - // The bug this task exists to fix. - const feet = formatConversion(arena.allocator(), &buf, "12", "in", "ft"); - try testing.expect(!feet.is_error); - try testing.expectEqualStrings("12 in = 1 ft", feet.output); - - // Exact affine conversion. - const celsius = formatConversion(arena.allocator(), &buf, "98.6", "F", "C"); - try testing.expect(!celsius.is_error); - try testing.expectEqualStrings("98.6 F = 37 C", celsius.output); - - // Exact fractional factor. - const mps = formatConversion(arena.allocator(), &buf, "3.6", "km/h", "m/s"); - try testing.expect(!mps.is_error); - try testing.expectEqualStrings("3.6 km/h = 1 m/s", mps.output); +test "parseArgs: convert falls back to the usage text when no reading gets anywhere" { + // An empty token is the case that reaches the fallback: neither reading finds a + // separator with units on both sides, so neither has an error of its own to + // report and the usage text is all that is left to say. + const parsed = parseArgs(testing.allocator, &.{ "convert", "1", "km", "" }); + switch (parsed) { + .output => |o| { + try testing.expect(o.is_error); + try testing.expectEqualStrings(convert_usage, o.text); + }, + else => return error.ExpectedOutput, + } } // -- Amortization CLI -- @@ -1550,82 +910,3 @@ test "parseArgs: amort rejects incomplete or malformed terms" { const zero = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "0" }); try testing.expect(zero == .output and zero.output.is_error); } - -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(); - - // 1e40 used to produce a full report of "?" with exit status 0. It now fits, - // because the engine formatter groups the whole number. - const large = formatAmortization(arena.allocator(), .{ - .principal = 1e40, - .rate = 0.5, - .periods = 3, - }, false); - try testing.expect(!large.is_error); - try testing.expect(std.mem.indexOfScalar(u8, large.output, '?') == null); - try testing.expect(std.mem.indexOf(u8, large.output, "10,000,000,000,000,000,000,000,000,000,000,000,000,000.00") != null); - - // Past the width of the formatting buffer the report is refused outright, with - // a non-zero exit status, rather than printed with placeholders. - const huge = formatAmortization(arena.allocator(), .{ - .principal = 1e60, - .rate = 0.5, - .periods = 3, - }, false); - try testing.expect(huge.is_error); - try testing.expect(std.mem.indexOf(u8, huge.output, "too large to format") != null); - try testing.expect(std.mem.indexOfScalar(u8, huge.output, '?') == null); -} - -test "formatAmortization: table has a row per period and correct first row" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - - const result = formatAmortization(arena.allocator(), .{ - .principal = 200000, - .rate = 0.5, - .periods = 360, - }, false); - try testing.expect(!result.is_error); - - // Header, payment, blank, column head, 360 rows, blank, 4 summary lines. - var lines: usize = 0; - var it = std.mem.splitScalar(u8, result.output, '\n'); - while (it.next()) |_| lines += 1; - try testing.expectEqual(@as(usize, 369), lines); - - try testing.expect(std.mem.indexOf(u8, result.output, "Payment 1,199.10 per period") != null); - try testing.expect(std.mem.indexOf(u8, result.output, "1,000.00") != null); - try testing.expect(std.mem.indexOf(u8, result.output, "199,800.90") != null); - try testing.expect(std.mem.indexOf(u8, result.output, "Total interest 231,677.04") != null); -} - -test "formatAmortization: summary only omits the rows" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - - const result = formatAmortization(arena.allocator(), .{ - .principal = 200000, - .rate = 0.5, - .periods = 360, - }, true); - try testing.expect(!result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "Period Payment") == null); - try testing.expect(std.mem.indexOf(u8, result.output, "Periods paid 360") != null); -} - -test "formatAmortization: bad terms explain themselves" { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer _ = arena.deinit(); - - // A payment that never covers the interest. - const result = formatAmortization(arena.allocator(), .{ - .principal = 200000, - .rate = 0.5, - .periods = 360, - .payment = 500, - }, false); - try testing.expect(result.is_error); - try testing.expect(std.mem.indexOf(u8, result.output, "loan terms") != null); -} diff --git a/src/tui/help.zig b/src/tui/help.zig index 4f8a175..7582ac0 100644 --- a/src/tui/help.zig +++ b/src/tui/help.zig @@ -10,6 +10,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const vxfw = vaxis.vxfw; +const engine = @import("engine"); const draw = @import("draw.zig"); const C = draw.C; @@ -24,7 +25,21 @@ const Line = union(enum) { blank, }; -const lines = [_]Line{ +const lines = bindings_and_functions ++ financial_functions ++ operators_and_units; + +/// The financial section, rendered from the engine's function table: the list a +/// frontend shows is not a frontend's to keep in step with the evaluator. +const financial_functions = blk: { + var section: [engine.financial.help_lines.len + 2]Line = undefined; + section[0] = .{ .header = "Financial Functions" }; + for (section[1 .. section.len - 1], engine.financial.help_lines) |*line, text| { + line.* = .{ .text = text }; + } + section[section.len - 1] = .blank; + break :blk section; +}; + +const bindings_and_functions = [_]Line{ .{ .header = "Keybindings" }, .{ .key = .{ .name = "Enter", .desc = "Evaluate expression" } }, .{ .key = .{ .name = "Tab", .desc = "Next mode (Standard/Programmer/Financial/Convert)" } }, @@ -79,21 +94,9 @@ const lines = [_]Line{ .{ .text = "sin cos tan asin acos atan log ln sqrt abs" }, .{ .text = "ceil floor round factorial max min exp" }, .blank, +}; - .{ .header = "Financial Functions" }, - .{ .text = "cagr(start, end, periods) growth rate as a fraction" }, - .{ .text = "fv(pv, rate%, years [, per year]) future value" }, - .{ .text = "pv(fv, rate%, years [, per year]) present value" }, - .{ .text = "compound_rate(pv, fv, years [, m]) nominal annual rate" }, - .{ .text = "compound_years(pv, fv, rate [, m]) time to get there" }, - .{ .text = "apy(nominal_rate, per_year) effective annual rate" }, - .{ .text = "tvm_pmt(n, rate%, pv, fv) solve a payment" }, - .{ .text = "tvm_fv / tvm_pv / tvm_n / tvm_rate solve the other variables" }, - .{ .text = "amort_payment(principal, rate%, n) level payment" }, - .{ .text = "amort_interest / _principal / _balance (.., n, period)" }, - .{ .text = "amort_total_interest / _total_paid (principal, rate%, n)" }, - .blank, - +const operators_and_units = [_]Line{ .{ .header = "Operators" }, .{ .text = "Standard: + - * / % ^ (power)" }, .{ .text = "Programmer: & | ~ << >> >>> ^/** (pow) and or xor not rol ror" },