financial mode
This commit is contained in:
parent
e12c239a5b
commit
4045a7bf68
17 changed files with 6542 additions and 166 deletions
|
|
@ -67,7 +67,7 @@ build.zig (workspace root)
|
|||
| `programmer.zig` | Integer operations, base conversion, bit manipulation |
|
||||
| `struct_layout.zig` | Struct DSL parser, layout computation, ABI profiles |
|
||||
| `units.zig` | Unit conversion tables and resolver |
|
||||
| `financial.zig` | CAGR, TVM, compound interest |
|
||||
| `financial.zig` | CAGR, TVM, compound interest, amortization |
|
||||
| `types.zig` | Shared types (Value, Error, etc.) |
|
||||
| `engine.zig` | Public API surface (Zig-native) |
|
||||
| `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers |
|
||||
|
|
@ -190,7 +190,7 @@ pub const Environment = struct {
|
|||
};
|
||||
```
|
||||
|
||||
Standard mode evaluates to `f64`. Programmer mode evaluates to `Integer` (exact, truncated to bit width). Financial functions return `f64` with optional step-by-step breakdown.
|
||||
Standard mode evaluates to `Number`, the exact/inexact union of section 2.7. Programmer mode evaluates to `Integer` (exact, truncated to bit width). Financial functions compute in `f64` and enter the expression language as inexact `Number` values (section 5.6).
|
||||
|
||||
### 2.6 Number Display Formatting
|
||||
|
||||
|
|
@ -201,9 +201,18 @@ The engine provides raw values; frontends apply display formatting. The engine i
|
|||
| Context | Display | Clipboard |
|
||||
|---------|---------|-----------|
|
||||
| Standard result | `4,294,967,295` | `4294967295` |
|
||||
| Fractional result | `231,677.04` | `231677.04` |
|
||||
| Financial result | `$1,234,567.89` | `1234567.89` |
|
||||
| Large decimals | `4,294,967,295` (never scientific unless > 15 digits of precision) | `4294967295` |
|
||||
|
||||
**Grouping applies to the integer part regardless of a fractional part.** Commas
|
||||
group the digits left of the decimal point and never appear to the right of it,
|
||||
so `231677.04` displays as `231,677.04` rather than ungrouped. Grouping only
|
||||
whole numbers would make the same magnitude read two different ways depending on
|
||||
whether cents happened to be present, which showed up immediately in financial
|
||||
output. Because FR-1.8 accepts commas as input digit separators, the grouped
|
||||
display remains valid input. Text already in scientific notation is left alone.
|
||||
|
||||
**Scientific notation threshold**: Only use scientific notation when the number exceeds what can be meaningfully displayed in decimal (> 15 significant digits, or absolute value > 10^15 or < 10^-15). Otherwise, always show full decimal with commas. This is a deliberate departure from calculators that jump to scientific notation at 10+ digits.
|
||||
|
||||
#### Programmer Mode Formatting
|
||||
|
|
@ -863,7 +872,40 @@ answers.
|
|||
CAGR = (end_value / start_value) ^ (1 / periods) - 1
|
||||
```
|
||||
|
||||
### 5.2 TVM (Time Value of Money)
|
||||
### 5.2 Compound Interest
|
||||
|
||||
```
|
||||
FV = PV * (1 + rate/100/m)^(m * years)
|
||||
```
|
||||
|
||||
Solvable for any of its four variables, all in closed form:
|
||||
|
||||
```
|
||||
PV = FV / (1 + rate/100/m)^(m*years)
|
||||
rate = m * ((FV/PV)^(1/(m*years)) - 1) * 100
|
||||
years = ln(FV/PV) / (m * ln(1 + rate/100/m))
|
||||
```
|
||||
|
||||
`m` (compounds per year) is deliberately NOT solvable. As `m` rises, FV climbs
|
||||
from the annual case toward the continuous limit `PV * e^(rate*years/100)`, so a
|
||||
solution exists only inside that band: for 10,000 at 6% over 10 years, only for
|
||||
future values between about 17,908 and 18,221. Nearly every real input has no
|
||||
answer, and a fractional answer ("compound 7.3 times a year") would be
|
||||
meaningless, so it stays an input with a prefilled default of 1.
|
||||
|
||||
**Nominal versus effective.** `compoundRate` returns a NOMINAL annual rate: at
|
||||
`m = 12` it is the APR a lender quotes, not what the money earned.
|
||||
`effectiveAnnualRate` converts (18% nominal monthly is 19.56% effective), and the
|
||||
TUI shows both whenever `m > 1`. This only matters when solving, because a rate
|
||||
that was typed in is one the user already understands; a rate the calculator
|
||||
produces is one they are about to compare against something else.
|
||||
|
||||
Solving for the rate at `m = 1` is exactly CAGR. Both entry points are kept: CAGR
|
||||
is a named thing people look for, and it takes periods rather than years so it
|
||||
works for quarters or months without a frequency argument. A test asserts the two
|
||||
agree, so they cannot drift.
|
||||
|
||||
### 5.3 TVM (Time Value of Money)
|
||||
|
||||
The five TVM variables: N (periods), I/Y (interest rate per period), PV (present value), PMT (payment), FV (future value).
|
||||
|
||||
|
|
@ -878,6 +920,87 @@ Solving for the unknown variable:
|
|||
- **N**: logarithmic solution
|
||||
- **I/Y**: Newton-Raphson iterative solver (no closed form)
|
||||
|
||||
Sign convention is the cash-flow one used by financial calculators: money
|
||||
received is positive, money paid out is negative. A borrower taking a 200,000
|
||||
loan has `pv = 200000` and a negative `pmt`. The solver never flips signs on the
|
||||
caller's behalf, because a silent flip is harder to debug than a wrong-looking
|
||||
answer.
|
||||
|
||||
The rate solver takes its derivative by central difference rather than
|
||||
analytically: the annuity-due form of the derivative is unwieldy and the
|
||||
numerical version is not the accuracy bottleneck. Two details make it reliable:
|
||||
|
||||
1. **Scale-relative acceptance.** The residual of the TVM equation is
|
||||
proportional to the size of the cash flows, so it is compared against
|
||||
`tolerance * max(|pv|, |fv|, |pmt| * n, 1)`. With an absolute tolerance a
|
||||
200,000 mortgage cannot converge at all, because floating-point noise in the
|
||||
residual exceeds any fixed epsilon.
|
||||
2. **Multiple seeds.** The residual can be flat or badly sloped near a poor
|
||||
initial guess, so the solver retries from a small set of starting rates
|
||||
(5%, 1%, 10%, 0.5%, 25%, -5%, 50%). A single seed fails on well-posed inputs.
|
||||
|
||||
### 5.4 Amortization
|
||||
|
||||
An amortization schedule is generated period by period from the balance rather
|
||||
than from a closed form, because that is the only way the rounding matches what a
|
||||
lender's table shows. Each period:
|
||||
|
||||
```
|
||||
interest = round(balance * r)
|
||||
principal = payment - interest
|
||||
balance = balance - principal
|
||||
```
|
||||
|
||||
Rows are read from the borrower's side, so every figure is positive: `payment` is
|
||||
an amount paid, `balance` an amount owed. Mixing in the TVM sign convention only
|
||||
makes a table harder to read.
|
||||
|
||||
Three behaviors are deliberate:
|
||||
|
||||
- **The final period settles the balance exactly.** Cent rounding leaves a
|
||||
residue over hundreds of periods, so the last payment differs slightly from the
|
||||
rest. This is what real schedules do, and it is why the total of the principal
|
||||
column equals the loan amount to the cent.
|
||||
- **An overlarge payment retires the loan early**, and the schedule simply ends
|
||||
short of `periods`.
|
||||
- **An underfunded term ends in a balloon.** A payment that covers the interest
|
||||
but not the full term leaves a large final payment, which is shown rather than
|
||||
smoothed away. A payment that does not cover even the first period's interest
|
||||
never amortizes at all, so it is a domain error rather than an infinite loop.
|
||||
|
||||
Single rows (`amortizationEntry`) and totals (`amortizationTotals`) walk the same
|
||||
cursor as the full schedule, so a row fetched on its own can never disagree with
|
||||
the same row inside a table. Only the full schedule allocates.
|
||||
|
||||
### 5.5 Reachability: expression functions, not just a mode
|
||||
|
||||
Financial calculations are exposed as ordinary expression functions in standard
|
||||
mode, mirroring how unit conversion is reachable from a bare expression. That
|
||||
keeps them composable (`cagr(10000, 25000, 5) * 100`) and usable from the CLI
|
||||
without a subcommand per formula. See FR-5.7 for the function list.
|
||||
|
||||
This required the evaluator's function dispatch to grow beyond the 0/1/2-argument
|
||||
cases it previously handled; financial functions take three or four arguments. An
|
||||
unknown name at those arities still reports `UnknownFunction`, and a wrong arity
|
||||
for a known financial function does too, rather than filling in defaults.
|
||||
|
||||
The one exception is the amortization table itself: a table is not a value, so it
|
||||
lives behind the `tally amort` subcommand (FR-6.11). The per-period figures are
|
||||
still reachable as functions (`amort_interest(...)` and friends).
|
||||
|
||||
### 5.6 Precision: f64 plus explicit rounding
|
||||
|
||||
This module is f64, not the exact `Number` tier used elsewhere (design 2.7).
|
||||
Every formula here needs a non-integer power or a logarithm - CAGR raises to
|
||||
`1/n`, the period solver takes `ln`, the rate solver iterates - and those escape
|
||||
the rationals by definition, so there is nothing for the exact tier to preserve.
|
||||
|
||||
What money needs is not exactness but controlled rounding, which is a separate
|
||||
concern: `roundToScale(value, decimals, mode)` and `roundToCents` are applied at
|
||||
the point a figure becomes an amount. The default is half-even (banker's
|
||||
rounding); half-up is available but is not the default because it biases totals
|
||||
upward across many roundings.
|
||||
|
||||
---
|
||||
|
||||
## 6. C API (for Android/FFI)
|
||||
|
|
@ -951,8 +1074,7 @@ Design notes:
|
|||
tally [OPTIONS] <expression>
|
||||
tally struct [OPTIONS] <definition | --file path>
|
||||
tally convert <value> <from_unit> <to_unit>
|
||||
tally cagr <start_value> <end_value> <periods>
|
||||
tally tvm [--n N] [--rate R] [--pv PV] [--pmt PMT] [--fv FV]
|
||||
tally amort <principal> <rate> <periods> [payment] [--monthly] [--summary] [--exact]
|
||||
|
||||
Options:
|
||||
-p, --programmer Programmer mode
|
||||
|
|
@ -1022,6 +1144,28 @@ $ tally "5 kg + 3 lb"
|
|||
|
||||
## 8. TUI Layout
|
||||
|
||||
### 8.0.0 How the TUI is tested
|
||||
|
||||
Terminal code is usually left uncovered on the grounds that it needs a terminal.
|
||||
Most of it does not. Two harnesses cover nearly all of it:
|
||||
|
||||
- **Rendered frames.** `src/tui/test_render.zig` draws a real frame through the
|
||||
real widget draw path into a real surface, then reads the cells back as rows of
|
||||
text. Assertions are made against what a user would see (`"= PMT -1,199.10"`,
|
||||
`"rows 1-7 of 360"`), plus a structural check that every row is exactly the
|
||||
requested width, since the drawing helpers clip silently rather than erroring
|
||||
when a column is miscomputed. Views are exercised at 20x6 through 200x60 to
|
||||
catch layouts that only work at one size.
|
||||
- **Real event handlers.** `vxfw.EventContext` is constructible in a test, so key
|
||||
presses and mouse events go through `handleKey` and `handleMouse` rather than
|
||||
through a reimplementation of them. This is what catches a binding that was
|
||||
never routed, or one mode swallowing another mode's keys.
|
||||
|
||||
What is left uncovered is the terminal boundary itself: the event loop, `vaxis`
|
||||
initialisation, and `main`. Coverage is reported per source tree (engine, CLI,
|
||||
TUI) with disjoint include paths, so a file is never accounted for twice and a
|
||||
query tool never has to guess which report answers for it.
|
||||
|
||||
### 8.0 Mouse Input Architecture
|
||||
|
||||
The TUI is mouse-driven as well as keyboard-driven (FR-7.11). Rather than each
|
||||
|
|
@ -1036,6 +1180,7 @@ pub const Action = union(enum) {
|
|||
focus_input, close_help, toggle_float,
|
||||
cycle_width, toggle_endian, toggle_signedness, toggle_float_format,
|
||||
conv_category: UnitCategory, conv_from: usize, conv_to: usize, conv_swap,
|
||||
fin_form: Form, fin_field: usize, // financial chips and rows
|
||||
};
|
||||
|
||||
pub const RegionSet = struct {
|
||||
|
|
@ -1061,7 +1206,11 @@ Key properties:
|
|||
from cell to bit index is written once. `registerDigitRegions` mirrors
|
||||
`drawFieldWithCursor`'s digit walk for the same reason.
|
||||
- **Left press only.** Release, motion, and drag are ignored so one physical
|
||||
click produces exactly one action.
|
||||
click produces exactly one action. The wheel is the one exception: in financial
|
||||
mode `wheel_up`/`wheel_down` scroll the amortization schedule, handled before
|
||||
the press filter because a wheel event is not a press. The wheel does not go
|
||||
through the region table at all, since it acts on the view rather than on a
|
||||
specific cell.
|
||||
- **A fixed-size buffer, not an allocation.** Drawing happens on every keystroke
|
||||
and must not fail; an exhausted budget silently drops extra regions (a click
|
||||
does nothing) instead of erroring. 512 comfortably covers the worst case, the
|
||||
|
|
@ -1300,6 +1449,136 @@ Interaction:
|
|||
|
||||
---
|
||||
|
||||
### 8.5 Financial Mode
|
||||
|
||||
```
|
||||
Tally [Standard] [Programmer] [Financial] [Convert]
|
||||
|
||||
Calculation:
|
||||
CAGR Compound TVM Amortization
|
||||
|
||||
> Principal 200,000
|
||||
Rate per period 0.5 %
|
||||
Periods 360
|
||||
Payment (derived)
|
||||
|
||||
Payment 1,199.10 | total interest 231,677.04 | total paid 431,677.04
|
||||
|
||||
Period Payment Interest Principal Balance
|
||||
1 1,199.10 1,000.00 199.10 199,800.90
|
||||
2 1,199.10 999.00 200.10 199,600.80
|
||||
3 1,199.10 998.00 201.10 199,399.70
|
||||
...
|
||||
rows 1-7 of 360 (PgUp/PgDn)
|
||||
------------------------------------------------------------------
|
||||
>
|
||||
Up/Dn:field | L/R:calc | Enter:eval | Space:END/BGN | Ctrl-U:clear
|
||||
```
|
||||
|
||||
Four forms behind one set of chips: CAGR, Compound, TVM, Amortization. The TVM
|
||||
form adds a sixth row, an END/BGN toggle for annuity due.
|
||||
|
||||
**Solving by omission.** TVM and compound interest solve for whichever field is
|
||||
left blank, the way a financial calculator does and the way the engine's own
|
||||
`TvmParams` is shaped. Blank is therefore a meaningful state rather than an error,
|
||||
and a blank solve-for field renders as `[solve]` instead of looking like missing
|
||||
input. When the form is not yet answerable it says what it is waiting for ("clear
|
||||
one field to solve for it"), so an incomplete form is never a silent no-op.
|
||||
Amortization treats a blank payment differently: it means "derive it", since a
|
||||
schedule always has a payment.
|
||||
|
||||
**Defaults are prefilled, not placeheld.** Compounds/year starts at `1` rather
|
||||
than showing an empty field that means "annual". That keeps "blank means solve for
|
||||
this" true for every field the user sees, instead of blank meaning a default in one
|
||||
place and a solve slot in another. The prefill is baked into the comptime initial
|
||||
state rather than applied at draw time, because a draw-time default cannot tell
|
||||
"never touched" from "deliberately cleared". Clearing a non-solvable field has
|
||||
to report that it needs a value, not try to solve for it.
|
||||
|
||||
**A solved rate says which kind it is.** Above annual compounding the compound
|
||||
form reports `= rate 18.00% nominal (19.56% effective)`. See design 5.2 for why:
|
||||
the nominal figure alone invites a wrong comparison against a quoted APY, and this
|
||||
is the only place the calculator produces a rate rather than consuming one.
|
||||
|
||||
**Two ways to enter a number, deliberately.** Typing in the form zone goes
|
||||
straight into the focused field, which is what makes quick entry work. The shared
|
||||
input line at the bottom evaluates an expression and drops the result into the
|
||||
focused field, so a value can also be computed from outside the form.
|
||||
|
||||
**Fields hold expressions, and group as you type.** `12 * 30` is valid input for a
|
||||
period count; it displays verbatim in orange with an `(Enter)` prompt, and Enter
|
||||
evaluates it in place. A plain number is grouped for display as it is typed
|
||||
(`200` then `0` shows `2,000`), while the buffer keeps the ungrouped text so it
|
||||
still parses and still round-trips through Enter. Grouping the buffer instead
|
||||
would mean stripping separators on every read and would make cursor arithmetic
|
||||
depend on where the commas happen to fall. A typed comma is absorbed for the same
|
||||
reason.
|
||||
|
||||
A field holding an unevaluated expression is reported as such rather than being
|
||||
treated as empty, since a blank field means "solve for this" in two of the four
|
||||
forms and silently conflating the two would be a wrong answer rather than a
|
||||
cosmetic problem.
|
||||
|
||||
**Rates say they are rates.** A percent field renders a trailing `%` after the
|
||||
value and absorbs a typed one, so `6%` and `6` both mean six percent. The label
|
||||
carries that information too, but not once the eye has moved to the value column,
|
||||
which is where a bare `6` is ambiguous between a rate and a multiplier.
|
||||
|
||||
**Focus styling.** The focused field is a dark selection fill (Molokai's own
|
||||
selection colour) with the value in its normal foreground, and a single yellow
|
||||
block cursor. The earlier version reversed bright cyan into the background, which
|
||||
read as a 1980s terminal and fought every other colour on screen; a neutral fill
|
||||
keeps the number legible and leaves the cursor as the only bright cell on the row.
|
||||
The row marker and focused label are yellow, matching the mode's own tab colour
|
||||
rather than introducing a fourth accent.
|
||||
|
||||
**Results are live, not history.** Every keystroke recomputes, so there is
|
||||
nothing to submit and nothing is appended to history. That is why financial mode
|
||||
is the one mode whose input line does not produce a history entry.
|
||||
|
||||
**The schedule is the reason this mode exists.** Everything else here is
|
||||
available as an expression function (FR-5.7), but a 360-row table is not a value.
|
||||
It scrolls with PgUp/PgDn from either zone and with the mouse wheel, and shows
|
||||
`rows X-Y of N` whenever it is showing a window rather than the whole thing. The
|
||||
scroll offset is clamped at draw time rather than at scroll time, so a resize or a
|
||||
changed period count cannot leave a stale offset showing an empty table.
|
||||
|
||||
**Formula breakdown.** Under the result, the relationship being solved is shown,
|
||||
plus a substituted line for the forms where it fits (`= (25000 / 10000)^(1/5) -
|
||||
1`). TVM and amortization show the general form only; substituting five variables
|
||||
into the TVM identity produces a line too long to be useful.
|
||||
|
||||
Interaction, consistent with the other modes:
|
||||
|
||||
- Backtick toggles the input zone and the form zone.
|
||||
- Up/Down move between fields, wrapping. Left/Right switch calculation, so every
|
||||
form is reachable from the keyboard alone (FR-7.7).
|
||||
- Enter evaluates the focused field in place.
|
||||
- Space toggles END/BGN on the payments row; Ctrl-U (or Delete) empties a field,
|
||||
which is also how a TVM variable is marked as the one to solve for.
|
||||
- Clicking a chip selects a calculation; clicking a row focuses that field.
|
||||
Switching calculation resets the focused field and the scroll offset, but each
|
||||
form keeps its own entries, so moving between them does not discard typing.
|
||||
|
||||
### 8.6 Help Overlay
|
||||
|
||||
The overlay is a static line list (headers, bindings, free text) rendered through
|
||||
one windowed loop, not a sequence of sections that each gate themselves on the
|
||||
remaining height. The old shape had a specific failure: every section ended with
|
||||
`if (row < height - 6)`, so as sections were added the later ones silently
|
||||
disappeared on a 24-row terminal with no indication anything was missing. That was
|
||||
found by a test asserting the Convert section was present, which failed once the
|
||||
Financial section was added above it.
|
||||
|
||||
As a line list the content can be scrolled (Up/Down, PgUp/PgDn, wheel) and its
|
||||
length is knowable without drawing, which is what lets the App clamp its own
|
||||
scroll offset. Any non-scrolling key returns, preserving the "press a key to get
|
||||
out" behaviour. The footer shows `lines X-Y of N` only when something is off
|
||||
screen, so a tall terminal is not cluttered with a position indicator it does not
|
||||
need.
|
||||
|
||||
---
|
||||
|
||||
## 9. Android UI Design
|
||||
|
||||
The Android app uses a fundamentally different interaction model from the TUI. Where the TUI is keyboard-driven with a prompt, Android is **touch-first with purpose-built input surfaces** for each mode. No text-cursor-in-a-prompt paradigm - instead, tappable buttons, interactive grids, and form fields.
|
||||
|
|
|
|||
|
|
@ -94,9 +94,14 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
|
||||
- **FR-5.1**: Compute CAGR given start value, end value, and number of periods.
|
||||
- **FR-5.2**: Compute compound interest (future value given principal, rate, periods, compounding frequency).
|
||||
- **FR-5.2.1**: The compound-interest relationship is solvable for any of its four variables: present value, future value, nominal annual rate, or years. The compounding frequency is an input rather than a variable, because a solution for it only exists in the narrow band between annual and continuous compounding and a fractional answer would be meaningless.
|
||||
- **FR-5.2.2**: A solved rate is a NOMINAL annual rate. When the compounding frequency exceeds 1 the effective annual rate (APY) must be reported alongside it, since a nominal rate compared against a quoted effective rate is a wrong comparison. At annual compounding the two coincide and only one figure is shown.
|
||||
- **FR-5.3**: Compute present value (discount future cash flow).
|
||||
- **FR-5.4**: Support TVM (Time Value of Money) solver - given any 4 of: N, I/Y, PV, PMT, FV, solve for the 5th.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
|
@ -110,15 +115,21 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
- **FR-6.8**: Subcommand `tally convert <value> <from_unit> <to_unit>` for unit conversion.
|
||||
- **FR-6.9**: Output format flags: `--json` for machine-readable output, plain text default.
|
||||
- **FR-6.10**: Exit code 0 on success, non-zero on parse/evaluation errors with stderr message.
|
||||
- **FR-6.11**: Subcommand `tally amort <principal> <rate> <periods> [payment]` prints an amortization schedule as a table. Flags: `--monthly` (the rate given is an annual nominal rate charged over monthly periods, so rate/12 applies per period), `--summary` (totals only, no per-period rows), `--exact` (skip cent rounding). A table is the one financial output that cannot be an expression function, which is why it is a subcommand.
|
||||
|
||||
### FR-7: TUI Frontend
|
||||
|
||||
- **FR-7.1**: Full-screen terminal UI with mode tabs (Standard, Programmer, Financial, Convert).
|
||||
- **FR-7.1**: Full-screen terminal UI with mode tabs (Standard, Programmer, Financial, Convert). Tab moves to the next mode and Shift-Tab to the previous one, so a mis-hit costs one keystroke rather than a full lap.
|
||||
- **FR-7.2**: Standard mode: expression input line, result display, scrollable history.
|
||||
- **FR-7.3**: Programmer mode: bit grid (navigable with arrow keys, toggle with Space/Enter), simultaneous base displays, expression input.
|
||||
- **FR-7.4**: Programmer mode struct sub-view: field list editor, live-updating memory map visualization.
|
||||
- **FR-7.5**: Financial mode: form-style input for parameters, result display with formula breakdown.
|
||||
- **FR-7.5**: Financial mode: form-style input for parameters, result display with formula breakdown, and a scrollable amortization schedule. Four calculations behind one selector: CAGR, compound interest, TVM, and amortization. TVM and compound interest solve for whichever field is left blank; an unanswerable form reports what it is waiting for rather than doing nothing. Results are live, so financial mode records nothing in history.
|
||||
- **FR-7.5.1**: Form fields group digits as they are typed: entering `200` then `0` displays `2,000`. The stored text stays ungrouped so it still parses, and a typed comma is accepted and discarded rather than corrupting the value.
|
||||
- **FR-7.5.2**: Form fields accept expressions, not only numbers. `12 * 30` in a period count is valid input, shown verbatim while it is an expression and evaluated in place by Enter. A field holding an unevaluated expression is reported as such, so it is never mistaken for a blank field the form is waiting to solve for. A field that does not evaluate keeps its text rather than being silently cleared.
|
||||
- **FR-7.5.3**: Rate fields display a trailing `%` after the value, and accept a typed `%` as a no-op, so `6%` and `6` mean the same thing. The label alone is not sufficient affordance once the eye is on the value column.
|
||||
- **FR-7.5.4**: Where a default is near-universal, the form prefills it as a visible value rather than showing an empty field with a placeholder. Compounds/year starts at `1`. This keeps "blank means solve for this" true for every field the user sees, instead of blank meaning a default in one place and a solve slot in another. Clearing a prefilled non-solvable field asks for a value rather than for an answer.
|
||||
- **FR-7.6**: Convert mode: select category, input value, select from/to units, live result.
|
||||
- **FR-7.6.1**: The help overlay scrolls (Up/Down, PgUp/PgDn, or the mouse wheel) and shows its position when content is off screen. Any other key returns. Sections are a single line list rather than individually height-gated draw calls, which is what previously caused later sections to be dropped silently on a normal-sized terminal.
|
||||
- **FR-7.7**: Every action must be reachable from the keyboard alone; the TUI is fully usable without a mouse.
|
||||
- **FR-7.8**: Quick-switch keys for base display in programmer mode (e.g., `d`=dec, `h`=hex, `o`=oct, `b`=bin to highlight primary).
|
||||
- **FR-7.9**: Support terminal resize gracefully.
|
||||
|
|
@ -140,8 +151,9 @@ still required (FR-7.7): the mouse never becomes the only way to do something.
|
|||
- **FR-7.11.8**: In convert mode, clicking a category chip selects that category (resetting the unit pair to that category's defaults); clicking a unit in the From or To column selects it.
|
||||
- **FR-7.11.9**: Clicking the input line returns keyboard focus to the expression prompt.
|
||||
- **FR-7.11.10**: Clicking anywhere dismisses the help overlay, matching its "any key dismisses" keyboard behavior.
|
||||
- **FR-7.11.11**: Only a left button press acts. Release, motion, and drag events are ignored so a single click cannot fire an action twice.
|
||||
- **FR-7.11.11**: Only a left button press acts. Release, motion, and drag events are ignored so a single click cannot fire an action twice. The wheel is the one exception: in financial mode it scrolls the amortization schedule (FR-7.11.13).
|
||||
- **FR-7.11.12**: Clickable regions are rebuilt every frame from what was actually drawn, so hit targets can never drift out of sync with the display.
|
||||
- **FR-7.11.13**: In financial mode, clicking a calculation chip selects that calculation and clicking a field row focuses it for editing; the mouse wheel scrolls the amortization schedule. In the help overlay the wheel scrolls and a click returns.
|
||||
|
||||
### FR-8: Android Frontend
|
||||
|
||||
|
|
@ -192,7 +204,8 @@ still required (FR-7.7): the mouse never becomes the only way to do something.
|
|||
- Engine must be testable in isolation (no I/O dependencies).
|
||||
- Struct layout results must be verifiable against `pahole` or compiler output for reference structs.
|
||||
- Engine test coverage target: >= 80% line coverage. The engine is pure computation with no I/O, so this is achievable and expected.
|
||||
- Frontend coverage: best-effort via integration and smoke tests (TUI key-sequence tests, Android instrumented tests). No hard coverage target on frontend code.
|
||||
- Frontend coverage is measured, not waived. The CLI and TUI each get their own instrumented coverage report alongside the engine's, with include paths kept disjoint so no file is accounted for twice. The TUI is testable further than "smoke tests" suggests: views are drawn into a surface and the cells read back, and key and mouse handling goes through the real handlers, so layout and binding regressions are caught rather than eyeballed. Target is the same >= 80% as the engine.
|
||||
- What genuinely cannot be covered is the terminal itself: the event loop, `vaxis` setup, and the process entry point. Those are the only frontend paths exempt.
|
||||
- All public engine API functions must have corresponding unit tests covering happy path, error cases, and edge cases.
|
||||
|
||||
### NFR-6: Build & Distribution
|
||||
|
|
|
|||
|
|
@ -313,16 +313,36 @@ binary approximation before `convertUnits` did its own multiply and divide.
|
|||
- This affects the byte-map visualization labels, not the layout computation itself
|
||||
- Verify: unit test showing a u32 value's bytes in both endiannesses
|
||||
|
||||
### Task 2.5: Implement financial module
|
||||
- Create `engine/src/financial.zig`
|
||||
- Implement CAGR: `(end/start)^(1/n) - 1`
|
||||
- Implement compound interest (future value): `PV * (1 + r/m)^(m*t)`
|
||||
- Implement present value: `FV / (1 + r/m)^(m*t)`
|
||||
- Implement TVM solver:
|
||||
- Closed-form for FV, PV, PMT, N
|
||||
- Newton-Raphson for I/Y (rate) with convergence check, max 1000 iterations, tolerance 1e-10
|
||||
- Return step-by-step formula string alongside numeric result
|
||||
- Verify: unit tests against known financial calculation results (textbook examples)
|
||||
### Task 2.5: Implement financial module [DONE]
|
||||
- Created `engine/src/financial.zig`
|
||||
- CAGR: `(end/start)^(1/n) - 1`
|
||||
- Compound interest (future value): `PV * (1 + r/m)^(m*t)`; present value as its inverse
|
||||
- TVM solver over `TvmParams`/`TvmSolution`: exactly one of the five variables is
|
||||
null and gets solved for
|
||||
- Closed-form for FV, PV, PMT; logarithmic for N
|
||||
- Newton for I/Y, max 1000 iterations, tolerance 1e-10
|
||||
- `TvmVariable.label()` gives the calculator names (N, I/Y, PV, PMT, FV)
|
||||
- Amortization (FR-5.6): `amortizationPayment`, `amortizationEntry`,
|
||||
`amortizationSchedule`, `amortizationTotals`, sharing one cursor so a single row
|
||||
cannot disagree with the same row in a table. Final period settles the balance
|
||||
exactly; overlarge payments end early; underfunded terms balloon
|
||||
- Money rounding: `roundToScale(value, decimals, mode)` and `roundToCents`,
|
||||
half-even by default
|
||||
- Reachable from standard-mode expressions (FR-5.7): `evalFunction` in
|
||||
`evaluator.zig` gained 3- and 4-argument dispatch, and `evalFinancialFn` maps
|
||||
the names `cagr`, `fv`, `pv`, `tvm_pmt`/`tvm_fv`/`tvm_pv`/`tvm_n`/`tvm_rate`,
|
||||
`amort_payment`, `amort_interest`/`_principal`/`_balance`,
|
||||
`amort_total_interest`/`_total_paid`
|
||||
- CLI `tally amort <principal> <rate> <periods> [payment]` with `--monthly`,
|
||||
`--summary`, `--exact` (FR-6.11), since a table is not an expression value
|
||||
- DESIGN CHANGE from the original spec: f64 rather than the exact `Number` tier.
|
||||
Every formula needs a non-integer power or a logarithm, so nothing exact
|
||||
survives; money is handled by explicit rounding instead (design.md 5.6)
|
||||
- DEFERRED from the original spec: the step-by-step formula string. The
|
||||
expression-function route made the formula visible in the input itself, and the
|
||||
breakdown belongs with the TUI financial form (Task 5.6)
|
||||
- 60 financial tests plus 12 evaluator tests and 10 CLI tests; financial.zig is
|
||||
100% covered apart from an unreachable test-sweep guard
|
||||
|
||||
### Task 2.6: Implement unit conversion engine [DONE]
|
||||
- Created `engine/src/units.zig`
|
||||
|
|
@ -446,6 +466,25 @@ Remaining subcommands deferred until their engine modules exist.
|
|||
- Verify: bare conversions, expression values, inch ambiguity, error paths, and
|
||||
unchanged behavior for ordinary expressions all confirmed by running the binary
|
||||
|
||||
### Task 4.5: Financial functions in expressions + amort subcommand [DONE]
|
||||
- Financial math is reachable from any expression rather than only a mode
|
||||
(FR-5.7), the same way conversion is: `tally 'cagr(10000, 25000, 5) * 100'`
|
||||
- SUPERSEDES the originally planned `tally cagr` and `tally tvm` subcommands
|
||||
(FR-6.6, FR-6.7). A subcommand per formula buys nothing once the functions
|
||||
compose inside expressions, and flag-per-variable parsing is more to type than
|
||||
`tvm_pmt(360, 0.5, 200000, 0)`
|
||||
- `tally amort` (aliased `amortize`) is the exception, because a schedule is a
|
||||
table rather than a value: `--monthly`, `--summary`, `--exact` flags, money
|
||||
formatted with thousands separators and two decimals
|
||||
- Domain errors from loan terms get a message that names the likely cause instead
|
||||
of a bare "domain error"
|
||||
- Help text lists the financial functions and both subcommands
|
||||
- 10 CLI tests (arg parsing, flags, money formatting, table shape, error path)
|
||||
- Verify: ran `tally amort 200000 6 360 --monthly` and confirmed the first row
|
||||
(1,199.10 / 1,000.00 / 199.10 / 199,800.90), the balloon and early-payoff
|
||||
variants, the zero-rate case, and a total interest of 231,677.04 against an
|
||||
independent calculation
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: TUI Frontend
|
||||
|
|
@ -561,13 +600,104 @@ Remaining subcommands deferred until their engine modules exist.
|
|||
- Transition: from programmer mode, press a key (e.g., `S`) to enter struct sub-view, Esc to return
|
||||
- Verify: can enter struct definition, see layout update, toggle ABI/endian
|
||||
|
||||
### Task 5.6: Implement financial mode TUI view
|
||||
- Create `tui/src/views/financial.zig`
|
||||
- Form-style interface: labeled input fields for each financial function
|
||||
- Sub-mode selector: CAGR, Compound Interest, TVM
|
||||
- For TVM: 5 input fields, leave one empty to solve
|
||||
- Result display with formula breakdown
|
||||
- Verify: can input values, compute results, see formula steps
|
||||
### Task 5.6: Implement financial mode TUI view [DONE]
|
||||
- Created `src/tui/financial.zig`; financial is now a top-level mode and Tab
|
||||
cycles Standard -> Programmer -> Financial -> Convert
|
||||
- Four forms behind one chip row: CAGR, Compound, TVM, Amortization, with a sixth
|
||||
END/BGN row on TVM for annuity due
|
||||
- TVM and compound solve for whichever field is left blank, matching the engine's
|
||||
`TvmParams` convention; a blank solve-for field renders `[solve]` and an
|
||||
unanswerable form says what it is waiting for instead of doing nothing
|
||||
- Amortization shows payment and totals plus a scrollable schedule (FR-7.5):
|
||||
PgUp/PgDn from either zone and the mouse wheel. The scroll offset is clamped at
|
||||
draw time, so a resize or a changed period count cannot leave a stale offset
|
||||
showing an empty table
|
||||
- Two entry routes: digits typed in the form zone go into the focused field, and
|
||||
the shared input line evaluates an expression into it (`30 * 12` -> 360), which
|
||||
keeps the full expression language available inside a field
|
||||
- Results are live, so financial mode is the one mode that appends nothing to
|
||||
history
|
||||
- Formula breakdown under the result: the general relationship always, plus a
|
||||
substituted line for CAGR and compound where it fits on one row
|
||||
- State and solving live in `financial.zig` as a pure `State` struct, so the
|
||||
behavior is testable without a terminal; `App` embeds one and forwards keys
|
||||
- DEFERRED from the original spec: nothing. The step-by-step breakdown deferred
|
||||
from Task 2.5 is included here
|
||||
- 43 form-logic tests, 12 rendered-frame tests (a real surface is drawn and the
|
||||
cells read back, which is what catches a layout that writes nothing or runs off
|
||||
the bottom), and 11 wiring tests through the actual key and mouse handlers
|
||||
- Verify: rendered frames inspected for all four forms at 80x24, plus narrow (34
|
||||
columns) and short (10 rows) terminals; confirmed the first mortgage row reads
|
||||
1,199.10 / 1,000.00 / 199.10 / 199,800.90 and totals 231,677.04
|
||||
|
||||
### Task 5.6.1: Fix AST ownership leaks found while testing 5.6 [DONE]
|
||||
- The financial-mode wiring tests run on `testing.allocator` rather than an arena,
|
||||
which exposed two pre-existing leaks the whole suite had been hiding:
|
||||
- `evalStringInfo` and `evalProgrammerString` never freed the parsed tree at
|
||||
all, on any path. Every evaluated expression leaked its whole AST. Invisible
|
||||
until now because the CLI and every existing test pass in an arena
|
||||
- the parser leaked partial trees on every error path (a parser test comment
|
||||
even said an arena was needed "so error paths don't leak")
|
||||
- Fixed by giving the parser proper cleanup (`errdefer freeExpr` at each site
|
||||
where a child is parsed before a step that can fail, and explicit frees on the
|
||||
trailing-token and unmatched-paren paths) and having both eval entry points
|
||||
release the tree they own. `freeExpr` is now public, since callers need it
|
||||
- This mattered most in the TUI, which is the only long-running caller: every
|
||||
expression and every typo grew the arena for the life of the session
|
||||
- 4 regression tests on `testing.allocator`: 16 malformed inputs, programmer-mode
|
||||
malformed inputs, successful parses freed exactly once, and 200 repeated
|
||||
evaluations
|
||||
|
||||
### Task 5.6.2: Financial mode UX refinements and frontend coverage [DONE]
|
||||
- Shift-Tab walks back through the modes (FR-7.1); `nextMode`/`prevMode` are one
|
||||
ordering used by both directions, tested as inverses
|
||||
- Form fields group digits as they are typed (FR-7.5.1): `200` then `0` shows
|
||||
`2,000`. Display-only, reusing the engine formatter's grouping helpers (now
|
||||
public) rather than a third copy of the logic; the buffer stays ungrouped so it
|
||||
still parses, and a typed comma is absorbed
|
||||
- Form fields accept expressions (FR-7.5.2): `12 * 30` shows verbatim with an
|
||||
`(Enter)` prompt and Enter evaluates it in place. An unevaluated field is
|
||||
reported as such rather than treated as a blank, which in TVM and compound means
|
||||
"solve for this"
|
||||
- Rate fields show a trailing `%` and absorb a typed one (FR-7.5.3)
|
||||
- Focus styling changed from reversed cyan to a dark selection fill with a yellow
|
||||
block cursor; the mode's own yellow is reused for the row marker and label
|
||||
instead of introducing another accent
|
||||
- Help overlay rewritten as a scrollable line list (FR-7.6.1). The previous
|
||||
per-section height gating silently dropped the Convert section once Financial
|
||||
was added above it, which a render test caught
|
||||
- Coverage now measured for the CLI and TUI, not just the engine (NFR-5): three
|
||||
reports with disjoint include paths. `Coverage.addModule` takes include paths
|
||||
instead of hardcoding `engine/src`, resolved to absolute paths because the
|
||||
substring form matched any home directory containing `/src/`
|
||||
- TUI coverage went from unmeasured (the view modules had literally zero
|
||||
instrumented lines) to 98.91%, via the render harness in
|
||||
`src/tui/test_render.zig` and event tests through the real handlers
|
||||
- Verify: 811 tests, engine 99.30%, CLI 95.74%, TUI 98.91%; frames inspected for
|
||||
every mode at sizes from 20x6 to 200x60
|
||||
|
||||
### Task 5.6.3: Solve compound interest for rate and years [DONE]
|
||||
- `financial.compoundRate` and `financial.compoundPeriods`: the compound
|
||||
relationship solved for its remaining two variables, both closed form, no solver
|
||||
- `financial.effectiveAnnualRate` (APY), because a solved rate is NOMINAL and
|
||||
reporting it alone invites comparison against a quoted effective rate. The form
|
||||
shows both whenever compounds/year > 1; at annual compounding they coincide and
|
||||
only one figure appears
|
||||
- Compounds/year is deliberately not solvable (FR-5.2.1): a solution exists only
|
||||
between annual and continuous compounding, and a fractional frequency is
|
||||
meaningless. It is prefilled with `1` instead (FR-7.5.4), which is what keeps
|
||||
"blank means solve" true for every field the user sees. The prefill is baked into
|
||||
the comptime initial state, since a draw-time default could not distinguish
|
||||
"never touched" from "deliberately cleared"
|
||||
- Expression parity (FR-5.7): `compound_rate`, `compound_years`, and
|
||||
`apy(nominal, per_year)` in 3- and 4-argument forms, so the form cannot solve
|
||||
something the expression language cannot
|
||||
- Solving for rate at annual compounding IS CAGR; both entry points are kept (CAGR
|
||||
takes periods, not years) and a test asserts they agree so they cannot drift
|
||||
- 19 engine tests including a four-way round trip through all four variables, 9
|
||||
form tests, 3 evaluator tests, 3 rendered-frame tests
|
||||
- Verify: `tally 'compound_rate(10000, 25000, 5)'` gives 20.11244339814313, equal
|
||||
to `cagr(10000, 25000, 5) * 100`; `apy(18, 12)` gives 19.5618
|
||||
|
||||
### Task 5.7: Implement unit conversion TUI view [DONE]
|
||||
- Created `src/tui/convert.zig`; convert is now a third top-level mode and Tab
|
||||
|
|
|
|||
36
build.zig
36
build.zig
|
|
@ -102,14 +102,46 @@ pub fn build(b: *std.Build) void {
|
|||
test_step.dependOn(&run_tui_tests.step);
|
||||
|
||||
// -- Coverage step (uses kcov, Linux x86_64/aarch64 only) --
|
||||
//
|
||||
// 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.
|
||||
{
|
||||
var cov = Coverage.init(b);
|
||||
const cov_mod = b.createModule(.{
|
||||
|
||||
const engine_cov = b.createModule(.{
|
||||
.root_source_file = b.path("engine/src/engine.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
_ = cov.addModule(cov_mod, "tally-engine");
|
||||
_ = cov.addModule(engine_cov, "tally-engine", &.{"engine/src"});
|
||||
|
||||
const cli_cov = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "engine", .module = engine_mod },
|
||||
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
|
||||
},
|
||||
});
|
||||
_ = cov.addModule(cli_cov, "tally-cli", &.{"src/main.zig"});
|
||||
|
||||
const tui_cov = b.createModule(.{
|
||||
.root_source_file = b.path("src/tui.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "engine", .module = engine_mod },
|
||||
.{ .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.
|
||||
_ = cov.addModule(tui_cov, "tally-tui", &.{ "src/tui.zig", "src/tui" });
|
||||
}
|
||||
|
||||
// -- Run step --
|
||||
|
|
|
|||
|
|
@ -70,13 +70,33 @@ pub fn init(b: *Build) Coverage {
|
|||
/// then reads the coverage JSON and prints a summary (with per-file
|
||||
/// breakdown if --verbose). Fails if below -Dcoverage-threshold.
|
||||
///
|
||||
/// `include_paths` are paths relative to the build root deciding which sources
|
||||
/// this report accounts for; each may be a directory or a single file. Keep them
|
||||
/// disjoint across modules: a file appearing in two reports means a query tool has
|
||||
/// to guess which one answers for it, and the wrong guess reports covered lines as
|
||||
/// uncovered.
|
||||
///
|
||||
/// These are resolved to absolute paths rather than passed as substrings, because
|
||||
/// a substring like "/src/" also matches any user whose home directory contains
|
||||
/// one (which is how this was first written, and it silently pulled in the whole
|
||||
/// dependency tree).
|
||||
///
|
||||
/// Returns the test executable so the caller can add any extra linking steps.
|
||||
pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile {
|
||||
pub fn addModule(
|
||||
self: *Coverage,
|
||||
root_module: *Build.Module,
|
||||
name: []const u8,
|
||||
include_paths: []const []const u8,
|
||||
) *Build.Step.Compile {
|
||||
const b = self.b;
|
||||
|
||||
const run_coverage = b.addSystemCommand(&.{self.kcov_path});
|
||||
const include_path = b.pathJoin(&.{ b.build_root.path.?, "engine", "src" });
|
||||
run_coverage.addArgs(&.{ "--include-path", include_path });
|
||||
var joined = std.ArrayList(u8).empty;
|
||||
for (include_paths, 0..) |path, i| {
|
||||
if (i != 0) joined.append(b.allocator, ',') catch @panic("OOM");
|
||||
joined.appendSlice(b.allocator, b.pathJoin(&.{ b.build_root.path.?, path })) catch @panic("OOM");
|
||||
}
|
||||
run_coverage.addArgs(&.{ "--include-path", joined.items });
|
||||
const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" });
|
||||
run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file}));
|
||||
run_coverage.addArg(self.coverage_dir);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub const programmer = @import("programmer.zig");
|
|||
pub const formatter = @import("formatter.zig");
|
||||
pub const float_interp = @import("float_interp.zig");
|
||||
pub const units = @import("units.zig");
|
||||
pub const financial = @import("financial.zig");
|
||||
// Exact numeric model (design.md 2.7). Not yet wired into the evaluator; see
|
||||
// Task 2.0b. Exported here so its tests run as part of `zig build test`.
|
||||
pub const rational = @import("rational.zig");
|
||||
|
|
@ -43,6 +44,19 @@ pub const ConvertResult = units.ConvertResult;
|
|||
pub const convert = units.convert;
|
||||
pub const findUnit = units.findUnit;
|
||||
|
||||
// Financial
|
||||
pub const TvmVariable = financial.TvmVariable;
|
||||
pub const TvmParams = financial.TvmParams;
|
||||
pub const TvmSolution = financial.TvmSolution;
|
||||
pub const solveTvm = financial.solveTvm;
|
||||
pub const cagr = financial.cagr;
|
||||
pub const compoundFutureValue = financial.compoundFutureValue;
|
||||
pub const compoundPresentValue = financial.compoundPresentValue;
|
||||
pub const compoundRate = financial.compoundRate;
|
||||
pub const compoundPeriods = financial.compoundPeriods;
|
||||
pub const effectiveAnnualRate = financial.effectiveAnnualRate;
|
||||
pub const roundToCents = financial.roundToCents;
|
||||
|
||||
// Exact numeric model
|
||||
pub const Rational = rational.Rational;
|
||||
pub const Number = number.Number;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const parser_mod = @import("parser.zig");
|
|||
const Parser = parser_mod.Parser;
|
||||
const number_mod = @import("number.zig");
|
||||
const Number = number_mod.Number;
|
||||
const financial = @import("financial.zig");
|
||||
|
||||
/// Evaluation environment holding variables, history, and config.
|
||||
///
|
||||
|
|
@ -322,6 +323,11 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
|
|||
const x = a.toFloat(scratch);
|
||||
const y = b.toFloat(scratch);
|
||||
if (std.mem.eql(u8, name, "atan2")) return Number.fromFloat(math.atan2(x, y));
|
||||
// apy(nominal_rate, compounds_per_year): the effective annual rate, so a
|
||||
// nominal rate can be compared against one.
|
||||
if (std.mem.eql(u8, name, "apy")) {
|
||||
return Number.fromFloat(try financial.effectiveAnnualRate(x, y));
|
||||
}
|
||||
if (std.mem.eql(u8, name, "log")) {
|
||||
// log(value, base)
|
||||
if (y <= 0 or y == 1 or x <= 0) return CalcError.DomainError;
|
||||
|
|
@ -329,6 +335,22 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
|
|||
}
|
||||
}
|
||||
|
||||
// Financial functions take three or four arguments.
|
||||
//
|
||||
// These are inexact by construction: every financial formula needs a
|
||||
// non-integer power or a logarithm, so the exact tier has nothing to
|
||||
// preserve (see the header of financial.zig).
|
||||
if (args.len == 3 or args.len == 4) {
|
||||
var values: [4]f64 = undefined;
|
||||
for (args, 0..) |arg, i| {
|
||||
const value = try evalExact(env, scratch, arg);
|
||||
values[i] = value.toFloat(scratch);
|
||||
}
|
||||
if (try evalFinancialFn(name, values[0..args.len])) |result| {
|
||||
return Number.fromFloat(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-argument functions
|
||||
if (args.len == 0) {
|
||||
if (std.mem.eql(u8, name, "rand")) {
|
||||
|
|
@ -340,6 +362,114 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
|
|||
return CalcError.UnknownFunction;
|
||||
}
|
||||
|
||||
/// A whole period count or 1-based period index, validated.
|
||||
fn periodCount(value: f64) CalcError!usize {
|
||||
if (!math.isFinite(value)) return CalcError.DomainError;
|
||||
if (@floor(value) != value) return CalcError.DomainError;
|
||||
if (value < 1 or value > @as(f64, @floatFromInt(financial.max_schedule_periods))) {
|
||||
return CalcError.DomainError;
|
||||
}
|
||||
return @intFromFloat(value);
|
||||
}
|
||||
|
||||
/// Financial functions callable from a standard-mode expression.
|
||||
///
|
||||
/// Exposed as functions rather than only as a separate mode so that they compose
|
||||
/// with the rest of the language: `cagr(10000, 25000, 5) * 100` and
|
||||
/// `amort_interest(200000, 0.5, 360, 1) + 50` both work, the same way unit
|
||||
/// conversion is reachable from a bare expression.
|
||||
///
|
||||
/// Returns null when `name` is not a financial function, so the caller can carry
|
||||
/// on to report an unknown function.
|
||||
fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 {
|
||||
if (a.len == 3) {
|
||||
// cagr(start, end, periods) -> growth rate as a fraction.
|
||||
if (std.mem.eql(u8, name, "cagr")) return try financial.cagr(a[0], a[1], a[2]);
|
||||
// fv(pv, annual_rate_percent, years) compounded annually.
|
||||
if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], 1);
|
||||
// pv(fv, annual_rate_percent, years) compounded annually.
|
||||
if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], 1);
|
||||
// The same relationship solved for its other two variables. The rate is
|
||||
// NOMINAL; use apy() to convert.
|
||||
if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], 1);
|
||||
if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], 1);
|
||||
|
||||
// amort_payment(principal, rate_per_period_percent, periods)
|
||||
if (std.mem.eql(u8, name, "amort_payment")) {
|
||||
return try financial.amortizationPayment(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
});
|
||||
}
|
||||
if (std.mem.eql(u8, name, "amort_total_interest")) {
|
||||
const totals = try financial.amortizationTotals(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
});
|
||||
return totals.interest;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "amort_total_paid")) {
|
||||
const totals = try financial.amortizationTotals(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
});
|
||||
return totals.paid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// fv(pv, rate, years, compounds_per_year) and its inverse.
|
||||
if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], a[3]);
|
||||
if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], a[3]);
|
||||
// compound_rate(pv, fv, years, compounds_per_year) -> nominal annual rate.
|
||||
if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], a[3]);
|
||||
// compound_years(pv, fv, rate, compounds_per_year)
|
||||
if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], a[3]);
|
||||
|
||||
// TVM: each function names the variable it solves for, and takes the other
|
||||
// four in the calculator's N, I/Y, PV, PMT, FV order.
|
||||
if (std.mem.eql(u8, name, "tvm_fv")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .payment = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_pv")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .payment = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_pmt")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_n")) {
|
||||
const s = try financial.solveTvm(.{ .rate = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_rate")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
|
||||
// Amortization rows: (principal, rate_per_period_percent, periods, period)
|
||||
const is_interest = std.mem.eql(u8, name, "amort_interest");
|
||||
const is_principal = std.mem.eql(u8, name, "amort_principal");
|
||||
const is_balance = std.mem.eql(u8, name, "amort_balance");
|
||||
if (is_interest or is_principal or is_balance) {
|
||||
const entry = try financial.amortizationEntry(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
}, try periodCount(a[3]));
|
||||
if (is_interest) return entry.interest;
|
||||
if (is_principal) return entry.principal;
|
||||
return entry.balance;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Evaluate a single-argument built-in function that has no exact form.
|
||||
fn evalSingleArgFn(name: []const u8, x: f64) ?f64 {
|
||||
if (std.mem.eql(u8, name, "sin")) return @sin(x);
|
||||
|
|
@ -384,6 +514,12 @@ pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) C
|
|||
pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u8) CalcError!EvalInfo {
|
||||
var p = Parser.init(allocator, source, env.mode);
|
||||
const expr = try p.parse();
|
||||
// The parser hands over ownership. Nothing in the result borrows from the
|
||||
// tree (literal text points into `source`, and the value is cloned out of the
|
||||
// scratch arena), so it can be released as soon as evaluation is done.
|
||||
// Without this every evaluated expression leaked its whole AST, which only
|
||||
// went unnoticed because the CLI hands in an arena.
|
||||
defer parser_mod.freeExpr(allocator, expr);
|
||||
|
||||
// Intermediates live in a scratch arena; only the final value is copied out
|
||||
// into the caller's allocator.
|
||||
|
|
@ -1076,3 +1212,209 @@ test "Number API: a variable name outliving its source text stays valid" {
|
|||
const result = try evalString(&env, a, "myvar + 1");
|
||||
try testing.expectEqual(@as(f64, 43.0), result.toFloat(a));
|
||||
}
|
||||
|
||||
// -- Financial functions in standard mode --
|
||||
//
|
||||
// The point of these tests is reachability and composition: the financial math
|
||||
// itself is covered in financial.zig. What matters here is that a plain
|
||||
// expression can call them, with the argument counts the parser now allows.
|
||||
|
||||
test "financial: cagr is callable from a standard expression" {
|
||||
try testing.expectApproxEqAbs(@as(f64, 0.2011244), try testEval("cagr(10000, 25000, 5)"), 1e-7);
|
||||
}
|
||||
|
||||
test "financial: a financial result composes with ordinary arithmetic" {
|
||||
// The fraction-to-percent conversion users will reach for immediately.
|
||||
try testing.expectApproxEqAbs(@as(f64, 20.11244), try testEval("cagr(10000, 25000, 5) * 100"), 1e-5);
|
||||
}
|
||||
|
||||
test "financial: arguments may themselves be expressions" {
|
||||
try testing.expectApproxEqAbs(
|
||||
@as(f64, 0.2011244),
|
||||
try testEval("cagr(10000, 5 * 5000, 60 / 12)"),
|
||||
1e-7,
|
||||
);
|
||||
}
|
||||
|
||||
test "financial: compound interest with and without a frequency argument" {
|
||||
// Three arguments compounds annually.
|
||||
try testing.expectApproxEqAbs(@as(f64, 1628.894627), try testEval("fv(1000, 5, 10)"), 1e-6);
|
||||
// The fourth argument is the compounding frequency; monthly beats annual.
|
||||
try testing.expectApproxEqAbs(@as(f64, 1647.009498), try testEval("fv(1000, 5, 10, 12)"), 1e-6);
|
||||
try testing.expect(try testEval("fv(1000, 5, 10, 12)") > try testEval("fv(1000, 5, 10)"));
|
||||
}
|
||||
|
||||
test "financial: present value inverts future value" {
|
||||
const future = try testEval("fv(1000, 5, 10, 4)");
|
||||
var buffer: [64]u8 = undefined;
|
||||
const source = try std.fmt.bufPrint(&buffer, "pv({d}, 5, 10, 4)", .{future});
|
||||
try testing.expectApproxEqAbs(@as(f64, 1000.0), try testEval(source), 1e-6);
|
||||
}
|
||||
|
||||
test "financial: compound interest solves for its rate and its time" {
|
||||
// The same relationship as fv(), read backwards.
|
||||
try testing.expectApproxEqAbs(
|
||||
@as(f64, 5.0),
|
||||
try testEval("compound_rate(1000, 1628.894627, 10)"),
|
||||
1e-6,
|
||||
);
|
||||
try testing.expectApproxEqAbs(
|
||||
@as(f64, 10.0),
|
||||
try testEval("compound_years(1000, 1628.894627, 5)"),
|
||||
1e-6,
|
||||
);
|
||||
|
||||
// With a compounding frequency the rate is nominal, so it is lower.
|
||||
const monthly = try testEval("compound_rate(1000, 2000, 10, 12)");
|
||||
const annual = try testEval("compound_rate(1000, 2000, 10)");
|
||||
try testing.expect(monthly < annual);
|
||||
try testing.expectApproxEqAbs(@as(f64, 6.95152928), monthly, 1e-8);
|
||||
|
||||
// At annual compounding, solving for the rate is CAGR.
|
||||
try testing.expectApproxEqAbs(
|
||||
try testEval("cagr(10000, 25000, 5) * 100"),
|
||||
try testEval("compound_rate(10000, 25000, 5)"),
|
||||
1e-9,
|
||||
);
|
||||
}
|
||||
|
||||
test "financial: apy converts a nominal rate to an effective one" {
|
||||
try testing.expectApproxEqAbs(@as(f64, 19.5618), try testEval("apy(18, 12)"), 1e-4);
|
||||
// Annual compounding is its own effective rate.
|
||||
try testing.expectApproxEqAbs(@as(f64, 5.0), try testEval("apy(5, 1)"), 1e-12);
|
||||
// And it composes, which is the point of exposing it as a function.
|
||||
try testing.expectApproxEqAbs(
|
||||
@as(f64, 19.5618),
|
||||
try testEval("apy(compound_rate(1000, fv(1000, 18, 5, 12), 5, 12), 12)"),
|
||||
1e-4,
|
||||
);
|
||||
try testing.expectError(CalcError.DomainError, testEval("apy(5, 0)"));
|
||||
}
|
||||
|
||||
test "financial: the tvm solvers are reachable as four-argument functions" {
|
||||
// 200,000 at 0.5% a period over 360 periods: the classic mortgage payment.
|
||||
try testing.expectApproxEqAbs(
|
||||
@as(f64, -1199.10105),
|
||||
try testEval("tvm_pmt(360, 0.5, 200000, 0)"),
|
||||
1e-5,
|
||||
);
|
||||
try testing.expectApproxEqAbs(@as(f64, 7.1773462), try testEval("tvm_rate(10, -1000, 0, 2000)"), 1e-6);
|
||||
try testing.expectApproxEqAbs(@as(f64, 10.244768), try testEval("tvm_n(7, -1000, 0, 2000)"), 1e-6);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1257.789254), try testEval("tvm_fv(10, 5, 0, -100)"), 1e-6);
|
||||
try testing.expectApproxEqAbs(@as(f64, -1016.698584), try testEval("tvm_pv(10, 7, 0, 2000)"), 1e-6);
|
||||
}
|
||||
|
||||
test "financial: amortization rows are reachable from an expression" {
|
||||
try testing.expectEqual(@as(f64, 1199.10), try testEval("amort_payment(200000, 0.5, 360)"));
|
||||
// Month one of a 6% loan on 200,000 is exactly 1000 of interest.
|
||||
try testing.expectEqual(@as(f64, 1000.0), try testEval("amort_interest(200000, 0.5, 360, 1)"));
|
||||
try testing.expectApproxEqAbs(@as(f64, 199.10), try testEval("amort_principal(200000, 0.5, 360, 1)"), 1e-9);
|
||||
try testing.expectApproxEqAbs(@as(f64, 199800.90), try testEval("amort_balance(200000, 0.5, 360, 1)"), 1e-9);
|
||||
try testing.expectEqual(@as(f64, 0.0), try testEval("amort_balance(200000, 0.5, 360, 360)"));
|
||||
}
|
||||
|
||||
test "financial: amortization totals" {
|
||||
const interest = try testEval("amort_total_interest(200000, 0.5, 360)");
|
||||
const paid = try testEval("amort_total_paid(200000, 0.5, 360)");
|
||||
try testing.expect(interest > 231000 and interest < 232000);
|
||||
try testing.expectApproxEqAbs(paid - interest, 200000, 0.05);
|
||||
}
|
||||
|
||||
test "financial: results are inexact, so they do not claim exactness" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
var env = Environment.init(alloc, .standard);
|
||||
defer env.deinit();
|
||||
|
||||
const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)");
|
||||
try testing.expect(!result.isExact());
|
||||
}
|
||||
|
||||
test "financial: bad arguments are domain errors, not wrong answers" {
|
||||
// Zero periods.
|
||||
try testing.expectError(CalcError.DomainError, testEval("cagr(1000, 2000, 0)"));
|
||||
// A fractional period count cannot index an amortization schedule.
|
||||
try testing.expectError(CalcError.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)"));
|
||||
// Period past the end of the schedule.
|
||||
try testing.expectError(CalcError.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)"));
|
||||
// Payments that never retire the loan.
|
||||
try testing.expectError(CalcError.DomainError, testEval("amort_payment(0, 0.5, 360)"));
|
||||
// Period counts outside the schedule bounds.
|
||||
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 0)"));
|
||||
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 20000)"));
|
||||
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 10^400)"));
|
||||
}
|
||||
|
||||
test "financial: wrong argument counts are unknown functions, not silent defaults" {
|
||||
try testing.expectError(CalcError.UnknownFunction, testEval("cagr(10000, 25000)"));
|
||||
try testing.expectError(CalcError.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)"));
|
||||
try testing.expectError(CalcError.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)"));
|
||||
// A three or four argument call to something that is not a function at all.
|
||||
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3)"));
|
||||
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3, 4)"));
|
||||
}
|
||||
|
||||
// -- The AST is not the caller's problem --
|
||||
//
|
||||
// These use testing.allocator directly rather than testEval's arena, because an
|
||||
// arena hides exactly the bug they guard against: evalStringInfo used to leak the
|
||||
// whole parsed tree on every call, which an arena silently absorbs.
|
||||
|
||||
test "no leak: a successful evaluation releases the parsed tree" {
|
||||
var env = Environment.init(testing.allocator, .standard);
|
||||
defer env.deinit();
|
||||
|
||||
const sources = [_][]const u8{
|
||||
"1 + 2 * 3",
|
||||
"-(4 + 5)",
|
||||
"max(1, min(2, 3))",
|
||||
"sqrt(2) + factorial(10)",
|
||||
"X = 7 * 6",
|
||||
"X + 1",
|
||||
"cagr(10000, 25000, 5) * 100",
|
||||
"amort_interest(200000, 0.5, 360, 1)",
|
||||
"0xFF and 0x0F",
|
||||
};
|
||||
for (sources) |source| {
|
||||
var value = try evalString(&env, testing.allocator, source);
|
||||
value.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
test "no leak: a failed evaluation releases the parsed tree" {
|
||||
var env = Environment.init(testing.allocator, .standard);
|
||||
defer env.deinit();
|
||||
|
||||
const sources = [_][]const u8{
|
||||
"2 +",
|
||||
"(1 + 2",
|
||||
"max(1, 2",
|
||||
"1 / 0",
|
||||
"unknownfn(1)",
|
||||
"undefined_variable + 1",
|
||||
"sqrt(-1)",
|
||||
"cagr(1000, 2000, 0)",
|
||||
};
|
||||
for (sources) |source| {
|
||||
if (evalString(&env, testing.allocator, source)) |value| {
|
||||
var owned = value;
|
||||
owned.deinit();
|
||||
std.debug.print("expected an error for \"{s}\"\n", .{source});
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
|
||||
test "no leak: repeated evaluation does not accumulate" {
|
||||
// A long interactive session is the case that made this visible: the TUI
|
||||
// evaluates on every Enter and never frees anything itself.
|
||||
var env = Environment.init(testing.allocator, .standard);
|
||||
defer env.deinit();
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < 200) : (i += 1) {
|
||||
var value = try evalString(&env, testing.allocator, "Ans + 1");
|
||||
value.deinit();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1370
engine/src/financial.zig
Normal file
1370
engine/src/financial.zig
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,9 @@
|
|||
//! - `raw`: clipboard-friendly without separators (but with base prefix)
|
||||
//!
|
||||
//! Formatting rules per the spec:
|
||||
//! - Decimal: comma-separated groups of 3 (e.g. "4,294,967,295")
|
||||
//! - Decimal: comma-separated groups of 3 (e.g. "4,294,967,295"). The integer
|
||||
//! part is grouped whether or not there is a fractional part, so "231,677.04"
|
||||
//! and "231,677" read consistently. A fractional part is never grouped.
|
||||
//! - Hex value view: underscore per 16-bit word (e.g. "0xFFFF_FFFF")
|
||||
//! - Binary: space per nibble (e.g. "1111 1111")
|
||||
//! - Octal: underscore per 3-digit group (e.g. "0o37_777_777_777")
|
||||
|
|
@ -55,9 +57,21 @@ pub fn formatFloat(buf: []u8, value: f64) FormattedValue {
|
|||
return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] };
|
||||
}
|
||||
|
||||
// Regular float formatting
|
||||
// Regular float formatting. The integer part gets the same comma grouping an
|
||||
// integer or an exact value gets, so `231677.04` does not read differently
|
||||
// from `231677`.
|
||||
const raw_len = (std.fmt.bufPrint(buf, "{d}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
|
||||
return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] };
|
||||
const raw = buf[0..raw_len];
|
||||
|
||||
const display_len = groupedDecimalLen(raw);
|
||||
// Nothing to group, or no room for a second copy: display is the raw text.
|
||||
if (display_len == raw_len or raw_len + display_len > buf.len) {
|
||||
return .{ .display = raw, .raw = raw };
|
||||
}
|
||||
// Writes into the region after `raw`, so source and destination never
|
||||
// overlap.
|
||||
const written = writeGroupedDecimal(buf[raw_len..], raw);
|
||||
return .{ .display = buf[raw_len..][0..written], .raw = raw };
|
||||
}
|
||||
|
||||
/// Format a float for compact single-line display (used by the float view).
|
||||
|
|
@ -225,32 +239,52 @@ pub const NumberDisplay = struct {
|
|||
/// Insert comma separators into the integer part of decimal text, leaving any
|
||||
/// sign and fractional part alone.
|
||||
fn groupDecimalText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
|
||||
const len = groupedDecimalLen(text);
|
||||
if (len == text.len) return allocator.dupe(u8, text);
|
||||
const out = try allocator.alloc(u8, len);
|
||||
const written = writeGroupedDecimal(out, text);
|
||||
std.debug.assert(written == len);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Bytes `writeGroupedDecimal` will produce for `text`. Equal to `text.len` when
|
||||
/// there is nothing to group, which callers use to skip the copy entirely.
|
||||
///
|
||||
/// Public because the TUI groups partially typed input, where reformatting
|
||||
/// through an f64 would discard what the user typed (trailing zeros, a lone
|
||||
/// decimal point).
|
||||
pub fn groupedDecimalLen(text: []const u8) usize {
|
||||
// Text already in scientific notation has no long integer part to group, and
|
||||
// inserting commas around an exponent would only corrupt it.
|
||||
if (std.mem.indexOfAny(u8, text, "eE") != null) return text.len;
|
||||
const int_digits = integerDigitCount(text);
|
||||
if (int_digits <= 3) return text.len;
|
||||
return text.len + (int_digits - 1) / 3;
|
||||
}
|
||||
|
||||
/// Copy `text` into `dest` with commas grouping the integer part. `dest` must be
|
||||
/// at least `groupedDecimalLen(text)` bytes and must not overlap `text`.
|
||||
pub fn writeGroupedDecimal(dest: []u8, text: []const u8) usize {
|
||||
var start: usize = 0;
|
||||
if (text.len > 0 and (text[0] == '-' or text[0] == '+')) start = 1;
|
||||
const dot = std.mem.indexOfScalar(u8, text, '.') orelse text.len;
|
||||
const int_digits = dot - start;
|
||||
|
||||
// Nothing to group.
|
||||
if (int_digits <= 3) return allocator.dupe(u8, text);
|
||||
|
||||
const separators = (int_digits - 1) / 3;
|
||||
var out = try allocator.alloc(u8, text.len + separators);
|
||||
|
||||
var w: usize = 0;
|
||||
@memcpy(out[0..start], text[0..start]);
|
||||
w = start;
|
||||
@memcpy(dest[0..start], text[0..start]);
|
||||
var w: usize = start;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < int_digits) : (i += 1) {
|
||||
if (i > 0 and (int_digits - i) % 3 == 0) {
|
||||
out[w] = ',';
|
||||
dest[w] = ',';
|
||||
w += 1;
|
||||
}
|
||||
out[w] = text[start + i];
|
||||
dest[w] = text[start + i];
|
||||
w += 1;
|
||||
}
|
||||
@memcpy(out[w..], text[dot..]);
|
||||
return out;
|
||||
const tail = text[dot..];
|
||||
@memcpy(dest[w..][0..tail.len], tail);
|
||||
return w + tail.len;
|
||||
}
|
||||
|
||||
/// Format an integer for programmer mode hex display.
|
||||
|
|
@ -1050,3 +1084,124 @@ test "integerDigitCount ignores sign and fraction" {
|
|||
try testing.expectEqual(@as(usize, 3), integerDigitCount("123.456"));
|
||||
try testing.expectEqual(@as(usize, 1), integerDigitCount("0.5"));
|
||||
}
|
||||
|
||||
// -- Grouping of values with a fractional part --
|
||||
//
|
||||
// Previously only whole numbers were grouped, so a financial result read as
|
||||
// `231677.04` while the same magnitude as an integer read as `231,677`. The
|
||||
// spec (design.md 2.6) asks for full decimal with commas, and FR-1.8 makes the
|
||||
// grouped form valid input again, so the integer part is grouped either way.
|
||||
|
||||
test "formatFloat: fractional value groups its integer part" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 231677.04);
|
||||
try testing.expectEqualStrings("231,677.04", result.display);
|
||||
try testing.expectEqualStrings("231677.04", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: raw form never carries separators" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1234567.891);
|
||||
try testing.expectEqualStrings("1,234,567.891", result.display);
|
||||
try testing.expectEqualStrings("1234567.891", result.raw);
|
||||
try testing.expect(std.mem.indexOfScalar(u8, result.raw, ',') == null);
|
||||
}
|
||||
|
||||
test "formatFloat: negative fractional value" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, -9876543.21);
|
||||
try testing.expectEqualStrings("-9,876,543.21", result.display);
|
||||
try testing.expectEqualStrings("-9876543.21", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: fewer than four integer digits is left alone" {
|
||||
var buf: [256]u8 = undefined;
|
||||
// Display and raw are the same slice in this case, which is intentional:
|
||||
// there is nothing to group, so there is no reason to copy.
|
||||
const small = formatFloat(&buf, 123.456);
|
||||
try testing.expectEqualStrings("123.456", small.display);
|
||||
try testing.expectEqualStrings("123.456", small.raw);
|
||||
|
||||
const sub_one = formatFloat(&buf, 0.5);
|
||||
try testing.expectEqualStrings("0.5", sub_one.display);
|
||||
|
||||
const boundary = formatFloat(&buf, 999.99);
|
||||
try testing.expectEqualStrings("999.99", boundary.display);
|
||||
}
|
||||
|
||||
test "formatFloat: grouping starts at four integer digits" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1000.25);
|
||||
try testing.expectEqualStrings("1,000.25", result.display);
|
||||
}
|
||||
|
||||
test "formatFloat: scientific notation is not grouped" {
|
||||
var buf: [256]u8 = undefined;
|
||||
// Above 1e15 the float path switches to scientific, where commas would only
|
||||
// corrupt the exponent.
|
||||
const big = formatFloat(&buf, 1.234e20);
|
||||
try testing.expect(std.mem.indexOfScalar(u8, big.display, ',') == null);
|
||||
try testing.expect(std.mem.indexOfAny(u8, big.display, "eE") != null);
|
||||
|
||||
const tiny = formatFloat(&buf, 1.5e-20);
|
||||
try testing.expect(std.mem.indexOfScalar(u8, tiny.display, ',') == null);
|
||||
}
|
||||
|
||||
test "formatFloat: a buffer too small for both forms falls back to the raw text" {
|
||||
// Just enough for "1234567.891" but not for a grouped second copy.
|
||||
var buf: [12]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1234567.891);
|
||||
try testing.expectEqualStrings("1234567.891", result.raw);
|
||||
try testing.expectEqualStrings("1234567.891", result.display);
|
||||
}
|
||||
|
||||
test "formatNumber: an inexact fractional result is grouped too" {
|
||||
var value = Number.fromFloat(231677.04);
|
||||
defer value.deinit();
|
||||
const shown = try formatNumber(testing.allocator, value);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("231,677.04", shown.display);
|
||||
try testing.expectEqualStrings("231677.04", shown.raw);
|
||||
try testing.expect(!shown.exact);
|
||||
}
|
||||
|
||||
test "formatNumber: an exact fractional result was already grouped and still is" {
|
||||
var value = try Number.parse(testing.allocator, "1234567.891");
|
||||
defer value.deinit();
|
||||
const shown = try formatNumber(testing.allocator, value);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("1,234,567.891", shown.display);
|
||||
try testing.expectEqualStrings("1234567.891", shown.raw);
|
||||
try testing.expect(shown.exact);
|
||||
}
|
||||
|
||||
test "groupedDecimalLen: agrees with what writeGroupedDecimal writes" {
|
||||
const cases = [_][]const u8{
|
||||
"0",
|
||||
"999",
|
||||
"1000",
|
||||
"-1234",
|
||||
"1234567.891",
|
||||
"-9876543.21",
|
||||
"0.5",
|
||||
"1000000000000",
|
||||
"1.5e+20",
|
||||
};
|
||||
var buf: [64]u8 = undefined;
|
||||
for (cases) |text| {
|
||||
const len = groupedDecimalLen(text);
|
||||
if (len == text.len) continue;
|
||||
try testing.expectEqual(len, writeGroupedDecimal(&buf, text));
|
||||
}
|
||||
}
|
||||
|
||||
test "grouped display re-parses to the same value" {
|
||||
// FR-1.8 accepts commas as digit separators, so the display form is valid
|
||||
// input. This is what makes grouping safe to apply to results.
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1234567.891);
|
||||
var reparsed = try Number.parse(testing.allocator, "1234567.891");
|
||||
defer reparsed.deinit();
|
||||
try testing.expectEqualStrings("1,234,567.891", result.display);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1234567.891), reparsed.toFloat(testing.allocator), 1e-9);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,9 +65,17 @@ pub const Parser = struct {
|
|||
}
|
||||
|
||||
/// Parse a complete expression. Returns error if parsing fails.
|
||||
///
|
||||
/// On success the caller owns the tree and must release it with `freeExpr`.
|
||||
/// On failure nothing is returned and nothing is left allocated: every error
|
||||
/// path below frees what it built. Without that, a single typo in an
|
||||
/// interactive session leaks the partial tree, which is exactly what the TUI
|
||||
/// does on every keystroke-completed expression.
|
||||
pub fn parse(self: *Parser) CalcError!*Expr {
|
||||
const expr = try self.parseExpr(.none);
|
||||
if (self.current.kind != .eof) {
|
||||
// Trailing tokens: the tree parsed so far is unreachable.
|
||||
freeExpr(self.allocator, expr);
|
||||
self.had_error = true;
|
||||
self.error_pos = self.current.start;
|
||||
return CalcError.UnexpectedToken;
|
||||
|
|
@ -78,6 +86,9 @@ pub const Parser = struct {
|
|||
/// Parse an expression with the given minimum precedence.
|
||||
fn parseExpr(self: *Parser, min_prec: Prec) CalcError!*Expr {
|
||||
var left = try self.parsePrefix();
|
||||
// Each successful parseInfix returns a node that has adopted `left`, so
|
||||
// this errdefer always covers the whole tree built so far.
|
||||
errdefer freeExpr(self.allocator, left);
|
||||
|
||||
while (true) {
|
||||
const prec = self.infixPrecedence(self.current.kind);
|
||||
|
|
@ -119,6 +130,7 @@ pub const Parser = struct {
|
|||
// "not" prefix keyword = bitwise NOT
|
||||
if (std.mem.eql(u8, name, "not")) {
|
||||
const operand = try self.parseExpr(.unary);
|
||||
errdefer freeExpr(self.allocator, operand);
|
||||
return self.makeNode(.{ .unary = .{
|
||||
.op = .bitwise_not,
|
||||
.operand = operand,
|
||||
|
|
@ -129,6 +141,7 @@ pub const Parser = struct {
|
|||
if (self.current.kind == .equals) {
|
||||
self.advance();
|
||||
const value = try self.parseExpr(.none);
|
||||
errdefer freeExpr(self.allocator, value);
|
||||
return self.makeNode(.{ .assignment = .{
|
||||
.name = name,
|
||||
.value = value,
|
||||
|
|
@ -140,15 +153,23 @@ pub const Parser = struct {
|
|||
self.advance(); // consume (
|
||||
var args = std.ArrayList(*Expr).empty;
|
||||
defer args.deinit(self.allocator);
|
||||
// Arguments parsed before the failure still own their trees.
|
||||
errdefer for (args.items) |arg| freeExpr(self.allocator, arg);
|
||||
|
||||
if (self.current.kind != .right_paren) {
|
||||
const first_arg = try self.parseExpr(.none);
|
||||
args.append(self.allocator, first_arg) catch return CalcError.OutOfMemory;
|
||||
args.append(self.allocator, first_arg) catch {
|
||||
freeExpr(self.allocator, first_arg);
|
||||
return CalcError.OutOfMemory;
|
||||
};
|
||||
|
||||
while (self.current.kind == .comma) {
|
||||
self.advance(); // consume ,
|
||||
const arg = try self.parseExpr(.none);
|
||||
args.append(self.allocator, arg) catch return CalcError.OutOfMemory;
|
||||
args.append(self.allocator, arg) catch {
|
||||
freeExpr(self.allocator, arg);
|
||||
return CalcError.OutOfMemory;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +182,7 @@ pub const Parser = struct {
|
|||
|
||||
const args_slice = self.allocator.dupe(*Expr, args.items) catch
|
||||
return CalcError.OutOfMemory;
|
||||
errdefer self.allocator.free(args_slice);
|
||||
|
||||
return self.makeNode(.{ .call = .{
|
||||
.name = name,
|
||||
|
|
@ -177,6 +199,7 @@ pub const Parser = struct {
|
|||
self.advance(); // consume (
|
||||
const inner = try self.parseExpr(.none);
|
||||
if (self.current.kind != .right_paren) {
|
||||
freeExpr(self.allocator, inner);
|
||||
self.had_error = true;
|
||||
self.error_pos = self.current.start;
|
||||
return CalcError.UnmatchedParen;
|
||||
|
|
@ -187,6 +210,7 @@ pub const Parser = struct {
|
|||
.minus => {
|
||||
self.advance();
|
||||
const operand = try self.parseExpr(.unary);
|
||||
errdefer freeExpr(self.allocator, operand);
|
||||
return self.makeNode(.{ .unary = .{
|
||||
.op = .negate,
|
||||
.operand = operand,
|
||||
|
|
@ -195,6 +219,7 @@ pub const Parser = struct {
|
|||
.tilde => {
|
||||
self.advance();
|
||||
const operand = try self.parseExpr(.unary);
|
||||
errdefer freeExpr(self.allocator, operand);
|
||||
return self.makeNode(.{ .unary = .{
|
||||
.op = .bitwise_not,
|
||||
.operand = operand,
|
||||
|
|
@ -223,6 +248,9 @@ pub const Parser = struct {
|
|||
if (keywordBinaryOp(name)) |op| {
|
||||
self.advance();
|
||||
const right = try self.parseExpr(prec);
|
||||
// `left` belongs to the caller's errdefer until makeNode adopts
|
||||
// it, so only the right operand is released here.
|
||||
errdefer freeExpr(self.allocator, right);
|
||||
return self.makeNode(.{ .binary = .{
|
||||
.op = op,
|
||||
.left = left,
|
||||
|
|
@ -245,6 +273,7 @@ pub const Parser = struct {
|
|||
prec;
|
||||
|
||||
const right = try self.parseExpr(next_prec);
|
||||
errdefer freeExpr(self.allocator, right);
|
||||
return self.makeNode(.{ .binary = .{
|
||||
.op = op,
|
||||
.left = left,
|
||||
|
|
@ -322,13 +351,12 @@ pub const Parser = struct {
|
|||
|
||||
const testing = std.testing;
|
||||
|
||||
// Use an arena for tests so error paths don't leak
|
||||
// Error-path tests used to need an arena because a failed parse leaked its
|
||||
// partial tree. They no longer do (the parser cleans up after itself), but the
|
||||
// arena helper is kept for the tests already written against it.
|
||||
var test_arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
|
||||
fn testParse(source: []const u8, mode: Mode) !*Expr {
|
||||
// Reset arena between test calls isn't needed since each test is independent
|
||||
// and we use testing.allocator for successful parses (with manual free),
|
||||
// but for error cases we need an arena.
|
||||
var parser = Parser.init(testing.allocator, source, mode);
|
||||
return parser.parse();
|
||||
}
|
||||
|
|
@ -339,7 +367,12 @@ fn testParseArena(source: []const u8, mode: Mode) CalcError!*Expr {
|
|||
return p.parse();
|
||||
}
|
||||
|
||||
fn freeExpr(allocator: Allocator, expr: *Expr) void {
|
||||
/// Release a parsed tree.
|
||||
///
|
||||
/// The parser hands ownership of the tree to the caller, so every successful
|
||||
/// `parse` needs a matching `freeExpr`. Error paths inside the parser clean up
|
||||
/// after themselves, so a failed parse leaves nothing to free.
|
||||
pub fn freeExpr(allocator: Allocator, expr: *Expr) void {
|
||||
switch (expr.*) {
|
||||
.number => {},
|
||||
.string_literal => {},
|
||||
|
|
@ -578,3 +611,107 @@ test "parse error: identifier in infix position (not a keyword op)" {
|
|||
const result = testParseArena("5 foo", .standard);
|
||||
try testing.expectError(CalcError.UnexpectedToken, result);
|
||||
}
|
||||
|
||||
// -- Ownership on the error paths --
|
||||
//
|
||||
// These run on testing.allocator rather than an arena, so a partial tree left
|
||||
// behind by a failed parse fails the test. Before the parser cleaned up after
|
||||
// itself, every one of these inputs leaked, which mattered in the TUI: a typo
|
||||
// at the prompt leaked the tree parsed up to that point.
|
||||
|
||||
test "a failed parse leaves nothing allocated" {
|
||||
const bad = [_][]const u8{
|
||||
"2 +", // missing right operand
|
||||
"1 2", // trailing token after a complete expression
|
||||
"(1 + 2", // unmatched paren
|
||||
"(1 + (2 * 3)", // unmatched outer paren, nested tree built
|
||||
"max(1, 2", // unterminated argument list
|
||||
"max(1, 2,", // trailing comma then end
|
||||
"sin(", // call with nothing in it
|
||||
"-", // unary with no operand
|
||||
"~", // bitwise not with no operand
|
||||
"not", // keyword not with no operand
|
||||
"x =", // assignment with no value
|
||||
"y = 1 +", // assignment whose value fails to parse
|
||||
"*", // operator in prefix position
|
||||
"", // empty input
|
||||
"1 + 2 3", // trailing token after a binary expression
|
||||
"-(1 + ", // nested failure under a unary
|
||||
};
|
||||
for (bad) |source| {
|
||||
var parser = Parser.init(testing.allocator, source, .standard);
|
||||
if (parser.parse()) |expr| {
|
||||
freeExpr(testing.allocator, expr);
|
||||
std.debug.print("expected a parse error for \"{s}\"\n", .{source});
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
|
||||
test "a failed parse in programmer mode also leaves nothing allocated" {
|
||||
const bad = [_][]const u8{ "0xFF and", "1 rol", "not", "0b1010 xor (1", "1 << " };
|
||||
for (bad) |source| {
|
||||
var parser = Parser.init(testing.allocator, source, .programmer);
|
||||
if (parser.parse()) |expr| {
|
||||
freeExpr(testing.allocator, expr);
|
||||
std.debug.print("expected a parse error for \"{s}\"\n", .{source});
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
|
||||
test "a successful parse hands over exactly one tree to free" {
|
||||
// The mirror of the above: freeing once must be enough and must not
|
||||
// double-free any shared node.
|
||||
const good = [_][]const u8{
|
||||
"1 + 2 * 3",
|
||||
"-(4 + 5)",
|
||||
"max(1, min(2, 3))",
|
||||
"x = 2 ^ 3 ^ 4",
|
||||
"not 0xFF",
|
||||
"sqrt(2) + factorial(5)",
|
||||
"cagr(10000, 25000, 5)",
|
||||
"tvm_pmt(360, 0.5, 200000, 0)",
|
||||
};
|
||||
for (good) |source| {
|
||||
var parser = Parser.init(testing.allocator, source, .standard);
|
||||
const expr = try parser.parse();
|
||||
freeExpr(testing.allocator, expr);
|
||||
}
|
||||
}
|
||||
|
||||
test "an allocation failure mid-parse frees whatever was built" {
|
||||
// The cleanup added for the error paths above is mostly reachable only when
|
||||
// an allocation fails partway through, so it is swept rather than assumed.
|
||||
// The wrapped testing.allocator reports any node the parser abandons.
|
||||
const sources = [_][]const u8{
|
||||
"1 + 2 * 3",
|
||||
"-(1 + 2)",
|
||||
"~5",
|
||||
"not 7",
|
||||
"max(1, 2, 3)",
|
||||
"min(max(1, 2), 3)",
|
||||
"x = 1 + 2",
|
||||
"1 rol 2",
|
||||
"(((1)))",
|
||||
"cagr(10000, 25000, 5)",
|
||||
};
|
||||
for (sources) |source| {
|
||||
var fail_index: usize = 0;
|
||||
while (fail_index < 64) : (fail_index += 1) {
|
||||
var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index });
|
||||
const allocator = failing.allocator();
|
||||
var parser = Parser.init(allocator, source, .standard);
|
||||
if (parser.parse()) |expr| {
|
||||
// Past the last allocation this input makes, so nothing is left
|
||||
// to fail; the tree itself must still be well formed.
|
||||
freeExpr(allocator, expr);
|
||||
break;
|
||||
} else |err| {
|
||||
try testing.expectEqual(CalcError.OutOfMemory, err);
|
||||
}
|
||||
} else {
|
||||
return error.AllocationSweepNeverCompleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,9 @@ fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u128, right: u128)
|
|||
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: ProgrammerConfig) CalcError!Integer {
|
||||
var p = Parser.init(allocator, source, .programmer);
|
||||
const expr = try p.parse();
|
||||
// Same ownership rule as evalStringInfo: the tree is ours to release, and the
|
||||
// returned Integer does not borrow from it.
|
||||
defer parser_mod.freeExpr(allocator, expr);
|
||||
return evalProgrammer(config, expr);
|
||||
}
|
||||
|
||||
|
|
@ -448,3 +451,18 @@ test "prog: arithmetic shift right amount >= width positive value" {
|
|||
const result = try testProgWith("0x40 >> 20", .{ .bit_width = .bits8 });
|
||||
try testing.expectEqual(@as(u128, 0), result.unsignedValue());
|
||||
}
|
||||
|
||||
test "no leak: evalProgrammerString releases the parsed tree" {
|
||||
// testing.allocator rather than an arena, so a retained AST fails the test.
|
||||
const config: ProgrammerConfig = .{};
|
||||
const good = [_][]const u8{ "0xFF and 0x0F", "1 << 8", "not 0", "0b1010 xor 0b0101", "5 rol 2" };
|
||||
for (good) |source| {
|
||||
_ = try evalProgrammerString(std.testing.allocator, source, config);
|
||||
}
|
||||
|
||||
const bad = [_][]const u8{ "0xFF and", "1 <<", "(1 | 2" };
|
||||
for (bad) |source| {
|
||||
_ = evalProgrammerString(std.testing.allocator, source, config) catch continue;
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
374
src/main.zig
374
src/main.zig
|
|
@ -21,6 +21,11 @@ pub const ParsedArgs = union(enum) {
|
|||
from: []const u8,
|
||||
to: []const u8,
|
||||
},
|
||||
amortization: struct {
|
||||
params: engine.financial.AmortizationParams,
|
||||
/// Print only the summary block, not the per-period rows.
|
||||
summary_only: bool,
|
||||
},
|
||||
output: struct {
|
||||
text: []const u8,
|
||||
is_error: bool,
|
||||
|
|
@ -38,6 +43,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
|
|||
return parseConvertArgs(args[1..]);
|
||||
}
|
||||
|
||||
// "amort" subcommand: the one financial output that is a table rather than a
|
||||
// single number, so it cannot be an expression function.
|
||||
if (args.len > 0 and (std.mem.eql(u8, args[0], "amort") or std.mem.eql(u8, args[0], "amortize"))) {
|
||||
return parseAmortArgs(args[1..]);
|
||||
}
|
||||
|
||||
for (args) |arg| {
|
||||
if (std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--programmer")) {
|
||||
mode = .programmer;
|
||||
|
|
@ -405,6 +416,212 @@ const convert_usage =
|
|||
\\
|
||||
;
|
||||
|
||||
const amort_usage =
|
||||
\\usage: tally amort <principal> <rate-per-period-%> <periods> [payment] [flags]
|
||||
\\
|
||||
\\flags:
|
||||
\\ --monthly The rate given is an annual nominal rate and the periods are
|
||||
\\ months, so rate/12 is charged each period
|
||||
\\ --summary Print only the totals, not every period
|
||||
\\ --exact Do not round each figure to whole cents
|
||||
\\
|
||||
\\examples:
|
||||
\\ tally amort 200000 0.5 360 200,000 at 0.5% a month
|
||||
\\ tally amort 200000 6 360 --monthly the same loan, 6% a year
|
||||
\\ tally amort 200000 6 360 1500 --monthly --summary
|
||||
\\
|
||||
;
|
||||
|
||||
/// Parse the arguments following the `amort` subcommand.
|
||||
///
|
||||
/// Positional form only for the loan terms: `<principal> <rate> <periods>` with
|
||||
/// an optional payment. A payment larger than the scheduled one retires the loan
|
||||
/// early; a smaller one ends in a balloon payment.
|
||||
fn parseAmortArgs(args: []const []const u8) ParsedArgs {
|
||||
var positional: [4][]const u8 = undefined;
|
||||
var count: usize = 0;
|
||||
var summary_only = false;
|
||||
var monthly = false;
|
||||
var round_cents = true;
|
||||
|
||||
for (args) |arg| {
|
||||
if (std.mem.eql(u8, arg, "--summary")) {
|
||||
summary_only = true;
|
||||
} else if (std.mem.eql(u8, arg, "--monthly")) {
|
||||
monthly = true;
|
||||
} else if (std.mem.eql(u8, arg, "--exact")) {
|
||||
round_cents = false;
|
||||
} else if (std.mem.startsWith(u8, arg, "--")) {
|
||||
return .{ .output = .{ .text = amort_usage, .is_error = true } };
|
||||
} else {
|
||||
if (count >= positional.len) {
|
||||
return .{ .output = .{ .text = amort_usage, .is_error = true } };
|
||||
}
|
||||
positional[count] = arg;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 3) return .{ .output = .{ .text = amort_usage, .is_error = true } };
|
||||
|
||||
const principal = std.fmt.parseFloat(f64, positional[0]) catch {
|
||||
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
|
||||
};
|
||||
const rate_input = std.fmt.parseFloat(f64, positional[1]) catch {
|
||||
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
|
||||
};
|
||||
const periods_input = std.fmt.parseFloat(f64, positional[2]) catch {
|
||||
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
|
||||
};
|
||||
const payment: ?f64 = if (count == 4)
|
||||
std.fmt.parseFloat(f64, positional[3]) catch {
|
||||
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
const max_periods: f64 = @floatFromInt(engine.financial.max_schedule_periods);
|
||||
if (!(periods_input >= 1) or periods_input > max_periods or @floor(periods_input) != periods_input) {
|
||||
return .{ .output = .{
|
||||
.text = "error: periods must be a whole number of at least 1\n",
|
||||
.is_error = true,
|
||||
} };
|
||||
}
|
||||
|
||||
return .{ .amortization = .{
|
||||
.params = .{
|
||||
.principal = principal,
|
||||
.rate = if (monthly) rate_input / 12.0 else rate_input,
|
||||
.periods = @intFromFloat(periods_input),
|
||||
.payment = payment,
|
||||
.round_cents = round_cents,
|
||||
},
|
||||
.summary_only = summary_only,
|
||||
} };
|
||||
}
|
||||
|
||||
/// Render an amount with thousands separators and two decimal places.
|
||||
fn formatMoney(buf: []u8, value: f64) []const u8 {
|
||||
var digits: [64]u8 = undefined;
|
||||
const text = std.fmt.bufPrint(&digits, "{d:.2}", .{@abs(value)}) catch return "?";
|
||||
// "{d:.2}" always emits ".dd", so the integer part is everything before the
|
||||
// last three characters.
|
||||
if (text.len < 4) return "?";
|
||||
const whole = text[0 .. text.len - 3];
|
||||
const fraction = text[text.len - 3 ..];
|
||||
|
||||
var written: usize = 0;
|
||||
if (value < 0) {
|
||||
if (buf.len == 0) return "?";
|
||||
buf[0] = '-';
|
||||
written = 1;
|
||||
}
|
||||
for (whole, 0..) |digit, i| {
|
||||
const remaining = whole.len - i;
|
||||
if (i != 0 and remaining % 3 == 0) {
|
||||
if (written == buf.len) return "?";
|
||||
buf[written] = ',';
|
||||
written += 1;
|
||||
}
|
||||
if (written == buf.len) return "?";
|
||||
buf[written] = digit;
|
||||
written += 1;
|
||||
}
|
||||
if (written + fraction.len > buf.len) return "?";
|
||||
@memcpy(buf[written..][0..fraction.len], fraction);
|
||||
return buf[0 .. written + fraction.len];
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
var line: [160]u8 = undefined;
|
||||
var money: [48]u8 = undefined;
|
||||
|
||||
const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{
|
||||
formatMoney(&money, params.principal),
|
||||
params.rate,
|
||||
params.periods,
|
||||
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
out.appendSlice(allocator, header) catch return oomResult();
|
||||
|
||||
const payment_line = std.fmt.bufPrint(&line, "Payment {s} per period\n\n", .{
|
||||
formatMoney(&money, payment),
|
||||
}) 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: [48]u8 = undefined;
|
||||
var int_buf: [48]u8 = undefined;
|
||||
var prin_buf: [48]u8 = undefined;
|
||||
var bal_buf: [48]u8 = undefined;
|
||||
const row_text = std.fmt.bufPrint(&line, "{d: >6} {s: >12} {s: >12} {s: >12} {s: >12}\n", .{
|
||||
row.period,
|
||||
formatMoney(&pay_buf, row.payment),
|
||||
formatMoney(&int_buf, row.interest),
|
||||
formatMoney(&prin_buf, row.principal),
|
||||
formatMoney(&bal_buf, row.balance),
|
||||
}) 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: [48]u8 = undefined;
|
||||
var interest_buf: [48]u8 = undefined;
|
||||
var principal_buf: [48]u8 = undefined;
|
||||
const summary = std.fmt.bufPrint(
|
||||
&line,
|
||||
"Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}",
|
||||
.{
|
||||
totals.periods,
|
||||
formatMoney(&paid_buf, totals.paid),
|
||||
formatMoney(&interest_buf, totals.interest),
|
||||
formatMoney(&principal_buf, totals.principal),
|
||||
},
|
||||
) 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 };
|
||||
}
|
||||
|
||||
fn oomResult() CliResult {
|
||||
return .{ .output = "error: out of memory\n", .is_error = true };
|
||||
}
|
||||
|
||||
fn amortErrorMessage(err: engine.CalcError) []const u8 {
|
||||
return switch (err) {
|
||||
engine.CalcError.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 =
|
||||
\\tally - a cross-platform calculator
|
||||
\\
|
||||
|
|
@ -422,8 +639,20 @@ const help_text =
|
|||
\\ tally 32F in C
|
||||
\\ tally '2*3 kg to lb' The value may be an expression
|
||||
\\
|
||||
\\A 'convert' subcommand is also accepted for explicitness:
|
||||
\\ tally convert 100 km to mi
|
||||
\\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)'
|
||||
\\
|
||||
\\Subcommands:
|
||||
\\ tally convert 100 km to mi Explicit unit conversion
|
||||
\\ tally amort 200000 6 360 --monthly Amortization schedule
|
||||
\\
|
||||
\\Run with no arguments to start the interactive TUI.
|
||||
\\
|
||||
|
|
@ -474,6 +703,13 @@ pub fn main(init: std.process.Init) u8 {
|
|||
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 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;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -932,3 +1168,137 @@ test "formatConversion: exact conversion prints exactly" {
|
|||
try testing.expect(!mps.is_error);
|
||||
try testing.expectEqualStrings("3.6 km/h = 1 m/s", mps.output);
|
||||
}
|
||||
|
||||
// -- Amortization CLI --
|
||||
|
||||
test "parseArgs: amort positional form" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "360" });
|
||||
switch (parsed) {
|
||||
.amortization => |a| {
|
||||
try testing.expectEqual(@as(f64, 200000), a.params.principal);
|
||||
try testing.expectEqual(@as(f64, 0.5), a.params.rate);
|
||||
try testing.expectEqual(@as(usize, 360), a.params.periods);
|
||||
try testing.expectEqual(@as(?f64, null), a.params.payment);
|
||||
try testing.expect(a.params.round_cents);
|
||||
try testing.expect(!a.summary_only);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: amortize is accepted as a longer spelling" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "amortize", "1000", "1", "12" });
|
||||
try testing.expect(parsed == .amortization);
|
||||
}
|
||||
|
||||
test "parseArgs: amort --monthly divides an annual rate by twelve" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "amort", "200000", "6", "360", "--monthly" });
|
||||
switch (parsed) {
|
||||
.amortization => |a| try testing.expectApproxEqAbs(@as(f64, 0.5), a.params.rate, 1e-12),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: amort flags and an explicit payment" {
|
||||
const parsed = parseArgs(
|
||||
testing.allocator,
|
||||
&.{ "amort", "200000", "0.5", "360", "1500", "--summary", "--exact" },
|
||||
);
|
||||
switch (parsed) {
|
||||
.amortization => |a| {
|
||||
try testing.expectEqual(@as(?f64, 1500), a.params.payment);
|
||||
try testing.expect(a.summary_only);
|
||||
try testing.expect(!a.params.round_cents);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: amort rejects incomplete or malformed terms" {
|
||||
// Too few terms.
|
||||
const short = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5" });
|
||||
try testing.expect(short == .output and short.output.is_error);
|
||||
// Too many.
|
||||
const long = parseArgs(testing.allocator, &.{ "amort", "1", "2", "3", "4", "5" });
|
||||
try testing.expect(long == .output and long.output.is_error);
|
||||
// Unknown flag.
|
||||
const bad_flag = parseArgs(testing.allocator, &.{ "amort", "1", "2", "3", "--nope" });
|
||||
try testing.expect(bad_flag == .output and bad_flag.output.is_error);
|
||||
// Non-numeric term.
|
||||
const bad_number = parseArgs(testing.allocator, &.{ "amort", "abc", "0.5", "360" });
|
||||
try testing.expect(bad_number == .output and bad_number.output.is_error);
|
||||
// A fractional period count has no meaning in a schedule.
|
||||
const fractional = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "360.5" });
|
||||
try testing.expect(fractional == .output and fractional.output.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, fractional.output.text, "whole number") != null);
|
||||
// Zero periods.
|
||||
const zero = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "0" });
|
||||
try testing.expect(zero == .output and zero.output.is_error);
|
||||
}
|
||||
|
||||
test "formatMoney: grouping and sign" {
|
||||
var buf: [48]u8 = undefined;
|
||||
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0));
|
||||
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1));
|
||||
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1));
|
||||
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000));
|
||||
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89));
|
||||
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1));
|
||||
}
|
||||
|
||||
test "formatMoney: a buffer too small reports rather than truncating silently" {
|
||||
var tiny: [4]u8 = undefined;
|
||||
try testing.expectEqualStrings("?", formatMoney(&tiny, 1234567.89));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
1650
src/tui.zig
1650
src/tui.zig
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,12 @@ pub const C = struct {
|
|||
pub const yellow: vaxis.Cell.Color = .{ .rgb = .{ 0xE6, 0xDB, 0x74 } };
|
||||
pub const purple: vaxis.Cell.Color = .{ .rgb = .{ 0xAE, 0x81, 0xFF } };
|
||||
pub const orange: vaxis.Cell.Color = .{ .rgb = .{ 0xFD, 0x97, 0x1F } };
|
||||
/// Selection background, from Molokai's own selection colour. Used where a
|
||||
/// field is being edited: reversing a bright accent colour into the
|
||||
/// background reads as a 1980s terminal and fights the surrounding text, while
|
||||
/// a dark neutral fill keeps the value legible in its normal colour and lets
|
||||
/// the cursor be the only bright thing on the row.
|
||||
pub const sel: vaxis.Cell.Color = .{ .rgb = .{ 0x49, 0x48, 0x3E } };
|
||||
};
|
||||
|
||||
/// Static single-byte grapheme strings with static lifetime.
|
||||
|
|
|
|||
1702
src/tui/financial.zig
Normal file
1702
src/tui/financial.zig
Normal file
File diff suppressed because it is too large
Load diff
280
src/tui/help.zig
280
src/tui/help.zig
|
|
@ -1,112 +1,208 @@
|
|||
//! Help overlay for the TUI.
|
||||
//!
|
||||
//! The content is a static line list rather than a sequence of hand-gated draw
|
||||
//! calls. That change came from a real failure: each section used to guard itself
|
||||
//! with `if (row < height - 6)`, so as sections were added the later ones were
|
||||
//! silently dropped on a normal-sized terminal, with no indication anything was
|
||||
//! missing. A line list can be windowed and scrolled, and its length is knowable
|
||||
//! without drawing.
|
||||
|
||||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const vxfw = vaxis.vxfw;
|
||||
const draw = @import("draw.zig");
|
||||
const C = draw.C;
|
||||
|
||||
pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void {
|
||||
/// One rendered line of help.
|
||||
const Line = union(enum) {
|
||||
/// Section heading.
|
||||
header: []const u8,
|
||||
/// A binding: name and what it does.
|
||||
key: struct { name: []const u8, desc: []const u8 },
|
||||
/// Free text, indented under a heading.
|
||||
text: []const u8,
|
||||
blank,
|
||||
};
|
||||
|
||||
const lines = [_]Line{
|
||||
.{ .header = "Keybindings" },
|
||||
.{ .key = .{ .name = "Enter", .desc = "Evaluate expression" } },
|
||||
.{ .key = .{ .name = "Tab", .desc = "Next mode (Standard/Programmer/Financial/Convert)" } },
|
||||
.{ .key = .{ .name = "Shift-Tab", .desc = "Previous mode" } },
|
||||
.{ .key = .{ .name = "Ctrl-C/D", .desc = "Quit" } },
|
||||
.{ .key = .{ .name = "Ctrl-L", .desc = "Clear history" } },
|
||||
.{ .key = .{ .name = "Up/Down", .desc = "Browse history" } },
|
||||
.{ .key = .{ .name = "?", .desc = "Toggle this help" } },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Mouse" },
|
||||
.{ .key = .{ .name = "Tabs", .desc = "Click a tab to switch mode" } },
|
||||
.{ .key = .{ .name = "Bit grid", .desc = "Click a bit to flip it" } },
|
||||
.{ .key = .{ .name = "HEX/OCT", .desc = "Click a digit to put the cursor on it" } },
|
||||
.{ .key = .{ .name = "BIN", .desc = "Click a digit to flip that bit" } },
|
||||
.{ .key = .{ .name = "DEC rows", .desc = "Click to focus the field" } },
|
||||
.{ .key = .{ .name = "Bits/Signed", .desc = "Click the label to toggle it" } },
|
||||
.{ .key = .{ .name = "Convert", .desc = "Click a category or unit to select it" } },
|
||||
.{ .key = .{ .name = "Financial", .desc = "Click a calculation or a field" } },
|
||||
.{ .key = .{ .name = "Wheel", .desc = "Scroll the schedule, or this help" } },
|
||||
.{ .key = .{ .name = "Input line", .desc = "Click to return focus to the prompt" } },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Programmer Mode" },
|
||||
.{ .key = .{ .name = "`", .desc = "Toggle input / value zone" } },
|
||||
.{ .key = .{ .name = "Ctrl-W", .desc = "Cycle bit width (8/16/32/64/128)" } },
|
||||
.{ .key = .{ .name = "Ctrl-E", .desc = "Toggle endianness (BE / LE)" } },
|
||||
.{ .key = .{ .name = "Ctrl-F", .desc = "IEEE 754 float view (Ctrl-W: f32/f64)" } },
|
||||
.{ .key = .{ .name = "Space", .desc = "Toggle bit (in grid)" } },
|
||||
.{ .key = .{ .name = "Arrows", .desc = "Navigate fields and bits (value zone)" } },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Financial Mode" },
|
||||
.{ .key = .{ .name = "`", .desc = "Toggle input / form zone" } },
|
||||
.{ .key = .{ .name = "Up/Down", .desc = "Move between fields" } },
|
||||
.{ .key = .{ .name = "Left/Right", .desc = "Switch calculation" } },
|
||||
.{ .key = .{ .name = "type", .desc = "Numbers or expressions: 12 * 30" } },
|
||||
.{ .key = .{ .name = "Enter", .desc = "Evaluate the focused field in place" } },
|
||||
.{ .key = .{ .name = "Space", .desc = "Toggle END / BGN on the payments row" } },
|
||||
.{ .key = .{ .name = "Ctrl-U", .desc = "Clear the field (marks it to solve for)" } },
|
||||
.{ .key = .{ .name = "PgUp/PgDn", .desc = "Scroll the amortization schedule" } },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Convert Mode" },
|
||||
.{ .key = .{ .name = "`", .desc = "Toggle input / selection zone" } },
|
||||
.{ .key = .{ .name = "Arrows", .desc = "Left/Right: column, Up/Down: select" } },
|
||||
.{ .key = .{ .name = "Ctrl-S", .desc = "Swap from and to units" } },
|
||||
.{ .key = .{ .name = "Enter", .desc = "Set the value to convert" } },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Functions" },
|
||||
.{ .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,
|
||||
|
||||
.{ .header = "Operators" },
|
||||
.{ .text = "Standard: + - * / % ^ (power)" },
|
||||
.{ .text = "Programmer: & | ~ << >> >>> ^/** (pow) and or xor not rol ror" },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Units and Conversion" },
|
||||
.{ .text = "100 km to mi, 32F in C, 2*3 kg to lb - works in any expression" },
|
||||
};
|
||||
|
||||
/// Total help lines, so the caller can clamp its scroll offset without drawing.
|
||||
pub fn lineCount() usize {
|
||||
return lines.len;
|
||||
}
|
||||
|
||||
/// Lines visible at a given terminal height: everything between the title and the
|
||||
/// footer.
|
||||
pub fn visibleLines(height: u16) usize {
|
||||
const first = content_start;
|
||||
const last = height -| 2;
|
||||
return if (last > first) last - first else 0;
|
||||
}
|
||||
|
||||
const content_start: u16 = 3;
|
||||
const key_col: u16 = 4;
|
||||
const desc_col: u16 = 18;
|
||||
|
||||
/// Draw the overlay, starting at line `scroll`. Out-of-range offsets are clamped
|
||||
/// here rather than trusted, so a resize cannot leave the view blank.
|
||||
pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16, scroll: usize) void {
|
||||
for (0..height) |r| {
|
||||
draw.fillRow(surface, @intCast(r), ' ', .{});
|
||||
}
|
||||
|
||||
var row: u16 = 1;
|
||||
draw.writeStr(surface, row, 2, "Tally - Help", .{ .fg = C.cyan, .bold = true });
|
||||
row += 2;
|
||||
draw.writeStr(surface, 1, 2, "Tally - Help", .{ .fg = C.cyan, .bold = true });
|
||||
|
||||
draw.writeStr(surface, row, 2, "Keybindings", .{ .fg = C.purple, .bold = true });
|
||||
row += 1;
|
||||
const keys = [_][2][]const u8{
|
||||
.{ "Enter", "Evaluate expression" },
|
||||
.{ "Tab", "Cycle mode (Standard/Programmer/Convert)" },
|
||||
.{ "Ctrl-C/D", "Quit" },
|
||||
.{ "Ctrl-L", "Clear history" },
|
||||
.{ "Up/Down", "Browse history" },
|
||||
.{ "?", "Toggle this help" },
|
||||
};
|
||||
for (keys) |kv| {
|
||||
draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow });
|
||||
draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg });
|
||||
row += 1;
|
||||
}
|
||||
row += 1;
|
||||
const visible = visibleLines(height);
|
||||
if (visible == 0) return;
|
||||
const max_scroll = if (lines.len > visible) lines.len - visible else 0;
|
||||
const first = @min(scroll, max_scroll);
|
||||
const last = @min(lines.len, first + visible);
|
||||
|
||||
draw.writeStr(surface, row, 2, "Mouse", .{ .fg = C.purple, .bold = true });
|
||||
row += 1;
|
||||
const mouse_keys = [_][2][]const u8{
|
||||
.{ "Tabs", "Click a tab to switch mode" },
|
||||
.{ "Bit grid", "Click a bit to flip it" },
|
||||
.{ "HEX/OCT", "Click a digit to put the cursor on it" },
|
||||
.{ "BIN", "Click a digit to flip that bit" },
|
||||
.{ "DEC rows", "Click to focus the field" },
|
||||
.{ "Bits/Signed/Endian", "Click the label to toggle it" },
|
||||
.{ "Convert", "Click a category or unit to select it" },
|
||||
.{ "Input line", "Click to return focus to the prompt" },
|
||||
};
|
||||
for (mouse_keys) |kv| {
|
||||
if (row >= height -| 4) break;
|
||||
draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow });
|
||||
draw.writeStr(surface, row, 24, kv[1], .{ .fg = C.fg });
|
||||
row += 1;
|
||||
}
|
||||
row += 1;
|
||||
|
||||
draw.writeStr(surface, row, 2, "Programmer Mode", .{ .fg = C.purple, .bold = true });
|
||||
row += 1;
|
||||
const prog_keys = [_][2][]const u8{
|
||||
.{ "`", "Toggle input / value zone" },
|
||||
.{ "Ctrl-W", "Cycle bit width (8/16/32/64/128)" },
|
||||
.{ "Ctrl-E", "Toggle endianness (BE / LE)" },
|
||||
.{ "Ctrl-F", "IEEE 754 float view (Ctrl-W: f32/f64)" },
|
||||
.{ "Space", "Toggle bit (in grid)" },
|
||||
.{ "Arrows", "Navigate fields and bits (value zone)" },
|
||||
};
|
||||
for (prog_keys) |kv| {
|
||||
if (row >= height -| 4) break;
|
||||
draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow });
|
||||
draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg });
|
||||
row += 1;
|
||||
}
|
||||
row += 1;
|
||||
|
||||
if (row < height -| 6) {
|
||||
draw.writeStr(surface, row, 2, "Convert Mode", .{ .fg = C.purple, .bold = true });
|
||||
row += 1;
|
||||
const conv_keys = [_][2][]const u8{
|
||||
.{ "`", "Toggle input / selection zone" },
|
||||
.{ "Arrows", "Left/Right: column, Up/Down: select" },
|
||||
.{ "Ctrl-S", "Swap from and to units" },
|
||||
.{ "Enter", "Set the value to convert" },
|
||||
};
|
||||
for (conv_keys) |kv| {
|
||||
if (row >= height -| 4) break;
|
||||
draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow });
|
||||
draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg });
|
||||
row += 1;
|
||||
var row: u16 = content_start;
|
||||
for (lines[first..last]) |line| {
|
||||
switch (line) {
|
||||
.header => |text| draw.writeStr(surface, row, 2, text, .{ .fg = C.purple, .bold = true }),
|
||||
.key => |kv| {
|
||||
draw.writeStr(surface, row, key_col, kv.name, .{ .fg = C.yellow });
|
||||
draw.writeStr(surface, row, desc_col, kv.desc, .{ .fg = C.fg });
|
||||
},
|
||||
.text => |text| draw.writeStr(surface, row, key_col, text, .{ .fg = C.fg }),
|
||||
.blank => {},
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
|
||||
if (row < height -| 6) {
|
||||
draw.writeStr(surface, row, 2, "Functions", .{ .fg = C.purple, .bold = true });
|
||||
row += 1;
|
||||
draw.writeStr(surface, row, 4, "sin cos tan asin acos atan log ln sqrt abs", .{ .fg = C.fg });
|
||||
row += 1;
|
||||
draw.writeStr(surface, row, 4, "ceil floor round factorial max min exp", .{ .fg = C.fg });
|
||||
row += 1;
|
||||
}
|
||||
|
||||
if (row < height -| 4) {
|
||||
row += 1;
|
||||
draw.writeStr(surface, row, 2, "Operators", .{ .fg = C.purple, .bold = true });
|
||||
row += 1;
|
||||
draw.writeStr(surface, row, 4, "Standard: + - * / % ^ (power)", .{ .fg = C.fg });
|
||||
row += 1;
|
||||
draw.writeStr(surface, row, 4, "Programmer: & | ~ << >> >>> ^/** (pow) and or xor not rol ror", .{ .fg = C.fg });
|
||||
row += 1;
|
||||
}
|
||||
|
||||
// Footer: the position indicator only appears when something is off screen,
|
||||
// so a full view is not cluttered with it.
|
||||
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted });
|
||||
draw.writeStr(surface, height -| 1, 1, "Press any key to return", .{ .fg = C.muted });
|
||||
if (lines.len > visible) {
|
||||
var buf: [96]u8 = undefined;
|
||||
const status = std.fmt.bufPrint(&buf, "lines {d}-{d} of {d} | Up/Down or wheel: scroll | any other key: return", .{
|
||||
first + 1, last, lines.len,
|
||||
}) catch "Up/Down: scroll | any other key: return";
|
||||
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted });
|
||||
} else {
|
||||
draw.writeStr(surface, height -| 1, 1, "Press any key to return", .{ .fg = C.muted });
|
||||
}
|
||||
_ = width;
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "every help line has content" {
|
||||
for (lines) |line| {
|
||||
switch (line) {
|
||||
.header => |text| try testing.expect(text.len > 0),
|
||||
.key => |kv| {
|
||||
try testing.expect(kv.name.len > 0);
|
||||
try testing.expect(kv.desc.len > 0);
|
||||
// Descriptions start at a fixed column, so a long name would
|
||||
// overwrite its own description.
|
||||
try testing.expect(kv.name.len < desc_col - key_col);
|
||||
},
|
||||
.text => |text| try testing.expect(text.len > 0),
|
||||
.blank => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "help lines fit an 80-column terminal" {
|
||||
for (lines) |line| {
|
||||
const used: usize = switch (line) {
|
||||
.header => |text| 2 + text.len,
|
||||
.key => |kv| desc_col + kv.desc.len,
|
||||
.text => |text| key_col + text.len,
|
||||
.blank => 0,
|
||||
};
|
||||
try testing.expect(used <= 80);
|
||||
}
|
||||
}
|
||||
|
||||
test "visibleLines shrinks with the terminal and never underflows" {
|
||||
try testing.expect(visibleLines(40) > visibleLines(24));
|
||||
try testing.expectEqual(@as(usize, 0), visibleLines(4));
|
||||
try testing.expectEqual(@as(usize, 0), visibleLines(0));
|
||||
}
|
||||
|
||||
test "the help content is longer than a standard terminal, which is why it scrolls" {
|
||||
try testing.expect(lineCount() > visibleLines(24));
|
||||
}
|
||||
|
|
|
|||
76
src/tui/test_render.zig
Normal file
76
src/tui/test_render.zig
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Test-only frame rendering for the TUI.
|
||||
//!
|
||||
//! Draws a real frame into a real surface and reads the cells back as text, which
|
||||
//! is the only way to test a terminal view without a terminal. It catches the
|
||||
//! failures that matter in drawing code: a layout that writes nothing, one that
|
||||
//! overlaps itself, and one that runs off the bottom or the right edge.
|
||||
//!
|
||||
//! Nothing here is used outside tests. It lives in its own file so both the view
|
||||
//! modules and tui.zig can share one harness rather than each growing a copy.
|
||||
|
||||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const vxfw = vaxis.vxfw;
|
||||
const tui = @import("../tui.zig");
|
||||
|
||||
/// Draw one full frame of `app` (title bar, tabs, and whichever mode is active)
|
||||
/// and return it as `height` rows of `width` characters. Rows are allocated from
|
||||
/// `arena`.
|
||||
pub fn frame(arena: std.mem.Allocator, app: *tui.App, width: u16, height: u16) ![][]u8 {
|
||||
const widget = app.widget();
|
||||
const ctx: vxfw.DrawContext = .{
|
||||
.arena = arena,
|
||||
.min = .{ .width = 0, .height = 0 },
|
||||
.max = .{ .width = width, .height = height },
|
||||
.cell_size = .{ .width = 8, .height = 16 },
|
||||
};
|
||||
const surface = try widget.drawFn(widget.userdata, ctx);
|
||||
return flatten(arena, surface, width, height);
|
||||
}
|
||||
|
||||
/// Read a surface back as text. Multi-byte graphemes become a single space: the
|
||||
/// TUI is ASCII by design, so anything wider is a bug this does not need to model.
|
||||
pub fn flatten(
|
||||
arena: std.mem.Allocator,
|
||||
surface: vxfw.Surface,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) ![][]u8 {
|
||||
const rows = try arena.alloc([]u8, height);
|
||||
for (0..height) |r| {
|
||||
const line = try arena.alloc(u8, width);
|
||||
for (0..width) |c| {
|
||||
const index = r * @as(usize, width) + c;
|
||||
const grapheme = if (index < surface.buffer.len) surface.buffer[index].char.grapheme else " ";
|
||||
line[c] = if (grapheme.len == 1) grapheme[0] else ' ';
|
||||
}
|
||||
rows[r] = line;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// True when any row contains `needle`.
|
||||
pub fn contains(rows: [][]u8, needle: []const u8) bool {
|
||||
for (rows) |row| {
|
||||
if (std.mem.indexOf(u8, row, needle) != null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The row index containing `needle`, or null.
|
||||
pub fn rowOf(rows: [][]u8, needle: []const u8) ?usize {
|
||||
for (rows, 0..) |row, i| {
|
||||
if (std.mem.indexOf(u8, row, needle) != null) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// True when every row is exactly `width` wide and nothing was written past it.
|
||||
/// A view that miscomputes a column would otherwise fail silently, since the
|
||||
/// drawing helpers clip rather than error.
|
||||
pub fn wellFormed(rows: [][]u8, width: u16) bool {
|
||||
for (rows) |row| {
|
||||
if (row.len != width) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue