more structural changes from human review

This commit is contained in:
Emil Lerch 2026-07-30 09:51:30 -07:00
parent cb7da0fe9e
commit 41d0e7cd11
Signed by: lobo
GPG key ID: A7B62D657EF764F8
16 changed files with 1451 additions and 1341 deletions

View file

@ -61,10 +61,9 @@ build.zig (workspace root)
| Module | Responsibility |
|--------|---------------|
| `errors.zig` | `CalcError` and the one table of error wording |
| `Integer.zig` | A fixed-width integer (file-as-struct), plus `IntType`, `BitWidth`, `Signedness` |
| `rational.zig` | Exact rationals over big integers |
| `number.zig` | The exact/inexact numeric model (section 2.7) |
| `Rational.zig` | Exact rationals over big integers (file-as-struct) |
| `number.zig` | The exact/inexact numeric model (section 2.7). Lowercase: `Number` is a tagged union, which a file-as-struct cannot express |
| `tokenizer.zig` | Lexer, and `Base` for literals |
| `ast.zig` | AST node definitions |
| `parser.zig` | Pratt parser -> AST |
@ -75,14 +74,25 @@ build.zig (workspace root)
| `float_interp.zig` | IEEE 754 bit-level interpretation |
| `units.zig` | Unit conversion tables and resolver |
| `financial.zig` | CAGR, TVM, compound interest, amortization |
| `engine.zig` | Public API surface (Zig-native). Imports and re-exports only; defines nothing |
| `message.zig` | Gone: the wording lives in `engine.zig` beside the `Error` union it words |
| `engine.zig` | Public API surface (Zig-native): the module re-exports, the `Error` union, and `phrase` |
| `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers |
There is deliberately no `types.zig`. It existed, and being named after a language
feature rather than a concept, it accumulated four unrelated groups: the error
vocabulary, the fixed-width integer model, `Base` (used only by the lexer and the
AST), and `Mode` (which the engine stored and never read). Each has gone to the
module that owns it. `struct_layout.zig` is still unimplemented (Phase 4).
Every module declares its own error set; there is no shared one (section 10).
There is deliberately no `types.zig` and no `errors.zig`. `types.zig` existed, and
being named after a language feature rather than a concept, it accumulated four
unrelated groups: the error vocabulary, the fixed-width integer model, `Base` (used
only by the lexer and the AST), and `Mode` (which the engine stored and never read).
`errors.zig` was where the error vocabulary landed, until the same question showed that
what needed splitting was the type inside it. Each piece has gone to the module that
owns it. `struct_layout.zig` is still unimplemented (Phase 4).
A file is named TitleCase when the file *is* the type, with its fields at container
level: `Integer.zig` and `Rational.zig`. `number.zig` stays lowercase because `Number`
is a tagged union and a Zig file is always a struct, so the file cannot be that type
without wrapping the union in a field, which would add a hop at every use of the tag
that is the type's whole identity.
### 2.2 Core Data Types
@ -1969,58 +1979,74 @@ The JNI bridge sends expression strings down and receives JSON results back. Thi
## 10. Error Handling Strategy
Each module declares what it can fail with, and the sets compose:
```zig
pub const CalcError = error{
// Parser errors
UnexpectedToken,
UnmatchedParen,
InvalidNumber,
UnknownFunction,
UnknownVariable,
// Evaluation errors
DivisionByZero,
Overflow,
InvalidOperandType,
DomainError, // e.g., sqrt(-1)
// Struct layout errors
InvalidType,
InvalidFieldName,
DuplicateFieldName,
StructTooLarge,
// Financial errors
InsufficientParameters,
ConvergenceFailure, // TVM Newton-Raphson didn't converge
// System
OutOfMemory,
// parser.zig
pub const Error = error{
UnexpectedToken, UnmatchedParen, UnexpectedEnd,
InvalidExpression, InvalidNumber, OutOfMemory,
};
pub const ErrorInfo = struct {
err: CalcError,
message: []const u8,
position: ?usize, // character position in input where error occurred
context: []const u8, // snippet of input around error
// Rational.zig, inherited by number.zig
pub const Error = error{
OutOfMemory, DivisionByZero, InvalidNumber,
ExponentTooLarge, NegativeRoot,
};
// bitwise.zig
pub const Error = error{DomainError};
// units.zig: its own two, plus whatever the exact path raises
pub const Error = error{ UnknownUnit, IncompatibleUnits, OutOfMemory } || number.Error;
// financial.zig
pub const Error = error{
InsufficientParameters, ConvergenceFailure,
DomainError, DivisionByZero, OutOfMemory,
};
// evaluator.zig: its own, plus every dependency's
pub const Error = error{ UnknownFunction, UnknownVariable, DomainError, Overflow } ||
parser.Error || number.Error || bitwise.Error || financial.Error;
// engine.zig: what any entry point can return, for frontends to switch over
pub const Error = evaluator.Error || programmer.Error || units.Error || financial.Error;
```
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.
This replaced one hand-written `CalcError` with 20 members that every engine function
claimed to return. That signature was false in both directions: `parser.parse` said it
might return `ConvergenceFailure` and `UnknownUnit`, so no caller could switch on what
a parse can actually produce, and four members (`InvalidType`, `InvalidFieldName`,
`DuplicateFieldName`, `StructTooLarge`) belonged to a struct layout module that does
not exist, so nothing could return them while `errorPhrase` still gave them wording.
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.
Error members unify by name in Zig, so the per-module sets compose with no
coordination: `parser.Error.OutOfMemory` and `units.Error.OutOfMemory` are the same
value. The unions are written with `||` rather than enumerated, so the compiler
maintains them.
All engine functions return `CalcError!Result`. Frontends translate these into user-facing messages.
Deleting the single set also deleted the translation between tiers. `number.toCalcError`
existed to map the numeric model's errors into the engine-wide set, and what it mapped
away were the more useful names: `ExponentTooLarge` became `Overflow` and `NegativeRoot`
became `DomainError`. Now `sqrt(-1)` reports "square root of a negative number" and
`2^3000000` reports "the exponent is too large to compute".
Errors carry no position or context. An `ErrorInfo` with a source position was
specified here and declared in the code, but nothing ever constructed one, and the
parser fields that would have fed it were written on every error path and never read.
Adding position reporting means threading it through the returns, which is worth doing
deliberately rather than leaving a half-built shape in place.
The wording lives in exactly one place, `engine.phrase`, in the engine rather than a
frontend because every frontend needs the same words, including the Android app across
the C ABI. Its switch has no `else`, so an error added to any module's set fails to
compile until it is given a phrase, and a comptime block checks the other direction, so
it cannot carry wording for an error the engine is incapable of producing. Frontends
decorate 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.
---

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.2**: Support operators: `+`, `-`, `*`, `/`, `%` (modulo), `^` (power), unary `-`.
- **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`. 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.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 reported as such, distinct from an unknown name: `asin(2)`, `ln(0)`, `log2(0)` and `factorial(-1)` report a domain error, `sqrt(-1)` reports "square root of a negative number", and only an unrecognized name reports an unknown function.
- **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.7**: Maintain calculation history with replay capability.

View file

@ -23,7 +23,7 @@ NOTE: Actual structure diverged from spec - single binary at `src/main.zig`
(CLI + TUI combined), engine as static lib + shared lib. No separate cli/ or
tui/ build files. kcov-based coverage wired in via `build/Coverage.zig`.
### Task 1.2: Implement core types module [DONE]
### Task 1.2: Implement core types module [DONE, later dismantled]
- Create `engine/src/types.zig`
- Define `Value` union, `Integer` struct, `BitWidth`, `Signedness`, `Endianness` enums
- Define `MultiBaseResult` struct
@ -31,6 +31,12 @@ tui/ build files. kcov-based coverage wired in via `build/Coverage.zig`.
- Define `Mode` enum (standard, programmer, financial)
- Verify: compiles, types are importable from other engine modules
SUPERSEDED by Tasks 5.12 and 5.13. `types.zig` no longer exists: a module named after
a language feature collected unrelated things. `Value`, `ErrorInfo` and `Mode` are gone
entirely (unused, never constructed, and stored-but-never-read respectively), `Integer`
and its enums are `Integer.zig`, `Endianness` is `std.builtin.Endian`, and `CalcError`
is replaced by a per-module error set in each module that can fail.
### Task 1.3: Implement tokenizer [DONE]
- Create `engine/src/tokenizer.zig`
- Token types: numbers (dec, hex `0x`, oct `0o`, bin `0b`), operators, parens, identifiers, comma, semicolon, EOF
@ -147,7 +153,9 @@ behavioral change (rationale in design.md 2.7.9), plus 2.0e for the unit factors
- Added to `rational.zig`: `floor`, `ceil`, `round`, `mod` (the
`a - b*floor(a/b)` definition), and unbounded exact `factorial`.
- Added to `number.zig`: the matching wrappers plus `max`/`min`.
- `rational.Error.ExponentTooLarge` maps to `CalcError.Overflow`.
- `rational.Error.ExponentTooLarge` reached the caller as `CalcError.Overflow`. Both
the mapping and the shared set are gone as of Task 5.13: the name now reaches the
user, as "the exponent is too large to compute".
- ALL 489 pre-existing tests pass unchanged. 522 total now (+33).
- Verified through the unchanged f64 API: `0.1 + 0.2` = `0.3`, `1.1 + 2.2` = `3.3`,
`0.1 * 3` = `0.3`, `0.1+0.2+0.3` = `0.6`, `(0.1+0.2)*10-3` = `0`,
@ -756,6 +764,62 @@ Remaining subcommands deferred until their engine modules exist.
- NOT DONE: mouse wheel scrolling for history
- Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal
### Task 5.13: One error set per module, not one for the engine
Found by asking the same question of `errors.zig` that killed `types.zig`. The file
was a defensible home; the type inside it was not. `CalcError` had 20 members and
every engine function returned it, so `parser.parse` claimed it might return
`ConvergenceFailure`, `UnknownUnit` and `StructTooLarge`, and no caller could switch
on what a parse actually produces.
- Each module now declares what it can fail with: `parser.Error`, `bitwise.Error`,
`units.Error`, `financial.Error`, `evaluator.Error`, `programmer.Error`, with
`rational.Error` (inherited by `number.zig`) already in place.
- The unions are `||` expressions, not lists: `evaluator.Error` is its own four members
plus its dependencies', and `message.Error` is the union of the entry points. The
compiler maintains them, so adding an error to one module propagates with no edit.
- Verified against the compiler before relying on it: error members unify by name
across sets, a set can be derived from a function's return type via `@typeInfo`, and
recursion with an inferred error set resolves (so `evalExact` needed no annotation).
- `number.toCalcError` is gone, and with it `units.mapNumberError` and
`evaluator.mapError`, which were aliases of it. Those call sites are plain `try`.
- Four members went away rather than staying unused: `InvalidType`,
`InvalidFieldName`, `DuplicateFieldName` and `StructTooLarge` belonged to the struct
layout module Phase 4 has not written, and `errorPhrase` was giving all four wording
nothing could produce.
- `errors.zig` is gone. The wording lives in `engine.zig`, next to the `Error` union
it words, and it stays in the engine rather than a frontend because the Android app
will receive these strings across the C ABI. It briefly lived in a `message.zig` of
its own; that file was folded in during review, since nothing in the engine calls it
and a separate file for one table did not pay for itself. `phrase`'s switch has no
`else` (a new error fails to compile until worded), and it cannot word an error the
engine is incapable of returning, because a prong naming one is a type error against
`Error`. That second property is the direction the hand-written set got wrong, and it
needs no assertion: a comptime block asserting it was written, found to be redundant
when tested against the compiler, and removed.
Two user-visible improvements fell out, because the translation being deleted was
flattening the better names: `sqrt(-1)` now says "square root of a negative number"
instead of "domain error", and `2^3000000` says "the exponent is too large to compute"
instead of "overflow". Two tests updated to match.
- Verify: 944 tests pass, fmt and zlint clean, CLI checked across parse, name,
arithmetic, unit and financial errors. `engine.zig` at 95.6% (the two uncovered lines
are a diagnostic branch inside a passing test).
### Task 5.14: File-as-struct for the types that are types
`Integer.zig` and `Rational.zig` are named TitleCase and the file *is* the type: the
fields sit at container level, `@This()` names it, and the auxiliary declarations
(`IntType`, `BitWidth`, `Signedness`; `Error`, `DecimalResult`) are nested inside.
`git mv` kept the history.
`number.zig` stays lowercase. `Number` is a tagged union and a Zig file is always a
struct container, so the file can only be that type by wrapping the union in a field.
That would put a `.value` hop in front of 53 tag tests, and the exact/inexact tag is
the type's whole identity, so the wrapper would cost more than the naming consistency
buys. Recorded here so the asymmetry reads as a decision rather than an oversight.
### Task 5.12: Break up types.zig
`types.zig` was named after a language feature rather than a concept, so it

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,10 @@ const Integer = @import("Integer.zig");
const BitWidth = Integer.BitWidth;
const Signedness = Integer.Signedness;
const IntType = Integer.IntType;
const CalcError = @import("errors.zig").CalcError;
/// The one way a fixed-width operation can fail: a shift or rotate distance that is
/// negative in the operand's type. Everything else about these operators is total.
pub const Error = error{DomainError};
/// The operators this module implements.
///
@ -79,8 +82,8 @@ const Distance = union(enum) {
///
/// A negative distance is a domain error rather than a very large one. Standard
/// mode used to reduce it modulo 64, so `8 >> -1` quietly became `8 >> 63`.
fn distance(int_type: IntType, right: u128) CalcError!Distance {
if (int_type.isNegative(right)) return CalcError.DomainError;
fn distance(int_type: IntType, right: u128) Error!Distance {
if (int_type.isNegative(right)) return Error.DomainError;
const value = right & int_type.mask();
if (value >= int_type.bits()) return .past_width;
return .{ .within = @intCast(value) };
@ -90,13 +93,13 @@ fn distance(int_type: IntType, right: u128) CalcError!Distance {
///
/// Rotation is cyclic, so a distance beyond the width is reduced rather than
/// saturated: rotating a 64-bit value by 65 is rotating it by 1.
fn rotation(int_type: IntType, right: u128) CalcError!u7 {
if (int_type.isNegative(right)) return CalcError.DomainError;
fn rotation(int_type: IntType, right: u128) Error!u7 {
if (int_type.isNegative(right)) return Error.DomainError;
return @intCast((right & int_type.mask()) % int_type.bits());
}
/// Apply a fixed-width operation. Operands and result are masked bit patterns.
pub fn apply(int_type: IntType, op: Op, left_in: u128, right_in: u128) CalcError!u128 {
pub fn apply(int_type: IntType, op: Op, left_in: u128, right_in: u128) Error!u128 {
const mask = int_type.mask();
const left = left_in & mask;
const right = right_in & mask;
@ -234,11 +237,11 @@ test "shifting by one less than the width still keeps a bit" {
test "a negative shift distance is a domain error, not a huge one" {
const neg_one: u128 = 0xFF; // -1 in 8-bit signed
try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_left, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_right, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_right_logical, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .rotate_left, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .rotate_right, 1, neg_one));
try testing.expectError(Error.DomainError, apply(i8_type, .shift_left, 1, neg_one));
try testing.expectError(Error.DomainError, apply(i8_type, .shift_right, 1, neg_one));
try testing.expectError(Error.DomainError, apply(i8_type, .shift_right_logical, 1, neg_one));
try testing.expectError(Error.DomainError, apply(i8_type, .rotate_left, 1, neg_one));
try testing.expectError(Error.DomainError, apply(i8_type, .rotate_right, 1, neg_one));
// The same pattern in an unsigned domain is 255, a distance past the width.
try testing.expectEqual(@as(u128, 0), try apply(u8_type, .shift_left, 1, neg_one));
}

View file

@ -2,16 +2,13 @@
//!
//! Pure computation library with no I/O. Provides expression parsing, evaluation,
//! programmer-mode bit manipulation, unit conversion, and financial calculations.
//!
//! This file is a facade: it imports and re-exports, and defines nothing. Anything
//! defined here would have to be imported back by the modules below, which is how a
//! root file becomes a dependency of its own leaves.
const std = @import("std");
// Vocabulary, lowest first.
pub const errors = @import("errors.zig");
pub const Integer = @import("Integer.zig");
// Exact numeric model (design.md 2.7). The evaluator computes in these.
pub const rational = @import("rational.zig");
pub const Rational = @import("Rational.zig");
pub const number = @import("number.zig");
// Language layer.
pub const tokenizer = @import("tokenizer.zig");
@ -33,7 +30,6 @@ pub const financial = @import("financial.zig");
// 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 CalcError = errors.CalcError;
pub const BitWidth = Integer.BitWidth;
pub const Environment = evaluator.Environment;
pub const evalString = evaluator.evalString;
@ -44,8 +40,140 @@ pub const UnitCategory = units.UnitCategory;
pub const UnitDef = units.UnitDef;
pub const Number = number.Number;
test {
std.testing.refAllDecls(@This());
/// Every error an engine entry point can return.
///
/// Derived, not enumerated: each module declares what it can fail with
/// (`parser.Error`, `units.Error`, `financial.Error`, and so on), and adding an error
/// to any of those adds it here with no list to keep in step. There used to be one
/// hand-written `CalcError` with 20 members that every engine function claimed to
/// return, four of which nothing could produce.
pub const Error = evaluator.Error ||
programmer.Error ||
units.Error ||
financial.Error;
/// The human-readable phrase for an error, with no prefix and no newline.
///
/// One table for every frontend, because they all need the same words: the CLI and
/// the TUI call this, and the Android app will receive these strings across the C
/// ABI. Each frontend adds its own decoration ("error: " and a newline for the CLI,
/// "error: " for the TUI) and a view with better context can override individual
/// cases, as the financial form does.
///
/// The switch has no `else`, so an error added to any module's set fails to compile
/// here rather than falling back to something vague. It also cannot word an error the
/// engine is incapable of returning: a prong naming one is a type error, since the
/// switch is over `Error`. That second property is what the hand-written `CalcError`
/// got wrong, and it needs no assertion of its own to hold. It carried `InvalidType`,
/// `InvalidFieldName`, `DuplicateFieldName` and `StructTooLarge` for a struct layout
/// module that does not exist yet, and gave all four a phrase.
pub fn phrase(err: Error) []const u8 {
return switch (err) {
// Parsing
error.UnexpectedToken => "unexpected token",
error.UnmatchedParen => "unmatched parenthesis",
error.UnexpectedEnd => "unexpected end of expression",
error.InvalidExpression => "invalid expression",
error.InvalidNumber => "invalid number",
// Names
error.UnknownFunction => "unknown function",
error.UnknownVariable => "unknown variable",
// Arithmetic
error.DivisionByZero => "division by zero",
error.DomainError => "domain error",
error.Overflow => "overflow",
error.InvalidOperandType => "invalid operand type",
// These two used to be flattened into Overflow and DomainError by a mapping
// between error sets. The specific wording is the whole reason the numeric
// tier bothered to distinguish them.
error.ExponentTooLarge => "the exponent is too large to compute",
error.NegativeRoot => "square root of a negative number",
// Units
error.UnknownUnit => "unknown unit",
error.IncompatibleUnits => "incompatible units (different categories)",
// Financial
error.InsufficientParameters => "these values do not determine an answer",
error.ConvergenceFailure => "no solution found",
// System
error.OutOfMemory => "out of memory",
};
}
const std = @import("std");
// -- Tests --
const testing = std.testing;
test {
testing.refAllDecls(@This());
}
test "every error the engine can return has its own phrase" {
// Exhaustive by construction; this checks the qualities the switch cannot state:
// non-empty, undecorated, single-line, and mutually distinct.
const fields = @typeInfo(Error).error_set.?;
var seen: [fields.len][]const u8 = undefined;
inline for (fields, 0..) |field, i| {
const text = phrase(@field(Error, field.name));
try testing.expect(text.len > 0);
// No prefix and no newline: decoration belongs to the caller.
try testing.expect(!std.mem.startsWith(u8, text, "error"));
try testing.expect(std.mem.indexOfScalar(u8, text, '\n') == null);
seen[i] = text;
}
for (seen, 0..) |text, i| {
for (seen[i + 1 ..]) |other| {
if (std.mem.eql(u8, text, other)) {
std.debug.print("two errors share the phrase \"{s}\"\n", .{text});
return error.TestUnexpectedResult;
}
}
}
}
test "the error set is derived from the modules, not enumerated here" {
// A module's errors reach `Error` without this file naming them, which is what
// makes the per-module sets safe to extend.
inline for (@typeInfo(financial.Error).error_set.?) |field| {
const promoted: Error = @field(Error, field.name);
try testing.expect(phrase(promoted).len > 0);
}
inline for (@typeInfo(units.Error).error_set.?) |field| {
const promoted: Error = @field(Error, field.name);
try testing.expect(phrase(promoted).len > 0);
}
inline for (@typeInfo(parser.Error).error_set.?) |field| {
const promoted: Error = @field(Error, field.name);
try testing.expect(phrase(promoted).len > 0);
}
}
test "the struct-layout errors are gone, not merely unused" {
// They were members of the old set with phrases nothing could produce. Absence
// is the assertion: naming one in `phrase` would not compile.
inline for (@typeInfo(Error).error_set.?) |field| {
try testing.expect(!std.mem.eql(u8, field.name, "StructTooLarge"));
try testing.expect(!std.mem.eql(u8, field.name, "InvalidFieldName"));
try testing.expect(!std.mem.eql(u8, field.name, "DuplicateFieldName"));
try testing.expect(!std.mem.eql(u8, field.name, "InvalidType"));
}
}
test "phrase is usable at comptime, which is how frontends decorate it" {
const decorated = comptime "error: " ++ phrase(error.DivisionByZero);
try testing.expectEqualStrings("error: division by zero", decorated);
}
test "the cases the TUI table used to lose" {
try testing.expectEqualStrings("invalid expression", phrase(error.InvalidExpression));
try testing.expectEqualStrings("no solution found", phrase(error.ConvergenceFailure));
try testing.expectEqualStrings(
"these values do not determine an answer",
phrase(error.InsufficientParameters),
);
}

View file

@ -1,136 +0,0 @@
//! The engine's error vocabulary, and the one place its wording lives.
//!
//! Every engine function returns `CalcError!T`. Errors carry no position or
//! context: an `ErrorInfo` with a source position was once specified and declared,
//! but nothing ever constructed one, and the parser fields that would have fed it
//! were written and never read. Adding position reporting means threading it
//! through the returns, which is a change to make deliberately.
const std = @import("std");
/// All possible engine errors.
pub const CalcError = error{
// Parser errors
UnexpectedToken,
UnmatchedParen,
InvalidNumber,
UnknownFunction,
UnknownVariable,
UnexpectedEnd,
InvalidExpression,
// Evaluation errors
DivisionByZero,
Overflow,
InvalidOperandType,
DomainError,
// Struct layout errors
InvalidType,
InvalidFieldName,
DuplicateFieldName,
StructTooLarge,
// Financial errors
InsufficientParameters,
ConvergenceFailure,
// Unit conversion errors
UnknownUnit,
IncompatibleUnits,
// System
OutOfMemory,
};
/// The human-readable phrase for an error, with no prefix and no newline.
///
/// The single source of these strings. The CLI and the TUI each had their own
/// switch over the same error set, differing only in punctuation and in what they
/// had forgotten: the TUI was missing `InsufficientParameters`, `ConvergenceFailure`
/// 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.
///
/// The switch has no `else`, so an error added to the set is a compile error here
/// rather than a silent fallback to a vague phrase.
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",
};
}
// -- Tests --
const testing = std.testing;
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 testing.expect(phrase.len > 0);
// No prefix and no newline: decoration belongs to the caller.
try testing.expect(!std.mem.startsWith(u8, phrase, "error"));
try 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 testing.expectEqualStrings("error: division by zero", decorated);
}
test "errorPhrase: the cases the TUI table used to lose" {
try testing.expectEqualStrings("invalid expression", errorPhrase(CalcError.InvalidExpression));
try testing.expectEqualStrings("no solution found", errorPhrase(CalcError.ConvergenceFailure));
try testing.expectEqualStrings(
"these values do not determine an answer",
errorPhrase(CalcError.InsufficientParameters),
);
}

View file

@ -13,7 +13,18 @@ const Expr = ast.Expr;
const BinaryOp = ast.BinaryOp;
const Integer = @import("Integer.zig");
const IntType = Integer.IntType;
const CalcError = @import("errors.zig").CalcError;
/// What standard-mode evaluation can fail with.
///
/// Its own name and range errors, plus everything its dependencies can raise. The
/// `||` chain is the honest signature: financial functions are callable from an
/// expression, so `ConvergenceFailure` really can come out of `evalString`, while a
/// bare `parser.parse` cannot produce it and no longer claims to.
pub const Error = error{
UnknownFunction,
UnknownVariable,
DomainError,
Overflow,
} || parser_mod.Error || number_mod.Error || bitwise.Error || financial.Error;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
const number_mod = @import("number.zig");
@ -122,7 +133,7 @@ pub const Environment = struct {
///
/// Task 2.0c replaces this boundary with a `Number`-returning API, which is what
/// the remaining integer-precision cases need.
pub fn evaluate(env: *Environment, expr: *const Expr) CalcError!f64 {
pub fn evaluate(env: *Environment, expr: *const Expr) Error!f64 {
// A scratch arena keeps Number lifetimes trivial: nothing in the recursive
// evaluator has to free intermediates, and the caller's allocator is never
// left holding them regardless of whether it is an arena itself.
@ -136,35 +147,35 @@ pub fn evaluate(env: *Environment, expr: *const Expr) CalcError!f64 {
/// The exact evaluation core. Produces a `Number`, staying exact until an
/// operation forces the float fallback (see design.md 2.7.4).
fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) CalcError!Number {
fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) Error!Number {
switch (expr.*) {
.number => |n| return literalToNumber(scratch, n),
.string_literal => |text| {
// Pack ASCII bytes into an integer (BE packing, as programmer mode).
var packed_value: u128 = 0;
for (text) |byte| {
if (byte > 0x7F) return CalcError.InvalidNumber;
if (byte > 0x7F) return Error.InvalidNumber;
packed_value = (packed_value << 8) | byte;
}
return Number.fromInt(scratch, packed_value) catch |err| return mapError(err);
return try Number.fromInt(scratch, packed_value);
},
.variable => |name| {
// getVar hands back a borrowed value owned by the environment, so
// copy it into the evaluation arena before it takes part in
// arithmetic that the arena will later free.
const value = env.getVar(name) orelse return CalcError.UnknownVariable;
return value.cloneWith(scratch) catch |err| mapError(err);
const value = env.getVar(name) orelse return Error.UnknownVariable;
return try value.cloneWith(scratch);
},
.assignment => |a| {
const val = try evalExact(env, scratch, a.value);
// setVar copies, so storing an arena-allocated value is safe.
env.setVar(a.name, val) catch return CalcError.OutOfMemory;
env.setVar(a.name, val) catch return Error.OutOfMemory;
return val;
},
.unary => |u| {
const operand = try evalExact(env, scratch, u.operand);
return switch (u.op) {
.negate => Number.negate(scratch, operand) catch |err| mapError(err),
.negate => try Number.negate(scratch, operand),
// Bitwise NOT is a fixed-width integer operation, not rational
// arithmetic, so it drops to the float/integer path. The width is
// the fixed standard-mode one: this used to read
@ -193,7 +204,7 @@ fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) CalcError
/// Decimal literals are re-parsed from their source text rather than taken from
/// `float_value`, because `float_value` has already rounded: `0.1` cannot be
/// recovered from its binary approximation.
fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) CalcError!Number {
fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) Error!Number {
if (n.base == .decimal and n.text.len > 0) {
if (Number.parse(scratch, n.text)) |value| return value else |_| {
// Fall through to the approximations below rather than failing: the
@ -204,25 +215,20 @@ fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) CalcError!Number {
// Non-decimal literals are integers; use the exact integer the tokenizer
// recovered when it fits, otherwise accept the float approximation.
if (n.int_value) |int_val| {
return Number.fromInt(scratch, int_val) catch |err| return mapError(err);
return try Number.fromInt(scratch, int_val);
}
return Number.fromFloat(n.float_value);
}
/// Map the numeric model's errors onto the engine's error set.
///
/// One mapping, in `number.zig`; this alias keeps the call sites short.
const mapError = number_mod.toCalcError;
/// 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) Error!Number {
return switch (op) {
.add => Number.add(scratch, left, right) catch |err| mapError(err),
.sub => Number.sub(scratch, left, right) catch |err| mapError(err),
.mul => Number.mul(scratch, left, right) catch |err| mapError(err),
.div => Number.div(scratch, left, right) catch |err| mapError(err),
.mod => Number.mod(scratch, left, right) catch |err| mapError(err),
.pow => Number.pow(scratch, left, right) catch |err| mapError(err),
.add => try Number.add(scratch, left, right),
.sub => try Number.sub(scratch, left, right),
.mul => try Number.mul(scratch, left, right),
.div => try Number.div(scratch, left, right),
.mod => try Number.mod(scratch, left, right),
.pow => try Number.pow(scratch, left, right),
// The remaining operators are fixed-width integer operations rather than
// rational arithmetic, so they work on the 64-bit projection, in the shared
// implementation programmer mode also uses (FR-2.12). `inline else`
@ -257,12 +263,12 @@ const standard_int_type: IntType = .{};
/// process: `2^64 and 1` and `~1e30` both killed it, and a NaN operand produced a
/// garbage answer instead. An operand that does not fit the width is a reportable
/// error, not a crash.
fn toFixedWidthBits(value: f64) CalcError!u128 {
if (!math.isFinite(value)) return CalcError.DomainError;
fn toFixedWidthBits(value: f64) Error!u128 {
if (!math.isFinite(value)) return Error.DomainError;
// i64 covers [-2^63, 2^63); 2^63 itself is the first excluded value and is
// exactly representable, so these bounds are exact.
if (value >= 9223372036854775808.0 or value < -9223372036854775808.0) {
return CalcError.Overflow;
return Error.Overflow;
}
const bits: u64 = @bitCast(@as(i64, @intFromFloat(value)));
return @as(u128, bits);
@ -274,39 +280,40 @@ fn fromFixedWidthBits(bits: u128) Number {
}
/// Evaluate a built-in function call.
fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) CalcError!Number {
fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) Error!Number {
// Single-argument functions
if (args.len == 1) {
const x = try evalExact(env, scratch, args[0]);
// Functions with an exact implementation.
if (std.mem.eql(u8, name, "abs")) {
return Number.abs(scratch, x) catch |err| mapError(err);
return try Number.abs(scratch, x);
}
if (std.mem.eql(u8, name, "floor")) {
return Number.floor(scratch, x) catch |err| mapError(err);
return try Number.floor(scratch, x);
}
if (std.mem.eql(u8, name, "ceil")) {
return Number.ceil(scratch, x) catch |err| mapError(err);
return try Number.ceil(scratch, x);
}
if (std.mem.eql(u8, name, "round")) {
return Number.round(scratch, x) catch |err| mapError(err);
return try Number.round(scratch, x);
}
if (std.mem.eql(u8, name, "sqrt")) {
// The negative-input rule lives in Number.sqrt, which raises
// NegativeRoot; mapError turns that into a domain error.
return Number.sqrt(scratch, x) catch |err| mapError(err);
// NegativeRoot. That name now reaches the user instead of being
// flattened into "domain error".
return try Number.sqrt(scratch, x);
}
if (std.mem.eql(u8, name, "factorial")) {
const result = Number.factorial(scratch, x) catch |err| return mapError(err);
const result = try Number.factorial(scratch, x);
// Null means the argument was negative or fractional, which is a domain
// error, not an unknown function.
return result orelse CalcError.DomainError;
return result orelse Error.DomainError;
}
// Everything else escapes the rationals, so it falls back to f64.
const f = try evalSingleArgFn(name, x.toFloat(scratch)) orelse
return CalcError.UnknownFunction;
return Error.UnknownFunction;
return Number.fromFloat(f);
}
@ -316,10 +323,10 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
const b = try evalExact(env, scratch, args[1]);
if (std.mem.eql(u8, name, "max")) {
return Number.max(scratch, a, b) catch |err| mapError(err);
return try Number.max(scratch, a, b);
}
if (std.mem.eql(u8, name, "min")) {
return Number.min(scratch, a, b) catch |err| mapError(err);
return try Number.min(scratch, a, b);
}
const x = a.toFloat(scratch);
@ -332,7 +339,7 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
}
if (std.mem.eql(u8, name, "log")) {
// log(value, base)
if (y <= 0 or y == 1 or x <= 0) return CalcError.DomainError;
if (y <= 0 or y == 1 or x <= 0) return Error.DomainError;
return Number.fromFloat(@log(x) / @log(y));
}
}
@ -361,15 +368,15 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [
}
}
return CalcError.UnknownFunction;
return Error.UnknownFunction;
}
/// A whole period count or 1-based period index, validated.
fn periodCount(value: f64) CalcError!usize {
if (!math.isFinite(value)) return CalcError.DomainError;
if (@floor(value) != value) return CalcError.DomainError;
fn periodCount(value: f64) Error!usize {
if (!math.isFinite(value)) return Error.DomainError;
if (@floor(value) != value) return Error.DomainError;
if (value < 1 or value > @as(f64, @floatFromInt(financial.max_schedule_periods))) {
return CalcError.DomainError;
return Error.DomainError;
}
return @intFromFloat(value);
}
@ -383,7 +390,7 @@ fn periodCount(value: f64) CalcError!usize {
///
/// Returns null when `name` is not a financial function, so the caller can carry
/// on to report an unknown function.
fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 {
fn evalFinancialFn(name: []const u8, a: []const f64) Error!?f64 {
if (a.len == 3) {
// cagr(start, end, periods) -> growth rate as a fraction.
if (std.mem.eql(u8, name, "cagr")) return try financial.cagr(a[0], a[1], a[2]);
@ -478,16 +485,16 @@ fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 {
/// 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 {
fn evalSingleArgFn(name: []const u8, x: f64) Error!?f64 {
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, "tan")) return @tan(x);
if (std.mem.eql(u8, name, "asin")) {
if (x < -1 or x > 1) return CalcError.DomainError;
if (x < -1 or x > 1) return Error.DomainError;
return math.asin(x);
}
if (std.mem.eql(u8, name, "acos")) {
if (x < -1 or x > 1) return CalcError.DomainError;
if (x < -1 or x > 1) return Error.DomainError;
return math.acos(x);
}
if (std.mem.eql(u8, name, "atan")) return math.atan(x);
@ -495,15 +502,15 @@ fn evalSingleArgFn(name: []const u8, x: f64) CalcError!?f64 {
// log already reported this as a domain error; the one-argument forms returned
// -inf or NaN.
if (std.mem.eql(u8, name, "log") or std.mem.eql(u8, name, "log10")) {
if (x <= 0) return CalcError.DomainError;
if (x <= 0) return Error.DomainError;
return @log10(x);
}
if (std.mem.eql(u8, name, "ln")) {
if (x <= 0) return CalcError.DomainError;
if (x <= 0) return Error.DomainError;
return @log(x);
}
if (std.mem.eql(u8, name, "log2")) {
if (x <= 0) return CalcError.DomainError;
if (x <= 0) return Error.DomainError;
return @log2(x);
}
if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x);
@ -522,14 +529,14 @@ pub const EvalInfo = struct {
/// High-level evaluate: parse a string and evaluate it.
/// Updates env.ans on success. The caller owns the returned value.
pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) CalcError!Number {
pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) Error!Number {
const info = try evalStringInfo(env, allocator, source);
return info.value;
}
/// Like evalString but returns metadata (whether the expression used
/// 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) Error!EvalInfo {
var p = Parser.init(allocator, source);
const expr = try p.parse();
// The parser hands over ownership. Nothing in the result borrows from the
@ -546,9 +553,9 @@ pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u
const scratch = arena.allocator();
const raw = try evalExact(env, scratch, expr);
const result = raw.cloneWith(allocator) catch |err| return mapError(err);
const result = try raw.cloneWith(allocator);
env.setAns(result) catch return CalcError.OutOfMemory;
env.setAns(result) catch return Error.OutOfMemory;
return .{
.value = result,
.has_nondecimal_literal = hasNonDecimalLiteral(expr),
@ -629,7 +636,7 @@ test "eval division" {
test "eval division by zero" {
const result = testEval("1 / 0");
try testing.expectError(CalcError.DivisionByZero, result);
try testing.expectError(Error.DivisionByZero, result);
}
test "eval modulo" {
@ -754,12 +761,12 @@ test "eval min" {
test "eval unknown function" {
const result = testEval("bogus(1)");
try testing.expectError(CalcError.UnknownFunction, result);
try testing.expectError(Error.UnknownFunction, result);
}
test "eval unknown variable" {
const result = testEval("xyz");
try testing.expectError(CalcError.UnknownVariable, result);
try testing.expectError(Error.UnknownVariable, result);
}
test "eval variable assignment and use" {
@ -906,10 +913,10 @@ test "standard mode: a shift runs to completion instead of wrapping the distance
test "standard mode: a negative shift distance is a domain error" {
// It used to be reduced modulo 64, so `8 >> -1` quietly became `8 >> 63`.
try testing.expectError(CalcError.DomainError, testEval("8 >> 0 - 1"));
try testing.expectError(CalcError.DomainError, testEval("8 << 0 - 1"));
try testing.expectError(CalcError.DomainError, testEval("8 >>> 0 - 1"));
try testing.expectError(CalcError.DomainError, testEval("8 rol 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 >> 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 << 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 >>> 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 rol 0 - 1"));
}
test "standard mode: rotation is cyclic, not clamped" {
@ -965,30 +972,34 @@ test "eval log with base" {
test "eval log domain error" {
const result = testEval("log(-1, 10)");
try testing.expectError(CalcError.DomainError, result);
try testing.expectError(Error.DomainError, result);
}
test "domain errors are domain errors, not unknown functions" {
// These pinned the wrong contract: the name is known, the argument is not in
// its domain. Reporting "unknown function" sent the user looking for a typo.
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)"));
try testing.expectError(Error.DomainError, testEval("asin(2)"));
try testing.expectError(Error.DomainError, testEval("asin(-2)"));
try testing.expectError(Error.DomainError, testEval("acos(2)"));
try testing.expectError(Error.DomainError, testEval("factorial(-1)"));
try testing.expectError(Error.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)"));
try testing.expectError(Error.DomainError, testEval("ln(0)"));
try testing.expectError(Error.DomainError, testEval("ln(0 - 1)"));
try testing.expectError(Error.DomainError, testEval("log(0)"));
try testing.expectError(Error.DomainError, testEval("log10(0 - 5)"));
try testing.expectError(Error.DomainError, testEval("log2(0)"));
try testing.expectError(Error.DomainError, testEval("log(100, 1)"));
// sqrt says which domain rule was broken, because the numeric tier raises its
// own error and nothing flattens it on the way out.
try testing.expectError(Error.NegativeRoot, testEval("sqrt(-1)"));
try testing.expectError(Error.NegativeRoot, testEval("sqrt(0 - 4)"));
// A genuinely unknown name still reports one.
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1)"));
try testing.expectError(CalcError.UnknownFunction, testEval("asin(1, 2)"));
try testing.expectError(Error.UnknownFunction, testEval("nope(1)"));
try testing.expectError(Error.UnknownFunction, testEval("asin(1, 2)"));
}
test "the functions themselves still work inside their domains" {
@ -1053,7 +1064,7 @@ test "eval string literal multi-char in standard mode" {
test "eval string literal with non-ASCII byte errors" {
// byte > 0x7F is rejected
const result = testEval("'\x80'");
try testing.expectError(CalcError.InvalidNumber, result);
try testing.expectError(Error.InvalidNumber, result);
}
test "eval rand zero-arg function returns 0" {
@ -1063,17 +1074,17 @@ test "eval rand zero-arg function returns 0" {
test "eval unknown zero-arg function" {
const result = testEval("bogus()");
try testing.expectError(CalcError.UnknownFunction, result);
try testing.expectError(Error.UnknownFunction, result);
}
test "eval unknown three-arg function" {
const result = testEval("bogus(1, 2, 3)");
try testing.expectError(CalcError.UnknownFunction, result);
try testing.expectError(Error.UnknownFunction, result);
}
test "eval unknown two-arg function" {
const result = testEval("bogus(1, 2)");
try testing.expectError(CalcError.UnknownFunction, result);
try testing.expectError(Error.UnknownFunction, result);
}
// -- Exact arithmetic (Task 2.0b) --
@ -1191,10 +1202,10 @@ test "exact: a transcendental contaminates the rest of the expression" {
try testing.expectApproxEqAbs(@as(f64, 0.3), result, 1e-15);
}
test "exact: overflow from an absurd exponent is reported as overflow" {
// The exponent guard in the rational layer surfaces as Overflow rather than
// silently producing infinity or exhausting memory.
try testing.expectError(CalcError.Overflow, testEval("2 ^ 3000000"));
test "exact: an absurd exponent is reported as an exponent that is too large" {
// The rational layer's guard reaches the caller by its own name rather than as a
// generic Overflow, which is what the old single error set turned it into.
try testing.expectError(Error.ExponentTooLarge, testEval("2 ^ 3000000"));
}
// -- Exactness visible through the Number API (Task 2.0c) --
@ -1430,7 +1441,7 @@ test "financial: apy converts a nominal rate to an effective one" {
try testEval("apy(compound_rate(1000, fv(1000, 18, 5, 12), 5, 12), 12)"),
1e-4,
);
try testing.expectError(CalcError.DomainError, testEval("apy(5, 0)"));
try testing.expectError(Error.DomainError, testEval("apy(5, 0)"));
}
test "financial: the tvm solvers are reachable as four-argument functions" {
@ -1475,26 +1486,26 @@ test "financial: results are inexact, so they do not claim exactness" {
test "financial: bad arguments are domain errors, not wrong answers" {
// Zero periods.
try testing.expectError(CalcError.DomainError, testEval("cagr(1000, 2000, 0)"));
try testing.expectError(Error.DomainError, testEval("cagr(1000, 2000, 0)"));
// A fractional period count cannot index an amortization schedule.
try testing.expectError(CalcError.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)"));
try testing.expectError(Error.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)"));
// Period past the end of the schedule.
try testing.expectError(CalcError.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)"));
try testing.expectError(Error.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)"));
// Payments that never retire the loan.
try testing.expectError(CalcError.DomainError, testEval("amort_payment(0, 0.5, 360)"));
try testing.expectError(Error.DomainError, testEval("amort_payment(0, 0.5, 360)"));
// Period counts outside the schedule bounds.
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 0)"));
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 20000)"));
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 10^400)"));
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 0)"));
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 20000)"));
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 10^400)"));
}
test "financial: wrong argument counts are unknown functions, not silent defaults" {
try testing.expectError(CalcError.UnknownFunction, testEval("cagr(10000, 25000)"));
try testing.expectError(CalcError.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)"));
try testing.expectError(CalcError.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)"));
try testing.expectError(Error.UnknownFunction, testEval("cagr(10000, 25000)"));
try testing.expectError(Error.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)"));
try testing.expectError(Error.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)"));
// A three or four argument call to something that is not a function at all.
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3)"));
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3, 4)"));
try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3)"));
try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3, 4)"));
}
// -- The AST is not the caller's problem --
@ -1596,9 +1607,9 @@ test "grouped digits still work, inside and outside a call" {
test "a malformed group is an error, not a silently merged number" {
// Two digits after the comma is neither a group nor a valid argument list
// here, so it fails loudly instead of evaluating as 100.
try testing.expectError(CalcError.UnexpectedToken, testEval("1,00"));
try testing.expectError(CalcError.UnexpectedToken, testEval("1,0000"));
try testing.expectError(CalcError.UnexpectedToken, testEval("2+3,4"));
try testing.expectError(Error.UnexpectedToken, testEval("1,00"));
try testing.expectError(Error.UnexpectedToken, testEval("1,0000"));
try testing.expectError(Error.UnexpectedToken, testEval("2+3,4"));
}
test "a grouped literal past 2^53 is still exact" {
@ -1638,16 +1649,16 @@ test "bitwise operands outside i64 report overflow instead of aborting" {
};
for (cases) |source| {
const result = testEval(source);
try testing.expectError(CalcError.Overflow, result);
try testing.expectError(Error.Overflow, result);
}
}
test "a non-finite bitwise operand is a domain error" {
// ln(-1) is NaN, and 1/0 raises before it can reach here, so NaN arrives via
// the transcendental fallback.
try testing.expectError(CalcError.DomainError, testEval("~ln(-1)"));
try testing.expectError(CalcError.DomainError, testEval("ln(-1) and 1"));
try testing.expectError(CalcError.DomainError, testEval("1 << ln(-1)"));
try testing.expectError(Error.DomainError, testEval("~ln(-1)"));
try testing.expectError(Error.DomainError, testEval("ln(-1) and 1"));
try testing.expectError(Error.DomainError, testEval("1 << ln(-1)"));
}
test "bitwise operators still work at the edges of the range" {

View file

@ -27,7 +27,18 @@
const std = @import("std");
const math = std.math;
const CalcError = @import("errors.zig").CalcError;
/// What the financial calculations can fail with.
///
/// `InsufficientParameters` and `ConvergenceFailure` are theirs alone: no other part
/// of the engine can produce either, and under the old single error set every
/// function in the engine claimed both.
pub const Error = error{
InsufficientParameters,
ConvergenceFailure,
DomainError,
DivisionByZero,
OutOfMemory,
};
/// Iteration cap for the rate solver.
pub const max_iterations: usize = 1000;
@ -40,11 +51,11 @@ pub const tolerance: f64 = 1e-10;
/// Compound annual growth rate, as a decimal fraction (0.2011 means 20.11%).
///
/// cagr = (end / start)^(1/periods) - 1
pub fn cagr(start_value: f64, end_value: f64, periods: f64) CalcError!f64 {
if (periods <= 0) return CalcError.DomainError;
pub fn cagr(start_value: f64, end_value: f64, periods: f64) Error!f64 {
if (periods <= 0) return Error.DomainError;
// A zero or negative starting value has no meaningful growth rate, and a
// negative ending value would need a complex root.
if (start_value <= 0 or end_value < 0) return CalcError.DomainError;
if (start_value <= 0 or end_value < 0) return Error.DomainError;
return math.pow(f64, end_value / start_value, 1.0 / periods) - 1.0;
}
@ -59,11 +70,11 @@ pub fn compoundFutureValue(
annual_rate: f64,
years: f64,
compounds_per_year: f64,
) CalcError!f64 {
if (compounds_per_year <= 0) return CalcError.DomainError;
if (years < 0) return CalcError.DomainError;
) Error!f64 {
if (compounds_per_year <= 0) return Error.DomainError;
if (years < 0) return Error.DomainError;
const periodic = annual_rate / 100.0 / compounds_per_year;
if (periodic <= -1.0) return CalcError.DomainError;
if (periodic <= -1.0) return Error.DomainError;
return present_value * math.pow(f64, 1.0 + periodic, compounds_per_year * years);
}
@ -74,13 +85,13 @@ pub fn compoundPresentValue(
annual_rate: f64,
years: f64,
compounds_per_year: f64,
) CalcError!f64 {
if (compounds_per_year <= 0) return CalcError.DomainError;
if (years < 0) return CalcError.DomainError;
) Error!f64 {
if (compounds_per_year <= 0) return Error.DomainError;
if (years < 0) return Error.DomainError;
const periodic = annual_rate / 100.0 / compounds_per_year;
if (periodic <= -1.0) return CalcError.DomainError;
if (periodic <= -1.0) return Error.DomainError;
const factor = math.pow(f64, 1.0 + periodic, compounds_per_year * years);
if (factor == 0) return CalcError.DivisionByZero;
if (factor == 0) return Error.DivisionByZero;
return future_value / factor;
}
@ -101,21 +112,21 @@ pub fn compoundRate(
future_value: f64,
years: f64,
compounds_per_year: f64,
) CalcError!f64 {
if (compounds_per_year <= 0) return CalcError.DomainError;
) Error!f64 {
if (compounds_per_year <= 0) return Error.DomainError;
// With no time elapsed, any rate satisfies pv == fv and none satisfies
// pv != fv, so there is no answer to give.
if (years <= 0) return CalcError.DomainError;
if (present_value == 0) return CalcError.DomainError;
if (years <= 0) return Error.DomainError;
if (present_value == 0) return Error.DomainError;
const ratio = future_value / present_value;
// A sign change has no real root: no rate turns 1000 into -500.
if (!(ratio > 0)) return CalcError.DomainError;
if (!(ratio > 0)) return Error.DomainError;
const periods = compounds_per_year * years;
const periodic = math.pow(f64, ratio, 1.0 / periods) - 1.0;
const rate = periodic * compounds_per_year * 100.0;
if (!math.isFinite(rate)) return CalcError.DomainError;
if (!math.isFinite(rate)) return Error.DomainError;
return rate;
}
@ -128,23 +139,23 @@ pub fn compoundPeriods(
future_value: f64,
annual_rate: f64,
compounds_per_year: f64,
) CalcError!f64 {
if (compounds_per_year <= 0) return CalcError.DomainError;
if (present_value == 0) return CalcError.DomainError;
) Error!f64 {
if (compounds_per_year <= 0) return Error.DomainError;
if (present_value == 0) return Error.DomainError;
const ratio = future_value / present_value;
if (!(ratio > 0)) return CalcError.DomainError;
if (!(ratio > 0)) return Error.DomainError;
// Already there, whatever the rate.
if (ratio == 1) return 0;
const periodic = annual_rate / 100.0 / compounds_per_year;
if (periodic <= -1.0) return CalcError.DomainError;
if (periodic <= -1.0) return Error.DomainError;
// A zero rate never moves the balance, so no amount of time reaches a
// different future value.
if (periodic == 0) return CalcError.DomainError;
if (periodic == 0) return Error.DomainError;
const years = @log(ratio) / @log(1.0 + periodic) / compounds_per_year;
if (!math.isFinite(years)) return CalcError.DomainError;
if (!math.isFinite(years)) return Error.DomainError;
return years;
}
@ -155,12 +166,12 @@ pub fn compoundPeriods(
///
/// 18% compounded monthly is 19.56% effective. Reporting a solved nominal rate
/// without this is how rate comparisons go wrong.
pub fn effectiveAnnualRate(annual_rate: f64, compounds_per_year: f64) CalcError!f64 {
if (compounds_per_year <= 0) return CalcError.DomainError;
pub fn effectiveAnnualRate(annual_rate: f64, compounds_per_year: f64) Error!f64 {
if (compounds_per_year <= 0) return Error.DomainError;
const periodic = annual_rate / 100.0 / compounds_per_year;
if (periodic <= -1.0) return CalcError.DomainError;
if (periodic <= -1.0) return Error.DomainError;
const grown = math.pow(f64, 1.0 + periodic, compounds_per_year);
if (!math.isFinite(grown)) return CalcError.DomainError;
if (!math.isFinite(grown)) return Error.DomainError;
return (grown - 1.0) * 100.0;
}
@ -226,7 +237,7 @@ fn tvmResidual(rate: f64, periods: f64, pv: f64, pmt: f64, fv: f64, due: bool) f
}
/// Solve for whichever variable is null.
pub fn solveTvm(params: TvmParams) CalcError!TvmSolution {
pub fn solveTvm(params: TvmParams) Error!TvmSolution {
// Exactly one unknown.
var unknowns: usize = 0;
var which: TvmVariable = .future_value;
@ -250,7 +261,7 @@ pub fn solveTvm(params: TvmParams) CalcError!TvmSolution {
unknowns += 1;
which = .future_value;
}
if (unknowns != 1) return CalcError.InsufficientParameters;
if (unknowns != 1) return Error.InsufficientParameters;
return switch (which) {
.future_value => .{ .variable = which, .value = try solveFutureValue(params) },
@ -261,41 +272,41 @@ pub fn solveTvm(params: TvmParams) CalcError!TvmSolution {
};
}
fn solveFutureValue(p: TvmParams) CalcError!f64 {
fn solveFutureValue(p: TvmParams) Error!f64 {
const r = p.rate.? / 100.0;
const n = p.periods.?;
if (r <= -1.0) return CalcError.DomainError;
if (r <= -1.0) return Error.DomainError;
return -(p.present_value.? * growth(r, n) + p.payment.? * annuityFactor(r, n, p.due));
}
fn solvePresentValue(p: TvmParams) CalcError!f64 {
fn solvePresentValue(p: TvmParams) Error!f64 {
const r = p.rate.? / 100.0;
const n = p.periods.?;
if (r <= -1.0) return CalcError.DomainError;
if (r <= -1.0) return Error.DomainError;
const g = growth(r, n);
if (g == 0) return CalcError.DivisionByZero;
if (g == 0) return Error.DivisionByZero;
return -(p.future_value.? + p.payment.? * annuityFactor(r, n, p.due)) / g;
}
fn solvePayment(p: TvmParams) CalcError!f64 {
fn solvePayment(p: TvmParams) Error!f64 {
const r = p.rate.? / 100.0;
const n = p.periods.?;
if (r <= -1.0) return CalcError.DomainError;
if (r <= -1.0) return Error.DomainError;
const af = annuityFactor(r, n, p.due);
if (af == 0) return CalcError.DivisionByZero;
if (af == 0) return Error.DivisionByZero;
return -(p.present_value.? * growth(r, n) + p.future_value.?) / af;
}
fn solvePeriods(p: TvmParams) CalcError!f64 {
fn solvePeriods(p: TvmParams) Error!f64 {
const r = p.rate.? / 100.0;
const pv = p.present_value.?;
const pmt = p.payment.?;
const fv = p.future_value.?;
if (r <= -1.0) return CalcError.DomainError;
if (r <= -1.0) return Error.DomainError;
// With no interest the equation is linear: pv + pmt*n + fv = 0.
if (r == 0) {
if (pmt == 0) return CalcError.InsufficientParameters;
if (pmt == 0) return Error.InsufficientParameters;
return -(pv + fv) / pmt;
}
@ -304,14 +315,14 @@ fn solvePeriods(p: TvmParams) CalcError!f64 {
const d: f64 = if (p.due) 1.0 + r else 1.0;
const a = pmt * d / r;
const denominator = pv + a;
if (denominator == 0) return CalcError.DivisionByZero;
if (denominator == 0) return Error.DivisionByZero;
const g = (a - fv) / denominator;
// A non-positive growth factor has no real logarithm: the cash flows cannot
// reach the requested future value at this rate.
if (g <= 0) return CalcError.DomainError;
if (g <= 0) return Error.DomainError;
const base = 1.0 + r;
if (base <= 0) return CalcError.DomainError;
if (base <= 0) return Error.DomainError;
return @log(g) / @log(base);
}
@ -343,15 +354,15 @@ fn solvePeriods(p: TvmParams) CalcError!f64 {
/// input that does have an answer (n=360 with r near 6% and a matching payment
/// is one). Fixing that means scaling by the computed terms rather than by the
/// inputs, which changes acceptance for every case and needs its own testing.
fn solveRate(p: TvmParams) CalcError!TvmSolution {
fn solveRate(p: TvmParams) Error!TvmSolution {
const n = p.periods.?;
const pv = p.present_value.?;
const pmt = p.payment.?;
const fv = p.future_value.?;
if (n <= 0) return CalcError.DomainError;
if (n <= 0) return Error.DomainError;
// A sign change in the cash flows is necessary for a solution to exist.
if (pv == 0 and pmt == 0 and fv == 0) return CalcError.InsufficientParameters;
if (pv == 0 and pmt == 0 and fv == 0) return Error.InsufficientParameters;
// Residuals are proportional to the size of the cash flows, so the
// acceptance threshold has to be too.
@ -396,7 +407,7 @@ fn solveRate(p: TvmParams) CalcError!TvmSolution {
r = next;
}
}
return CalcError.ConvergenceFailure;
return Error.ConvergenceFailure;
}
// -- Money rounding --
@ -500,15 +511,15 @@ pub const AmortizationTotals = struct {
};
/// The level payment implied by a loan, as a positive amount.
pub fn amortizationPayment(p: AmortizationParams) CalcError!f64 {
if (p.principal <= 0) return CalcError.DomainError;
if (p.periods == 0 or p.periods > max_schedule_periods) return CalcError.DomainError;
pub fn amortizationPayment(p: AmortizationParams) Error!f64 {
if (p.principal <= 0) return Error.DomainError;
if (p.periods == 0 or p.periods > max_schedule_periods) return Error.DomainError;
// A negative rate would mean the balance shrinks on its own, which is not
// something an amortization table describes.
if (p.rate < 0) return CalcError.DomainError;
if (p.rate < 0) return Error.DomainError;
if (p.payment) |given| {
if (given <= 0) return CalcError.DomainError;
if (given <= 0) return Error.DomainError;
return if (p.round_cents) roundToCents(given) else given;
}
@ -521,7 +532,7 @@ pub fn amortizationPayment(p: AmortizationParams) CalcError!f64 {
// solveTvm returns the payment as a cash outflow; a schedule wants the
// magnitude.
const amount = -solution.value;
if (!math.isFinite(amount) or amount <= 0) return CalcError.DomainError;
if (!math.isFinite(amount) or amount <= 0) return Error.DomainError;
return if (p.round_cents) roundToCents(amount) else amount;
}
@ -537,7 +548,7 @@ const AmortizationCursor = struct {
balance: f64,
period: usize = 0,
fn init(p: AmortizationParams) CalcError!AmortizationCursor {
fn init(p: AmortizationParams) Error!AmortizationCursor {
const payment = try amortizationPayment(p);
const rate = p.rate / 100.0;
const balance = if (p.round_cents) roundToCents(p.principal) else p.principal;
@ -546,7 +557,7 @@ const AmortizationCursor = struct {
// reduces the balance: the loan grows forever, and there is no schedule
// to print.
const first_interest = balance * rate;
if (payment <= first_interest) return CalcError.DomainError;
if (payment <= first_interest) return Error.DomainError;
return .{ .params = p, .payment = payment, .rate = rate, .balance = balance };
}
@ -584,21 +595,21 @@ const AmortizationCursor = struct {
};
/// A single period of a schedule, without building the whole table.
pub fn amortizationEntry(p: AmortizationParams, period: usize) CalcError!AmortizationEntry {
if (period == 0) return CalcError.DomainError;
pub fn amortizationEntry(p: AmortizationParams, period: usize) Error!AmortizationEntry {
if (period == 0) return Error.DomainError;
var cursor = try AmortizationCursor.init(p);
while (cursor.next()) |entry| {
if (entry.period == period) return entry;
}
// The loan was retired before this period, so the period does not exist.
return CalcError.DomainError;
return Error.DomainError;
}
/// The full schedule. Caller owns the returned slice.
pub fn amortizationSchedule(
allocator: std.mem.Allocator,
p: AmortizationParams,
) CalcError![]AmortizationEntry {
) Error![]AmortizationEntry {
var cursor = try AmortizationCursor.init(p);
var rows: std.ArrayList(AmortizationEntry) = .empty;
errdefer rows.deinit(allocator);
@ -609,7 +620,7 @@ pub fn amortizationSchedule(
}
/// Schedule totals, computed without allocating a table.
pub fn amortizationTotals(p: AmortizationParams) CalcError!AmortizationTotals {
pub fn amortizationTotals(p: AmortizationParams) Error!AmortizationTotals {
var cursor = try AmortizationCursor.init(p);
var totals: AmortizationTotals = .{ .periods = 0, .paid = 0, .interest = 0, .principal = 0 };
while (cursor.next()) |entry| {
@ -661,11 +672,11 @@ test "cagr: single period is the simple return" {
}
test "cagr: domain errors" {
try testing.expectError(CalcError.DomainError, cagr(1000, 2000, 0));
try testing.expectError(CalcError.DomainError, cagr(1000, 2000, -5));
try testing.expectError(CalcError.DomainError, cagr(0, 2000, 5));
try testing.expectError(CalcError.DomainError, cagr(-1000, 2000, 5));
try testing.expectError(CalcError.DomainError, cagr(1000, -1, 5));
try testing.expectError(Error.DomainError, cagr(1000, 2000, 0));
try testing.expectError(Error.DomainError, cagr(1000, 2000, -5));
try testing.expectError(Error.DomainError, cagr(0, 2000, 5));
try testing.expectError(Error.DomainError, cagr(-1000, 2000, 5));
try testing.expectError(Error.DomainError, cagr(1000, -1, 5));
}
test "compound interest: annual compounding" {
@ -707,11 +718,11 @@ test "compound interest: present value textbook figure" {
}
test "compound interest: domain errors" {
try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, 5, 10, 0));
try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, 5, -1, 12));
try testing.expectError(CalcError.DomainError, compoundPresentValue(1000, 5, 10, 0));
try testing.expectError(Error.DomainError, compoundFutureValue(1000, 5, 10, 0));
try testing.expectError(Error.DomainError, compoundFutureValue(1000, 5, -1, 12));
try testing.expectError(Error.DomainError, compoundPresentValue(1000, 5, 10, 0));
// A rate of -100% per period wipes the base out entirely.
try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, -1200, 10, 12));
try testing.expectError(Error.DomainError, compoundFutureValue(1000, -1200, 10, 12));
}
test "tvm: solve payment for a classic 30-year mortgage" {
@ -880,7 +891,7 @@ test "tvm: annuity due round-trips too" {
test "tvm: requires exactly one unknown" {
// All five supplied.
try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
.periods = 10,
.rate = 5,
.present_value = 100,
@ -888,13 +899,13 @@ test "tvm: requires exactly one unknown" {
.future_value = 0,
}));
// Two unknowns.
try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
.periods = 10,
.rate = 5,
.present_value = 100,
}));
// Nothing supplied at all.
try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{}));
try testing.expectError(Error.InsufficientParameters, solveTvm(.{}));
}
test "tvm: unsolvable cash flows report convergence failure, not a wrong answer" {
@ -905,17 +916,17 @@ test "tvm: unsolvable cash flows report convergence failure, not a wrong answer"
.payment = 100,
.future_value = 5000,
});
try testing.expectError(CalcError.ConvergenceFailure, result);
try testing.expectError(Error.ConvergenceFailure, result);
}
test "tvm: rate solver rejects degenerate input" {
try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
.periods = 10,
.present_value = 0,
.payment = 0,
.future_value = 0,
}));
try testing.expectError(CalcError.DomainError, solveTvm(.{
try testing.expectError(Error.DomainError, solveTvm(.{
.periods = 0,
.present_value = -100,
.payment = 0,
@ -925,7 +936,7 @@ test "tvm: rate solver rejects degenerate input" {
test "tvm: unreachable future value has no real period count" {
// Paying nothing can never grow 1000 into 5000.
try testing.expectError(CalcError.DomainError, solveTvm(.{
try testing.expectError(Error.DomainError, solveTvm(.{
.rate = 5,
.present_value = 1000,
.payment = 0,
@ -934,7 +945,7 @@ test "tvm: unreachable future value has no real period count" {
}
test "tvm: periods with zero rate and zero payment is unsolvable" {
try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
.rate = 0,
.present_value = 1000,
.payment = 0,
@ -1181,13 +1192,13 @@ test "amortization: an underfunded term ends in a balloon payment" {
test "amortization: a payment below the first interest charge is rejected" {
// 1000 of interest in month one, so 500 never touches principal.
try testing.expectError(CalcError.DomainError, amortizationPayment(.{
try testing.expectError(Error.DomainError, amortizationPayment(.{
.principal = 200000,
.rate = 0.5,
.periods = 360,
.payment = -1,
}));
try testing.expectError(CalcError.DomainError, amortizationEntry(.{
try testing.expectError(Error.DomainError, amortizationEntry(.{
.principal = 200000,
.rate = 0.5,
.periods = 360,
@ -1196,22 +1207,22 @@ test "amortization: a payment below the first interest charge is rejected" {
}
test "amortization: rejects nonsense loan terms" {
try testing.expectError(CalcError.DomainError, amortizationPayment(.{
try testing.expectError(Error.DomainError, amortizationPayment(.{
.principal = 0,
.rate = 0.5,
.periods = 12,
}));
try testing.expectError(CalcError.DomainError, amortizationPayment(.{
try testing.expectError(Error.DomainError, amortizationPayment(.{
.principal = 1000,
.rate = 0.5,
.periods = 0,
}));
try testing.expectError(CalcError.DomainError, amortizationPayment(.{
try testing.expectError(Error.DomainError, amortizationPayment(.{
.principal = 1000,
.rate = -1,
.periods = 12,
}));
try testing.expectError(CalcError.DomainError, amortizationPayment(.{
try testing.expectError(Error.DomainError, amortizationPayment(.{
.principal = 1000,
.rate = 0.5,
.periods = max_schedule_periods + 1,
@ -1220,8 +1231,8 @@ test "amortization: rejects nonsense loan terms" {
test "amortization: periods outside the schedule are an error" {
const params: AmortizationParams = .{ .principal = 1200, .rate = 0, .periods = 12 };
try testing.expectError(CalcError.DomainError, amortizationEntry(params, 0));
try testing.expectError(CalcError.DomainError, amortizationEntry(params, 13));
try testing.expectError(Error.DomainError, amortizationEntry(params, 0));
try testing.expectError(Error.DomainError, amortizationEntry(params, 13));
}
test "amortization: unrounded mode keeps full precision" {
@ -1252,7 +1263,7 @@ test "amortization: schedule allocation failure frees the partial table" {
allocator.free(rows);
return;
} else |err| {
try testing.expectEqual(CalcError.OutOfMemory, err);
try testing.expectEqual(Error.OutOfMemory, err);
}
}
return error.AllocationSweepNeverCompleted;
@ -1300,18 +1311,18 @@ test "compoundRate: unchanged value is a zero rate" {
test "compoundRate: domain errors" {
// No time elapsed: nothing to solve.
try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 0, 1));
try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, -5, 1));
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 0, 1));
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, -5, 1));
// No compounding frequency.
try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 10, 0));
try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 10, -12));
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 10, 0));
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 10, -12));
// Nothing to grow from.
try testing.expectError(CalcError.DomainError, compoundRate(0, 2000, 10, 1));
try testing.expectError(Error.DomainError, compoundRate(0, 2000, 10, 1));
// A sign change has no real root.
try testing.expectError(CalcError.DomainError, compoundRate(1000, -500, 10, 1));
try testing.expectError(CalcError.DomainError, compoundRate(-1000, 500, 10, 1));
try testing.expectError(Error.DomainError, compoundRate(1000, -500, 10, 1));
try testing.expectError(Error.DomainError, compoundRate(-1000, 500, 10, 1));
// Reaching exactly zero would need a rate of -100%, which is a limit.
try testing.expectError(CalcError.DomainError, compoundRate(1000, 0, 10, 1));
try testing.expectError(Error.DomainError, compoundRate(1000, 0, 10, 1));
}
test "compoundPeriods: inverts compoundFutureValue" {
@ -1350,13 +1361,13 @@ test "compoundPeriods: already there takes no time at all" {
test "compoundPeriods: domain errors" {
// A zero rate never reaches a different value.
try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, 0, 1));
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, 0, 1));
// -100% or worse is not a rate.
try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, -100, 1));
try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, -150, 1));
try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, 5, 0));
try testing.expectError(CalcError.DomainError, compoundPeriods(0, 2000, 5, 1));
try testing.expectError(CalcError.DomainError, compoundPeriods(1000, -2000, 5, 1));
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, -100, 1));
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, -150, 1));
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, 5, 0));
try testing.expectError(Error.DomainError, compoundPeriods(0, 2000, 5, 1));
try testing.expectError(Error.DomainError, compoundPeriods(1000, -2000, 5, 1));
}
test "effectiveAnnualRate: monthly compounding beats its nominal rate" {
@ -1376,8 +1387,8 @@ test "effectiveAnnualRate: a negative nominal rate stays negative" {
}
test "effectiveAnnualRate: domain errors" {
try testing.expectError(CalcError.DomainError, effectiveAnnualRate(5, 0));
try testing.expectError(CalcError.DomainError, effectiveAnnualRate(-100, 1));
try testing.expectError(Error.DomainError, effectiveAnnualRate(5, 0));
try testing.expectError(Error.DomainError, effectiveAnnualRate(-100, 1));
}
test "compound interest: the four variables round-trip through each other" {

View file

@ -19,29 +19,17 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const rational = @import("rational.zig");
const Rational = rational.Rational;
const CalcError = @import("errors.zig").CalcError;
const Rational = @import("Rational.zig");
/// Map a numeric-model error onto the engine's error set.
/// The errors arithmetic on `Number` can produce, which are `Rational`'s: this tier
/// adds no failure of its own.
///
/// 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;
/// There used to be a `toCalcError` here translating these into a single engine-wide
/// error set, because every module returned that one set. The names it translated
/// away were the more useful ones: `ExponentTooLarge` became `Overflow` and
/// `NegativeRoot` became `DomainError`, so `sqrt(-1)` reported "domain error" when
/// the engine knew exactly what was wrong.
pub const Error = Rational.Error;
/// Denominator size at which an exact result is demoted to inexact.
///
@ -617,31 +605,12 @@ test "sqrt: a negative input is a domain error, not a silent NaN" {
try testing.expect(root_zero.isExact());
}
test "sqrt: the domain error reaches the engine error set as a domain error" {
test "sqrt: a negative input surfaces as NegativeRoot, not a vaguer error" {
var neg = try Number.fromInt(alloc, -4);
defer neg.deinit();
try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, neg));
}
test "toCalcError maps every numeric error, with no default" {
const CalcErr = @import("errors.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" {
var a = try Number.fromInt(alloc, 42);
defer a.deinit();
@ -911,7 +880,7 @@ test "max and min with an inexact operand return that operand as-is" {
// -- Allocation-failure safety --
//
// Mirrors the sweep in rational.zig: fail the Nth allocation for every N, and
// Mirrors the sweep in Rational.zig: fail the Nth allocation for every N, and
// let testing.allocator's leak detection verify that partially-built values are
// released. This is what actually validates the cleanup paths; merely executing
// them proves nothing.

View file

@ -20,7 +20,21 @@ const Tokenizer = tokenizer_mod.Tokenizer;
const TokenKind = tokenizer_mod.TokenKind;
const Token = tokenizer_mod.Token;
const parseNumber = tokenizer_mod.parseNumber;
const CalcError = @import("errors.zig").CalcError;
/// What parsing can fail with.
///
/// Declared here rather than shared: the parser used to return a single engine-wide
/// error set, so its signature claimed it might return `ConvergenceFailure`,
/// `UnknownUnit` and `StructTooLarge`. No caller could switch on what a parse can
/// actually produce.
pub const Error = error{
UnexpectedToken,
UnmatchedParen,
UnexpectedEnd,
InvalidExpression,
InvalidNumber,
OutOfMemory,
};
/// Precedence levels (higher = tighter binding).
///
@ -92,19 +106,19 @@ pub const Parser = struct {
/// path below frees what it built. Without that, a single typo in an
/// interactive session leaks the partial tree, which is exactly what the TUI
/// does on every keystroke-completed expression.
pub fn parse(self: *Parser) CalcError!*Expr {
pub fn parse(self: *Parser) Error!*Expr {
const expr = try self.parseExpr(.none);
if (self.current.kind != .eof) {
// Trailing tokens: the tree parsed so far is unreachable.
freeExpr(self.allocator, expr);
return CalcError.UnexpectedToken;
return Error.UnexpectedToken;
}
return expr;
}
/// Parse an expression with the given minimum precedence.
fn parseExpr(self: *Parser, min_prec: Prec) CalcError!*Expr {
if (self.nest_depth >= max_nest_depth) return CalcError.InvalidExpression;
fn parseExpr(self: *Parser, min_prec: Prec) Error!*Expr {
if (self.nest_depth >= max_nest_depth) return Error.InvalidExpression;
self.nest_depth += 1;
defer self.nest_depth -= 1;
@ -124,13 +138,13 @@ pub const Parser = struct {
}
/// Parse a prefix expression (number, identifier, unary op, parenthesized).
fn parsePrefix(self: *Parser) CalcError!*Expr {
fn parsePrefix(self: *Parser) Error!*Expr {
const tok = self.current;
switch (tok.kind) {
.number => {
self.advance();
const text = tok.text(self.source);
const num = parseNumber(text) catch return CalcError.InvalidNumber;
const num = parseNumber(text) catch return Error.InvalidNumber;
return self.makeNode(.{ .number = .{
.float_value = num.float,
.int_value = num.int_value,
@ -142,7 +156,7 @@ pub const Parser = struct {
self.advance();
const text = tok.text(self.source);
// Strip quotes: 'abc' -> abc
if (text.len < 2) return CalcError.InvalidNumber;
if (text.len < 2) return Error.InvalidNumber;
const content = text[1 .. text.len - 1];
return self.makeNode(.{ .string_literal = content });
},
@ -183,7 +197,7 @@ pub const Parser = struct {
const first_arg = try self.parseExpr(.none);
args.append(self.allocator, first_arg) catch {
freeExpr(self.allocator, first_arg);
return CalcError.OutOfMemory;
return Error.OutOfMemory;
};
while (self.current.kind == .comma) {
@ -191,18 +205,18 @@ pub const Parser = struct {
const arg = try self.parseExpr(.none);
args.append(self.allocator, arg) catch {
freeExpr(self.allocator, arg);
return CalcError.OutOfMemory;
return Error.OutOfMemory;
};
}
}
if (self.current.kind != .right_paren) {
return CalcError.UnmatchedParen;
return Error.UnmatchedParen;
}
self.advance(); // consume )
const args_slice = self.allocator.dupe(*Expr, args.items) catch
return CalcError.OutOfMemory;
return Error.OutOfMemory;
errdefer self.allocator.free(args_slice);
return self.makeNode(.{ .call = .{
@ -221,7 +235,7 @@ pub const Parser = struct {
const inner = try self.parseExpr(.none);
if (self.current.kind != .right_paren) {
freeExpr(self.allocator, inner);
return CalcError.UnmatchedParen;
return Error.UnmatchedParen;
}
self.advance(); // consume )
return inner;
@ -245,16 +259,16 @@ pub const Parser = struct {
} });
},
.eof => {
return CalcError.UnexpectedEnd;
return Error.UnexpectedEnd;
},
else => {
return CalcError.UnexpectedToken;
return Error.UnexpectedToken;
},
}
}
/// Parse an infix expression given the left-hand side and precedence.
fn parseInfix(self: *Parser, left: *Expr, prec: Prec) CalcError!*Expr {
fn parseInfix(self: *Parser, left: *Expr, prec: Prec) Error!*Expr {
const tok = self.current;
// Handle keyword operators (rol, ror, and, or, xor)
@ -276,7 +290,7 @@ pub const Parser = struct {
self.advance();
const op = self.tokenToBinaryOp(tok.kind) orelse {
return CalcError.UnexpectedToken;
return Error.UnexpectedToken;
};
// Right-associative for power
@ -352,13 +366,13 @@ pub const Parser = struct {
self.current = self.tokenizer.next();
}
fn makeNode(self: *Parser, expr: Expr) CalcError!*Expr {
fn makeNode(self: *Parser, expr: Expr) Error!*Expr {
// Budget checked here so every construction site is covered by one test.
if (self.node_count >= max_nodes) {
return CalcError.InvalidExpression;
return Error.InvalidExpression;
}
self.node_count += 1;
const node = self.allocator.create(Expr) catch return CalcError.OutOfMemory;
const node = self.allocator.create(Expr) catch return Error.OutOfMemory;
node.* = expr;
return node;
}
@ -378,7 +392,7 @@ fn testParse(source: []const u8) !*Expr {
return parser.parse();
}
fn testParseArena(source: []const u8) CalcError!*Expr {
fn testParseArena(source: []const u8) Error!*Expr {
const alloc = test_arena_instance.allocator();
var p = Parser.init(alloc, source);
return p.parse();
@ -501,19 +515,19 @@ test "parse assignment" {
test "parse adjacent number and identifier is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("2pi");
try testing.expectError(CalcError.UnexpectedToken, result);
try testing.expectError(Error.UnexpectedToken, result);
}
test "parse adjacent number and paren is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("3(4+5)");
try testing.expectError(CalcError.UnexpectedToken, result);
try testing.expectError(Error.UnexpectedToken, result);
}
test "parse adjacent paren paren is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("(2)(3)");
try testing.expectError(CalcError.UnexpectedToken, result);
try testing.expectError(Error.UnexpectedToken, result);
}
test "parse caret is power in programmer mode (not XOR)" {
@ -581,20 +595,20 @@ test "parse bitwise not" {
test "parse error: unmatched paren" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("(2 + 3");
try testing.expectError(CalcError.UnmatchedParen, result);
try testing.expectError(Error.UnmatchedParen, result);
}
test "parse error: unexpected token" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("+ +");
// + at start is not a valid prefix
try testing.expectError(CalcError.UnexpectedToken, result);
try testing.expectError(Error.UnexpectedToken, result);
}
test "parse error: empty expression" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("");
try testing.expectError(CalcError.UnexpectedEnd, result);
try testing.expectError(Error.UnexpectedEnd, result);
}
test "parse complex expression" {
@ -617,7 +631,7 @@ test "parse nested function calls" {
test "parse error: unmatched paren in function call args" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("max(1, 2");
try testing.expectError(CalcError.UnmatchedParen, result);
try testing.expectError(Error.UnmatchedParen, result);
}
test "parse error: identifier in infix position (not a keyword op)" {
@ -626,7 +640,7 @@ test "parse error: identifier in infix position (not a keyword op)" {
// parse() reports the leftover token as unexpected.
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("5 foo");
try testing.expectError(CalcError.UnexpectedToken, result);
try testing.expectError(Error.UnexpectedToken, result);
}
// -- Ownership on the error paths --
@ -725,7 +739,7 @@ test "an allocation failure mid-parse frees whatever was built" {
freeExpr(allocator, expr);
break;
} else |err| {
try testing.expectEqual(CalcError.OutOfMemory, err);
try testing.expectEqual(Error.OutOfMemory, err);
}
} else {
return error.AllocationSweepNeverCompleted;
@ -751,7 +765,7 @@ test "a tree larger than the node budget is rejected, not built" {
}
var parser = Parser.init(testing.allocator, over.items);
try testing.expectError(CalcError.InvalidExpression, parser.parse());
try testing.expectError(Error.InvalidExpression, parser.parse());
// Nothing is left allocated: testing.allocator would report a leak otherwise.
}
@ -777,7 +791,7 @@ test "nesting deeper than the depth limit is rejected" {
for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')');
var parser = Parser.init(testing.allocator, deep.items);
try testing.expectError(CalcError.InvalidExpression, parser.parse());
try testing.expectError(Error.InvalidExpression, parser.parse());
}
test "nesting within the depth limit parses" {
@ -811,7 +825,7 @@ test "the depth limit also covers nested calls and unary operators" {
for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')');
var parser = Parser.init(testing.allocator, deep.items);
try testing.expectError(CalcError.InvalidExpression, parser.parse());
try testing.expectError(Error.InvalidExpression, parser.parse());
var unary = std.ArrayList(u8).empty;
defer unary.deinit(testing.allocator);
@ -819,5 +833,5 @@ test "the depth limit also covers nested calls and unary operators" {
try unary.append(testing.allocator, '1');
var unary_parser = Parser.init(testing.allocator, unary.items);
try testing.expectError(CalcError.InvalidExpression, unary_parser.parse());
try testing.expectError(Error.InvalidExpression, unary_parser.parse());
}

View file

@ -13,7 +13,18 @@ const BinaryOp = ast.BinaryOp;
const Integer = @import("Integer.zig");
const IntType = Integer.IntType;
const Endianness = std.builtin.Endian;
const CalcError = @import("errors.zig").CalcError;
/// What programmer-mode evaluation can fail with: the parse, the fixed-width
/// operators, and its own arithmetic and name errors.
pub const Error = error{
DivisionByZero,
DomainError,
InvalidNumber,
InvalidOperandType,
Overflow,
UnknownFunction,
UnknownVariable,
} || parser_mod.Error || bitwise.Error;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
const bitwise = @import("bitwise.zig");
@ -29,7 +40,7 @@ pub const Config = struct {
};
/// Evaluate an AST in programmer mode, producing an exact integer result.
pub fn evalProgrammer(config: Config, expr: *const Expr) CalcError!Integer {
pub fn evalProgrammer(config: Config, expr: *const Expr) Error!Integer {
return .{
.raw = try evalExpr(config, expr),
.int_type = config.int_type,
@ -37,7 +48,7 @@ pub fn evalProgrammer(config: Config, expr: *const Expr) CalcError!Integer {
}
/// Recursively evaluate an expression to a raw u128.
fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 {
fn evalExpr(config: Config, expr: *const Expr) Error!u128 {
switch (expr.*) {
.number => |n| {
if (n.int_value) |int_val| {
@ -51,8 +62,8 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 {
// value is illegal behaviour, and `tally -p '1e40'` aborted the process
// before this guard existed.
const value = n.float_value;
if (!std.math.isFinite(value) or value < 0) return CalcError.DomainError;
if (value >= 340282366920938463463374607431768211456.0) return CalcError.Overflow;
if (!std.math.isFinite(value) or value < 0) return Error.DomainError;
if (value >= 340282366920938463463374607431768211456.0) return Error.Overflow;
const val: u128 = @intFromFloat(value);
return val & config.int_type.mask();
},
@ -60,19 +71,19 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 {
// Pack ASCII bytes into integer.
// Big-endian packing: first char -> most significant used byte.
const max_bytes = @as(usize, config.int_type.bits()) / 8;
if (text.len > max_bytes) return CalcError.Overflow;
if (text.len > max_bytes) return Error.Overflow;
var result: u128 = 0;
for (text) |byte| {
if (byte > 0x7F) return CalcError.InvalidNumber;
if (byte > 0x7F) return Error.InvalidNumber;
result = (result << 8) | byte;
}
return result & config.int_type.mask();
},
.variable => {
return CalcError.UnknownVariable;
return Error.UnknownVariable;
},
.assignment => {
return CalcError.InvalidOperandType;
return Error.InvalidOperandType;
},
.unary => |u| {
const operand = try evalExpr(config, u.operand);
@ -89,7 +100,7 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 {
},
.call => {
// No function calls in programmer mode
return CalcError.UnknownFunction;
return Error.UnknownFunction;
},
}
}
@ -100,7 +111,7 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 {
/// `bitwise.zig`, which standard mode uses too, so the two modes cannot drift
/// apart again. What remains is the arithmetic, which genuinely differs between the
/// modes: it wraps at the width here and is exact rational arithmetic there.
fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError!u128 {
fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) Error!u128 {
const mask = config.int_type.mask();
const result: u128 = switch (op) {
@ -108,11 +119,11 @@ fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError
.sub => (left -% right) & mask,
.mul => (left *% right) & mask,
.div => blk: {
if (right == 0) return CalcError.DivisionByZero;
if (right == 0) return Error.DivisionByZero;
break :blk (left / right) & mask;
},
.mod => blk: {
if (right == 0) return CalcError.DivisionByZero;
if (right == 0) return Error.DivisionByZero;
break :blk (left % right) & mask;
},
.pow => blk: {
@ -141,7 +152,7 @@ fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError
}
/// High-level: parse and evaluate a string in programmer mode.
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) CalcError!Integer {
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) Error!Integer {
var p = Parser.init(allocator, source);
const expr = try p.parse();
// Same ownership rule as evalStringInfo: the tree is ours to release, and the
@ -211,7 +222,7 @@ test "prog: division" {
test "prog: division by zero" {
const result = testProg("10 / 0");
try testing.expectError(CalcError.DivisionByZero, result);
try testing.expectError(Error.DivisionByZero, result);
}
test "prog: modulo" {
@ -369,21 +380,21 @@ test "prog: variable reference errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "x", .{});
try testing.expectError(CalcError.UnknownVariable, result);
try testing.expectError(Error.UnknownVariable, result);
}
test "prog: assignment errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "X = 5", .{});
try testing.expectError(CalcError.InvalidOperandType, result);
try testing.expectError(Error.InvalidOperandType, result);
}
test "prog: function call errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "sin(1)", .{});
try testing.expectError(CalcError.UnknownFunction, result);
try testing.expectError(Error.UnknownFunction, result);
}
test "prog: ASCII literal single char" {
@ -411,7 +422,7 @@ test "prog: ASCII literal overflow 8-bit" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "'AB'", .{ .int_type = .{ .width = .bits8 } });
try testing.expectError(CalcError.Overflow, result);
try testing.expectError(Error.Overflow, result);
}
test "prog: float literal truncates to integer" {
@ -465,11 +476,11 @@ test "programmer mode: a float literal out of range errors instead of aborting"
// `tally -p '1e40'` used to abort the process here: @intFromFloat on a value
// past u128 is illegal behaviour, and only 3.14 was ever tested.
try std.testing.expectError(
CalcError.Overflow,
Error.Overflow,
evalProgrammerString(std.testing.allocator, "1e40", config),
);
try std.testing.expectError(
CalcError.Overflow,
Error.Overflow,
evalProgrammerString(std.testing.allocator, "1e100", config),
);
// Still truncates the values that do fit.
@ -483,7 +494,7 @@ test "programmer mode: an infinite or NaN literal is a domain error" {
const config: Config = .{};
// 10^400 overflows the exact tier's float projection to infinity.
try std.testing.expectError(
CalcError.DomainError,
Error.DomainError,
evalProgrammerString(std.testing.allocator, "1e400", config),
);
}

View file

@ -19,9 +19,19 @@
//! No allocation, no I/O. Adding a unit means adding a table entry.
const std = @import("std");
const CalcError = @import("errors.zig").CalcError;
const rational_mod = @import("rational.zig");
const Rational = rational_mod.Rational;
/// What unit conversion can fail with: its own two errors, plus whatever the exact
/// numeric path can raise, since the exact conversions compute in `Number`.
///
/// The `||` is the point: this set grows when `number.Error` grows, without anyone
/// maintaining a list. It used to be one engine-wide set, and the translation from
/// numeric errors into it (`mapNumberError`) was a second copy of the same mapping
/// the evaluator had.
pub const Error = error{
UnknownUnit,
IncompatibleUnits,
OutOfMemory,
} || number_mod.Error;
const Rational = @import("Rational.zig");
const number_mod = @import("number.zig");
const Number = number_mod.Number;
@ -440,8 +450,8 @@ pub fn findUnit(name: []const u8) ?UnitDef {
/// Convert a value between two already-resolved units.
/// Returns IncompatibleUnits if the units are in different categories.
pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) CalcError!f64 {
if (from.category != to.category) return CalcError.IncompatibleUnits;
pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) Error!f64 {
if (from.category != to.category) return Error.IncompatibleUnits;
if (std.mem.eql(u8, from.name, to.name)) return value;
return to.fromBase(from.toBase(value));
}
@ -449,9 +459,9 @@ pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) CalcError!f64 {
/// Convert a value between two units named by string (canonical name or alias).
/// Returns UnknownUnit if either name is unrecognized, or IncompatibleUnits if
/// the units belong to different categories.
pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) CalcError!ConvertResult {
const from = findUnit(from_name) orelse return CalcError.UnknownUnit;
const to = findUnit(to_name) orelse return CalcError.UnknownUnit;
pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) Error!ConvertResult {
const from = findUnit(from_name) orelse return Error.UnknownUnit;
const to = findUnit(to_name) orelse return Error.UnknownUnit;
const result = try convertUnits(value, from, to);
// A single scaling factor only describes the relationship when neither
@ -570,14 +580,14 @@ fn splitTrailingUnit(text: []const u8) ?struct { value_text: []const u8, unit: U
/// valid conversion. That is what disambiguates `in` the separator from `in` the
/// unit: in "5 in in cm" the later `in` is the separator, while in "100 mm in in"
/// the earlier one is, because only that reading resolves.
pub fn parseRequest(text: []const u8) CalcError!?ConversionRequest {
pub fn parseRequest(text: []const u8) Error!?ConversionRequest {
var separators: [max_separators]Span = undefined;
const count = collectSeparators(text, &separators);
if (count == 0) return null;
// Remember why the most recent candidate failed, so a committed-but-invalid
// request reports a useful error instead of a parse error.
var failure: ?CalcError = null;
var failure: ?Error = null;
var idx = count;
while (idx > 0) {
@ -589,15 +599,15 @@ pub fn parseRequest(text: []const u8) CalcError!?ConversionRequest {
if (left.len == 0 or right.len == 0) continue;
const to_unit = findUnit(right) orelse {
failure = CalcError.UnknownUnit;
failure = Error.UnknownUnit;
continue;
};
const split = splitTrailingUnit(left) orelse {
failure = CalcError.UnknownUnit;
failure = Error.UnknownUnit;
continue;
};
if (split.unit.category != to_unit.category) {
failure = CalcError.IncompatibleUnits;
failure = Error.IncompatibleUnits;
continue;
}
@ -623,10 +633,10 @@ pub fn convertExactUnits(
value: Number,
from: UnitDef,
to: UnitDef,
) CalcError!Number {
if (from.category != to.category) return CalcError.IncompatibleUnits;
) Error!Number {
if (from.category != to.category) return Error.IncompatibleUnits;
if (std.mem.eql(u8, from.name, to.name)) {
return value.cloneWith(allocator) catch |err| return mapNumberError(err);
return try value.cloneWith(allocator);
}
// No exact factor available, or the value is already inexact: use floats.
@ -635,7 +645,7 @@ pub fn convertExactUnits(
return Number.fromFloat(converted);
}
return convertExactInner(allocator, value, from, to) catch |err| mapNumberError(err);
return convertExactInner(allocator, value, from, to);
}
fn convertExactInner(
@ -666,9 +676,6 @@ fn convertExactInner(
return Number.div(allocator, shifted, to_factor);
}
/// One mapping, in `number.zig`; this alias keeps the call sites short.
const mapNumberError = number_mod.toCalcError;
// -- Tests --
const testing = std.testing;
@ -956,16 +963,16 @@ test "findUnit returns null for unknown names" {
}
test "convert: unknown source unit errors" {
try testing.expectError(CalcError.UnknownUnit, convert(1, "bogus", "m"));
try testing.expectError(Error.UnknownUnit, convert(1, "bogus", "m"));
}
test "convert: unknown target unit errors" {
try testing.expectError(CalcError.UnknownUnit, convert(1, "m", "bogus"));
try testing.expectError(Error.UnknownUnit, convert(1, "m", "bogus"));
}
test "convert: incompatible categories error" {
try testing.expectError(CalcError.IncompatibleUnits, convert(1, "kg", "m"));
try testing.expectError(CalcError.IncompatibleUnits, convert(1, "C", "s"));
try testing.expectError(Error.IncompatibleUnits, convert(1, "kg", "m"));
try testing.expectError(Error.IncompatibleUnits, convert(1, "C", "s"));
}
test "convert: same unit is identity" {
@ -1146,21 +1153,21 @@ test "parseRequest: 'to' inside a word is not a keyword" {
}
test "parseRequest: unknown target unit errors" {
try testing.expectError(CalcError.UnknownUnit, parseRequest("100 km to smoots"));
try testing.expectError(Error.UnknownUnit, parseRequest("100 km to smoots"));
}
test "parseRequest: unknown source unit errors" {
try testing.expectError(CalcError.UnknownUnit, parseRequest("100 smoots to km"));
try testing.expectError(Error.UnknownUnit, parseRequest("100 smoots to km"));
}
test "parseRequest: missing value errors" {
// "km to mi" has a unit with no value in front of it
try testing.expectError(CalcError.UnknownUnit, parseRequest("km to mi"));
try testing.expectError(Error.UnknownUnit, parseRequest("km to mi"));
}
test "parseRequest: mismatched categories error" {
try testing.expectError(CalcError.IncompatibleUnits, parseRequest("1 kg to m"));
try testing.expectError(CalcError.IncompatibleUnits, parseRequest("32F to km"));
try testing.expectError(Error.IncompatibleUnits, parseRequest("1 kg to m"));
try testing.expectError(Error.IncompatibleUnits, parseRequest("32F to km"));
}
test "parseRequest: dangling keyword returns null" {
@ -1176,8 +1183,8 @@ test "parseRequest: result feeds convertUnits correctly" {
test "parseRequest: does not find a unit buried inside a word" {
// The trailing "s" of "smoots" must not be read as seconds.
try testing.expectError(CalcError.UnknownUnit, parseRequest("100 smoots to km"));
try testing.expectError(CalcError.UnknownUnit, parseRequest("5 bananas to kg"));
try testing.expectError(Error.UnknownUnit, parseRequest("100 smoots to km"));
try testing.expectError(Error.UnknownUnit, parseRequest("5 bananas to kg"));
}
test "parseRequest: glued digit boundary still works" {
@ -1240,8 +1247,8 @@ test "parseRequest: trailing whitespace after the separator still resolves" {
}
test "parseRequest: backtracking reports the useful error, not a parse error" {
try testing.expectError(CalcError.UnknownUnit, parseRequest("5 cm in smoots"));
try testing.expectError(CalcError.IncompatibleUnits, parseRequest("5 cm in kg"));
try testing.expectError(Error.UnknownUnit, parseRequest("5 cm in smoots"));
try testing.expectError(Error.IncompatibleUnits, parseRequest("5 cm in kg"));
}
// -- Exact conversion (Task 2.0e) --
@ -1360,7 +1367,7 @@ test "exact: incompatible categories still error" {
var value = try Number.parse(alloc, "1");
defer value.deinit();
try testing.expectError(
CalcError.IncompatibleUnits,
Error.IncompatibleUnits,
convertExactUnits(alloc, value, findUnit("kg").?, findUnit("m").?),
);
}
@ -1446,8 +1453,9 @@ test "exact: only pi-derived units lack an exact factor" {
}
test "OOM safety: exact conversion releases everything at any failure point" {
// Also the only realistic way to reach mapNumberError, which translates the
// numeric model's errors into the engine's error set.
// The exact path computes in `Number`, so `OutOfMemory` reaches the caller from
// the numeric tier directly; `units.Error` includes `number.Error` for exactly
// this reason and nothing translates between them.
const alloc = testing.allocator;
const from = findUnit("in").?;
const to = findUnit("ft").?;
@ -1458,14 +1466,14 @@ test "OOM safety: exact conversion releases everything at any failure point" {
const a = failing.allocator();
var value = Number.parse(a, "12") catch |err| {
try testing.expectEqual(rational_mod.Error.OutOfMemory, err);
try testing.expectEqual(Rational.Error.OutOfMemory, err);
continue;
};
defer value.deinit();
var result = convertExactUnits(a, value, from, to) catch |err| {
// The numeric model's OutOfMemory must surface as the engine's.
try testing.expectEqual(CalcError.OutOfMemory, err);
try testing.expectEqual(Error.OutOfMemory, err);
continue;
};
result.deinit();

View file

@ -363,9 +363,9 @@ pub fn formatConversion(
to_name: []const u8,
) CliResult {
const from = engine.units.findUnit(from_name) orelse
return .{ .output = errorMessage(engine.CalcError.UnknownUnit), .is_error = true };
return .{ .output = errorMessage(engine.Error.UnknownUnit), .is_error = true };
const to = engine.units.findUnit(to_name) orelse
return .{ .output = errorMessage(engine.CalcError.UnknownUnit), .is_error = true };
return .{ .output = errorMessage(engine.Error.UnknownUnit), .is_error = true };
// Parse the value exactly rather than through f64, so a decimal input like
// 2.5 enters the conversion without being rounded first.
@ -540,23 +540,23 @@ fn formatProgrammerResult(buf: []u8, result: engine.Integer, config: engine.prog
/// Turn an engine error into a CLI line.
///
/// The phrases live once, in `engine.errors.errorPhrase`. This adds the prefix and
/// The phrases live once, in `engine.phrase`. This adds the prefix and
/// the newline at comptime, so the strings still have static lifetime and there is
/// no second copy of the wording to drift. The CLI and the TUI previously each kept
/// their own switch over the whole error set; the TUI's was already missing three
/// cases and rendered them as "evaluation error".
fn errorMessage(err: engine.CalcError) []const u8 {
fn errorMessage(err: engine.Error) []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 {
/// `inline else` makes this exhaustive over the error set with no fallback branch: an
/// error added to any engine module fails to compile in `message.phrase` rather than
/// silently reading "evaluation error" here.
fn decoratedError(err: engine.Error) []const u8 {
return switch (err) {
inline else => |e| comptime "error: " ++ engine.errors.errorPhrase(e) ++ "\n",
inline else => |e| comptime "error: " ++ engine.phrase(e) ++ "\n",
};
}
@ -766,9 +766,9 @@ fn oomResult() CliResult {
return .{ .output = "error: out of memory\n", .is_error = true };
}
fn amortErrorMessage(err: engine.CalcError) []const u8 {
fn amortErrorMessage(err: engine.Error) []const u8 {
return switch (err) {
engine.CalcError.DomainError =>
engine.Error.DomainError =>
// The realistic causes are all one of these, and a bare "domain error"
// would leave the user guessing which.
"error: check the loan terms: principal and periods must be positive, the rate cannot be negative, and the payment must at least cover the first period's interest\n",

View file

@ -1407,13 +1407,13 @@ 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.errors.errorPhrase`; the prefix is added at
/// The phrases live once, in `engine.phrase`; 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.Error) []const u8 {
return switch (err) {
inline else => |e| comptime "error: " ++ engine.errors.errorPhrase(e),
inline else => |e| comptime "error: " ++ engine.phrase(e),
};
}
@ -2987,19 +2987,19 @@ test "typed characters reach the prompt and Ctrl-C quits" {
}
test "errorStr covers the errors the TUI can surface" {
const errors = [_]engine.CalcError{
engine.CalcError.DivisionByZero,
engine.CalcError.UnknownFunction,
engine.CalcError.UnknownVariable,
engine.CalcError.UnmatchedParen,
engine.CalcError.UnexpectedToken,
engine.CalcError.UnexpectedEnd,
engine.CalcError.InvalidNumber,
engine.CalcError.DomainError,
engine.CalcError.Overflow,
engine.CalcError.UnknownUnit,
engine.CalcError.IncompatibleUnits,
engine.CalcError.ConvergenceFailure,
const errors = [_]engine.Error{
engine.Error.DivisionByZero,
engine.Error.UnknownFunction,
engine.Error.UnknownVariable,
engine.Error.UnmatchedParen,
engine.Error.UnexpectedToken,
engine.Error.UnexpectedEnd,
engine.Error.InvalidNumber,
engine.Error.DomainError,
engine.Error.Overflow,
engine.Error.UnknownUnit,
engine.Error.IncompatibleUnits,
engine.Error.ConvergenceFailure,
};
for (errors) |err| {
const text = errorStr(err);

View file

@ -207,7 +207,7 @@ pub const Field = struct {
pub const Outcome = union(enum) {
/// The form is not yet answerable; the text says what it needs.
hint: []const u8,
err: engine.CalcError,
err: engine.Error,
value: Value,
schedule: Schedule,
@ -870,15 +870,15 @@ fn money(buf: []u8, value: f64) []const u8 {
/// Error text for this view.
///
/// Only the cases where a form knows more than the engine does are overridden; the
/// rest defer to `engine.errors.errorPhrase`, so this is no longer a third copy of
/// rest defer to `engine.phrase`, 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.Error) []const u8 {
return switch (err) {
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.InsufficientParameters => "these values do not determine an answer",
else => engine.errors.errorPhrase(err),
engine.Error.DomainError => "check the entries: values must be positive and a payment must cover the interest",
engine.Error.ConvergenceFailure => "no rate solves these cash flows",
engine.Error.InsufficientParameters => "these values do not determine an answer",
else => engine.phrase(err),
};
}
@ -1103,7 +1103,7 @@ test "cagr form: says what it is waiting for" {
test "cagr form: a bad entry is an error, not a wrong answer" {
var state = stateWith(.cagr, &.{ "0", "25000", "5" });
try testing.expectEqual(engine.CalcError.DomainError, state.outcome().err);
try testing.expectEqual(engine.Error.DomainError, state.outcome().err);
}
test "compound form: solves whichever of the four variables is blank" {
@ -1370,19 +1370,19 @@ test "every form has a label, a formula, and at most max_fields fields" {
}
test "errorText: every financial error has its own wording" {
const errors = [_]engine.CalcError{
engine.CalcError.DomainError,
engine.CalcError.ConvergenceFailure,
engine.CalcError.InsufficientParameters,
engine.CalcError.DivisionByZero,
engine.CalcError.Overflow,
engine.CalcError.UnknownUnit,
const errors = [_]engine.Error{
engine.Error.DomainError,
engine.Error.ConvergenceFailure,
engine.Error.InsufficientParameters,
engine.Error.DivisionByZero,
engine.Error.Overflow,
engine.Error.UnknownUnit,
};
for (errors) |err| try testing.expect(errorText(err).len > 0);
try testing.expect(!std.mem.eql(
u8,
errorText(engine.CalcError.DomainError),
errorText(engine.CalcError.ConvergenceFailure),
errorText(engine.Error.DomainError),
errorText(engine.Error.ConvergenceFailure),
));
}