dedup pass

This commit is contained in:
Emil Lerch 2026-07-29 15:52:04 -07:00
parent 171641f381
commit 5a8e0980ef
Signed by: lobo
GPG key ID: A7B62D657EF764F8
17 changed files with 917 additions and 509 deletions

View file

@ -187,6 +187,12 @@ thing in standard and programmer modes.
use explicit `*`. Spaces within hex/oct/bin literals are separators use explicit `*`. Spaces within hex/oct/bin literals are separators
(e.g. `0xFF FF`). (e.g. `0xFF FF`).
**Parsing does not depend on the mode.** `Tokenizer.init(source)` and
`Parser.init(allocator, source)` take no `Mode`: the same text produces the same
tree in standard and programmer mode, and only evaluation differs. Both used to
accept and store a `Mode` that no code read, which implied a mode-dependent grammar
that does not exist.
### 2.5 Evaluation ### 2.5 Evaluation
The evaluator maintains an `Environment`: The evaluator maintains an `Environment`:
@ -199,9 +205,6 @@ pub const Environment = struct {
/// expression had: `X = 0.1` stores exactly one tenth. /// expression had: `X = 0.1` stores exactly one tenth.
variables: std.StringHashMap(Number), variables: std.StringHashMap(Number),
ans: ?Number, ans: ?Number,
/// A counter only. Displayed history lives in the frontend (`src/tui.zig`),
/// because the engine has no I/O and no display concerns.
history_len: usize,
programmer_config: ProgrammerConfig, programmer_config: ProgrammerConfig,
}; };
@ -1959,7 +1962,21 @@ pub const ErrorInfo = struct {
}; };
``` ```
All engine functions return `CalcError!Result`. Frontends translate these into user-facing messages with position highlighting. NOT IMPLEMENTED, and removed: nothing ever constructed an `ErrorInfo`, and the
parser's `error_pos`/`had_error` fields that would have fed it were written on every
error path and never read. Adding position reporting means threading it through the
`CalcError` returns, which is worth doing deliberately rather than leaving a
half-built shape in the code.
The phrase for each error lives in exactly one place, `types.errorPhrase`, whose
switch has no `else`, so a new member of `CalcError` fails to compile until it is
given a phrase. Frontends decorate that phrase at comptime (`switch (err) { inline
else => ... }`): the CLI adds `error: ` and a newline, the TUI adds `error: `, and a
view with better context can override individual cases, as the financial form does
for `DomainError`. Each frontend used to carry its own copy of the whole table, and
the TUI's had drifted three errors behind.
All engine functions return `CalcError!Result`. Frontends translate these into user-facing messages.
--- ---

View file

@ -13,7 +13,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **FR-1.1**: Parse and evaluate infix mathematical expressions with correct operator precedence (PEMDAS). - **FR-1.1**: Parse and evaluate infix mathematical expressions with correct operator precedence (PEMDAS).
- **FR-1.2**: Support operators: `+`, `-`, `*`, `/`, `%` (modulo), `^` (power), unary `-`. - **FR-1.2**: Support operators: `+`, `-`, `*`, `/`, `%` (modulo), `^` (power), unary `-`.
- **FR-1.3**: Support parentheses for grouping. - **FR-1.3**: Support parentheses for grouping.
- **FR-1.4**: Support built-in functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `log` (base-10), `ln` (natural), `sqrt`, `cbrt`, `abs`, `ceil`, `floor`, `round`, `factorial`. - **FR-1.4**: Support built-in functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `log` (base-10), `ln` (natural), `sqrt`, `cbrt`, `abs`, `ceil`, `floor`, `round`, `factorial`. An argument outside a function's domain is a domain error, distinct from an unknown name: `sqrt(-1)`, `asin(2)`, `ln(0)`, `log2(0)` and `factorial(-1)` all report a domain error, and only an unrecognized name reports an unknown function.
- **FR-1.5**: Support constants: `pi`, `e`, `tau`. - **FR-1.5**: Support constants: `pi`, `e`, `tau`.
- **FR-1.6**: Support variable storage: `Ans` for the last result, plus any identifier as a named variable. (The original wording restricted this to `A-F, X, Y, Z`; the implementation accepts any name, which is a superset and the better behaviour, so the requirement follows the code.) Assignment to a constant name (`pi`, `e`, `tau`, `Ans`) is currently accepted and then ignored, which is a known defect rather than intended behaviour. - **FR-1.6**: Support variable storage: `Ans` for the last result, plus any identifier as a named variable. (The original wording restricted this to `A-F, X, Y, Z`; the implementation accepts any name, which is a superset and the better behaviour, so the requirement follows the code.) Assignment to a constant name (`pi`, `e`, `tau`, `Ans`) is currently accepted and then ignored, which is a known defect rather than intended behaviour.
- **FR-1.7**: Maintain calculation history with replay capability. - **FR-1.7**: Maintain calculation history with replay capability.
@ -24,8 +24,8 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **FR-2.1**: Accept input in decimal, hexadecimal (`0x`), octal (`0o`), and binary (`0b`) formats. - **FR-2.1**: Accept input in decimal, hexadecimal (`0x`), octal (`0o`), and binary (`0b`) formats.
- **FR-2.2**: Simultaneously display results in all four bases (dec, hex, oct, bin). - **FR-2.2**: Simultaneously display results in all four bases (dec, hex, oct, bin).
- **FR-2.3**: Support configurable bit widths: 8, 16, 32, 64, 128-bit. - **FR-2.3**: Support configurable bit widths: 8, 16, 32, 64, 128-bit. Standard mode is fixed at 64-bit signed; a width other than 64 is what programmer mode is for.
- **FR-2.4**: Support bitwise operators: AND (`&` or `and`), OR (`|` or `or`), XOR (`xor` keyword), NOT (`~` or `not`), left shift (`<<`), right shift (logical `>>>`), arithmetic right shift (`>>`), rotate left (`rol`), rotate right (`ror`). Note: `^` is always exponentiation (never XOR) - see FR-2.12. - **FR-2.4**: Support bitwise operators: AND (`&` or `and`), OR (`|` or `or`), XOR (`xor` keyword), NOT (`~` or `not`), left shift (`<<`), right shift (logical `>>>`), arithmetic right shift (`>>`), rotate left (`rol`), rotate right (`ror`). Note: `^` is always exponentiation (never XOR) - see FR-2.12. `>>` fills the vacated high bits with copies of the sign bit, so `-8 >> 1` is -4; `>>>` fills them with zeros. Bits shifted past the width are discarded rather than wrapped (`0b1000 << 1` is 16); `rol` and `ror` are the operators that wrap.
- **FR-2.12**: The `^` operator means exponentiation in all modes (never XOR). This avoids mode-dependent operator overloading. XOR is available only via the `xor` keyword. Power is also available via `**`. This keeps every operator's meaning identical across standard and programmer modes. - **FR-2.12**: The `^` operator means exponentiation in all modes (never XOR). This avoids mode-dependent operator overloading. XOR is available only via the `xor` keyword. Power is also available via `**`. This keeps every operator's meaning identical across standard and programmer modes.
- **FR-2.5**: Display both signed (two's complement) and unsigned interpretations of the current value. - **FR-2.5**: Display both signed (two's complement) and unsigned interpretations of the current value.
- **FR-2.6**: Visualize the bit pattern as a grid (integer.exposed style) - bits individually addressable/toggleable in TUI and Android. - **FR-2.6**: Visualize the bit pattern as a grid (integer.exposed style) - bits individually addressable/toggleable in TUI and Android.

View file

@ -828,11 +828,73 @@ DELIBERATELY NOT FIXED, documentation corrected instead:
shapes), design 7's programmer and conversion output, design 8.1's footer, and shapes), design 7's programmer and conversion output, design 8.1's footer, and
design 8.0/8.0.0's overclaims about hit regions and the render harness. design 8.0/8.0.0's overclaims about hit regions and the render harness.
DE-DUPLICATION PASS (done before the human review, since duplicated logic is what
the review would have spent its time on):
- **One money formatter.** `formatter.formatAmount`/`formatMoney` in the engine;
`main.zig` and `src/tui/financial.zig` had a copy each, and all three disagreed
about overflow. The formatter returns `null` rather than `"?"`, so the CLI now
reports "an amount in this schedule is too large to format" with exit status 1
where it used to print a table of question marks and exit 0 (open item 9 below,
now closed).
- **One error phrase table.** `types.errorPhrase` owns the strings, with an
exhaustive switch and no `else`, so an added `CalcError` is a compile error. The
CLI and TUI decorate it at comptime (`switch (err) { inline else => ... }`). The
TUI's private copy had fallen behind and rendered `InvalidExpression`,
`ConvergenceFailure` and `InsufficientParameters` as "evaluation error".
- **One comma-grouping implementation and one scientific renderer.**
`writeUnsignedWithCommas` now writes plain digits and calls `writeGroupedDecimal`;
new `splitDecimalText` is the only place decimal text is taken apart. The 65-line
`scientificFromDecimalText` is gone: both magnitude branches call
`Rational.toScientificString`.
- **One negative-sqrt rule, one numeric-error mapping.** `Number.sqrt` raises
`NegativeRoot`; `number.toCalcError` is the single mapping (`evaluator.mapError`
and `units.mapNumberError` were two copies). `evalSingleArgFn` now distinguishes
"unknown name" from "bad argument", so `sqrt(-1)`, `asin(2)`, `ln(0)`,
`factorial(-1)` and `log2(0)` report `DomainError` instead of `UnknownFunction`.
- **One mode order, one form order.** `tui.mode_tabs` is indexed by the `Mode` tag
(checked at comptime) and drives Tab, Shift-Tab and the drawn bar, which were
three encodings of one sequence. Form cycling moved into
`financial.State.nextForm`/`prevForm`. That copy had a live bug: the `Form` tag is
a `u2`, so `@intFromEnum(form) + 1` overflowed and pressing Right on Amortization
panicked in a debug build. The existing test only wrapped backwards.
- **Dead code.** `engine.zig` lost 20 of 31 curated re-exports (the ones with no
caller, including `Value` after `Number` replaced it); `types.Value` and
`types.ErrorInfo` are gone; `Parser` lost `mode`, `previous`, `had_error` and
`error_pos` and `Tokenizer` lost `mode`, all written and never read, so both
`init` signatures dropped their `Mode` parameter (parsing does not depend on the
mode, only evaluation does); `Number.applyFloatFn` and `Environment.history_len`
are gone; and the `0x`/`0o`/`0b` prefix-skipping branches in the programmer view
could never run, because only formatter `raw` strings carry a prefix.
STILL OPEN, in the order I would take them: STILL OPEN, in the order I would take them:
1. `>>` is logical in standard mode and arithmetic in programmer mode, shift 1. `>>` is logical in standard mode and arithmetic in programmer mode, shift
amounts wrap in one and clamp in the other, and `evaluator.zig` ignores the amounts wrap in one and clamp in the other, and `evaluator.zig` ignores the
configured bit width entirely. The two implementations of these nine operators configured bit width entirely. The two implementations of these nine operators
need to become one, parameterised by width (FR-2.12 promises they agree). need to become one, parameterised by width (FR-2.12 promises they agree).
DECIDED: standard mode is 64-bit signed. A user who wants another width uses
programmer mode, which keeps its configurable width (FR-2.3). The shared
implementation therefore takes width and signedness as parameters, and standard
mode passes 64 and signed. Signedness is the status quo rather than a change:
`evaluator.zig` already converts results back through `@as(i64, @bitCast(...))`,
so standard-mode `~0` is -1.
DECIDED: `>>` fills with the sign bit (arithmetic) in both modes, so standard
`-8 >> 1` becomes -4 instead of 9223372036854775804. `>>>` fills with zeros
(logical) in both modes.
Bits shifted past the width are discarded, not wrapped: `0b1000 << 1` is 16.
Wrap-around is what `rol`/`ror` are for, and both modes already agree on those.
STILL TO DECIDE: a shift distance at or beyond the width. Standard mode reduces
the distance modulo 64 (`1 << 64` is 1) and programmer mode clamps it to
`width - 1` (8-bit `0xFF >>> 20` shifts by 7 and gives 1). Both turn "shift
everything out" into "shift a little". Recommendation: let the shift run to
completion, so `<<` and `>>>` yield 0 and `>>` yields 0 or -1 by sign. That makes
`1 << 64` yield 0, which is a visible change with test expectations attached.
Zig's saturating `<<|` was considered as a home for `<<<` and rejected: `>>>`
means "the zero-filling variant of `>>`", so `<<<` would have to mean the
zero-filling variant of `<<`, which is `<<` itself.
2. Signed division and modulo in programmer mode use unsigned semantics: 2. Signed division and modulo in programmer mode use unsigned semantics:
`-10 / 2` gives 9223372036854775803. `-10 / 2` gives 9223372036854775803.
3. Multi-base detail lines are computed through an f64 round trip, so 3. Multi-base detail lines are computed through an f64 round trip, so
@ -849,7 +911,8 @@ STILL OPEN, in the order I would take them:
8. History display caps at 512 flattened lines built oldest-first, so results stop 8. History display caps at 512 flattened lines built oldest-first, so results stop
appearing after roughly 102 detailed entries. History memory is never reclaimed appearing after roughly 102 detailed entries. History memory is never reclaimed
(Ctrl-L frees into an arena). (Ctrl-L frees into an arena).
9. Money formatting degrades to `?` and still exits 0 at large magnitudes. 9. ~~Money formatting degrades to `?` and still exits 0 at large magnitudes.~~
Fixed by the de-duplication pass above.
10. Assignment parses in prefix position (`1 + x = 2` mutates `x`), and assignment 10. Assignment parses in prefix position (`1 + x = 2` mutates `x`), and assignment
to a constant name is silently discarded. to a constant name is silently discarded.
11. Literals longer than 128 characters are rejected by a fixed tokenizer buffer, 11. Literals longer than 128 characters are rejected by a fixed tokenizer buffer,

View file

@ -14,51 +14,26 @@ pub const formatter = @import("formatter.zig");
pub const float_interp = @import("float_interp.zig"); pub const float_interp = @import("float_interp.zig");
pub const units = @import("units.zig"); pub const units = @import("units.zig");
pub const financial = @import("financial.zig"); pub const financial = @import("financial.zig");
// Exact numeric model (design.md 2.7). Not yet wired into the evaluator; see // Exact numeric model (design.md 2.7). The evaluator computes in these.
// Task 2.0b. Exported here so its tests run as part of `zig build test`.
pub const rational = @import("rational.zig"); pub const rational = @import("rational.zig");
pub const number = @import("number.zig"); pub const number = @import("number.zig");
// Re-export primary types for convenience // The modules above are the engine's surface: a caller writes `engine.units.convert`
pub const Value = types.Value; // or `engine.financial.solveTvm`. The aliases below exist only for the handful of
// names used often enough that the module prefix is noise. There used to be a
// curated re-export of nearly every public declaration, which drifted: two thirds
// of it had no callers, and `Value` was re-exported after the type it named had
// stopped being the engine's result type.
pub const Mode = types.Mode; pub const Mode = types.Mode;
pub const BitWidth = types.BitWidth; pub const BitWidth = types.BitWidth;
pub const CalcError = types.CalcError; pub const CalcError = types.CalcError;
pub const Parser = parser.Parser;
pub const Expr = ast.Expr;
pub const Environment = evaluator.Environment; pub const Environment = evaluator.Environment;
pub const evalString = evaluator.evalString; pub const evalString = evaluator.evalString;
pub const evalStringInfo = evaluator.evalStringInfo; pub const evalStringInfo = evaluator.evalStringInfo;
pub const EvalInfo = evaluator.EvalInfo;
pub const evalProgrammerString = programmer.evalProgrammerString; pub const evalProgrammerString = programmer.evalProgrammerString;
// Float interpretation
pub const FloatFormat = float_interp.FloatFormat; pub const FloatFormat = float_interp.FloatFormat;
pub const FloatClass = float_interp.FloatClass;
pub const FloatInfo = float_interp.FloatInfo;
// Unit conversion
pub const UnitCategory = units.UnitCategory; pub const UnitCategory = units.UnitCategory;
pub const UnitDef = units.UnitDef; pub const UnitDef = units.UnitDef;
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; pub const Number = number.Number;
test { test {

View file

@ -32,7 +32,6 @@ pub const Environment = struct {
programmer_config: ProgrammerConfig, programmer_config: ProgrammerConfig,
variables: std.StringHashMap(Number), variables: std.StringHashMap(Number),
ans: Number, ans: Number,
history_len: usize,
pub fn init(allocator: Allocator, mode: Mode) Environment { pub fn init(allocator: Allocator, mode: Mode) Environment {
return .{ return .{
@ -43,7 +42,6 @@ pub const Environment = struct {
// Starts inexact so that `init` cannot fail; the first evaluation // Starts inexact so that `init` cannot fail; the first evaluation
// replaces it. // replaces it.
.ans = Number.fromFloat(0), .ans = Number.fromFloat(0),
.history_len = 0,
}; };
} }
@ -209,15 +207,9 @@ fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) CalcError!Number {
} }
/// Map the numeric model's errors onto the engine's error set. /// Map the numeric model's errors onto the engine's error set.
fn mapError(err: number_mod.Error) CalcError { ///
return switch (err) { /// One mapping, in `number.zig`; this alias keeps the call sites short.
error.OutOfMemory => CalcError.OutOfMemory, const mapError = number_mod.toCalcError;
error.DivisionByZero => CalcError.DivisionByZero,
error.InvalidNumber => CalcError.InvalidNumber,
// An exponent too large to compute is an overflow from the caller's view.
error.ExponentTooLarge => CalcError.Overflow,
};
}
/// Evaluate a binary operation. /// Evaluate a binary operation.
fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) CalcError!Number { fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) CalcError!Number {
@ -321,17 +313,19 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
return Number.round(scratch, x) catch |err| mapError(err); return Number.round(scratch, x) catch |err| mapError(err);
} }
if (std.mem.eql(u8, name, "sqrt")) { if (std.mem.eql(u8, name, "sqrt")) {
// Negative inputs are a domain error rather than a NaN. // The negative-input rule lives in Number.sqrt, which raises
if (x.isNegative()) return CalcError.UnknownFunction; // NegativeRoot; mapError turns that into a domain error.
return Number.sqrt(scratch, x) catch |err| mapError(err); return Number.sqrt(scratch, x) catch |err| mapError(err);
} }
if (std.mem.eql(u8, name, "factorial")) { if (std.mem.eql(u8, name, "factorial")) {
const result = Number.factorial(scratch, x) catch |err| return mapError(err); const result = Number.factorial(scratch, x) catch |err| return mapError(err);
return result orelse CalcError.UnknownFunction; // Null means the argument was negative or fractional, which is a domain
// error, not an unknown function.
return result orelse CalcError.DomainError;
} }
// Everything else escapes the rationals, so it falls back to f64. // Everything else escapes the rationals, so it falls back to f64.
const f = evalSingleArgFn(name, x.toFloat(scratch)) orelse const f = try evalSingleArgFn(name, x.toFloat(scratch)) orelse
return CalcError.UnknownFunction; return CalcError.UnknownFunction;
return Number.fromFloat(f); return Number.fromFloat(f);
} }
@ -499,23 +493,39 @@ fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 {
} }
/// Evaluate a single-argument built-in function that has no exact form. /// Evaluate a single-argument built-in function that has no exact form.
fn evalSingleArgFn(name: []const u8, x: f64) ?f64 { /// Evaluate a single-argument built-in that has no exact form.
///
/// Returns null when `name` is not one of these functions, and an error when the
/// name is known but the argument is outside its domain. The two used to be the
/// same answer (null), so the caller reported `asin(2)` as "unknown function".
fn evalSingleArgFn(name: []const u8, x: f64) CalcError!?f64 {
if (std.mem.eql(u8, name, "sin")) return @sin(x); if (std.mem.eql(u8, name, "sin")) return @sin(x);
if (std.mem.eql(u8, name, "cos")) return @cos(x); if (std.mem.eql(u8, name, "cos")) return @cos(x);
if (std.mem.eql(u8, name, "tan")) return @tan(x); if (std.mem.eql(u8, name, "tan")) return @tan(x);
if (std.mem.eql(u8, name, "asin")) { if (std.mem.eql(u8, name, "asin")) {
if (x < -1 or x > 1) return null; // domain error if (x < -1 or x > 1) return CalcError.DomainError;
return math.asin(x); return math.asin(x);
} }
if (std.mem.eql(u8, name, "acos")) { if (std.mem.eql(u8, name, "acos")) {
if (x < -1 or x > 1) return null; if (x < -1 or x > 1) return CalcError.DomainError;
return math.acos(x); return math.acos(x);
} }
if (std.mem.eql(u8, name, "atan")) return math.atan(x); if (std.mem.eql(u8, name, "atan")) return math.atan(x);
if (std.mem.eql(u8, name, "log")) return @log10(x); // log/log10/ln/log2 of a non-positive value has no real result. The two-argument
if (std.mem.eql(u8, name, "log10")) return @log10(x); // log already reported this as a domain error; the one-argument forms returned
if (std.mem.eql(u8, name, "ln")) return @log(x); // -inf or NaN.
if (std.mem.eql(u8, name, "log2")) return @log2(x); if (std.mem.eql(u8, name, "log") or std.mem.eql(u8, name, "log10")) {
if (x <= 0) return CalcError.DomainError;
return @log10(x);
}
if (std.mem.eql(u8, name, "ln")) {
if (x <= 0) return CalcError.DomainError;
return @log(x);
}
if (std.mem.eql(u8, name, "log2")) {
if (x <= 0) return CalcError.DomainError;
return @log2(x);
}
if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x); if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x);
if (std.mem.eql(u8, name, "exp")) return @exp(x); if (std.mem.eql(u8, name, "exp")) return @exp(x);
return null; return null;
@ -540,7 +550,7 @@ pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) C
/// Like evalString but returns metadata (whether the expression used /// Like evalString but returns metadata (whether the expression used
/// non-decimal literals) so frontends can decide to show a multi-base view. /// non-decimal literals) so frontends can decide to show a multi-base view.
pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u8) CalcError!EvalInfo { pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u8) CalcError!EvalInfo {
var p = Parser.init(allocator, source, env.mode); var p = Parser.init(allocator, source);
const expr = try p.parse(); const expr = try p.parse();
// The parser hands over ownership. Nothing in the result borrows from the // 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 // tree (literal text points into `source`, and the value is cloned out of the
@ -559,7 +569,6 @@ pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u
const result = raw.cloneWith(allocator) catch |err| return mapError(err); const result = raw.cloneWith(allocator) catch |err| return mapError(err);
env.setAns(result) catch return CalcError.OutOfMemory; env.setAns(result) catch return CalcError.OutOfMemory;
env.history_len += 1;
return .{ return .{
.value = result, .value = result,
.has_nondecimal_literal = hasNonDecimalLiteral(expr), .has_nondecimal_literal = hasNonDecimalLiteral(expr),
@ -881,10 +890,37 @@ test "eval log domain error" {
try testing.expectError(CalcError.DomainError, result); try testing.expectError(CalcError.DomainError, result);
} }
test "eval asin domain error" { test "domain errors are domain errors, not unknown functions" {
const result = testEval("asin(2)"); // These pinned the wrong contract: the name is known, the argument is not in
// asin(2) is domain error since |2| > 1 // its domain. Reporting "unknown function" sent the user looking for a typo.
try testing.expectError(CalcError.UnknownFunction, result); try testing.expectError(CalcError.DomainError, testEval("asin(2)"));
try testing.expectError(CalcError.DomainError, testEval("asin(-2)"));
try testing.expectError(CalcError.DomainError, testEval("acos(2)"));
try testing.expectError(CalcError.DomainError, testEval("sqrt(-1)"));
try testing.expectError(CalcError.DomainError, testEval("factorial(-1)"));
try testing.expectError(CalcError.DomainError, testEval("factorial(2.5)"));
// Logarithms of non-positive values, which used to return -inf or NaN. The
// two-argument form already reported this correctly.
try testing.expectError(CalcError.DomainError, testEval("ln(0)"));
try testing.expectError(CalcError.DomainError, testEval("ln(0 - 1)"));
try testing.expectError(CalcError.DomainError, testEval("log(0)"));
try testing.expectError(CalcError.DomainError, testEval("log10(0 - 5)"));
try testing.expectError(CalcError.DomainError, testEval("log2(0)"));
try testing.expectError(CalcError.DomainError, testEval("log(100, 1)"));
// A genuinely unknown name still reports one.
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1)"));
try testing.expectError(CalcError.UnknownFunction, testEval("asin(1, 2)"));
}
test "the functions themselves still work inside their domains" {
try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("asin(0)"), 1e-15);
try testing.expectApproxEqAbs(math.pi / 2.0, try testEval("acos(0)"), 1e-15);
try testing.expectEqual(@as(f64, 2.0), try testEval("log10(100)"));
try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("ln(e)"), 1e-15);
try testing.expectEqual(@as(f64, 3.0), try testEval("log2(8)"));
try testing.expectEqual(@as(f64, 120.0), try testEval("factorial(5)"));
try testing.expectEqual(@as(f64, 12.0), try testEval("sqrt(144)"));
} }
test "eval acos" { test "eval acos" {

View file

@ -132,17 +132,20 @@ pub fn formatNumber(allocator: std.mem.Allocator, value: Number) !NumberDisplay
// Very long values are abbreviated for display only. The `raw` // Very long values are abbreviated for display only. The `raw`
// (clipboard) form always keeps every digit, so the exact value is // (clipboard) form always keeps every digit, so the exact value is
// never actually lost, just not shown inline. // never actually lost, just not shown inline.
//
// Both this and the small-magnitude case below go through
// `toScientificString`. There used to be a second renderer here that
// worked on the already-rendered text; two implementations of the same
// notation is one more than needed, and the rational-based one handles
// both ends of the range.
if (integerDigitCount(rendered.text) > max_display_integer_digits) { if (integerDigitCount(rendered.text) > max_display_integer_digits) {
const display = try scientificFromDecimalText(allocator, rendered.text); const display = try r.toScientificString(allocator, scientific_significant_digits);
return .{ .display = display, .raw = rendered.text, .exact = false }; return .{ .display = display, .raw = rendered.text, .exact = false };
} }
// The other end of the same problem: a value smaller than the // The other end of the same problem: a value smaller than the
// fractional budget renders as all zeros, which destroys it in the // fractional budget renders as all zeros, which destroys it in the
// clipboard as well as on screen (2^-70 printed as 0.00000...). // clipboard as well as on screen (2^-70 printed as 0.00000...).
// Scientific notation is the only honest rendering, and it has to come
// from the rational rather than from this text, which has no digits
// left in it.
if (!isZeroText(rendered.text)) { if (!isZeroText(rendered.text)) {
// Group the integer part for readability; the raw form stays plain. // Group the integer part for readability; the raw form stays plain.
const display = try groupDecimalText(allocator, rendered.text); const display = try groupDecimalText(allocator, rendered.text);
@ -188,75 +191,34 @@ pub const max_display_integer_digits: usize = 40;
/// Significant digits kept when abbreviating to scientific notation. /// Significant digits kept when abbreviating to scientific notation.
const scientific_significant_digits: usize = 17; const scientific_significant_digits: usize = 17;
/// Count digits before the decimal point, ignoring sign. /// The three parts of decimal text: an optional sign, the integer digits, and
fn integerDigitCount(text: []const u8) usize { /// everything from the decimal point onward.
var start: usize = 0; ///
if (text.len > 0 and (text[0] == '-' or text[0] == '+')) start = 1; /// One place that knows how to take decimal text apart. `integerDigitCount` and
/// `writeGroupedDecimal` each used to work it out themselves, which is two chances
/// to disagree about where the sign ends.
const DecimalParts = struct {
/// Length of the sign, 0 or 1.
sign_len: usize,
/// Number of digits before the decimal point.
int_digits: usize,
/// The decimal point and fractional digits, empty for an integer.
tail: []const u8,
};
fn splitDecimalText(text: []const u8) DecimalParts {
const sign_len: usize = if (text.len > 0 and (text[0] == '-' or text[0] == '+')) 1 else 0;
const dot = std.mem.indexOfScalar(u8, text, '.') orelse text.len; const dot = std.mem.indexOfScalar(u8, text, '.') orelse text.len;
return dot - start; return .{
.sign_len = sign_len,
.int_digits = dot - sign_len,
.tail = text[dot..],
};
} }
/// Render decimal text in scientific notation, rounding the mantissa. /// Count digits before the decimal point, ignoring sign.
/// fn integerDigitCount(text: []const u8) usize {
/// Only called for values with more integer digits than the display cap, so the return splitDecimalText(text).int_digits;
/// exponent is always large and positive; no denormal or leading-zero handling
/// is needed.
fn scientificFromDecimalText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
var start: usize = 0;
var negative = false;
if (text.len > 0 and (text[0] == '-' or text[0] == '+')) {
negative = text[0] == '-';
start = 1;
}
const dot = std.mem.indexOfScalar(u8, text, '.') orelse text.len;
const int_digits = text[start..dot];
std.debug.assert(int_digits.len > scientific_significant_digits);
const exponent = int_digits.len - 1;
// Copy one extra digit so the mantissa can be rounded half-up.
var digits: [scientific_significant_digits + 1]u8 = undefined;
@memcpy(&digits, int_digits[0 .. scientific_significant_digits + 1]);
var kept = digits[0..scientific_significant_digits];
if (digits[scientific_significant_digits] >= '5') {
var i = kept.len;
var carried = true;
while (i > 0 and carried) {
i -= 1;
if (kept[i] == '9') {
kept[i] = '0';
} else {
kept[i] += 1;
carried = false;
}
}
// Rounding 999... up to 1000... shifts the exponent, e.g. 9.99e9 -> 1e10.
if (carried) {
return std.fmt.allocPrint(allocator, "{s}1e{d}", .{
if (negative) "-" else "",
exponent + 1,
});
}
}
// Trim trailing zeros from the fractional part of the mantissa.
var frac_end = kept.len;
while (frac_end > 1 and kept[frac_end - 1] == '0') frac_end -= 1;
if (frac_end == 1) {
return std.fmt.allocPrint(allocator, "{s}{c}e{d}", .{
if (negative) "-" else "",
kept[0],
exponent,
});
}
return std.fmt.allocPrint(allocator, "{s}{c}.{s}e{d}", .{
if (negative) "-" else "",
kept[0],
kept[1..frac_end],
exponent,
});
} }
pub const NumberDisplay = struct { pub const NumberDisplay = struct {
@ -284,6 +246,43 @@ fn groupDecimalText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
return out; return out;
} }
/// Format a value as an amount: grouped integer part, exactly `decimals` places.
///
/// The single implementation of this. It existed three times before: character for
/// character in `src/main.zig` and `src/tui/financial.zig`, both of which also
/// reimplemented the comma grouping that lives a few lines below here.
///
/// Returns null rather than a placeholder when the result does not fit `buf`. The
/// copies returned the string "?", so `tally amort 1e40 0.5 3` printed a full table
/// of question marks and exited 0. A caller that cannot format a number should say
/// so, not render one.
pub fn formatAmount(buf: []u8, value: f64, decimals: u8) ?[]const u8 {
if (!std.math.isFinite(value)) return null;
// Enough for f64's widest fixed-point rendering (about 310 integer digits)
// plus separators and a fractional part.
var plain: [400]u8 = undefined;
const text = switch (decimals) {
0 => std.fmt.bufPrint(&plain, "{d:.0}", .{value}),
1 => std.fmt.bufPrint(&plain, "{d:.1}", .{value}),
2 => std.fmt.bufPrint(&plain, "{d:.2}", .{value}),
else => std.fmt.bufPrint(&plain, "{d:.6}", .{value}),
} catch return null;
const needed = groupedDecimalLen(text);
if (needed > buf.len) return null;
if (needed == text.len) {
@memcpy(buf[0..text.len], text);
return buf[0..text.len];
}
return buf[0..writeGroupedDecimal(buf, text)];
}
/// `formatAmount` at two decimal places, the money case.
pub fn formatMoney(buf: []u8, value: f64) ?[]const u8 {
return formatAmount(buf, value, 2);
}
/// Bytes `writeGroupedDecimal` will produce for `text`. Equal to `text.len` when /// Bytes `writeGroupedDecimal` will produce for `text`. Equal to `text.len` when
/// there is nothing to group, which callers use to skip the copy entirely. /// there is nothing to group, which callers use to skip the copy entirely.
/// ///
@ -302,10 +301,9 @@ pub fn groupedDecimalLen(text: []const u8) usize {
/// Copy `text` into `dest` with commas grouping the integer part. `dest` must be /// Copy `text` into `dest` with commas grouping the integer part. `dest` must be
/// at least `groupedDecimalLen(text)` bytes and must not overlap `text`. /// at least `groupedDecimalLen(text)` bytes and must not overlap `text`.
pub fn writeGroupedDecimal(dest: []u8, text: []const u8) usize { pub fn writeGroupedDecimal(dest: []u8, text: []const u8) usize {
var start: usize = 0; const parts = splitDecimalText(text);
if (text.len > 0 and (text[0] == '-' or text[0] == '+')) start = 1; const start = parts.sign_len;
const dot = std.mem.indexOfScalar(u8, text, '.') orelse text.len; const int_digits = parts.int_digits;
const int_digits = dot - start;
@memcpy(dest[0..start], text[0..start]); @memcpy(dest[0..start], text[0..start]);
var w: usize = start; var w: usize = start;
@ -319,9 +317,8 @@ pub fn writeGroupedDecimal(dest: []u8, text: []const u8) usize {
dest[w] = text[start + i]; dest[w] = text[start + i];
w += 1; w += 1;
} }
const tail = text[dot..]; @memcpy(dest[w..][0..parts.tail.len], parts.tail);
@memcpy(dest[w..][0..tail.len], tail); return w + parts.tail.len;
return w + tail.len;
} }
/// Format an integer for programmer mode hex display. /// Format an integer for programmer mode hex display.
@ -569,33 +566,15 @@ fn absoluteValue(value: i128) u128 {
return if (value < 0) ~bits +% 1 else bits; return if (value < 0) ~bits +% 1 else bits;
} }
/// Write `value` with comma grouping, reusing the text grouper.
///
/// This used to be a second grouping implementation: it built the digits in reverse
/// and inserted separators itself, so the codebase had two places that knew what a
/// thousands group is. Writing the plain digits and then grouping them keeps one.
fn writeUnsignedWithCommas(buf: []u8, value: u128) usize { fn writeUnsignedWithCommas(buf: []u8, value: u128) usize {
if (value == 0) { var plain: [40]u8 = undefined;
buf[0] = '0'; const digits = plain[0..writeUnsignedInt(&plain, value)];
return 1; return writeGroupedDecimal(buf, digits);
}
var digits: [39]u8 = undefined;
var count: usize = 0;
var v = value;
while (v > 0) : (v /= 10) {
digits[count] = @intCast(v % 10);
count += 1;
}
// digits[0] is least significant, digits[count-1] is most significant
// Write most significant first, inserting commas every 3 from the right
var pos: usize = 0;
var i: usize = count;
while (i > 0) {
i -= 1;
buf[pos] = '0' + digits[i];
pos += 1;
// Insert comma if there are more digits and position from right is multiple of 3
if (i > 0 and i % 3 == 0) {
buf[pos] = ',';
pos += 1;
}
}
return pos;
} }
fn writeDecimalWithCommas(buf: []u8, value: i128) usize { fn writeDecimalWithCommas(buf: []u8, value: i128) usize {
@ -1106,18 +1085,26 @@ test "formatNumber: 9007199254740993 is above NFR-7's f64 bound but must print i
try testing.expectEqualStrings("9007199254740993", shown.raw); try testing.expectEqualStrings("9007199254740993", shown.raw);
} }
test "scientificFromDecimalText: mantissa trimming and exponents" { test "abbreviated huge values go through the same renderer as tiny ones" {
// This case used to have its own text-based renderer. Both ends of the range
// now use Rational.toScientificString, so this checks the shared path from the
// formatter's side: mantissa trimming, sign, and the exponent.
const alloc = testing.allocator; const alloc = testing.allocator;
const cases = [_][2][]const u8{ const cases = [_][2][]const u8{
// 41 digits so the cap is exceeded in every case. // 41 digits, one past max_display_integer_digits.
.{ "10000000000000000000000000000000000000000", "1e40" }, .{ "10000000000000000000000000000000000000000", "1e40" },
.{ "12000000000000000000000000000000000000000", "1.2e40" }, .{ "12000000000000000000000000000000000000000", "1.2e40" },
.{ "-25000000000000000000000000000000000000000", "-2.5e40" }, .{ "-25000000000000000000000000000000000000000", "-2.5e40" },
}; };
for (cases) |c| { for (cases) |c| {
const got = try scientificFromDecimalText(alloc, c[0]); var value = try Number.parse(alloc, c[0]);
defer alloc.free(got); defer value.deinit();
try testing.expectEqualStrings(c[1], got); const shown = try formatNumber(alloc, value);
defer shown.deinit(alloc);
try testing.expectEqualStrings(c[1], shown.display);
// The clipboard form still carries every digit.
try testing.expectEqualStrings(c[0], shown.raw);
try testing.expect(!shown.exact);
} }
} }
@ -1351,3 +1338,154 @@ test "formatDecimalSigned: the rest of the signed range still formats" {
formatDecimalSigned(&buf, std.math.maxInt(i128)).display, formatDecimalSigned(&buf, std.math.maxInt(i128)).display,
); );
} }
// -- One amount formatter --
//
// This logic existed three times: character for character in src/main.zig and
// src/tui/financial.zig, each reimplementing the comma grouping that already lived
// in this file. Both copies also returned the string "?" when the buffer was too
// small, so `tally amort 1e40 0.5 3` printed a table of question marks and exited 0.
test "formatMoney: grouping, sign and two decimals" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0).?);
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1).?);
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1).?);
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000).?);
try testing.expectEqualStrings("231,677.04", formatMoney(&buf, 231677.04).?);
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89).?);
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1).?);
try testing.expectEqualStrings("-0.01", formatMoney(&buf, -0.01).?);
}
test "formatMoney: rounds to the cent" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.101050305518).?);
try testing.expectEqualStrings("2.00", formatMoney(&buf, 1.995).?);
}
test "formatMoney: reports failure instead of a placeholder" {
var tiny: [4]u8 = undefined;
try testing.expect(formatMoney(&tiny, 1234567.89) == null);
// Non-finite values have no amount rendering at all.
var buf: [64]u8 = undefined;
try testing.expect(formatMoney(&buf, std.math.inf(f64)) == null);
try testing.expect(formatMoney(&buf, std.math.nan(f64)) == null);
}
test "formatMoney: very large amounts render or fail cleanly, never partially" {
var buf: [64]u8 = undefined;
// 1e40 needs 41 integer digits, 13 separators and cents: 57 bytes, so it fits.
const forty = formatMoney(&buf, 1e40).?;
try testing.expectEqual(@as(usize, 57), forty.len);
try testing.expect(std.mem.startsWith(u8, forty, "10,000,000,000"));
try testing.expect(std.mem.endsWith(u8, forty, ".00"));
// 1e300 needs 404 bytes, so the same buffer must refuse rather than truncate.
try testing.expect(formatMoney(&buf, 1e300) == null);
var wide: [512]u8 = undefined;
const huge = formatMoney(&wide, 1e300).?;
try testing.expect(std.mem.endsWith(u8, huge, ".00"));
}
test "formatAmount: other decimal counts" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("1,000", formatAmount(&buf, 1000.4, 0).?);
try testing.expectEqualStrings("1,000.4", formatAmount(&buf, 1000.44, 1).?);
try testing.expectEqualStrings("1,000.44", formatAmount(&buf, 1000.44, 2).?);
}
test "formatMoney: agrees with the grouping used for ordinary results" {
// The whole point of collapsing these: an amount and a plain result group the
// same way.
var money_buf: [64]u8 = undefined;
var value_buf: [256]u8 = undefined;
const as_money = formatMoney(&money_buf, 231677).?;
const as_value = formatFloat(&value_buf, 231677).display;
try testing.expectEqualStrings("231,677.00", as_money);
try testing.expectEqualStrings("231,677", as_value);
// Same separators, differing only in the fixed decimal places.
try testing.expect(std.mem.startsWith(u8, as_money, as_value));
}
// -- One grouping implementation --
//
// Grouping existed twice: once over text (groupedDecimalLen/writeGroupedDecimal) and
// once over integers (writeUnsignedWithCommas built digits in reverse and inserted
// its own separators). The integer path now writes plain digits and groups them, so
// there is a single definition of what a thousands group is.
test "integer and text grouping agree on every width" {
var integer_buf: [512]u8 = undefined;
var text_buf: [512]u8 = undefined;
var plain_buf: [64]u8 = undefined;
const values = [_]u128{
0, 1,
9, 10,
99, 100,
999, 1000,
1001, 12345,
999999, 1000000,
123456789, std.math.maxInt(u64),
std.math.maxInt(u128), 4294967295,
3735928559, 1180591620717411303424,
};
for (values) |value| {
const grouped = integer_buf[0..writeUnsignedWithCommas(&integer_buf, value)];
// Independently: render the digits, then group the text.
const plain = plain_buf[0..writeUnsignedInt(&plain_buf, value)];
const via_text = text_buf[0..writeGroupedDecimal(&text_buf, plain)];
try testing.expectEqualStrings(via_text, grouped);
}
}
test "integer grouping: known shapes" {
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("0", buf[0..writeUnsignedWithCommas(&buf, 0)]);
try testing.expectEqualStrings("100", buf[0..writeUnsignedWithCommas(&buf, 100)]);
try testing.expectEqualStrings("1,000", buf[0..writeUnsignedWithCommas(&buf, 1000)]);
try testing.expectEqualStrings("4,294,967,295", buf[0..writeUnsignedWithCommas(&buf, 4294967295)]);
try testing.expectEqualStrings(
"340,282,366,920,938,463,463,374,607,431,768,211,455",
buf[0..writeUnsignedWithCommas(&buf, std.math.maxInt(u128))],
);
}
test "signed integer grouping keeps the sign outside the groups" {
var buf: [128]u8 = undefined;
try testing.expectEqualStrings("-1,234", buf[0..writeDecimalWithCommas(&buf, -1234)]);
try testing.expectEqualStrings("-1", buf[0..writeDecimalWithCommas(&buf, -1)]);
try testing.expectEqualStrings("0", buf[0..writeDecimalWithCommas(&buf, 0)]);
try testing.expectEqualStrings(
"-170,141,183,460,469,231,731,687,303,715,884,105,728",
buf[0..writeDecimalWithCommas(&buf, std.math.minInt(i128))],
);
}
test "splitDecimalText: one place that takes decimal text apart" {
const unsigned = splitDecimalText("1234.56");
try testing.expectEqual(@as(usize, 0), unsigned.sign_len);
try testing.expectEqual(@as(usize, 4), unsigned.int_digits);
try testing.expectEqualStrings(".56", unsigned.tail);
const negative = splitDecimalText("-1234.56");
try testing.expectEqual(@as(usize, 1), negative.sign_len);
try testing.expectEqual(@as(usize, 4), negative.int_digits);
try testing.expectEqualStrings(".56", negative.tail);
const integer = splitDecimalText("-70");
try testing.expectEqual(@as(usize, 1), integer.sign_len);
try testing.expectEqual(@as(usize, 2), integer.int_digits);
try testing.expectEqualStrings("", integer.tail);
const explicit_plus = splitDecimalText("+5");
try testing.expectEqual(@as(usize, 1), explicit_plus.sign_len);
try testing.expectEqual(@as(usize, 1), explicit_plus.int_digits);
const empty = splitDecimalText("");
try testing.expectEqual(@as(usize, 0), empty.sign_len);
try testing.expectEqual(@as(usize, 0), empty.int_digits);
}

View file

@ -21,6 +21,25 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const rational = @import("rational.zig"); const rational = @import("rational.zig");
const Rational = rational.Rational; const Rational = rational.Rational;
const CalcError = @import("types.zig").CalcError;
/// Map a numeric-model error onto the engine's error set.
///
/// Lives here so there is one mapping. The evaluator and the unit converter each
/// had their own copy, which is how they came to disagree: one turned
/// `ExponentTooLarge` into `Overflow` and neither knew what to do with a newly
/// added member until the compiler complained in two places.
pub fn toCalcError(err: Error) CalcError {
return switch (err) {
error.OutOfMemory => CalcError.OutOfMemory,
error.DivisionByZero => CalcError.DivisionByZero,
error.InvalidNumber => CalcError.InvalidNumber,
// An exponent too large to compute is an overflow from the caller's view.
error.ExponentTooLarge => CalcError.Overflow,
// The square root of a negative value is outside the domain.
error.NegativeRoot => CalcError.DomainError,
};
}
pub const Error = rational.Error; pub const Error = rational.Error;
@ -220,8 +239,15 @@ pub const Number = union(enum) {
/// Square root. Exact for perfect rational squares (`sqrt(4)` is 2), inexact /// Square root. Exact for perfect rational squares (`sqrt(4)` is 2), inexact
/// otherwise (`sqrt(2)`), per design.md 2.7.4. /// otherwise (`sqrt(2)`), per design.md 2.7.4.
///
/// A negative input is `error.NegativeRoot`. The domain rule lives here rather
/// than in each caller: it used to be checked in three places (here, in
/// `Rational.sqrtExact`, and again in the evaluator, which returned
/// `UnknownFunction` for it so `sqrt(-1)` reported "unknown function"). The
/// float fallback would otherwise return a silent NaN.
pub fn sqrt(allocator: Allocator, a: Number) Error!Number { pub fn sqrt(allocator: Allocator, a: Number) Error!Number {
if (a == .exact and !a.exact.isNegative()) { if (a.isNegative()) return Error.NegativeRoot;
if (a == .exact) {
if (try Rational.sqrtExact(allocator, a.exact)) |root| { if (try Rational.sqrtExact(allocator, a.exact)) |root| {
return capped(allocator, root); return capped(allocator, root);
} }
@ -229,13 +255,6 @@ pub const Number = union(enum) {
return .{ .inexact = @sqrt(a.toFloat(allocator)) }; return .{ .inexact = @sqrt(a.toFloat(allocator)) };
} }
/// Apply a float-only function, always producing an inexact result. This is
/// the single entry point for transcendentals, so the fallback boundary is
/// visible in one place.
pub fn applyFloatFn(allocator: Allocator, a: Number, comptime f: fn (f64) f64) Number {
return .{ .inexact = f(a.toFloat(allocator)) };
}
/// Shape of a unary operation that has an exact implementation. /// Shape of a unary operation that has an exact implementation.
fn unary( fn unary(
allocator: Allocator, allocator: Allocator,
@ -578,26 +597,49 @@ test "sqrt: perfect squares stay exact, others fall back" {
try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), r2.toFloat(alloc), 1e-15); try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), r2.toFloat(alloc), 1e-15);
} }
test "sqrt: negative input falls back to a float NaN" { test "sqrt: a negative input is a domain error, not a silent NaN" {
// This used to return an inexact NaN, and the evaluator separately rejected
// negatives with UnknownFunction, so `sqrt(-1)` reported "unknown function".
// The rule now lives here and nowhere else.
var neg = try Number.fromInt(alloc, -4); var neg = try Number.fromInt(alloc, -4);
defer neg.deinit(); defer neg.deinit();
var r = try Number.sqrt(alloc, neg); try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, neg));
defer r.deinit();
try testing.expect(!r.isExact()); var inexact_neg = Number.fromFloat(-4.0);
try testing.expect(std.math.isNan(r.toFloat(alloc))); defer inexact_neg.deinit();
try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, inexact_neg));
// Zero and positives are unaffected.
var zero = try Number.fromInt(alloc, 0);
defer zero.deinit();
var root_zero = try Number.sqrt(alloc, zero);
defer root_zero.deinit();
try testing.expect(root_zero.isExact());
} }
test "applyFloatFn always yields inexact" { test "sqrt: the domain error reaches the engine error set as a domain error" {
var one = try Number.fromInt(alloc, 1); var neg = try Number.fromInt(alloc, -4);
defer one.deinit(); defer neg.deinit();
var r = Number.applyFloatFn(alloc, one, floatLog2); try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, neg));
defer r.deinit();
try testing.expect(!r.isExact());
try testing.expectEqual(@as(f64, 0.0), r.toFloat(alloc));
} }
fn floatLog2(x: f64) f64 { test "toCalcError maps every numeric error, with no default" {
return @log2(x); const CalcErr = @import("types.zig").CalcError;
// One mapping for the evaluator and the unit converter, which used to have a
// copy each. Walking the whole set keeps the two tiers of error vocabulary
// lined up: a member added to Error has to be given a CalcError here.
try testing.expectEqual(CalcErr.OutOfMemory, toCalcError(Error.OutOfMemory));
try testing.expectEqual(CalcErr.DivisionByZero, toCalcError(Error.DivisionByZero));
try testing.expectEqual(CalcErr.InvalidNumber, toCalcError(Error.InvalidNumber));
try testing.expectEqual(CalcErr.Overflow, toCalcError(Error.ExponentTooLarge));
try testing.expectEqual(CalcErr.DomainError, toCalcError(Error.NegativeRoot));
inline for (@typeInfo(Error).error_set.?) |field| {
// Every member is handled: this would not compile past an unhandled one,
// and every mapping lands in the engine's error set.
const mapped = toCalcError(@field(Error, field.name));
try testing.expect(@TypeOf(mapped) == CalcErr);
}
} }
test "asExactInt" { test "asExactInt" {

View file

@ -21,10 +21,15 @@ const TokenKind = tokenizer_mod.TokenKind;
const Token = tokenizer_mod.Token; const Token = tokenizer_mod.Token;
const parseNumber = tokenizer_mod.parseNumber; const parseNumber = tokenizer_mod.parseNumber;
const types = @import("types.zig"); const types = @import("types.zig");
const Mode = types.Mode;
const CalcError = types.CalcError; const CalcError = types.CalcError;
/// Precedence levels (higher = tighter binding). /// Precedence levels (higher = tighter binding).
///
/// `assignment` and `call` are part of the table but are never returned by
/// `infixPrecedence`: assignment is recognized in prefix position (`X = expr`) and
/// a call is part of a primary, so neither goes through the precedence climb. They
/// stay here because the table is what documents the language's binding order, and
/// removing them would leave misleading gaps in the numbering.
const Prec = enum(u8) { const Prec = enum(u8) {
none = 0, none = 0,
assignment = 1, // = assignment = 1, // =
@ -43,11 +48,7 @@ pub const Parser = struct {
source: []const u8, source: []const u8,
tokenizer: Tokenizer, tokenizer: Tokenizer,
current: Token, current: Token,
previous: Token,
mode: Mode,
allocator: Allocator, allocator: Allocator,
had_error: bool,
error_pos: ?usize,
/// Nodes built so far, checked against `max_nodes`. /// Nodes built so far, checked against `max_nodes`.
node_count: usize, node_count: usize,
/// Current parseExpr/parsePrefix nesting, checked against `max_nest_depth`. /// Current parseExpr/parsePrefix nesting, checked against `max_nest_depth`.
@ -67,18 +68,19 @@ pub const Parser = struct {
/// Reached long before `max_nodes` by input like `((((...1...))))`. /// Reached long before `max_nodes` by input like `((((...1...))))`.
pub const max_nest_depth: usize = 128; pub const max_nest_depth: usize = 128;
pub fn init(allocator: Allocator, source: []const u8, mode: Mode) Parser { /// The grammar does not depend on the mode: the same source parses to the same
var tok = Tokenizer.init(source, mode); /// tree in standard and programmer mode, and only evaluation differs. `init`
/// used to take a `Mode` and store it, along with `previous`, `had_error` and
/// `error_pos`; nothing ever read any of them. Errors are reported by returning
/// them, not by leaving a flag behind.
pub fn init(allocator: Allocator, source: []const u8) Parser {
var tok = Tokenizer.init(source);
const first = tok.next(); const first = tok.next();
return .{ return .{
.source = source, .source = source,
.tokenizer = tok, .tokenizer = tok,
.current = first, .current = first,
.previous = .{ .kind = .eof, .start = 0, .len = 0 },
.mode = mode,
.allocator = allocator, .allocator = allocator,
.had_error = false,
.error_pos = null,
.node_count = 0, .node_count = 0,
.nest_depth = 0, .nest_depth = 0,
}; };
@ -96,8 +98,6 @@ pub const Parser = struct {
if (self.current.kind != .eof) { if (self.current.kind != .eof) {
// Trailing tokens: the tree parsed so far is unreachable. // Trailing tokens: the tree parsed so far is unreachable.
freeExpr(self.allocator, expr); freeExpr(self.allocator, expr);
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.UnexpectedToken; return CalcError.UnexpectedToken;
} }
return expr; return expr;
@ -198,8 +198,6 @@ pub const Parser = struct {
} }
if (self.current.kind != .right_paren) { if (self.current.kind != .right_paren) {
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.UnmatchedParen; return CalcError.UnmatchedParen;
} }
self.advance(); // consume ) self.advance(); // consume )
@ -224,8 +222,6 @@ pub const Parser = struct {
const inner = try self.parseExpr(.none); const inner = try self.parseExpr(.none);
if (self.current.kind != .right_paren) { if (self.current.kind != .right_paren) {
freeExpr(self.allocator, inner); freeExpr(self.allocator, inner);
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.UnmatchedParen; return CalcError.UnmatchedParen;
} }
self.advance(); // consume ) self.advance(); // consume )
@ -250,13 +246,9 @@ pub const Parser = struct {
} }); } });
}, },
.eof => { .eof => {
self.had_error = true;
self.error_pos = tok.start;
return CalcError.UnexpectedEnd; return CalcError.UnexpectedEnd;
}, },
else => { else => {
self.had_error = true;
self.error_pos = tok.start;
return CalcError.UnexpectedToken; return CalcError.UnexpectedToken;
}, },
} }
@ -285,8 +277,6 @@ pub const Parser = struct {
self.advance(); self.advance();
const op = self.tokenToBinaryOp(tok.kind) orelse { const op = self.tokenToBinaryOp(tok.kind) orelse {
self.had_error = true;
self.error_pos = tok.start;
return CalcError.UnexpectedToken; return CalcError.UnexpectedToken;
}; };
@ -360,15 +350,12 @@ pub const Parser = struct {
} }
fn advance(self: *Parser) void { fn advance(self: *Parser) void {
self.previous = self.current;
self.current = self.tokenizer.next(); self.current = self.tokenizer.next();
} }
fn makeNode(self: *Parser, expr: Expr) CalcError!*Expr { fn makeNode(self: *Parser, expr: Expr) CalcError!*Expr {
// Budget checked here so every construction site is covered by one test. // Budget checked here so every construction site is covered by one test.
if (self.node_count >= max_nodes) { if (self.node_count >= max_nodes) {
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.InvalidExpression; return CalcError.InvalidExpression;
} }
self.node_count += 1; self.node_count += 1;
@ -387,14 +374,14 @@ const testing = std.testing;
// arena helper is kept for the tests already written against it. // arena helper is kept for the tests already written against it.
var test_arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator); var test_arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator);
fn testParse(source: []const u8, mode: Mode) !*Expr { fn testParse(source: []const u8) !*Expr {
var parser = Parser.init(testing.allocator, source, mode); var parser = Parser.init(testing.allocator, source);
return parser.parse(); return parser.parse();
} }
fn testParseArena(source: []const u8, mode: Mode) CalcError!*Expr { fn testParseArena(source: []const u8) CalcError!*Expr {
const alloc = test_arena_instance.allocator(); const alloc = test_arena_instance.allocator();
var p = Parser.init(alloc, source, mode); var p = Parser.init(alloc, source);
return p.parse(); return p.parse();
} }
@ -423,20 +410,20 @@ pub fn freeExpr(allocator: Allocator, expr: *Expr) void {
} }
test "parse simple number" { test "parse simple number" {
const expr = try testParse("42", .standard); const expr = try testParse("42");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(@as(f64, 42.0), expr.number.float_value); try testing.expectEqual(@as(f64, 42.0), expr.number.float_value);
try testing.expectEqual(@as(?u64, 42), expr.number.int_value); try testing.expectEqual(@as(?u64, 42), expr.number.int_value);
} }
test "parse hex number" { test "parse hex number" {
const expr = try testParse("0xFF", .programmer); const expr = try testParse("0xFF");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(@as(?u64, 255), expr.number.int_value); try testing.expectEqual(@as(?u64, 255), expr.number.int_value);
} }
test "parse addition" { test "parse addition" {
const expr = try testParse("2 + 3", .standard); const expr = try testParse("2 + 3");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op); try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value); try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
@ -445,7 +432,7 @@ test "parse addition" {
test "parse precedence: mul before add" { test "parse precedence: mul before add" {
// 2 + 3 * 4 should parse as 2 + (3 * 4) // 2 + 3 * 4 should parse as 2 + (3 * 4)
const expr = try testParse("2 + 3 * 4", .standard); const expr = try testParse("2 + 3 * 4");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op); try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value); try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
@ -454,7 +441,7 @@ test "parse precedence: mul before add" {
test "parse precedence: power right-associative" { test "parse precedence: power right-associative" {
// 2^3^4 should parse as 2^(3^4) // 2^3^4 should parse as 2^(3^4)
const expr = try testParse("2^3^4", .standard); const expr = try testParse("2^3^4");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op); try testing.expectEqual(BinaryOp.pow, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value); try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
@ -462,7 +449,7 @@ test "parse precedence: power right-associative" {
} }
test "parse unary negation" { test "parse unary negation" {
const expr = try testParse("-5", .standard); const expr = try testParse("-5");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(UnaryOp.negate, expr.unary.op); try testing.expectEqual(UnaryOp.negate, expr.unary.op);
try testing.expectEqual(@as(f64, 5.0), expr.unary.operand.number.float_value); try testing.expectEqual(@as(f64, 5.0), expr.unary.operand.number.float_value);
@ -470,7 +457,7 @@ test "parse unary negation" {
test "parse negation in expression" { test "parse negation in expression" {
// -2 + 3 should be (-2) + 3 // -2 + 3 should be (-2) + 3
const expr = try testParse("-2 + 3", .standard); const expr = try testParse("-2 + 3");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op); try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqual(UnaryOp.negate, expr.binary.left.unary.op); try testing.expectEqual(UnaryOp.negate, expr.binary.left.unary.op);
@ -478,14 +465,14 @@ test "parse negation in expression" {
test "parse parentheses" { test "parse parentheses" {
// (2 + 3) * 4 // (2 + 3) * 4
const expr = try testParse("(2 + 3) * 4", .standard); const expr = try testParse("(2 + 3) * 4");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.mul, expr.binary.op); try testing.expectEqual(BinaryOp.mul, expr.binary.op);
try testing.expectEqual(BinaryOp.add, expr.binary.left.binary.op); try testing.expectEqual(BinaryOp.add, expr.binary.left.binary.op);
} }
test "parse function call" { test "parse function call" {
const expr = try testParse("sin(3.14)", .standard); const expr = try testParse("sin(3.14)");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("sin", expr.call.name); try testing.expectEqualStrings("sin", expr.call.name);
try testing.expectEqual(@as(usize, 1), expr.call.args.len); try testing.expectEqual(@as(usize, 1), expr.call.args.len);
@ -493,20 +480,20 @@ test "parse function call" {
} }
test "parse multi-arg function call" { test "parse multi-arg function call" {
const expr = try testParse("max(1, 2, 3)", .standard); const expr = try testParse("max(1, 2, 3)");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("max", expr.call.name); try testing.expectEqualStrings("max", expr.call.name);
try testing.expectEqual(@as(usize, 3), expr.call.args.len); try testing.expectEqual(@as(usize, 3), expr.call.args.len);
} }
test "parse variable" { test "parse variable" {
const expr = try testParse("pi", .standard); const expr = try testParse("pi");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("pi", expr.variable); try testing.expectEqualStrings("pi", expr.variable);
} }
test "parse assignment" { test "parse assignment" {
const expr = try testParse("X = 42", .standard); const expr = try testParse("X = 42");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("X", expr.assignment.name); try testing.expectEqualStrings("X", expr.assignment.name);
try testing.expectEqual(@as(f64, 42.0), expr.assignment.value.number.float_value); try testing.expectEqual(@as(f64, 42.0), expr.assignment.value.number.float_value);
@ -514,66 +501,66 @@ test "parse assignment" {
test "parse adjacent number and identifier is an error (no implicit mul)" { test "parse adjacent number and identifier is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("2pi", .standard); const result = testParseArena("2pi");
try testing.expectError(CalcError.UnexpectedToken, result); try testing.expectError(CalcError.UnexpectedToken, result);
} }
test "parse adjacent number and paren is an error (no implicit mul)" { test "parse adjacent number and paren is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("3(4+5)", .standard); const result = testParseArena("3(4+5)");
try testing.expectError(CalcError.UnexpectedToken, result); try testing.expectError(CalcError.UnexpectedToken, result);
} }
test "parse adjacent paren paren is an error (no implicit mul)" { test "parse adjacent paren paren is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("(2)(3)", .standard); const result = testParseArena("(2)(3)");
try testing.expectError(CalcError.UnexpectedToken, result); try testing.expectError(CalcError.UnexpectedToken, result);
} }
test "parse caret is power in programmer mode (not XOR)" { test "parse caret is power in programmer mode (not XOR)" {
const expr = try testParse("0xF ^ 0x3", .programmer); const expr = try testParse("0xF ^ 0x3");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op); try testing.expectEqual(BinaryOp.pow, expr.binary.op);
} }
test "parse xor keyword is XOR" { test "parse xor keyword is XOR" {
const expr = try testParse("0xF xor 0x3", .programmer); const expr = try testParse("0xF xor 0x3");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_xor, expr.binary.op); try testing.expectEqual(BinaryOp.bit_xor, expr.binary.op);
} }
test "parse and keyword" { test "parse and keyword" {
const expr = try testParse("0xF and 0x3", .programmer); const expr = try testParse("0xF and 0x3");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_and, expr.binary.op); try testing.expectEqual(BinaryOp.bit_and, expr.binary.op);
} }
test "parse or keyword" { test "parse or keyword" {
const expr = try testParse("0xF or 0x3", .programmer); const expr = try testParse("0xF or 0x3");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_or, expr.binary.op); try testing.expectEqual(BinaryOp.bit_or, expr.binary.op);
} }
test "parse not prefix keyword" { test "parse not prefix keyword" {
const expr = try testParse("not 0xFF", .programmer); const expr = try testParse("not 0xFF");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(UnaryOp.bitwise_not, expr.unary.op); try testing.expectEqual(UnaryOp.bitwise_not, expr.unary.op);
} }
test "parse caret as power in standard mode" { test "parse caret as power in standard mode" {
const expr = try testParse("2 ^ 10", .standard); const expr = try testParse("2 ^ 10");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op); try testing.expectEqual(BinaryOp.pow, expr.binary.op);
} }
test "parse ** as power in programmer mode" { test "parse ** as power in programmer mode" {
const expr = try testParse("2 ** 10", .programmer); const expr = try testParse("2 ** 10");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op); try testing.expectEqual(BinaryOp.pow, expr.binary.op);
} }
test "parse bitwise operators" { test "parse bitwise operators" {
const expr = try testParse("0xF & 0x3 | 0x1", .programmer); const expr = try testParse("0xF & 0x3 | 0x1");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
// | has lowest precedence of these, so: (0xF & 0x3) | 0x1 // | has lowest precedence of these, so: (0xF & 0x3) | 0x1
try testing.expectEqual(BinaryOp.bit_or, expr.binary.op); try testing.expectEqual(BinaryOp.bit_or, expr.binary.op);
@ -581,46 +568,46 @@ test "parse bitwise operators" {
} }
test "parse shift operators" { test "parse shift operators" {
const expr = try testParse("1 << 4", .programmer); const expr = try testParse("1 << 4");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.shift_left, expr.binary.op); try testing.expectEqual(BinaryOp.shift_left, expr.binary.op);
} }
test "parse bitwise not" { test "parse bitwise not" {
const expr = try testParse("~0xFF", .programmer); const expr = try testParse("~0xFF");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(UnaryOp.bitwise_not, expr.unary.op); try testing.expectEqual(UnaryOp.bitwise_not, expr.unary.op);
} }
test "parse error: unmatched paren" { test "parse error: unmatched paren" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("(2 + 3", .standard); const result = testParseArena("(2 + 3");
try testing.expectError(CalcError.UnmatchedParen, result); try testing.expectError(CalcError.UnmatchedParen, result);
} }
test "parse error: unexpected token" { test "parse error: unexpected token" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("+ +", .standard); const result = testParseArena("+ +");
// + at start is not a valid prefix // + at start is not a valid prefix
try testing.expectError(CalcError.UnexpectedToken, result); try testing.expectError(CalcError.UnexpectedToken, result);
} }
test "parse error: empty expression" { test "parse error: empty expression" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("", .standard); const result = testParseArena("");
try testing.expectError(CalcError.UnexpectedEnd, result); try testing.expectError(CalcError.UnexpectedEnd, result);
} }
test "parse complex expression" { test "parse complex expression" {
// sin(2*pi) + 1 // sin(2*pi) + 1
const expr = try testParse("sin(2*pi) + 1", .standard); const expr = try testParse("sin(2*pi) + 1");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op); try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqualStrings("sin", expr.binary.left.call.name); try testing.expectEqualStrings("sin", expr.binary.left.call.name);
} }
test "parse nested function calls" { test "parse nested function calls" {
const expr = try testParse("max(sin(1), cos(2))", .standard); const expr = try testParse("max(sin(1), cos(2))");
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("max", expr.call.name); try testing.expectEqualStrings("max", expr.call.name);
try testing.expectEqual(@as(usize, 2), expr.call.args.len); try testing.expectEqual(@as(usize, 2), expr.call.args.len);
@ -630,7 +617,7 @@ test "parse nested function calls" {
test "parse error: unmatched paren in function call args" { test "parse error: unmatched paren in function call args" {
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("max(1, 2", .standard); const result = testParseArena("max(1, 2");
try testing.expectError(CalcError.UnmatchedParen, result); try testing.expectError(CalcError.UnmatchedParen, result);
} }
@ -639,7 +626,7 @@ test "parse error: identifier in infix position (not a keyword op)" {
// a keyword operator, so it has .none precedence. The loop stops and // a keyword operator, so it has .none precedence. The loop stops and
// parse() reports the leftover token as unexpected. // parse() reports the leftover token as unexpected.
defer _ = test_arena_instance.reset(.retain_capacity); defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("5 foo", .standard); const result = testParseArena("5 foo");
try testing.expectError(CalcError.UnexpectedToken, result); try testing.expectError(CalcError.UnexpectedToken, result);
} }
@ -670,7 +657,7 @@ test "a failed parse leaves nothing allocated" {
"-(1 + ", // nested failure under a unary "-(1 + ", // nested failure under a unary
}; };
for (bad) |source| { for (bad) |source| {
var parser = Parser.init(testing.allocator, source, .standard); var parser = Parser.init(testing.allocator, source);
if (parser.parse()) |expr| { if (parser.parse()) |expr| {
freeExpr(testing.allocator, expr); freeExpr(testing.allocator, expr);
std.debug.print("expected a parse error for \"{s}\"\n", .{source}); std.debug.print("expected a parse error for \"{s}\"\n", .{source});
@ -682,7 +669,7 @@ test "a failed parse leaves nothing allocated" {
test "a failed parse in programmer mode also leaves nothing allocated" { test "a failed parse in programmer mode also leaves nothing allocated" {
const bad = [_][]const u8{ "0xFF and", "1 rol", "not", "0b1010 xor (1", "1 << " }; const bad = [_][]const u8{ "0xFF and", "1 rol", "not", "0b1010 xor (1", "1 << " };
for (bad) |source| { for (bad) |source| {
var parser = Parser.init(testing.allocator, source, .programmer); var parser = Parser.init(testing.allocator, source);
if (parser.parse()) |expr| { if (parser.parse()) |expr| {
freeExpr(testing.allocator, expr); freeExpr(testing.allocator, expr);
std.debug.print("expected a parse error for \"{s}\"\n", .{source}); std.debug.print("expected a parse error for \"{s}\"\n", .{source});
@ -705,7 +692,7 @@ test "a successful parse hands over exactly one tree to free" {
"tvm_pmt(360, 0.5, 200000, 0)", "tvm_pmt(360, 0.5, 200000, 0)",
}; };
for (good) |source| { for (good) |source| {
var parser = Parser.init(testing.allocator, source, .standard); var parser = Parser.init(testing.allocator, source);
const expr = try parser.parse(); const expr = try parser.parse();
freeExpr(testing.allocator, expr); freeExpr(testing.allocator, expr);
} }
@ -732,7 +719,7 @@ test "an allocation failure mid-parse frees whatever was built" {
while (fail_index < 64) : (fail_index += 1) { while (fail_index < 64) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index }); var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index });
const allocator = failing.allocator(); const allocator = failing.allocator();
var parser = Parser.init(allocator, source, .standard); var parser = Parser.init(allocator, source);
if (parser.parse()) |expr| { if (parser.parse()) |expr| {
// Past the last allocation this input makes, so nothing is left // Past the last allocation this input makes, so nothing is left
// to fail; the tree itself must still be well formed. // to fail; the tree itself must still be well formed.
@ -764,7 +751,7 @@ test "a tree larger than the node budget is rejected, not built" {
try over.appendSlice(testing.allocator, "1"); try over.appendSlice(testing.allocator, "1");
} }
var parser = Parser.init(testing.allocator, over.items, .standard); var parser = Parser.init(testing.allocator, over.items);
try testing.expectError(CalcError.InvalidExpression, parser.parse()); try testing.expectError(CalcError.InvalidExpression, parser.parse());
// Nothing is left allocated: testing.allocator would report a leak otherwise. // Nothing is left allocated: testing.allocator would report a leak otherwise.
} }
@ -777,7 +764,7 @@ test "an expression within the node budget still parses" {
try ok.appendSlice(testing.allocator, "1"); try ok.appendSlice(testing.allocator, "1");
} }
var parser = Parser.init(testing.allocator, ok.items, .standard); var parser = Parser.init(testing.allocator, ok.items);
const expr = try parser.parse(); const expr = try parser.parse();
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expect(parser.node_count <= Parser.max_nodes); try testing.expect(parser.node_count <= Parser.max_nodes);
@ -790,7 +777,7 @@ test "nesting deeper than the depth limit is rejected" {
try deep.append(testing.allocator, '1'); try deep.append(testing.allocator, '1');
for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')'); for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')');
var parser = Parser.init(testing.allocator, deep.items, .standard); var parser = Parser.init(testing.allocator, deep.items);
try testing.expectError(CalcError.InvalidExpression, parser.parse()); try testing.expectError(CalcError.InvalidExpression, parser.parse());
} }
@ -801,7 +788,7 @@ test "nesting within the depth limit parses" {
try deep.append(testing.allocator, '7'); try deep.append(testing.allocator, '7');
for (0..64) |_| try deep.append(testing.allocator, ')'); for (0..64) |_| try deep.append(testing.allocator, ')');
var parser = Parser.init(testing.allocator, deep.items, .standard); var parser = Parser.init(testing.allocator, deep.items);
const expr = try parser.parse(); const expr = try parser.parse();
defer freeExpr(testing.allocator, expr); defer freeExpr(testing.allocator, expr);
try testing.expectEqual(@as(f64, 7.0), expr.number.float_value); try testing.expectEqual(@as(f64, 7.0), expr.number.float_value);
@ -813,7 +800,7 @@ test "unbalanced deep nesting is rejected without leaking the partial tree" {
for (0..8000) |_| try deep.append(testing.allocator, '('); for (0..8000) |_| try deep.append(testing.allocator, '(');
try deep.append(testing.allocator, '1'); try deep.append(testing.allocator, '1');
var parser = Parser.init(testing.allocator, deep.items, .standard); var parser = Parser.init(testing.allocator, deep.items);
try testing.expect(if (parser.parse()) |_| false else |_| true); try testing.expect(if (parser.parse()) |_| false else |_| true);
} }
@ -824,7 +811,7 @@ test "the depth limit also covers nested calls and unary operators" {
try deep.append(testing.allocator, '4'); try deep.append(testing.allocator, '4');
for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')'); for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')');
var parser = Parser.init(testing.allocator, deep.items, .standard); var parser = Parser.init(testing.allocator, deep.items);
try testing.expectError(CalcError.InvalidExpression, parser.parse()); try testing.expectError(CalcError.InvalidExpression, parser.parse());
var unary = std.ArrayList(u8).empty; var unary = std.ArrayList(u8).empty;
@ -832,6 +819,6 @@ test "the depth limit also covers nested calls and unary operators" {
for (0..Parser.max_nest_depth + 10) |_| try unary.append(testing.allocator, '-'); for (0..Parser.max_nest_depth + 10) |_| try unary.append(testing.allocator, '-');
try unary.append(testing.allocator, '1'); try unary.append(testing.allocator, '1');
var unary_parser = Parser.init(testing.allocator, unary.items, .standard); var unary_parser = Parser.init(testing.allocator, unary.items);
try testing.expectError(CalcError.InvalidExpression, unary_parser.parse()); try testing.expectError(CalcError.InvalidExpression, unary_parser.parse());
} }

View file

@ -168,7 +168,7 @@ fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u128, right: u128)
/// High-level: parse and evaluate a string in programmer mode. /// High-level: parse and evaluate a string in programmer mode.
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: ProgrammerConfig) CalcError!Integer { pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: ProgrammerConfig) CalcError!Integer {
var p = Parser.init(allocator, source, .programmer); var p = Parser.init(allocator, source);
const expr = try p.parse(); const expr = try p.parse();
// Same ownership rule as evalStringInfo: the tree is ours to release, and the // Same ownership rule as evalStringInfo: the tree is ours to release, and the
// returned Integer does not borrow from it. // returned Integer does not borrow from it.

View file

@ -27,6 +27,9 @@ pub const Error = error{
InvalidNumber, InvalidNumber,
/// The exponent of an integer power did not fit the supported range. /// The exponent of an integer power did not fit the supported range.
ExponentTooLarge, ExponentTooLarge,
/// Square root of a negative value. Raised by the numeric model rather than
/// checked by each caller, so the domain rule lives in one place.
NegativeRoot,
}; };
pub const Rational = struct { pub const Rational = struct {

View file

@ -7,7 +7,6 @@
const std = @import("std"); const std = @import("std");
const types = @import("types.zig"); const types = @import("types.zig");
const Mode = types.Mode;
const Base = types.Base; const Base = types.Base;
pub const TokenKind = enum { pub const TokenKind = enum {
@ -135,16 +134,18 @@ pub fn parseNumber(token_text: []const u8) !NumberValue {
// -- Raw Tokenizer -- // -- Raw Tokenizer --
/// Tokenizing is mode-independent: `0xFF`, `<<` and `'A'` are recognized in both
/// standard and programmer mode, and what differs is how the evaluator treats the
/// result (FR-2.12). The tokenizer used to take a `Mode` and store it, which
/// suggested otherwise, and no code ever read it.
pub const Tokenizer = struct { pub const Tokenizer = struct {
source: []const u8, source: []const u8,
pos: usize, pos: usize,
mode: Mode,
pub fn init(source: []const u8, mode: Mode) Tokenizer { pub fn init(source: []const u8) Tokenizer {
return .{ return .{
.source = source, .source = source,
.pos = 0, .pos = 0,
.mode = mode,
}; };
} }
@ -433,7 +434,7 @@ pub const Tokenizer = struct {
const testing = std.testing; const testing = std.testing;
test "tokenize simple arithmetic" { test "tokenize simple arithmetic" {
var tok = Tokenizer.init("2 + 3 * 4", .standard); var tok = Tokenizer.init("2 + 3 * 4");
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.plus, tok.next().kind); try testing.expectEqual(TokenKind.plus, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
@ -443,28 +444,28 @@ test "tokenize simple arithmetic" {
} }
test "tokenize hex number" { test "tokenize hex number" {
var tok = Tokenizer.init("0xFF", .programmer); var tok = Tokenizer.init("0xFF");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0xFF", t.text("0xFF")); try testing.expectEqualStrings("0xFF", t.text("0xFF"));
} }
test "tokenize binary number" { test "tokenize binary number" {
var tok = Tokenizer.init("0b1010", .programmer); var tok = Tokenizer.init("0b1010");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0b1010", t.text("0b1010")); try testing.expectEqualStrings("0b1010", t.text("0b1010"));
} }
test "tokenize octal number" { test "tokenize octal number" {
var tok = Tokenizer.init("0o777", .programmer); var tok = Tokenizer.init("0o777");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0o777", t.text("0o777")); try testing.expectEqualStrings("0o777", t.text("0o777"));
} }
test "tokenize shift operators" { test "tokenize shift operators" {
var tok = Tokenizer.init("x << 3 >> 1 >>> 2", .programmer); var tok = Tokenizer.init("x << 3 >> 1 >>> 2");
try testing.expectEqual(TokenKind.identifier, tok.next().kind); try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.shift_left, tok.next().kind); try testing.expectEqual(TokenKind.shift_left, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
@ -476,28 +477,28 @@ test "tokenize shift operators" {
} }
test "tokenize star_star" { test "tokenize star_star" {
var tok = Tokenizer.init("2**10", .programmer); var tok = Tokenizer.init("2**10");
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.star_star, tok.next().kind); try testing.expectEqual(TokenKind.star_star, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
} }
test "tokenize number with underscores" { test "tokenize number with underscores" {
var tok = Tokenizer.init("1_000_000", .standard); var tok = Tokenizer.init("1_000_000");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("1_000_000", t.text("1_000_000")); try testing.expectEqualStrings("1_000_000", t.text("1_000_000"));
} }
test "tokenize hex with underscores" { test "tokenize hex with underscores" {
var tok = Tokenizer.init("0xFF_FF", .programmer); var tok = Tokenizer.init("0xFF_FF");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0xFF_FF", t.text("0xFF_FF")); try testing.expectEqualStrings("0xFF_FF", t.text("0xFF_FF"));
} }
test "tokenize number with commas" { test "tokenize number with commas" {
var tok = Tokenizer.init("1,000,000", .standard); var tok = Tokenizer.init("1,000,000");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("1,000,000", t.text("1,000,000")); try testing.expectEqualStrings("1,000,000", t.text("1,000,000"));
@ -505,7 +506,7 @@ test "tokenize number with commas" {
} }
test "tokenize hex with spaces" { test "tokenize hex with spaces" {
var tok = Tokenizer.init("0xFF FF FF FF", .programmer); var tok = Tokenizer.init("0xFF FF FF FF");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0xFF FF FF FF", t.text("0xFF FF FF FF")); try testing.expectEqualStrings("0xFF FF FF FF", t.text("0xFF FF FF FF"));
@ -513,7 +514,7 @@ test "tokenize hex with spaces" {
} }
test "tokenize binary with spaces" { test "tokenize binary with spaces" {
var tok = Tokenizer.init("0b1111 0000", .programmer); var tok = Tokenizer.init("0b1111 0000");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0b1111 0000", t.text("0b1111 0000")); try testing.expectEqualStrings("0b1111 0000", t.text("0b1111 0000"));
@ -521,7 +522,7 @@ test "tokenize binary with spaces" {
} }
test "tokenize octal with spaces" { test "tokenize octal with spaces" {
var tok = Tokenizer.init("0o777 111", .programmer); var tok = Tokenizer.init("0o777 111");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0o777 111", t.text("0o777 111")); try testing.expectEqualStrings("0o777 111", t.text("0o777 111"));
@ -529,7 +530,7 @@ test "tokenize octal with spaces" {
} }
test "tokenize base literal space before operator stops" { test "tokenize base literal space before operator stops" {
var tok = Tokenizer.init("0b1010 + 1", .programmer); var tok = Tokenizer.init("0b1010 + 1");
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.plus, tok.next().kind); try testing.expectEqual(TokenKind.plus, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
@ -537,7 +538,7 @@ test "tokenize base literal space before operator stops" {
} }
test "tokenize comma not eaten in function args" { test "tokenize comma not eaten in function args" {
var tok = Tokenizer.init("max(1, 2)", .standard); var tok = Tokenizer.init("max(1, 2)");
try testing.expectEqual(TokenKind.identifier, tok.next().kind); // max try testing.expectEqual(TokenKind.identifier, tok.next().kind); // max
try testing.expectEqual(TokenKind.left_paren, tok.next().kind); // ( try testing.expectEqual(TokenKind.left_paren, tok.next().kind); // (
try testing.expectEqual(TokenKind.number, tok.next().kind); // 1 try testing.expectEqual(TokenKind.number, tok.next().kind); // 1
@ -547,7 +548,7 @@ test "tokenize comma not eaten in function args" {
} }
test "tokenize function call" { test "tokenize function call" {
var tok = Tokenizer.init("sin(3.14)", .standard); var tok = Tokenizer.init("sin(3.14)");
try testing.expectEqual(TokenKind.identifier, tok.next().kind); try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.left_paren, tok.next().kind); try testing.expectEqual(TokenKind.left_paren, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
@ -555,35 +556,35 @@ test "tokenize function call" {
} }
test "tokenize floating point with exponent" { test "tokenize floating point with exponent" {
var tok = Tokenizer.init("1.5e10", .standard); var tok = Tokenizer.init("1.5e10");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("1.5e10", t.text("1.5e10")); try testing.expectEqualStrings("1.5e10", t.text("1.5e10"));
} }
test "tokenize negative exponent" { test "tokenize negative exponent" {
var tok = Tokenizer.init("2.5e-3", .standard); var tok = Tokenizer.init("2.5e-3");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("2.5e-3", t.text("2.5e-3")); try testing.expectEqualStrings("2.5e-3", t.text("2.5e-3"));
} }
test "tokenize number starting with dot" { test "tokenize number starting with dot" {
var tok = Tokenizer.init(".5", .standard); var tok = Tokenizer.init(".5");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings(".5", t.text(".5")); try testing.expectEqualStrings(".5", t.text(".5"));
} }
test "tokenize assignment" { test "tokenize assignment" {
var tok = Tokenizer.init("X = 42", .standard); var tok = Tokenizer.init("X = 42");
try testing.expectEqual(TokenKind.identifier, tok.next().kind); try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.equals, tok.next().kind); try testing.expectEqual(TokenKind.equals, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
} }
test "tokenize all bitwise ops" { test "tokenize all bitwise ops" {
var tok = Tokenizer.init("a & b | c ^ ~d", .programmer); var tok = Tokenizer.init("a & b | c ^ ~d");
try testing.expectEqual(TokenKind.identifier, tok.next().kind); try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.ampersand, tok.next().kind); try testing.expectEqual(TokenKind.ampersand, tok.next().kind);
try testing.expectEqual(TokenKind.identifier, tok.next().kind); try testing.expectEqual(TokenKind.identifier, tok.next().kind);
@ -596,38 +597,38 @@ test "tokenize all bitwise ops" {
} }
test "tokenize empty string" { test "tokenize empty string" {
var tok = Tokenizer.init("", .standard); var tok = Tokenizer.init("");
try testing.expectEqual(TokenKind.eof, tok.next().kind); try testing.expectEqual(TokenKind.eof, tok.next().kind);
} }
test "tokenize whitespace only" { test "tokenize whitespace only" {
var tok = Tokenizer.init(" \t\n ", .standard); var tok = Tokenizer.init(" \t\n ");
try testing.expectEqual(TokenKind.eof, tok.next().kind); try testing.expectEqual(TokenKind.eof, tok.next().kind);
} }
test "tokenize semicolon" { test "tokenize semicolon" {
var tok = Tokenizer.init(";", .standard); var tok = Tokenizer.init(";");
try testing.expectEqual(TokenKind.semicolon, tok.next().kind); try testing.expectEqual(TokenKind.semicolon, tok.next().kind);
} }
test "tokenize bare less-than is invalid" { test "tokenize bare less-than is invalid" {
var tok = Tokenizer.init("<", .programmer); var tok = Tokenizer.init("<");
try testing.expectEqual(TokenKind.invalid, tok.next().kind); try testing.expectEqual(TokenKind.invalid, tok.next().kind);
} }
test "tokenize bare greater-than is invalid" { test "tokenize bare greater-than is invalid" {
var tok = Tokenizer.init(">", .programmer); var tok = Tokenizer.init(">");
try testing.expectEqual(TokenKind.invalid, tok.next().kind); try testing.expectEqual(TokenKind.invalid, tok.next().kind);
} }
test "tokenize lone dot is invalid" { test "tokenize lone dot is invalid" {
var tok = Tokenizer.init(".x", .standard); var tok = Tokenizer.init(".x");
try testing.expectEqual(TokenKind.invalid, tok.next().kind); try testing.expectEqual(TokenKind.invalid, tok.next().kind);
} }
test "tokenize unrecognized character is invalid" { test "tokenize unrecognized character is invalid" {
// '@' is not handled by any dispatch case, so it hits the else branch // '@' is not handled by any dispatch case, so it hits the else branch
var tok = Tokenizer.init("@", .standard); var tok = Tokenizer.init("@");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.invalid, t.kind); try testing.expectEqual(TokenKind.invalid, t.kind);
try testing.expectEqual(@as(usize, 1), t.len); try testing.expectEqual(@as(usize, 1), t.len);
@ -637,7 +638,7 @@ test "tokenize base literal does not take a comma as a separator" {
// Base literals group with spaces and underscores (FR-1.8); commas are the // Base literals group with spaces and underscores (FR-1.8); commas are the
// decimal grouping character. Accepting them here only reintroduced the // decimal grouping character. Accepting them here only reintroduced the
// argument-separator ambiguity in another place. // argument-separator ambiguity in another place.
var tok = Tokenizer.init("0xFF,FF", .programmer); var tok = Tokenizer.init("0xFF,FF");
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0xFF", t.text("0xFF,FF")); try testing.expectEqualStrings("0xFF", t.text("0xFF,FF"));
@ -649,9 +650,9 @@ test "tokenize base literal does not take a comma as a separator" {
} }
test "tokenize base literal still groups with spaces and underscores" { test "tokenize base literal still groups with spaces and underscores" {
var tok = Tokenizer.init("0xFF FF", .programmer); var tok = Tokenizer.init("0xFF FF");
try testing.expectEqualStrings("0xFF FF", tok.next().text("0xFF FF")); try testing.expectEqualStrings("0xFF FF", tok.next().text("0xFF FF"));
var underscored = Tokenizer.init("0xFF_FF", .programmer); var underscored = Tokenizer.init("0xFF_FF");
try testing.expectEqualStrings("0xFF_FF", underscored.next().text("0xFF_FF")); try testing.expectEqualStrings("0xFF_FF", underscored.next().text("0xFF_FF"));
} }
@ -728,7 +729,7 @@ test "parseNumber with commas" {
test "no implicit mul: spaces are just whitespace" { test "no implicit mul: spaces are just whitespace" {
// Spaces between tokens don't create implicit multiplication // Spaces between tokens don't create implicit multiplication
var tok = Tokenizer.init("2 3", .standard); var tok = Tokenizer.init("2 3");
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.eof, tok.next().kind); try testing.expectEqual(TokenKind.eof, tok.next().kind);
@ -745,7 +746,7 @@ test "no implicit mul: spaces are just whitespace" {
test "comma groups digits only in threes" { test "comma groups digits only in threes" {
// Grouped: consumed as one number. // Grouped: consumed as one number.
for ([_][]const u8{ "1,000", "1,234,567", "12,345", "123,456,789" }) |source| { for ([_][]const u8{ "1,000", "1,234,567", "12,345", "123,456,789" }) |source| {
var tok = Tokenizer.init(source, .standard); var tok = Tokenizer.init(source);
const t = tok.next(); const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind); try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings(source, t.text(source)); try testing.expectEqualStrings(source, t.text(source));
@ -758,7 +759,7 @@ test "comma with the wrong number of digits is a separate token" {
// comma. This is what makes `log(100,10)` and `max(1,2)` parse as two // comma. This is what makes `log(100,10)` and `max(1,2)` parse as two
// arguments. // arguments.
for ([_][]const u8{ "100,10", "1,2", "1,00", "1,0000" }) |source| { for ([_][]const u8{ "100,10", "1,2", "1,00", "1,0000" }) |source| {
var tok = Tokenizer.init(source, .standard); var tok = Tokenizer.init(source);
const first = tok.next(); const first = tok.next();
try testing.expectEqual(TokenKind.number, first.kind); try testing.expectEqual(TokenKind.number, first.kind);
try testing.expectEqual(TokenKind.comma, tok.next().kind); try testing.expectEqual(TokenKind.comma, tok.next().kind);
@ -768,14 +769,14 @@ test "comma with the wrong number of digits is a separate token" {
} }
test "comma at the end of input is a separate token" { test "comma at the end of input is a separate token" {
var tok = Tokenizer.init("1,", .standard); var tok = Tokenizer.init("1,");
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.comma, tok.next().kind); try testing.expectEqual(TokenKind.comma, tok.next().kind);
try testing.expectEqual(TokenKind.eof, tok.next().kind); try testing.expectEqual(TokenKind.eof, tok.next().kind);
} }
test "comma followed by a non-digit is a separate token" { test "comma followed by a non-digit is a separate token" {
var tok = Tokenizer.init("max(1, 2)", .standard); var tok = Tokenizer.init("max(1, 2)");
try testing.expectEqual(TokenKind.identifier, tok.next().kind); try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.left_paren, tok.next().kind); try testing.expectEqual(TokenKind.left_paren, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind); try testing.expectEqual(TokenKind.number, tok.next().kind);
@ -787,7 +788,7 @@ test "comma followed by a non-digit is a separate token" {
test "a grouped literal is still exact and keeps its full text" { test "a grouped literal is still exact and keeps its full text" {
// The exact tier re-parses the literal text, so the separators have to remain // The exact tier re-parses the literal text, so the separators have to remain
// in the token for it to see them. // in the token for it to see them.
var tok = Tokenizer.init("9,007,199,254,740,993", .standard); var tok = Tokenizer.init("9,007,199,254,740,993");
const t = tok.next(); const t = tok.next();
try testing.expectEqualStrings("9,007,199,254,740,993", t.text("9,007,199,254,740,993")); try testing.expectEqualStrings("9,007,199,254,740,993", t.text("9,007,199,254,740,993"));
} }

View file

@ -94,13 +94,6 @@ pub const Integer = struct {
} }
}; };
/// Result of any calculation.
pub const Value = union(enum) {
integer: Integer,
float: f64,
boolean: bool,
};
/// Programmer mode configuration. /// Programmer mode configuration.
pub const ProgrammerConfig = struct { pub const ProgrammerConfig = struct {
bit_width: BitWidth = .bits64, bit_width: BitWidth = .bits64,
@ -146,13 +139,50 @@ pub const CalcError = error{
OutOfMemory, OutOfMemory,
}; };
/// Detailed error information with source position. /// The human-readable phrase for an error, with no prefix and no newline.
pub const ErrorInfo = struct { ///
code: CalcError, /// The single source of these strings. The CLI and the TUI each had their own
message: []const u8, /// switch over the same error set, differing only in punctuation and in what they
/// Character position in input where the error occurred (0-indexed). /// had forgotten: the TUI was missing `InsufficientParameters`, `ConvergenceFailure`
position: ?usize = null, /// and `InvalidExpression` and rendered all three as "evaluation error". Callers add
}; /// their own decoration ("error: " and a newline for the CLI, "error: " for the
/// TUI), and a view with better context can still override individual cases, as the
/// financial form does.
pub fn errorPhrase(err: CalcError) []const u8 {
return switch (err) {
// Parser
CalcError.UnexpectedToken => "unexpected token",
CalcError.UnmatchedParen => "unmatched parenthesis",
CalcError.InvalidNumber => "invalid number",
CalcError.UnknownFunction => "unknown function",
CalcError.UnknownVariable => "unknown variable",
CalcError.UnexpectedEnd => "unexpected end of expression",
CalcError.InvalidExpression => "invalid expression",
// Evaluation
CalcError.DivisionByZero => "division by zero",
CalcError.Overflow => "overflow",
CalcError.InvalidOperandType => "invalid operand type",
CalcError.DomainError => "domain error",
// Struct layout
CalcError.InvalidType => "invalid type",
CalcError.InvalidFieldName => "invalid field name",
CalcError.DuplicateFieldName => "duplicate field name",
CalcError.StructTooLarge => "struct too large",
// Financial
CalcError.InsufficientParameters => "these values do not determine an answer",
CalcError.ConvergenceFailure => "no solution found",
// Units
CalcError.UnknownUnit => "unknown unit",
CalcError.IncompatibleUnits => "incompatible units (different categories)",
// System
CalcError.OutOfMemory => "out of memory",
};
}
test "BitWidth.mask" { test "BitWidth.mask" {
try std.testing.expectEqual(@as(u128, 0xFF), BitWidth.bits8.mask()); try std.testing.expectEqual(@as(u128, 0xFF), BitWidth.bits8.mask());
@ -195,3 +225,50 @@ test "BitWidth.smallestFor" {
try std.testing.expectEqual(BitWidth.bits64, BitWidth.smallestFor(0x1_0000_0000)); try std.testing.expectEqual(BitWidth.bits64, BitWidth.smallestFor(0x1_0000_0000));
try std.testing.expectEqual(BitWidth.bits128, BitWidth.smallestFor(0x1_0000_0000_0000_0000)); try std.testing.expectEqual(BitWidth.bits128, BitWidth.smallestFor(0x1_0000_0000_0000_0000));
} }
// -- One error phrase table --
//
// The CLI and the TUI each had a full switch over this error set, and the TUI's had
// already fallen behind: InsufficientParameters, ConvergenceFailure and
// InvalidExpression all came out as "evaluation error". The phrases now live here
// once and each frontend adds its own decoration at comptime.
test "errorPhrase: every error in the set has its own phrase" {
// Exhaustive by construction: the switch in errorPhrase has no else branch, so
// adding an error to CalcError without a phrase is a compile error rather than a
// silent fallback. This walks the set to prove the phrases are distinct and
// non-empty.
const fields = @typeInfo(CalcError).error_set.?;
var seen: [fields.len][]const u8 = undefined;
inline for (fields, 0..) |field, i| {
const phrase = errorPhrase(@field(CalcError, field.name));
try std.testing.expect(phrase.len > 0);
// No prefix and no newline: decoration belongs to the caller.
try std.testing.expect(!std.mem.startsWith(u8, phrase, "error"));
try std.testing.expect(std.mem.indexOfScalar(u8, phrase, '\n') == null);
seen[i] = phrase;
}
for (seen, 0..) |phrase, i| {
for (seen[i + 1 ..]) |other| {
if (std.mem.eql(u8, phrase, other)) {
std.debug.print("two errors share the phrase \"{s}\"\n", .{phrase});
return error.TestUnexpectedResult;
}
}
}
}
test "errorPhrase: usable at comptime, which is how frontends decorate it" {
const decorated = comptime "error: " ++ errorPhrase(CalcError.DivisionByZero);
try std.testing.expectEqualStrings("error: division by zero", decorated);
}
test "errorPhrase: the cases the TUI table used to lose" {
try std.testing.expectEqualStrings("invalid expression", errorPhrase(CalcError.InvalidExpression));
try std.testing.expectEqualStrings("no solution found", errorPhrase(CalcError.ConvergenceFailure));
try std.testing.expectEqualStrings(
"these values do not determine an answer",
errorPhrase(CalcError.InsufficientParameters),
);
}

View file

@ -667,14 +667,8 @@ fn convertExactInner(
return Number.div(allocator, shifted, to_factor); return Number.div(allocator, shifted, to_factor);
} }
fn mapNumberError(err: number_mod.Error) CalcError { /// One mapping, in `number.zig`; this alias keeps the call sites short.
return switch (err) { const mapNumberError = number_mod.toCalcError;
error.OutOfMemory => CalcError.OutOfMemory,
error.DivisionByZero => CalcError.DivisionByZero,
error.InvalidNumber => CalcError.InvalidNumber,
error.ExponentTooLarge => CalcError.Overflow,
};
}
// -- Tests -- // -- Tests --

View file

@ -387,21 +387,25 @@ fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engin
return .{ .output = output, .is_error = false }; return .{ .output = output, .is_error = false };
} }
/// Turn an engine error into a CLI line.
///
/// The phrases live once, in `engine.types.errorPhrase`. This adds the prefix and
/// the newline at comptime, so the strings still have static lifetime and there is
/// no second copy of the wording to drift. The CLI and the TUI previously each kept
/// their own switch over the whole error set; the TUI's was already missing three
/// cases and rendered them as "evaluation error".
fn errorMessage(err: engine.CalcError) []const u8 { fn errorMessage(err: engine.CalcError) []const u8 {
return decoratedError(err);
}
/// Comptime-decorated form of every error phrase: "error: <phrase>\n".
///
/// `inline else` makes this exhaustive over the error set with no fallback branch:
/// a new `CalcError` member is a compile error in `errorPhrase`, not a string that
/// silently reads "evaluation error".
fn decoratedError(err: engine.CalcError) []const u8 {
return switch (err) { return switch (err) {
engine.CalcError.DivisionByZero => "error: division by zero\n", inline else => |e| comptime "error: " ++ engine.types.errorPhrase(e) ++ "\n",
engine.CalcError.UnknownFunction => "error: unknown function\n",
engine.CalcError.UnknownVariable => "error: unknown variable\n",
engine.CalcError.UnmatchedParen => "error: unmatched parenthesis\n",
engine.CalcError.UnexpectedToken => "error: unexpected token\n",
engine.CalcError.UnexpectedEnd => "error: unexpected end of expression\n",
engine.CalcError.InvalidNumber => "error: invalid number\n",
engine.CalcError.InvalidExpression => "error: invalid expression\n",
engine.CalcError.DomainError => "error: domain error\n",
engine.CalcError.Overflow => "error: overflow\n",
engine.CalcError.UnknownUnit => "error: unknown unit\n",
engine.CalcError.IncompatibleUnits => "error: incompatible units (different categories)\n",
else => "error: evaluation error\n",
}; };
} }
@ -501,37 +505,6 @@ fn parseAmortArgs(args: []const []const u8) ParsedArgs {
} }
/// Render an amount with thousands separators and two decimal places. /// 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 /// 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. /// 360-period schedule does not fit the fixed buffers the other outputs use.
pub fn formatAmortization( pub fn formatAmortization(
@ -549,18 +522,18 @@ pub fn formatAmortization(
var out = std.ArrayList(u8).empty; var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator); errdefer out.deinit(allocator);
var line: [160]u8 = undefined; var line: [256]u8 = undefined;
var money: [48]u8 = undefined; var money: [64]u8 = undefined;
const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{ const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{
formatMoney(&money, params.principal), formatMoney(&money, params.principal) orelse return unformattableResult(),
params.rate, params.rate,
params.periods, params.periods,
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; }) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, header) catch return oomResult(); out.appendSlice(allocator, header) catch return oomResult();
const payment_line = std.fmt.bufPrint(&line, "Payment {s} per period\n\n", .{ const payment_line = std.fmt.bufPrint(&line, "Payment {s} per period\n\n", .{
formatMoney(&money, payment), formatMoney(&money, payment) orelse return unformattableResult(),
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; }) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, payment_line) catch return oomResult(); out.appendSlice(allocator, payment_line) catch return oomResult();
@ -570,16 +543,16 @@ pub fn formatAmortization(
"Period Payment Interest Principal Balance\n", "Period Payment Interest Principal Balance\n",
) catch return oomResult(); ) catch return oomResult();
for (rows) |row| { for (rows) |row| {
var pay_buf: [48]u8 = undefined; var pay_buf: [64]u8 = undefined;
var int_buf: [48]u8 = undefined; var int_buf: [64]u8 = undefined;
var prin_buf: [48]u8 = undefined; var prin_buf: [64]u8 = undefined;
var bal_buf: [48]u8 = undefined; var bal_buf: [64]u8 = undefined;
const row_text = std.fmt.bufPrint(&line, "{d: >6} {s: >12} {s: >12} {s: >12} {s: >12}\n", .{ const row_text = std.fmt.bufPrint(&line, "{d: >6} {s: >12} {s: >12} {s: >12} {s: >12}\n", .{
row.period, row.period,
formatMoney(&pay_buf, row.payment), formatMoney(&pay_buf, row.payment) orelse return unformattableResult(),
formatMoney(&int_buf, row.interest), formatMoney(&int_buf, row.interest) orelse return unformattableResult(),
formatMoney(&prin_buf, row.principal), formatMoney(&prin_buf, row.principal) orelse return unformattableResult(),
formatMoney(&bal_buf, row.balance), formatMoney(&bal_buf, row.balance) orelse return unformattableResult(),
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; }) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, row_text) catch return oomResult(); out.appendSlice(allocator, row_text) catch return oomResult();
} }
@ -589,17 +562,17 @@ pub fn formatAmortization(
const totals = engine.financial.amortizationTotals(params) catch |err| { const totals = engine.financial.amortizationTotals(params) catch |err| {
return .{ .output = amortErrorMessage(err), .is_error = true }; return .{ .output = amortErrorMessage(err), .is_error = true };
}; };
var paid_buf: [48]u8 = undefined; var paid_buf: [64]u8 = undefined;
var interest_buf: [48]u8 = undefined; var interest_buf: [64]u8 = undefined;
var principal_buf: [48]u8 = undefined; var principal_buf: [64]u8 = undefined;
const summary = std.fmt.bufPrint( const summary = std.fmt.bufPrint(
&line, &line,
"Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}", "Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}",
.{ .{
totals.periods, totals.periods,
formatMoney(&paid_buf, totals.paid), formatMoney(&paid_buf, totals.paid) orelse return unformattableResult(),
formatMoney(&interest_buf, totals.interest), formatMoney(&interest_buf, totals.interest) orelse return unformattableResult(),
formatMoney(&principal_buf, totals.principal), formatMoney(&principal_buf, totals.principal) orelse return unformattableResult(),
}, },
) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; ) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
out.appendSlice(allocator, summary) catch return oomResult(); out.appendSlice(allocator, summary) catch return oomResult();
@ -608,6 +581,20 @@ pub fn formatAmortization(
return .{ .output = text, .is_error = false }; return .{ .output = text, .is_error = false };
} }
/// The engine formatter, so the CLI, the TUI and the engine all group amounts the
/// same way.
const formatMoney = engine.formatter.formatMoney;
/// An amount too large to render. Reported rather than printed as a placeholder:
/// the previous local formatter returned "?" for these, so a table of question
/// marks came out with exit status 0.
fn unformattableResult() CliResult {
return .{
.output = "error: an amount in this schedule is too large to format\n",
.is_error = true,
};
}
fn oomResult() CliResult { fn oomResult() CliResult {
return .{ .output = "error: out of memory\n", .is_error = true }; return .{ .output = "error: out of memory\n", .is_error = true };
} }
@ -1236,19 +1223,48 @@ test "parseArgs: amort rejects incomplete or malformed terms" {
try testing.expect(zero == .output and zero.output.is_error); try testing.expect(zero == .output and zero.output.is_error);
} }
test "formatMoney: grouping and sign" { test "formatMoney: the CLI uses the engine formatter, not its own copy" {
// This lived in main.zig character for character alongside a second copy in
// src/tui/financial.zig. These cases now exercise engine.formatter.formatMoney.
var buf: [48]u8 = undefined; var buf: [48]u8 = undefined;
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0)); try testing.expectEqualStrings("0.00", formatMoney(&buf, 0).?);
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1)); try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1).?);
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1)); try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1).?);
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000)); try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000).?);
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89)); try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89).?);
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1)); try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1).?);
} }
test "formatMoney: a buffer too small reports rather than truncating silently" { test "formatMoney: an amount that does not fit is reported, not rendered" {
var tiny: [4]u8 = undefined; var tiny: [4]u8 = undefined;
try testing.expectEqualStrings("?", formatMoney(&tiny, 1234567.89)); try testing.expect(formatMoney(&tiny, 1234567.89) == null);
}
test "formatAmortization: an unrenderable amount is an error, not a table of marks" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
// 1e40 used to produce a full report of "?" with exit status 0. It now fits,
// because the engine formatter groups the whole number.
const large = formatAmortization(arena.allocator(), .{
.principal = 1e40,
.rate = 0.5,
.periods = 3,
}, false);
try testing.expect(!large.is_error);
try testing.expect(std.mem.indexOfScalar(u8, large.output, '?') == null);
try testing.expect(std.mem.indexOf(u8, large.output, "10,000,000,000,000,000,000,000,000,000,000,000,000,000.00") != null);
// Past the width of the formatting buffer the report is refused outright, with
// a non-zero exit status, rather than printed with placeholders.
const huge = formatAmortization(arena.allocator(), .{
.principal = 1e60,
.rate = 0.5,
.periods = 3,
}, false);
try testing.expect(huge.is_error);
try testing.expect(std.mem.indexOf(u8, huge.output, "too large to format") != null);
try testing.expect(std.mem.indexOfScalar(u8, huge.output, '?') == null);
} }
test "formatAmortization: table has a row per period and correct first row" { test "formatAmortization: table has a row per period and correct first row" {

View file

@ -744,16 +744,11 @@ pub const App = struct {
// Left/Right cycle the calculation, so every form is reachable without // Left/Right cycle the calculation, so every form is reachable without
// the mouse (FR-7.7). // the mouse (FR-7.7).
if (key.matches(vaxis.Key.right, .{})) { if (key.matches(vaxis.Key.right, .{})) {
const forms = std.enums.values(financial_view.Form); self.fin.nextForm();
const next = (@intFromEnum(self.fin.form) + 1) % forms.len;
self.fin.setForm(@enumFromInt(next));
return; return;
} }
if (key.matches(vaxis.Key.left, .{})) { if (key.matches(vaxis.Key.left, .{})) {
const forms = std.enums.values(financial_view.Form); self.fin.prevForm();
const current = @intFromEnum(self.fin.form);
const prev = if (current == 0) forms.len - 1 else current - 1;
self.fin.setForm(@enumFromInt(prev));
return; return;
} }
if (key.matches(vaxis.Key.backspace, .{})) { if (key.matches(vaxis.Key.backspace, .{})) {
@ -1228,16 +1223,12 @@ pub const App = struct {
draw.writeStr(&surface, 0, 1, "Tally", .{ .fg = C.cyan, .bg = C.bg, .bold = true }); draw.writeStr(&surface, 0, 1, "Tally", .{ .fg = C.cyan, .bg = C.bg, .bold = true });
// Mode tabs. Each is registered as a clickable region. // Mode tabs. Each is registered as a clickable region.
const tabs = [_]struct { mode: Mode, text: []const u8, color: vaxis.Cell.Color }{ // Mode tabs, drawn from the same table the Tab order comes from. Each is
.{ .mode = .standard, .text = " Standard ", .color = C.green }, // registered as a clickable region.
.{ .mode = .programmer, .text = " Programmer ", .color = C.orange },
.{ .mode = .financial, .text = " Financial ", .color = C.yellow },
.{ .mode = .convert, .text = " Convert ", .color = C.purple },
};
var total_tab_width: u16 = 0; var total_tab_width: u16 = 0;
for (tabs) |tab| total_tab_width += @intCast(tab.text.len); for (mode_tabs) |tab| total_tab_width += @intCast(tab.text.len);
var tab_col = width -| (total_tab_width + 2); var tab_col = width -| (total_tab_width + 2);
for (tabs) |tab| { for (mode_tabs) |tab| {
const len: u16 = @intCast(tab.text.len); const len: u16 = @intCast(tab.text.len);
const style: vaxis.Style = if (self.mode == tab.mode) const style: vaxis.Style = if (self.mode == tab.mode)
.{ .fg = C.bg, .bg = tab.color, .bold = true } .{ .fg = C.bg, .bg = tab.color, .bold = true }
@ -1302,22 +1293,42 @@ pub const App = struct {
}; };
/// Mode order for Tab and Shift-Tab, matching the tab bar left to right. /// Mode order for Tab and Shift-Tab, matching the tab bar left to right.
/// The mode bar: order, label and colour, in one place.
///
/// Tab order, Shift-Tab order and the drawn tab bar all come from this. They used
/// to be three separate encodings of the same sequence: the array below plus a
/// hand-written switch in each direction, which is three places to update and two
/// chances to disagree.
///
/// The table is indexed by the `Mode` tag, which the comptime block below enforces,
/// so a mode added to the enum without a tab here is a compile error and looking up
/// a mode's position needs no search and no unreachable branch.
const mode_tabs = [_]struct { mode: Mode, text: []const u8, color: vaxis.Cell.Color }{
.{ .mode = .standard, .text = " Standard ", .color = C.green },
.{ .mode = .programmer, .text = " Programmer ", .color = C.orange },
.{ .mode = .financial, .text = " Financial ", .color = C.yellow },
.{ .mode = .convert, .text = " Convert ", .color = C.purple },
};
comptime {
const modes = std.enums.values(Mode);
if (mode_tabs.len != modes.len) @compileError("every Mode needs a tab in mode_tabs");
for (mode_tabs, 0..) |tab, i| {
if (@intFromEnum(tab.mode) != i) @compileError("mode_tabs must be in Mode declaration order");
}
}
/// Position of a mode in the bar.
fn modeIndex(mode: Mode) usize {
return @intFromEnum(mode);
}
pub fn nextMode(mode: Mode) Mode { pub fn nextMode(mode: Mode) Mode {
return switch (mode) { return mode_tabs[wrapIndex(modeIndex(mode), 1, mode_tabs.len)].mode;
.standard => .programmer,
.programmer => .financial,
.financial => .convert,
.convert => .standard,
};
} }
pub fn prevMode(mode: Mode) Mode { pub fn prevMode(mode: Mode) Mode {
return switch (mode) { return mode_tabs[wrapIndex(modeIndex(mode), -1, mode_tabs.len)].mode;
.standard => .convert,
.programmer => .standard,
.financial => .programmer,
.convert => .financial,
};
} }
/// Move an index by delta within [0, len), wrapping at both ends. /// Move an index by delta within [0, len), wrapping at both ends.
@ -1395,20 +1406,15 @@ pub fn drawHistory(items: []const App.HistoryEntry, surface: *vxfw.Surface, star
} }
} }
/// Turn an engine error into a status-line string.
///
/// The phrases live once, in `engine.types.errorPhrase`; the prefix is added at
/// comptime. This switch used to be a second full copy of the CLI's, and had fallen
/// behind: `InsufficientParameters`, `ConvergenceFailure` and `InvalidExpression`
/// all came out as "evaluation error".
fn errorStr(err: engine.CalcError) []const u8 { fn errorStr(err: engine.CalcError) []const u8 {
return switch (err) { return switch (err) {
engine.CalcError.DivisionByZero => "error: division by zero", inline else => |e| comptime "error: " ++ engine.types.errorPhrase(e),
engine.CalcError.UnknownFunction => "error: unknown function",
engine.CalcError.UnknownVariable => "error: unknown variable",
engine.CalcError.UnmatchedParen => "error: unmatched parenthesis",
engine.CalcError.UnexpectedToken => "error: unexpected token",
engine.CalcError.UnexpectedEnd => "error: unexpected end of expression",
engine.CalcError.InvalidNumber => "error: invalid number",
engine.CalcError.DomainError => "error: domain error",
engine.CalcError.Overflow => "error: overflow",
engine.CalcError.UnknownUnit => "error: unknown unit",
engine.CalcError.IncompatibleUnits => "error: incompatible units",
else => "error: evaluation error",
}; };
} }
@ -1629,6 +1635,12 @@ test "financial mode: left and right switch calculation without the mouse" {
// Wraps backwards to the last calculation. // Wraps backwards to the last calculation.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.left }); try press(&app, &ctx, .{ .codepoint = vaxis.Key.left });
try testing.expectEqual(financial_view.Form.amortization, app.fin.form); try testing.expectEqual(financial_view.Form.amortization, app.fin.form);
// And forwards off the end back to the first. This direction was never
// exercised, and it panicked with an integer overflow: the Form tag is a u2,
// so the handler's own `@intFromEnum(form) + 1` overflowed before the modulo
// could wrap it.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(financial_view.Form.cagr, app.fin.form);
} }
test "financial mode: the digits that edit a field do not switch modes" { test "financial mode: the digits that edit a field do not switch modes" {
@ -1788,6 +1800,30 @@ test "nextMode and prevMode are inverses in both directions" {
} }
} }
test "nextMode visits every mode once before repeating" {
// Cycling through as many steps as there are tabs must return to the start
// having seen each mode, which is what makes every mode reachable by Tab.
var seen = [_]bool{false} ** mode_tabs.len;
var mode: Mode = .standard;
for (0..mode_tabs.len) |_| {
const i = modeIndex(mode);
try testing.expect(!seen[i]);
seen[i] = true;
mode = nextMode(mode);
}
try testing.expectEqual(Mode.standard, mode);
for (seen) |visited| try testing.expect(visited);
}
test "the drawn tab order is the Tab key order" {
// The bar is drawn left to right from mode_tabs, so pressing Tab must move to
// the tab drawn to the right of the current one.
for (mode_tabs, 0..) |tab, i| {
const expected = mode_tabs[(i + 1) % mode_tabs.len].mode;
try testing.expectEqual(expected, nextMode(tab.mode));
}
}
test "plain tab is not shift-tab" { test "plain tab is not shift-tab" {
var app = testApp(); var app = testApp();
defer app.deinit(); defer app.deinit();
@ -2113,7 +2149,7 @@ test "render: convert mode says when a conversion is affine" {
var app = testApp(); var app = testApp();
defer app.deinit(); defer app.deinit();
app.setMode(.convert); app.setMode(.convert);
var ctx = testCtxNoCmds(); var ctx = testCtx();
try app.applyAction(&ctx, .{ .conv_category = .temperature }); try app.applyAction(&ctx, .{ .conv_category = .temperature });
const rows = try renderApp(arena, &app, 100, 34); const rows = try renderApp(arena, &app, 100, 34);
@ -2131,7 +2167,7 @@ test "render: convert mode is well formed for every category" {
app.setMode(.convert); app.setMode(.convert);
for (std.enums.values(engine.UnitCategory)) |category| { for (std.enums.values(engine.UnitCategory)) |category| {
var ctx = testCtxNoCmds(); var ctx = testCtx();
try app.applyAction(&ctx, .{ .conv_category = category }); try app.applyAction(&ctx, .{ .conv_category = category });
const rows = try renderApp(arena, &app, 100, 34); const rows = try renderApp(arena, &app, 100, 34);
try testing.expect(test_render.furniture(rows).intact()); try testing.expect(test_render.furniture(rows).intact());
@ -2330,12 +2366,6 @@ test "render: 128-bit programmer mode keeps its input line on a short terminal"
try testing.expect(!test_render.contains(tall, "terminal too short")); try testing.expect(!test_render.contains(tall, "terminal too short"));
} }
/// An EventContext for calls that cannot issue a command, so nothing needs to be
/// freed afterwards.
fn testCtxNoCmds() vxfw.EventContext {
return testCtx();
}
test "help overlay: arrows scroll, other keys dismiss" { test "help overlay: arrows scroll, other keys dismiss" {
var app = testApp(); var app = testApp();
defer app.deinit(); defer app.deinit();

View file

@ -279,6 +279,25 @@ pub const State = struct {
self.scroll = 0; self.scroll = 0;
} }
/// Move to the next form, wrapping. The order is the declaration order of
/// `Form`, which is also the order the tab strip is drawn in.
///
/// This lives here rather than in the key handler so that form order is
/// defined once: the handler had its own copy of the modulo arithmetic in each
/// direction, and could not be tested without a terminal. That copy also
/// overflowed: the enum tag is a `u2`, so `@intFromEnum(form) + 1` on the last
/// form panicked in a debug build instead of wrapping.
pub fn nextForm(self: *State) void {
const current: usize = @intFromEnum(self.form);
self.setForm(@enumFromInt((current + 1) % form_count));
}
pub fn prevForm(self: *State) void {
const current: usize = @intFromEnum(self.form);
const prev = if (current == 0) form_count - 1 else current - 1;
self.setForm(@enumFromInt(prev));
}
pub fn focusField(self: *State, index: usize) void { pub fn focusField(self: *State, index: usize) void {
if (index < self.fieldCount()) self.field = index; if (index < self.fieldCount()) self.field = index;
} }
@ -838,46 +857,28 @@ fn substitutedFormula(state: *const State, buf: []u8) ?[]const u8 {
}; };
} }
/// Format an amount with grouping and two decimals, matching the CLI table. /// Format an amount with grouping and two decimals, via the engine formatter so
/// the CLI table and this view cannot diverge.
///
/// Drawing cannot fail, so a value too large to render becomes "(too large)"
/// rather than being dropped. The CLI reports it as an error instead, because a
/// command can exit non-zero and a frame cannot.
fn money(buf: []u8, value: f64) []const u8 { fn money(buf: []u8, value: f64) []const u8 {
var digits: [64]u8 = undefined; return formatter.formatMoney(buf, value) orelse "(too large)";
const text = std.fmt.bufPrint(&digits, "{d:.2}", .{@abs(value)}) catch return "?";
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];
} }
/// Error text tuned to this view: the generic "domain error" is useless in a /// Error text for this view.
/// form, where the cause is always one of a few bad entries. ///
/// Only the cases where a form knows more than the engine does are overridden; the
/// rest defer to `engine.types.errorPhrase`, so this is no longer a third copy of
/// the whole table. A generic "domain error" is useless in a form, where the cause
/// is always one of a few bad entries, but "division by zero" needs no improving.
pub fn errorText(err: engine.CalcError) []const u8 { pub fn errorText(err: engine.CalcError) []const u8 {
return switch (err) { return switch (err) {
engine.CalcError.DomainError => "check the entries: values must be positive and a payment must cover the interest", engine.CalcError.DomainError => "check the entries: values must be positive and a payment must cover the interest",
engine.CalcError.ConvergenceFailure => "no rate solves these cash flows", engine.CalcError.ConvergenceFailure => "no rate solves these cash flows",
engine.CalcError.InsufficientParameters => "these values do not determine an answer", engine.CalcError.InsufficientParameters => "these values do not determine an answer",
engine.CalcError.DivisionByZero => "division by zero", else => engine.types.errorPhrase(err),
engine.CalcError.Overflow => "overflow",
else => "cannot compute with these values",
}; };
} }
@ -1322,9 +1323,9 @@ test "money: grouping and two decimals" {
try testing.expectEqualStrings("0.00", money(&buf, 0)); try testing.expectEqualStrings("0.00", money(&buf, 0));
} }
test "money: reports rather than truncating when the buffer is too small" { test "money: an amount too large for the slot says so rather than truncating" {
var tiny: [3]u8 = undefined; var tiny: [3]u8 = undefined;
try testing.expectEqualStrings("?", money(&tiny, 1234567.89)); try testing.expectEqualStrings("(too large)", money(&tiny, 1234567.89));
} }
test "formatValueLine: with and without an iteration count" { test "formatValueLine: with and without an iteration count" {
@ -1724,3 +1725,40 @@ test "render: the payments toggle shows its state and highlights when focused" {
const due = try renderFrame(testing.allocator, arena, state, 80, 24, true); const due = try renderFrame(testing.allocator, arena, state, 80, 24, true);
try testing.expect(frameContains(due, "BGN (start of period)")); try testing.expect(frameContains(due, "BGN (start of period)"));
} }
test "State: form cycling is a cycle in both directions" {
// Left/Right cycling used to live in the key handler, where it could only be
// exercised through a key event. It belongs to the state.
var state: State = .{};
for (0..form_count) |_| {
const before = state.form;
state.nextForm();
try testing.expect(state.form != before);
state.prevForm();
try testing.expectEqual(before, state.form);
state.nextForm();
}
// form_count steps forward from the start returns to the start.
try testing.expectEqual(Form.cagr, state.form);
}
test "State: form cycling visits every form and follows declaration order" {
var state: State = .{};
var seen = [_]bool{false} ** form_count;
for (0..form_count) |i| {
seen[@intFromEnum(state.form)] = true;
const expected: Form = @enumFromInt((i + 1) % form_count);
state.nextForm();
try testing.expectEqual(expected, state.form);
}
for (seen) |visited| try testing.expect(visited);
}
test "State: changing form resets the focused field and the scroll" {
var state = stateWith(.tvm, &.{ "360", "0.5", "200000", "", "0" });
state.focusField(4);
state.scroll = 3;
state.nextForm();
try testing.expectEqual(@as(usize, 0), state.field);
try testing.expectEqual(@as(usize, 0), state.scroll);
}

View file

@ -202,6 +202,10 @@ fn registerConfigRegions(app: *tui.App, row: u16, col: u16, text: []const u8) vo
/// When `toggles` is true a click flips the bit (used for BIN, where one digit /// When `toggles` is true a click flips the bit (used for BIN, where one digit
/// is exactly one bit). Otherwise a click just moves the cursor to that digit, /// is exactly one bit). Otherwise a click just moves the cursor to that digit,
/// since "toggling" a multi-bit nibble or octal digit has no single meaning. /// since "toggling" a multi-bit nibble or octal digit has no single meaning.
///
/// `text` is a formatter `display` string, which is digits and spaces only. This
/// used to begin by skipping a `0x`/`0o`/`0b` prefix; only the `raw` strings carry
/// one, so that branch never ran.
fn registerDigitRegions( fn registerDigitRegions(
app: *tui.App, app: *tui.App,
row: u16, row: u16,
@ -211,21 +215,15 @@ fn registerDigitRegions(
field: tui.App.ProgField, field: tui.App.ProgField,
toggles: bool, toggles: bool,
) void { ) void {
// Skip a base prefix if one is present (display strings normally omit it).
var start: usize = 0;
if (text.len >= 2 and text[0] == '0' and (text[1] == 'o' or text[1] == 'x' or text[1] == 'b')) {
start = 2;
}
var displayed_digits: u16 = 0; var displayed_digits: u16 = 0;
for (text[start..]) |ch| { for (text) |ch| {
if (ch != ' ') displayed_digits += 1; if (ch != ' ') displayed_digits += 1;
} }
if (displayed_digits == 0) return; if (displayed_digits == 0) return;
var text_col: u16 = col + @as(u16, @intCast(start)); var text_col: u16 = col;
var digit_idx: u16 = 0; var digit_idx: u16 = 0;
for (text[start..]) |ch| { for (text) |ch| {
if (ch != ' ') { if (ch != ' ') {
// Digits are drawn MSB-first; convert to a bit offset from the LSB. // Digits are drawn MSB-first; convert to a bit offset from the LSB.
const from_lsb: u16 = displayed_digits - 1 - digit_idx; const from_lsb: u16 = displayed_digits - 1 - digit_idx;
@ -246,16 +244,15 @@ fn registerDigitRegions(
/// Draw a field's display string with a cursor highlighting the digit at bit_cursor position. /// Draw a field's display string with a cursor highlighting the digit at bit_cursor position.
/// `bits_per_digit` is 4 for hex, 3 for oct, 1 for bin. /// `bits_per_digit` is 4 for hex, 3 for oct, 1 for bin.
///
/// `text` is a formatter `display` string: digits and spaces, never a `0x`/`0o`/`0b`
/// prefix. This used to skip a prefix and draw it unhighlighted, which was dead
/// code in both this function and `registerDigitRegions`.
fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const u8, bit_cursor: u7, bits_per_digit: u8, total_bits: u8, color: vaxis.Cell.Color) void { fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const u8, bit_cursor: u7, bits_per_digit: u8, total_bits: u8, color: vaxis.Cell.Color) void {
_ = total_bits; _ = total_bits;
// Count actual displayed digits (non-space, non-prefix characters)
var start: usize = 0;
if (text.len >= 2 and text[0] == '0' and (text[1] == 'o' or text[1] == 'x' or text[1] == 'b')) {
start = 2;
}
var displayed_digits: u16 = 0; var displayed_digits: u16 = 0;
for (text[start..]) |ch| { for (text) |ch| {
if (ch != ' ') displayed_digits += 1; if (ch != ' ') displayed_digits += 1;
} }
@ -267,16 +264,10 @@ fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const
else else
0; 0;
// Draw prefix
var text_col: u16 = col;
for (text[0..start]) |ch| {
draw.writeChar(surface, row, text_col, ch, .{ .fg = color });
text_col += 1;
}
// Draw digits with cursor highlight // Draw digits with cursor highlight
var text_col: u16 = col;
var digit_idx: u16 = 0; var digit_idx: u16 = 0;
for (text[start..]) |ch| { for (text) |ch| {
if (ch == ' ') { if (ch == ' ') {
draw.writeChar(surface, row, text_col, ' ', .{ .fg = color }); draw.writeChar(surface, row, text_col, ' ', .{ .fg = color });
} else { } else {