tally/engine/src/engine.zig

182 lines
7.9 KiB
Zig

//! Tally calculation engine.
//!
//! Pure computation library with no I/O. Provides expression parsing, evaluation,
//! programmer-mode bit manipulation, unit conversion, and financial calculations.
const std = @import("std");
// Vocabulary, lowest first.
pub const grouping = @import("grouping.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 number = @import("number.zig");
// Language layer.
pub const tokenizer = @import("tokenizer.zig");
pub const ast = @import("ast.zig");
pub const parser = @import("parser.zig");
pub const evaluator = @import("evaluator.zig");
// Fixed-width integer operations, shared by both modes.
pub const bitwise = @import("bitwise.zig");
pub const programmer = @import("programmer.zig");
// Domains and display.
pub const float_interp = @import("float_interp.zig");
pub const units = @import("units.zig");
pub const financial = @import("financial.zig");
// The modules above are the engine's surface: a caller writes `engine.units.convert`
// or `engine.financial.solveTvm`. The aliases below exist only for the handful of
// names used often enough that the module prefix is noise. There used to be a
// curated re-export of nearly every public declaration, which drifted: two thirds
// of it had no callers, and `Value` was re-exported after the type it named had
// stopped being the engine's result type.
pub const BitWidth = Integer.BitWidth;
pub const Environment = evaluator.Environment;
pub const evalString = evaluator.evalString;
pub const evalStringInfo = evaluator.evalStringInfo;
pub const evalProgrammerString = programmer.evalProgrammerString;
pub const FloatFormat = float_interp.FloatFormat;
pub const UnitCategory = units.UnitCategory;
pub const UnitDef = units.UnitDef;
pub const Number = number.Number;
/// 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 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",
// A valid number with more digits than the exact parser will hold, which is
// not the same as a malformed one.
error.TooManyDigits => "too many digits",
// 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",
};
}
// -- 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),
);
}