1547 lines
60 KiB
Zig
1547 lines
60 KiB
Zig
//! AST evaluator for Tally.
|
|
//!
|
|
//! Walks an AST and produces a Value. In standard mode, all computations
|
|
//! use f64 floating-point arithmetic. In programmer mode, integer operations
|
|
//! are exact (masked to bit width). The evaluator uses an Environment for
|
|
//! variable storage, history, and configuration.
|
|
|
|
const std = @import("std");
|
|
const math = std.math;
|
|
const Allocator = std.mem.Allocator;
|
|
const ast = @import("ast.zig");
|
|
const Expr = ast.Expr;
|
|
const BinaryOp = ast.BinaryOp;
|
|
const types = @import("types.zig");
|
|
const Mode = types.Mode;
|
|
const ProgrammerConfig = types.ProgrammerConfig;
|
|
const CalcError = types.CalcError;
|
|
const parser_mod = @import("parser.zig");
|
|
const Parser = parser_mod.Parser;
|
|
const number_mod = @import("number.zig");
|
|
const Number = number_mod.Number;
|
|
const financial = @import("financial.zig");
|
|
|
|
/// Evaluation environment holding variables, history, and config.
|
|
///
|
|
/// Variables and `Ans` are stored as `Number`, so an assignment keeps whatever
|
|
/// exactness its expression had: `X = 0.1` stores exactly one tenth rather than
|
|
/// a binary approximation of it.
|
|
pub const Environment = struct {
|
|
allocator: Allocator,
|
|
mode: Mode,
|
|
programmer_config: ProgrammerConfig,
|
|
variables: std.StringHashMap(Number),
|
|
ans: Number,
|
|
history_len: usize,
|
|
|
|
pub fn init(allocator: Allocator, mode: Mode) Environment {
|
|
return .{
|
|
.allocator = allocator,
|
|
.mode = mode,
|
|
.programmer_config = .{},
|
|
.variables = std.StringHashMap(Number).init(allocator),
|
|
// Starts inexact so that `init` cannot fail; the first evaluation
|
|
// replaces it.
|
|
.ans = Number.fromFloat(0),
|
|
.history_len = 0,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Environment) void {
|
|
var it = self.variables.iterator();
|
|
while (it.next()) |entry| {
|
|
self.allocator.free(entry.key_ptr.*);
|
|
entry.value_ptr.deinit();
|
|
}
|
|
self.variables.deinit();
|
|
self.ans.deinit();
|
|
}
|
|
|
|
/// Store a variable. Both the name and the value are copied, so neither has
|
|
/// to outlive this call.
|
|
///
|
|
/// The name is duplicated because it points into the expression source,
|
|
/// which callers are free to release: the TUI frees history entries on
|
|
/// Ctrl-L, which previously left dangling keys in this map.
|
|
pub fn setVar(self: *Environment, name: []const u8, value: Number) !void {
|
|
var copy = try value.cloneWith(self.allocator);
|
|
errdefer copy.deinit();
|
|
|
|
const gop = try self.variables.getOrPut(name);
|
|
if (gop.found_existing) {
|
|
gop.value_ptr.deinit();
|
|
} else {
|
|
const owned_name = self.allocator.dupe(u8, name) catch |err| {
|
|
// Remove the entry keyed by the borrowed name so the map never
|
|
// retains a key it does not own.
|
|
_ = self.variables.remove(name);
|
|
return err;
|
|
};
|
|
gop.key_ptr.* = owned_name;
|
|
}
|
|
gop.value_ptr.* = copy;
|
|
}
|
|
|
|
/// Replace the last answer, taking a copy.
|
|
pub fn setAns(self: *Environment, value: Number) !void {
|
|
const copy = try value.cloneWith(self.allocator);
|
|
self.ans.deinit();
|
|
self.ans = copy;
|
|
}
|
|
|
|
/// Borrowed view of a variable or built-in constant.
|
|
///
|
|
/// The result is owned by the environment (or is a freshly built constant),
|
|
/// so callers that need it to outlive the environment, or that will free it
|
|
/// separately, must `cloneWith` first.
|
|
///
|
|
/// The constants are inexact by nature: pi, e and tau are irrational and
|
|
/// have no rational representation.
|
|
pub fn getVar(self: *const Environment, name: []const u8) ?Number {
|
|
if (std.mem.eql(u8, name, "pi")) return Number.fromFloat(math.pi);
|
|
if (std.mem.eql(u8, name, "e")) return Number.fromFloat(math.e);
|
|
if (std.mem.eql(u8, name, "tau")) return Number.fromFloat(math.tau);
|
|
if (std.mem.eql(u8, name, "Ans") or std.mem.eql(u8, name, "ans")) return self.ans;
|
|
|
|
return self.variables.get(name);
|
|
}
|
|
|
|
/// The last answer collapsed to f64, for frontends that only need a float.
|
|
pub fn ansFloat(self: *const Environment) f64 {
|
|
return self.ans.toFloat(self.allocator);
|
|
}
|
|
};
|
|
|
|
/// Evaluate a parsed expression in the given environment.
|
|
/// Returns the computed value as f64 for standard mode.
|
|
///
|
|
/// Internally the computation runs on `Number`, so exact arithmetic is used
|
|
/// wherever possible and only collapses to f64 here, at the boundary. That
|
|
/// single final rounding is what fixes the accumulated-error class of bug:
|
|
/// `0.1 + 0.2` is computed as exactly `3/10` and rounds to the f64 nearest
|
|
/// `0.3`, rather than adding two separately-rounded operands.
|
|
///
|
|
/// 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 {
|
|
// 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.
|
|
var arena = std.heap.ArenaAllocator.init(env.allocator);
|
|
defer arena.deinit();
|
|
const scratch = arena.allocator();
|
|
|
|
const result = try evalExact(env, scratch, expr);
|
|
return result.toFloat(scratch);
|
|
}
|
|
|
|
/// 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 {
|
|
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;
|
|
packed_value = (packed_value << 8) | byte;
|
|
}
|
|
return Number.fromInt(scratch, packed_value) catch |err| return mapError(err);
|
|
},
|
|
.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);
|
|
},
|
|
.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;
|
|
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),
|
|
// Bitwise NOT is a fixed-width integer operation, not rational
|
|
// arithmetic, so it drops to the float/integer path.
|
|
.bitwise_not => blk: {
|
|
const bits = try toFixedWidthBits(operand.toFloat(scratch));
|
|
const mask_val: u64 = @truncate(env.programmer_config.bit_width.mask());
|
|
const result = ~bits & mask_val;
|
|
break :blk Number.fromFloat(@floatFromInt(@as(i64, @bitCast(result))));
|
|
},
|
|
};
|
|
},
|
|
.binary => |b| {
|
|
const left = try evalExact(env, scratch, b.left);
|
|
const right = try evalExact(env, scratch, b.right);
|
|
return evalBinaryOp(scratch, b.op, left, right);
|
|
},
|
|
.call => |c| {
|
|
return evalFunction(env, scratch, c.name, c.args);
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Turn a literal into a Number, exactly where possible.
|
|
///
|
|
/// 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 {
|
|
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
|
|
// tokenizer already accepted this text, so a parse mismatch here
|
|
// should degrade, not error.
|
|
}
|
|
}
|
|
// 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 Number.fromFloat(n.float_value);
|
|
}
|
|
|
|
/// Map the numeric model's errors onto the engine's error set.
|
|
fn mapError(err: number_mod.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,
|
|
};
|
|
}
|
|
|
|
/// Evaluate a binary operation.
|
|
fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) CalcError!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),
|
|
// The remaining operators are fixed-width integer operations rather than
|
|
// rational arithmetic, so they work on the float/integer projection.
|
|
.bit_and => Number.fromFloat(try floatBitwise(left.toFloat(scratch), right.toFloat(scratch), bitwiseAnd)),
|
|
.bit_or => Number.fromFloat(try floatBitwise(left.toFloat(scratch), right.toFloat(scratch), bitwiseOr)),
|
|
.bit_xor => Number.fromFloat(try floatBitwise(left.toFloat(scratch), right.toFloat(scratch), bitwiseXor)),
|
|
.shift_left => Number.fromFloat(try floatShift(left.toFloat(scratch), right.toFloat(scratch), true)),
|
|
.shift_right, .shift_right_logical => Number.fromFloat(try floatShift(left.toFloat(scratch), right.toFloat(scratch), false)),
|
|
.rotate_left, .rotate_right => blk: {
|
|
// Rotations need bit width context; in standard mode, use 64-bit
|
|
const l = try toFixedWidthBits(left.toFloat(scratch));
|
|
const r = try shiftAmount(right.toFloat(scratch));
|
|
const result = if (op == .rotate_left)
|
|
math.rotl(u64, l, r)
|
|
else
|
|
math.rotr(u64, l, r);
|
|
break :blk Number.fromFloat(@floatFromInt(@as(i64, @bitCast(result))));
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Project a float onto the 64-bit integer domain the bitwise operators work in.
|
|
///
|
|
/// Every one of these operators used to do `@intFromFloat` straight onto the
|
|
/// unchecked value, which is illegal behaviour out of range and aborted the
|
|
/// 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 a machine word is a
|
|
/// reportable error, not a crash.
|
|
fn toFixedWidthBits(value: f64) CalcError!u64 {
|
|
if (!math.isFinite(value)) return CalcError.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 @bitCast(@as(i64, @intFromFloat(value)));
|
|
}
|
|
|
|
/// The shift or rotate distance, reduced into 0..63.
|
|
///
|
|
/// NOTE: reducing modulo the width is the behaviour standard mode has always had,
|
|
/// and it disagrees with programmer mode, which saturates. That divergence is a
|
|
/// separate open issue (the two implementations of these operators need to become
|
|
/// one); this only stops the conversion from being undefined.
|
|
fn shiftAmount(value: f64) CalcError!u6 {
|
|
const bits = try toFixedWidthBits(value);
|
|
const signed: i64 = @bitCast(bits);
|
|
return @intCast(@mod(signed, 64));
|
|
}
|
|
|
|
fn bitwiseAnd(a: u64, b: u64) u64 {
|
|
return a & b;
|
|
}
|
|
fn bitwiseOr(a: u64, b: u64) u64 {
|
|
return a | b;
|
|
}
|
|
fn bitwiseXor(a: u64, b: u64) u64 {
|
|
return a ^ b;
|
|
}
|
|
|
|
fn floatBitwise(left: f64, right: f64, op: *const fn (u64, u64) u64) CalcError!f64 {
|
|
const l = try toFixedWidthBits(left);
|
|
const r = try toFixedWidthBits(right);
|
|
const result = op(l, r);
|
|
return @floatFromInt(@as(i64, @bitCast(result)));
|
|
}
|
|
|
|
fn floatShift(left: f64, right: f64, is_left: bool) CalcError!f64 {
|
|
const l = try toFixedWidthBits(left);
|
|
const shift_amt = try shiftAmount(right);
|
|
const result = if (is_left) l << shift_amt else l >> shift_amt;
|
|
return @floatFromInt(@as(i64, @bitCast(result)));
|
|
}
|
|
|
|
/// Evaluate a built-in function call.
|
|
fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) CalcError!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);
|
|
}
|
|
if (std.mem.eql(u8, name, "floor")) {
|
|
return Number.floor(scratch, x) catch |err| mapError(err);
|
|
}
|
|
if (std.mem.eql(u8, name, "ceil")) {
|
|
return Number.ceil(scratch, x) catch |err| mapError(err);
|
|
}
|
|
if (std.mem.eql(u8, name, "round")) {
|
|
return Number.round(scratch, x) catch |err| mapError(err);
|
|
}
|
|
if (std.mem.eql(u8, name, "sqrt")) {
|
|
// Negative inputs are a domain error rather than a NaN.
|
|
if (x.isNegative()) return CalcError.UnknownFunction;
|
|
return Number.sqrt(scratch, x) catch |err| mapError(err);
|
|
}
|
|
if (std.mem.eql(u8, name, "factorial")) {
|
|
const result = Number.factorial(scratch, x) catch |err| return mapError(err);
|
|
return result orelse CalcError.UnknownFunction;
|
|
}
|
|
|
|
// Everything else escapes the rationals, so it falls back to f64.
|
|
const f = evalSingleArgFn(name, x.toFloat(scratch)) orelse
|
|
return CalcError.UnknownFunction;
|
|
return Number.fromFloat(f);
|
|
}
|
|
|
|
// Multi-argument functions
|
|
if (args.len == 2) {
|
|
const a = try evalExact(env, scratch, args[0]);
|
|
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);
|
|
}
|
|
if (std.mem.eql(u8, name, "min")) {
|
|
return Number.min(scratch, a, b) catch |err| mapError(err);
|
|
}
|
|
|
|
const x = a.toFloat(scratch);
|
|
const y = b.toFloat(scratch);
|
|
if (std.mem.eql(u8, name, "atan2")) return Number.fromFloat(math.atan2(x, y));
|
|
// apy(nominal_rate, compounds_per_year): the effective annual rate, so a
|
|
// nominal rate can be compared against one.
|
|
if (std.mem.eql(u8, name, "apy")) {
|
|
return Number.fromFloat(try financial.effectiveAnnualRate(x, y));
|
|
}
|
|
if (std.mem.eql(u8, name, "log")) {
|
|
// log(value, base)
|
|
if (y <= 0 or y == 1 or x <= 0) return CalcError.DomainError;
|
|
return Number.fromFloat(@log(x) / @log(y));
|
|
}
|
|
}
|
|
|
|
// Financial functions take three or four arguments.
|
|
//
|
|
// These are inexact by construction: every financial formula needs a
|
|
// non-integer power or a logarithm, so the exact tier has nothing to
|
|
// preserve (see the header of financial.zig).
|
|
if (args.len == 3 or args.len == 4) {
|
|
var values: [4]f64 = undefined;
|
|
for (args, 0..) |arg, i| {
|
|
const value = try evalExact(env, scratch, arg);
|
|
values[i] = value.toFloat(scratch);
|
|
}
|
|
if (try evalFinancialFn(name, values[0..args.len])) |result| {
|
|
return Number.fromFloat(result);
|
|
}
|
|
}
|
|
|
|
// Zero-argument functions
|
|
if (args.len == 0) {
|
|
if (std.mem.eql(u8, name, "rand")) {
|
|
// Not truly random in a pure engine, but useful as placeholder
|
|
return Number.fromFloat(0.0);
|
|
}
|
|
}
|
|
|
|
return CalcError.UnknownFunction;
|
|
}
|
|
|
|
/// A whole period count or 1-based period index, validated.
|
|
fn periodCount(value: f64) CalcError!usize {
|
|
if (!math.isFinite(value)) return CalcError.DomainError;
|
|
if (@floor(value) != value) return CalcError.DomainError;
|
|
if (value < 1 or value > @as(f64, @floatFromInt(financial.max_schedule_periods))) {
|
|
return CalcError.DomainError;
|
|
}
|
|
return @intFromFloat(value);
|
|
}
|
|
|
|
/// Financial functions callable from a standard-mode expression.
|
|
///
|
|
/// Exposed as functions rather than only as a separate mode so that they compose
|
|
/// with the rest of the language: `cagr(10000, 25000, 5) * 100` and
|
|
/// `amort_interest(200000, 0.5, 360, 1) + 50` both work, the same way unit
|
|
/// conversion is reachable from a bare expression.
|
|
///
|
|
/// Returns null when `name` is not a financial function, so the caller can carry
|
|
/// on to report an unknown function.
|
|
fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 {
|
|
if (a.len == 3) {
|
|
// cagr(start, end, periods) -> growth rate as a fraction.
|
|
if (std.mem.eql(u8, name, "cagr")) return try financial.cagr(a[0], a[1], a[2]);
|
|
// fv(pv, annual_rate_percent, years) compounded annually.
|
|
if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], 1);
|
|
// pv(fv, annual_rate_percent, years) compounded annually.
|
|
if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], 1);
|
|
// The same relationship solved for its other two variables. The rate is
|
|
// NOMINAL; use apy() to convert.
|
|
if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], 1);
|
|
if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], 1);
|
|
|
|
// amort_payment(principal, rate_per_period_percent, periods)
|
|
if (std.mem.eql(u8, name, "amort_payment")) {
|
|
return try financial.amortizationPayment(.{
|
|
.principal = a[0],
|
|
.rate = a[1],
|
|
.periods = try periodCount(a[2]),
|
|
});
|
|
}
|
|
if (std.mem.eql(u8, name, "amort_total_interest")) {
|
|
const totals = try financial.amortizationTotals(.{
|
|
.principal = a[0],
|
|
.rate = a[1],
|
|
.periods = try periodCount(a[2]),
|
|
});
|
|
return totals.interest;
|
|
}
|
|
if (std.mem.eql(u8, name, "amort_total_paid")) {
|
|
const totals = try financial.amortizationTotals(.{
|
|
.principal = a[0],
|
|
.rate = a[1],
|
|
.periods = try periodCount(a[2]),
|
|
});
|
|
return totals.paid;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// fv(pv, rate, years, compounds_per_year) and its inverse.
|
|
if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], a[3]);
|
|
if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], a[3]);
|
|
// compound_rate(pv, fv, years, compounds_per_year) -> nominal annual rate.
|
|
if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], a[3]);
|
|
// compound_years(pv, fv, rate, compounds_per_year)
|
|
if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], a[3]);
|
|
|
|
// TVM: each function names the variable it solves for, and takes the other
|
|
// four in the calculator's N, I/Y, PV, PMT, FV order.
|
|
if (std.mem.eql(u8, name, "tvm_fv")) {
|
|
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .payment = a[3] });
|
|
return s.value;
|
|
}
|
|
if (std.mem.eql(u8, name, "tvm_pv")) {
|
|
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .payment = a[2], .future_value = a[3] });
|
|
return s.value;
|
|
}
|
|
if (std.mem.eql(u8, name, "tvm_pmt")) {
|
|
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .future_value = a[3] });
|
|
return s.value;
|
|
}
|
|
if (std.mem.eql(u8, name, "tvm_n")) {
|
|
const s = try financial.solveTvm(.{ .rate = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] });
|
|
return s.value;
|
|
}
|
|
if (std.mem.eql(u8, name, "tvm_rate")) {
|
|
const s = try financial.solveTvm(.{ .periods = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] });
|
|
return s.value;
|
|
}
|
|
|
|
// Amortization rows: (principal, rate_per_period_percent, periods, period)
|
|
const is_interest = std.mem.eql(u8, name, "amort_interest");
|
|
const is_principal = std.mem.eql(u8, name, "amort_principal");
|
|
const is_balance = std.mem.eql(u8, name, "amort_balance");
|
|
if (is_interest or is_principal or is_balance) {
|
|
const entry = try financial.amortizationEntry(.{
|
|
.principal = a[0],
|
|
.rate = a[1],
|
|
.periods = try periodCount(a[2]),
|
|
}, try periodCount(a[3]));
|
|
if (is_interest) return entry.interest;
|
|
if (is_principal) return entry.principal;
|
|
return entry.balance;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Evaluate a single-argument built-in function that has no exact form.
|
|
fn evalSingleArgFn(name: []const u8, x: f64) ?f64 {
|
|
if (std.mem.eql(u8, name, "sin")) return @sin(x);
|
|
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 null; // domain error
|
|
return math.asin(x);
|
|
}
|
|
if (std.mem.eql(u8, name, "acos")) {
|
|
if (x < -1 or x > 1) return null;
|
|
return math.acos(x);
|
|
}
|
|
if (std.mem.eql(u8, name, "atan")) return math.atan(x);
|
|
if (std.mem.eql(u8, name, "log")) return @log10(x);
|
|
if (std.mem.eql(u8, name, "log10")) return @log10(x);
|
|
if (std.mem.eql(u8, name, "ln")) return @log(x);
|
|
if (std.mem.eql(u8, name, "log2")) return @log2(x);
|
|
if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x);
|
|
if (std.mem.eql(u8, name, "exp")) return @exp(x);
|
|
return null;
|
|
}
|
|
|
|
/// Result of evaluation with metadata for display decisions.
|
|
pub const EvalInfo = struct {
|
|
/// The computed value. Allocated with the allocator passed to
|
|
/// `evalStringInfo`; the caller owns it and should `deinit` when done.
|
|
value: Number,
|
|
/// True if the expression contained any non-decimal literal (hex/oct/bin).
|
|
has_nondecimal_literal: bool,
|
|
};
|
|
|
|
/// 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 {
|
|
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 {
|
|
var p = Parser.init(allocator, source, env.mode);
|
|
const expr = try p.parse();
|
|
// The parser hands over ownership. Nothing in the result borrows from the
|
|
// tree (literal text points into `source`, and the value is cloned out of the
|
|
// scratch arena), so it can be released as soon as evaluation is done.
|
|
// Without this every evaluated expression leaked its whole AST, which only
|
|
// went unnoticed because the CLI hands in an arena.
|
|
defer parser_mod.freeExpr(allocator, expr);
|
|
|
|
// Intermediates live in a scratch arena; only the final value is copied out
|
|
// into the caller's allocator.
|
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
|
defer arena.deinit();
|
|
const scratch = arena.allocator();
|
|
|
|
const raw = try evalExact(env, scratch, expr);
|
|
const result = raw.cloneWith(allocator) catch |err| return mapError(err);
|
|
|
|
env.setAns(result) catch return CalcError.OutOfMemory;
|
|
env.history_len += 1;
|
|
return .{
|
|
.value = result,
|
|
.has_nondecimal_literal = hasNonDecimalLiteral(expr),
|
|
};
|
|
}
|
|
|
|
/// Walk an AST and report whether any number literal is non-decimal.
|
|
fn hasNonDecimalLiteral(expr: *const Expr) bool {
|
|
return switch (expr.*) {
|
|
.number => |n| n.base != .decimal,
|
|
.string_literal => false,
|
|
.variable => false,
|
|
.unary => |u| hasNonDecimalLiteral(u.operand),
|
|
.binary => |b| hasNonDecimalLiteral(b.left) or hasNonDecimalLiteral(b.right),
|
|
.call => |c| blk: {
|
|
for (c.args) |arg| {
|
|
if (hasNonDecimalLiteral(arg)) break :blk true;
|
|
}
|
|
break :blk false;
|
|
},
|
|
.assignment => |a| hasNonDecimalLiteral(a.value),
|
|
};
|
|
}
|
|
|
|
// -- Tests --
|
|
|
|
const testing = std.testing;
|
|
|
|
/// Evaluate and collapse to f64.
|
|
///
|
|
/// The exact result is converted here rather than at every call site, which is
|
|
/// what lets the ~90 pre-existing f64 assertions in this file stay untouched
|
|
/// while the engine itself moved to `Number`.
|
|
fn testEval(source: []const u8) !f64 {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
const result = try evalString(&env, alloc, source);
|
|
return result.toFloat(alloc);
|
|
}
|
|
|
|
fn testEvalProgrammer(source: []const u8) !f64 {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .programmer);
|
|
defer env.deinit();
|
|
const result = try evalString(&env, alloc, source);
|
|
return result.toFloat(alloc);
|
|
}
|
|
|
|
test "eval simple number" {
|
|
const result = try testEval("42");
|
|
try testing.expectEqual(@as(f64, 42.0), result);
|
|
}
|
|
|
|
test "eval addition" {
|
|
const result = try testEval("2 + 3");
|
|
try testing.expectEqual(@as(f64, 5.0), result);
|
|
}
|
|
|
|
test "eval subtraction" {
|
|
const result = try testEval("10 - 7");
|
|
try testing.expectEqual(@as(f64, 3.0), result);
|
|
}
|
|
|
|
test "eval multiplication" {
|
|
const result = try testEval("6 * 7");
|
|
try testing.expectEqual(@as(f64, 42.0), result);
|
|
}
|
|
|
|
test "eval division" {
|
|
const result = try testEval("10 / 4");
|
|
try testing.expectEqual(@as(f64, 2.5), result);
|
|
}
|
|
|
|
test "eval division by zero" {
|
|
const result = testEval("1 / 0");
|
|
try testing.expectError(CalcError.DivisionByZero, result);
|
|
}
|
|
|
|
test "eval modulo" {
|
|
const result = try testEval("10 % 3");
|
|
try testing.expectApproxEqAbs(@as(f64, 1.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval power" {
|
|
const result = try testEval("2^10");
|
|
try testing.expectEqual(@as(f64, 1024.0), result);
|
|
}
|
|
|
|
test "eval precedence" {
|
|
const result = try testEval("2 + 3 * 4");
|
|
try testing.expectEqual(@as(f64, 14.0), result);
|
|
}
|
|
|
|
test "eval parentheses" {
|
|
const result = try testEval("(2 + 3) * 4");
|
|
try testing.expectEqual(@as(f64, 20.0), result);
|
|
}
|
|
|
|
test "eval unary negation" {
|
|
const result = try testEval("-5 + 3");
|
|
try testing.expectEqual(@as(f64, -2.0), result);
|
|
}
|
|
|
|
test "eval nested parens" {
|
|
const result = try testEval("((2 + 3) * (4 - 1))");
|
|
try testing.expectEqual(@as(f64, 15.0), result);
|
|
}
|
|
|
|
test "eval pi constant" {
|
|
const result = try testEval("pi");
|
|
try testing.expectApproxEqAbs(math.pi, result, 1e-10);
|
|
}
|
|
|
|
test "eval e constant" {
|
|
const result = try testEval("e");
|
|
try testing.expectApproxEqAbs(math.e, result, 1e-10);
|
|
}
|
|
|
|
test "eval tau constant" {
|
|
const result = try testEval("tau");
|
|
try testing.expectApproxEqAbs(math.tau, result, 1e-10);
|
|
}
|
|
|
|
test "eval 2*pi" {
|
|
const result = try testEval("2*pi");
|
|
try testing.expectApproxEqAbs(2.0 * math.pi, result, 1e-10);
|
|
}
|
|
|
|
test "eval 3*(4+5)" {
|
|
const result = try testEval("3*(4+5)");
|
|
try testing.expectEqual(@as(f64, 27.0), result);
|
|
}
|
|
|
|
test "eval sin" {
|
|
const result = try testEval("sin(0)");
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval cos" {
|
|
const result = try testEval("cos(0)");
|
|
try testing.expectApproxEqAbs(@as(f64, 1.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval sqrt" {
|
|
const result = try testEval("sqrt(144)");
|
|
try testing.expectEqual(@as(f64, 12.0), result);
|
|
}
|
|
|
|
test "eval abs" {
|
|
const result = try testEval("abs(-42)");
|
|
try testing.expectEqual(@as(f64, 42.0), result);
|
|
}
|
|
|
|
test "eval floor" {
|
|
const result = try testEval("floor(3.7)");
|
|
try testing.expectEqual(@as(f64, 3.0), result);
|
|
}
|
|
|
|
test "eval ceil" {
|
|
const result = try testEval("ceil(3.2)");
|
|
try testing.expectEqual(@as(f64, 4.0), result);
|
|
}
|
|
|
|
test "eval round" {
|
|
const result = try testEval("round(3.5)");
|
|
try testing.expectEqual(@as(f64, 4.0), result);
|
|
}
|
|
|
|
test "eval factorial" {
|
|
const result = try testEval("factorial(5)");
|
|
try testing.expectEqual(@as(f64, 120.0), result);
|
|
}
|
|
|
|
test "eval factorial 0" {
|
|
const result = try testEval("factorial(0)");
|
|
try testing.expectEqual(@as(f64, 1.0), result);
|
|
}
|
|
|
|
test "eval ln" {
|
|
const result = try testEval("ln(1)");
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval exp" {
|
|
const result = try testEval("exp(0)");
|
|
try testing.expectEqual(@as(f64, 1.0), result);
|
|
}
|
|
|
|
test "eval max" {
|
|
const result = try testEval("max(3, 7)");
|
|
try testing.expectEqual(@as(f64, 7.0), result);
|
|
}
|
|
|
|
test "eval min" {
|
|
const result = try testEval("min(3, 7)");
|
|
try testing.expectEqual(@as(f64, 3.0), result);
|
|
}
|
|
|
|
test "eval unknown function" {
|
|
const result = testEval("bogus(1)");
|
|
try testing.expectError(CalcError.UnknownFunction, result);
|
|
}
|
|
|
|
test "eval unknown variable" {
|
|
const result = testEval("xyz");
|
|
try testing.expectError(CalcError.UnknownVariable, result);
|
|
}
|
|
|
|
test "eval variable assignment and use" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
|
|
const assign_result = try evalString(&env, alloc, "X = 42");
|
|
try testing.expectEqual(@as(f64, 42.0), assign_result.toFloat(alloc));
|
|
|
|
const use_result = try evalString(&env, alloc, "X + 8");
|
|
try testing.expectEqual(@as(f64, 50.0), use_result.toFloat(alloc));
|
|
}
|
|
|
|
test "eval Ans" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
|
|
_ = try evalString(&env, alloc, "7 * 6");
|
|
const result = try evalString(&env, alloc, "Ans + 1");
|
|
try testing.expectEqual(@as(f64, 43.0), result.toFloat(alloc));
|
|
}
|
|
|
|
test "eval complex expression" {
|
|
const result = try testEval("sin(pi/2) + cos(0)");
|
|
try testing.expectApproxEqAbs(@as(f64, 2.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval 2^32 - 1" {
|
|
const result = try testEval("2^32 - 1");
|
|
try testing.expectEqual(@as(f64, 4294967295.0), result);
|
|
}
|
|
|
|
test "eval programmer XOR" {
|
|
const result = try testEvalProgrammer("0xF xor 0x3");
|
|
try testing.expectEqual(@as(f64, 12.0), result);
|
|
}
|
|
|
|
test "eval programmer AND" {
|
|
const result = try testEvalProgrammer("0xFF & 0x0F");
|
|
try testing.expectEqual(@as(f64, 15.0), result);
|
|
}
|
|
|
|
test "eval programmer OR" {
|
|
const result = try testEvalProgrammer("0xF0 | 0x0F");
|
|
try testing.expectEqual(@as(f64, 255.0), result);
|
|
}
|
|
|
|
test "eval programmer shift left" {
|
|
const result = try testEvalProgrammer("1 << 8");
|
|
try testing.expectEqual(@as(f64, 256.0), result);
|
|
}
|
|
|
|
test "eval programmer shift right" {
|
|
const result = try testEvalProgrammer("256 >> 4");
|
|
try testing.expectEqual(@as(f64, 16.0), result);
|
|
}
|
|
|
|
test "eval number with underscores" {
|
|
const result = try testEval("1_000_000 + 1");
|
|
try testing.expectEqual(@as(f64, 1_000_001.0), result);
|
|
}
|
|
|
|
test "eval number with commas" {
|
|
const result = try testEval("1,000 * 2.3");
|
|
try testing.expectApproxEqAbs(@as(f64, 2300.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval commas not confused with function args" {
|
|
const result = try testEval("max(1,000, 500)");
|
|
try testing.expectEqual(@as(f64, 1000.0), result);
|
|
}
|
|
|
|
test "eval bitwise not in standard mode" {
|
|
const result = try testEval("~0");
|
|
// ~0 as i64 = -1
|
|
try testing.expectEqual(@as(f64, -1.0), result);
|
|
}
|
|
|
|
test "eval rotate left in standard mode" {
|
|
// 1 rol 4 = 16 (for 64-bit)
|
|
const result = try testEvalProgrammer("1 rol 4");
|
|
try testing.expectEqual(@as(f64, 16.0), result);
|
|
}
|
|
|
|
test "eval rotate right in standard mode" {
|
|
const result = try testEvalProgrammer("16 ror 4");
|
|
try testing.expectEqual(@as(f64, 1.0), result);
|
|
}
|
|
|
|
test "eval atan2" {
|
|
const result = try testEval("atan2(1, 1)");
|
|
try testing.expectApproxEqAbs(math.pi / 4.0, result, 1e-10);
|
|
}
|
|
|
|
test "eval log with base" {
|
|
const result = try testEval("log(100, 10)");
|
|
try testing.expectApproxEqAbs(@as(f64, 2.0), result, 1e-10);
|
|
}
|
|
|
|
test "eval log domain error" {
|
|
const result = testEval("log(-1, 10)");
|
|
try testing.expectError(CalcError.DomainError, result);
|
|
}
|
|
|
|
test "eval asin domain error" {
|
|
const result = testEval("asin(2)");
|
|
// asin(2) is domain error since |2| > 1
|
|
try testing.expectError(CalcError.UnknownFunction, result);
|
|
}
|
|
|
|
test "eval acos" {
|
|
const result = try testEval("acos(1)");
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
|
|
}
|
|
|
|
test "evalStringInfo: detects hex literal" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
const info = try evalStringInfo(&env, alloc, "0o777 - 0x0f");
|
|
try testing.expectEqual(@as(f64, 496.0), info.value.toFloat(alloc));
|
|
try testing.expect(info.has_nondecimal_literal);
|
|
}
|
|
|
|
test "evalStringInfo: pure decimal has no nondecimal literal" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
const info = try evalStringInfo(&env, alloc, "2 + 2");
|
|
try testing.expectEqual(@as(f64, 4.0), info.value.toFloat(alloc));
|
|
try testing.expect(!info.has_nondecimal_literal);
|
|
}
|
|
|
|
test "evalStringInfo: binary literal detected in nested expr" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
const info = try evalStringInfo(&env, alloc, "sqrt(0b100) + 1");
|
|
try testing.expect(info.has_nondecimal_literal);
|
|
}
|
|
|
|
test "eval string literal in standard mode packs ASCII" {
|
|
// 'A' -> 0x41 -> 65
|
|
const result = try testEval("'A'");
|
|
try testing.expectEqual(@as(f64, 65.0), result);
|
|
}
|
|
|
|
test "eval string literal multi-char in standard mode" {
|
|
// 'AB' -> 0x4142 -> 16706
|
|
const result = try testEval("'AB'");
|
|
try testing.expectEqual(@as(f64, 16706.0), result);
|
|
}
|
|
|
|
test "eval string literal with non-ASCII byte errors" {
|
|
// byte > 0x7F is rejected
|
|
const result = testEval("'\x80'");
|
|
try testing.expectError(CalcError.InvalidNumber, result);
|
|
}
|
|
|
|
test "eval rand zero-arg function returns 0" {
|
|
const result = try testEval("rand()");
|
|
try testing.expectEqual(@as(f64, 0.0), result);
|
|
}
|
|
|
|
test "eval unknown zero-arg function" {
|
|
const result = testEval("bogus()");
|
|
try testing.expectError(CalcError.UnknownFunction, result);
|
|
}
|
|
|
|
test "eval unknown three-arg function" {
|
|
const result = testEval("bogus(1, 2, 3)");
|
|
try testing.expectError(CalcError.UnknownFunction, result);
|
|
}
|
|
|
|
test "eval unknown two-arg function" {
|
|
const result = testEval("bogus(1, 2)");
|
|
try testing.expectError(CalcError.UnknownFunction, result);
|
|
}
|
|
|
|
// -- Exact arithmetic (Task 2.0b) --
|
|
//
|
|
// These verify the exact evaluation core through the unchanged f64 API. The
|
|
// payoff is visible here because today's errors are ACCUMULATED: f64 rounds
|
|
// each decimal literal before operating on it, whereas the exact core computes
|
|
// the true value and rounds once, at the boundary.
|
|
//
|
|
// Note the runtime-`var` dance in the comparisons against plain f64: Zig folds
|
|
// float literals at comptime as `comptime_float`, so `0.1 + 0.2 != 0.3` is
|
|
// false at comptime and would not exercise f64 at all.
|
|
|
|
test "exact: 0.1 + 0.2 is 0.3" {
|
|
try testing.expectEqual(@as(f64, 0.3), try testEval("0.1 + 0.2"));
|
|
|
|
var x: f64 = 0.1;
|
|
var y: f64 = 0.2;
|
|
_ = &x;
|
|
_ = &y;
|
|
try testing.expect(x + y != @as(f64, 0.3));
|
|
}
|
|
|
|
test "exact: 1.1 + 2.2 is 3.3" {
|
|
try testing.expectEqual(@as(f64, 3.3), try testEval("1.1 + 2.2"));
|
|
}
|
|
|
|
test "exact: 0.1 * 3 is 0.3" {
|
|
try testing.expectEqual(@as(f64, 0.3), try testEval("0.1 * 3"));
|
|
}
|
|
|
|
test "exact: chained decimal addition" {
|
|
try testing.expectEqual(@as(f64, 0.6), try testEval("0.1 + 0.2 + 0.3"));
|
|
try testing.expectEqual(@as(f64, 0.8), try testEval("0.7 + 0.1"));
|
|
try testing.expectEqual(@as(f64, 0.2), try testEval("0.3 - 0.1"));
|
|
try testing.expectEqual(@as(f64, 0.01), try testEval("0.1 * 0.1"));
|
|
}
|
|
|
|
test "exact: an expression that cancels reaches exactly zero" {
|
|
try testing.expectEqual(@as(f64, 0.0), try testEval("(0.1 + 0.2) * 10 - 3"));
|
|
|
|
var x: f64 = 0.1;
|
|
var y: f64 = 0.2;
|
|
_ = &x;
|
|
_ = &y;
|
|
try testing.expect((x + y) * 10.0 - 3.0 != 0.0);
|
|
}
|
|
|
|
test "exact: intermediates beyond f64 precision survive" {
|
|
// 1e20 + 1 is not representable in f64, so the f64 route loses the 1 and
|
|
// yields 0. Exact arithmetic keeps it and the final result fits.
|
|
try testing.expectEqual(@as(f64, 1.0), try testEval("1e20 + 1 - 1e20"));
|
|
|
|
var big: f64 = 1e20;
|
|
_ = &big;
|
|
try testing.expectEqual(@as(f64, 0.0), big + 1.0 - big);
|
|
}
|
|
|
|
test "exact: division round trip" {
|
|
try testing.expectEqual(@as(f64, 1.0), try testEval("1 / 3 * 3"));
|
|
try testing.expectEqual(@as(f64, 1.0), try testEval("1 / 7 * 7"));
|
|
try testing.expectEqual(@as(f64, 100.5), try testEval("1.005 * 100"));
|
|
}
|
|
|
|
test "exact: factorial is no longer capped at 170" {
|
|
// Previously `factorial(171)` reported "unknown function" because the f64
|
|
// implementation overflowed. It now computes exactly and only loses
|
|
// magnitude at the f64 boundary.
|
|
const result = try testEval("factorial(171)");
|
|
try testing.expect(math.isPositiveInf(result));
|
|
|
|
// And a value f64 can still hold comes back exact.
|
|
try testing.expectEqual(@as(f64, 120.0), try testEval("factorial(5)"));
|
|
}
|
|
|
|
test "exact: perfect square roots stay exact, irrational ones fall back" {
|
|
try testing.expectEqual(@as(f64, 12.0), try testEval("sqrt(144)"));
|
|
try testing.expectEqual(@as(f64, 0.5), try testEval("sqrt(0.25)"));
|
|
try testing.expectApproxEqAbs(math.sqrt2, try testEval("sqrt(2)"), 1e-15);
|
|
}
|
|
|
|
test "exact: floor, ceil and round match the float builtins" {
|
|
try testing.expectEqual(@as(f64, -4.0), try testEval("floor(-3.2)"));
|
|
try testing.expectEqual(@as(f64, -3.0), try testEval("ceil(-3.2)"));
|
|
try testing.expectEqual(@as(f64, 3.0), try testEval("round(2.5)"));
|
|
try testing.expectEqual(@as(f64, -3.0), try testEval("round(-2.5)"));
|
|
try testing.expectEqual(@as(f64, 0.1), try testEval("abs(-0.1)"));
|
|
}
|
|
|
|
test "exact: mod keeps the sign of the divisor" {
|
|
try testing.expectEqual(@as(f64, 1.0), try testEval("10 % 3"));
|
|
try testing.expectEqual(@as(f64, 2.0), try testEval("-10 % 3"));
|
|
try testing.expectEqual(@as(f64, 0.5), try testEval("7.5 % 1"));
|
|
}
|
|
|
|
test "exact: non-decimal literals are exact integers" {
|
|
try testing.expectEqual(@as(f64, 255.0), try testEval("0xFF"));
|
|
try testing.expectEqual(@as(f64, 496.0), try testEval("0o777 - 0x0f"));
|
|
try testing.expectEqual(@as(f64, 10.0), try testEval("0b1010"));
|
|
}
|
|
|
|
test "exact: transcendentals still fall back to floats" {
|
|
// These have no exact rational form, so they must go through f64 and are
|
|
// only expected to be approximately right.
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("sin(0)"), 1e-15);
|
|
try testing.expectApproxEqAbs(math.pi, try testEval("pi"), 1e-15);
|
|
try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("ln(e)"), 1e-15);
|
|
try testing.expectApproxEqAbs(@as(f64, 2.0), try testEval("log10(100)"), 1e-15);
|
|
}
|
|
|
|
test "exact: a transcendental contaminates the rest of the expression" {
|
|
// Once sin() enters, the result is inexact; it must still be numerically
|
|
// right, just not exact.
|
|
const result = try testEval("sin(0) + 0.1 + 0.2");
|
|
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"));
|
|
}
|
|
|
|
// -- Exactness visible through the Number API (Task 2.0c) --
|
|
//
|
|
// These are the cases the f64 boundary could not express. `testEval` collapses
|
|
// to f64 and would lose exactly the information under test here.
|
|
|
|
/// Evaluate and keep the exact result. The arena owns everything.
|
|
fn testEvalNumber(arena: *std.heap.ArenaAllocator, source: []const u8) !Number {
|
|
const a = arena.allocator();
|
|
var env = Environment.init(a, .standard);
|
|
defer env.deinit();
|
|
return evalString(&env, a, source);
|
|
}
|
|
|
|
fn expectExactDecimal(expected: []const u8, source: []const u8) !void {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const result = try testEvalNumber(&arena, source);
|
|
try testing.expect(result.isExact());
|
|
const shown = try result.toDecimalString(arena.allocator(), 20);
|
|
try testing.expectEqualStrings(expected, shown.text);
|
|
}
|
|
|
|
test "Number API: the integer f64 cannot hold round-trips" {
|
|
// The headline case. Through the f64 boundary this became
|
|
// 9.007199254740992e15, a DIFFERENT integer than the one typed.
|
|
try expectExactDecimal("9007199254740993", "9007199254740993");
|
|
try expectExactDecimal("9007199254740993", "2^53 + 1");
|
|
try expectExactDecimal("9007199254740992", "2^53");
|
|
}
|
|
|
|
test "Number API: exact integer arithmetic is unbounded" {
|
|
try expectExactDecimal("1267650600228229401496703205376", "2^100");
|
|
try expectExactDecimal("100000000000000000001", "1e20 + 1");
|
|
try expectExactDecimal("121932631112635269", "123456789 * 987654321");
|
|
}
|
|
|
|
test "Number API: one third is retained exactly, not as a decimal" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const result = try testEvalNumber(&arena, "1/3");
|
|
try testing.expect(result.isExact());
|
|
|
|
// The exact form is a fraction, which no float could express.
|
|
const frac = (try result.toFractionString(arena.allocator())).?;
|
|
try testing.expectEqualStrings("1/3", frac);
|
|
|
|
// And its decimal rendering is correctly reported as approximate.
|
|
const shown = try result.toDecimalString(arena.allocator(), 10);
|
|
try testing.expect(!shown.exact);
|
|
}
|
|
|
|
test "Number API: factorial is exact past the old 170 limit" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const result = try testEvalNumber(&arena, "factorial(171)");
|
|
try testing.expect(result.isExact());
|
|
|
|
const shown = try result.toDecimalString(arena.allocator(), 0);
|
|
// 171! has 310 digits; f64 could only report infinity.
|
|
try testing.expectEqual(@as(usize, 310), shown.text.len);
|
|
try testing.expect(shown.exact);
|
|
}
|
|
|
|
test "Number API: transcendentals are reported as inexact" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
|
|
const s = try testEvalNumber(&arena, "sin(1)");
|
|
try testing.expect(!s.isExact());
|
|
|
|
const p = try testEvalNumber(&arena, "pi");
|
|
try testing.expect(!p.isExact());
|
|
|
|
const r = try testEvalNumber(&arena, "sqrt(2)");
|
|
try testing.expect(!r.isExact());
|
|
|
|
// But a perfect square stays exact.
|
|
const q = try testEvalNumber(&arena, "sqrt(144)");
|
|
try testing.expect(q.isExact());
|
|
}
|
|
|
|
test "Number API: inexactness is contagious across an expression" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const result = try testEvalNumber(&arena, "0.1 + 0.2 + sin(0)");
|
|
try testing.expect(!result.isExact());
|
|
}
|
|
|
|
test "Number API: variables keep the exactness of their expression" {
|
|
// This is what storing Number in the Environment buys: previously the
|
|
// assignment round-tripped through f64 and 0.1 came back approximated.
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const a = arena.allocator();
|
|
var env = Environment.init(a, .standard);
|
|
defer env.deinit();
|
|
|
|
const assigned = try evalString(&env, a, "X = 0.1");
|
|
try testing.expect(assigned.isExact());
|
|
|
|
const sum = try evalString(&env, a, "X + 0.2");
|
|
try testing.expect(sum.isExact());
|
|
const shown = try sum.toDecimalString(a, 20);
|
|
try testing.expectEqualStrings("0.3", shown.text);
|
|
try testing.expect(shown.exact);
|
|
}
|
|
|
|
test "Number API: Ans keeps exactness between evaluations" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const a = arena.allocator();
|
|
var env = Environment.init(a, .standard);
|
|
defer env.deinit();
|
|
|
|
_ = try evalString(&env, a, "1/3");
|
|
const doubled = try evalString(&env, a, "Ans * 3");
|
|
try testing.expect(doubled.isExact());
|
|
const shown = try doubled.toDecimalString(a, 20);
|
|
try testing.expectEqualStrings("1", shown.text);
|
|
}
|
|
|
|
test "Number API: reassigning a variable releases the old value" {
|
|
// Exercises the replace path in setVar, which must deinit the previous
|
|
// Number rather than leaking it.
|
|
var env = Environment.init(testing.allocator, .standard);
|
|
defer env.deinit();
|
|
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const a = arena.allocator();
|
|
|
|
_ = try evalString(&env, a, "X = 1/3");
|
|
_ = try evalString(&env, a, "X = 2/7");
|
|
_ = try evalString(&env, a, "X = 5");
|
|
const result = try evalString(&env, a, "X * 2");
|
|
try testing.expectEqual(@as(f64, 10.0), result.toFloat(a));
|
|
}
|
|
|
|
test "Number API: a variable name outliving its source text stays valid" {
|
|
// setVar duplicates the name because it points into the expression source,
|
|
// which the caller may free (the TUI frees history on Ctrl-L).
|
|
var env = Environment.init(testing.allocator, .standard);
|
|
defer env.deinit();
|
|
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const a = arena.allocator();
|
|
|
|
{
|
|
const source = try testing.allocator.dupe(u8, "myvar = 42");
|
|
defer testing.allocator.free(source);
|
|
_ = try evalString(&env, a, source);
|
|
}
|
|
// The source is gone; the stored name must still resolve.
|
|
const result = try evalString(&env, a, "myvar + 1");
|
|
try testing.expectEqual(@as(f64, 43.0), result.toFloat(a));
|
|
}
|
|
|
|
// -- Financial functions in standard mode --
|
|
//
|
|
// The point of these tests is reachability and composition: the financial math
|
|
// itself is covered in financial.zig. What matters here is that a plain
|
|
// expression can call them, with the argument counts the parser now allows.
|
|
|
|
test "financial: cagr is callable from a standard expression" {
|
|
try testing.expectApproxEqAbs(@as(f64, 0.2011244), try testEval("cagr(10000, 25000, 5)"), 1e-7);
|
|
}
|
|
|
|
test "financial: a financial result composes with ordinary arithmetic" {
|
|
// The fraction-to-percent conversion users will reach for immediately.
|
|
try testing.expectApproxEqAbs(@as(f64, 20.11244), try testEval("cagr(10000, 25000, 5) * 100"), 1e-5);
|
|
}
|
|
|
|
test "financial: arguments may themselves be expressions" {
|
|
try testing.expectApproxEqAbs(
|
|
@as(f64, 0.2011244),
|
|
try testEval("cagr(10000, 5 * 5000, 60 / 12)"),
|
|
1e-7,
|
|
);
|
|
}
|
|
|
|
test "financial: compound interest with and without a frequency argument" {
|
|
// Three arguments compounds annually.
|
|
try testing.expectApproxEqAbs(@as(f64, 1628.894627), try testEval("fv(1000, 5, 10)"), 1e-6);
|
|
// The fourth argument is the compounding frequency; monthly beats annual.
|
|
try testing.expectApproxEqAbs(@as(f64, 1647.009498), try testEval("fv(1000, 5, 10, 12)"), 1e-6);
|
|
try testing.expect(try testEval("fv(1000, 5, 10, 12)") > try testEval("fv(1000, 5, 10)"));
|
|
}
|
|
|
|
test "financial: present value inverts future value" {
|
|
const future = try testEval("fv(1000, 5, 10, 4)");
|
|
var buffer: [64]u8 = undefined;
|
|
const source = try std.fmt.bufPrint(&buffer, "pv({d}, 5, 10, 4)", .{future});
|
|
try testing.expectApproxEqAbs(@as(f64, 1000.0), try testEval(source), 1e-6);
|
|
}
|
|
|
|
test "financial: compound interest solves for its rate and its time" {
|
|
// The same relationship as fv(), read backwards.
|
|
try testing.expectApproxEqAbs(
|
|
@as(f64, 5.0),
|
|
try testEval("compound_rate(1000, 1628.894627, 10)"),
|
|
1e-6,
|
|
);
|
|
try testing.expectApproxEqAbs(
|
|
@as(f64, 10.0),
|
|
try testEval("compound_years(1000, 1628.894627, 5)"),
|
|
1e-6,
|
|
);
|
|
|
|
// With a compounding frequency the rate is nominal, so it is lower.
|
|
const monthly = try testEval("compound_rate(1000, 2000, 10, 12)");
|
|
const annual = try testEval("compound_rate(1000, 2000, 10)");
|
|
try testing.expect(monthly < annual);
|
|
try testing.expectApproxEqAbs(@as(f64, 6.95152928), monthly, 1e-8);
|
|
|
|
// At annual compounding, solving for the rate is CAGR.
|
|
try testing.expectApproxEqAbs(
|
|
try testEval("cagr(10000, 25000, 5) * 100"),
|
|
try testEval("compound_rate(10000, 25000, 5)"),
|
|
1e-9,
|
|
);
|
|
}
|
|
|
|
test "financial: apy converts a nominal rate to an effective one" {
|
|
try testing.expectApproxEqAbs(@as(f64, 19.5618), try testEval("apy(18, 12)"), 1e-4);
|
|
// Annual compounding is its own effective rate.
|
|
try testing.expectApproxEqAbs(@as(f64, 5.0), try testEval("apy(5, 1)"), 1e-12);
|
|
// And it composes, which is the point of exposing it as a function.
|
|
try testing.expectApproxEqAbs(
|
|
@as(f64, 19.5618),
|
|
try testEval("apy(compound_rate(1000, fv(1000, 18, 5, 12), 5, 12), 12)"),
|
|
1e-4,
|
|
);
|
|
try testing.expectError(CalcError.DomainError, testEval("apy(5, 0)"));
|
|
}
|
|
|
|
test "financial: the tvm solvers are reachable as four-argument functions" {
|
|
// 200,000 at 0.5% a period over 360 periods: the classic mortgage payment.
|
|
try testing.expectApproxEqAbs(
|
|
@as(f64, -1199.10105),
|
|
try testEval("tvm_pmt(360, 0.5, 200000, 0)"),
|
|
1e-5,
|
|
);
|
|
try testing.expectApproxEqAbs(@as(f64, 7.1773462), try testEval("tvm_rate(10, -1000, 0, 2000)"), 1e-6);
|
|
try testing.expectApproxEqAbs(@as(f64, 10.244768), try testEval("tvm_n(7, -1000, 0, 2000)"), 1e-6);
|
|
try testing.expectApproxEqAbs(@as(f64, 1257.789254), try testEval("tvm_fv(10, 5, 0, -100)"), 1e-6);
|
|
try testing.expectApproxEqAbs(@as(f64, -1016.698584), try testEval("tvm_pv(10, 7, 0, 2000)"), 1e-6);
|
|
}
|
|
|
|
test "financial: amortization rows are reachable from an expression" {
|
|
try testing.expectEqual(@as(f64, 1199.10), try testEval("amort_payment(200000, 0.5, 360)"));
|
|
// Month one of a 6% loan on 200,000 is exactly 1000 of interest.
|
|
try testing.expectEqual(@as(f64, 1000.0), try testEval("amort_interest(200000, 0.5, 360, 1)"));
|
|
try testing.expectApproxEqAbs(@as(f64, 199.10), try testEval("amort_principal(200000, 0.5, 360, 1)"), 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, 199800.90), try testEval("amort_balance(200000, 0.5, 360, 1)"), 1e-9);
|
|
try testing.expectEqual(@as(f64, 0.0), try testEval("amort_balance(200000, 0.5, 360, 360)"));
|
|
}
|
|
|
|
test "financial: amortization totals" {
|
|
const interest = try testEval("amort_total_interest(200000, 0.5, 360)");
|
|
const paid = try testEval("amort_total_paid(200000, 0.5, 360)");
|
|
try testing.expect(interest > 231000 and interest < 232000);
|
|
try testing.expectApproxEqAbs(paid - interest, 200000, 0.05);
|
|
}
|
|
|
|
test "financial: results are inexact, so they do not claim exactness" {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer _ = arena.deinit();
|
|
const alloc = arena.allocator();
|
|
var env = Environment.init(alloc, .standard);
|
|
defer env.deinit();
|
|
|
|
const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)");
|
|
try testing.expect(!result.isExact());
|
|
}
|
|
|
|
test "financial: bad arguments are domain errors, not wrong answers" {
|
|
// Zero periods.
|
|
try testing.expectError(CalcError.DomainError, testEval("cagr(1000, 2000, 0)"));
|
|
// A fractional period count cannot index an amortization schedule.
|
|
try testing.expectError(CalcError.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)"));
|
|
// Period past the end of the schedule.
|
|
try testing.expectError(CalcError.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)"));
|
|
// Payments that never retire the loan.
|
|
try testing.expectError(CalcError.DomainError, testEval("amort_payment(0, 0.5, 360)"));
|
|
// Period counts outside the schedule bounds.
|
|
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 0)"));
|
|
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 20000)"));
|
|
try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 10^400)"));
|
|
}
|
|
|
|
test "financial: wrong argument counts are unknown functions, not silent defaults" {
|
|
try testing.expectError(CalcError.UnknownFunction, testEval("cagr(10000, 25000)"));
|
|
try testing.expectError(CalcError.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)"));
|
|
try testing.expectError(CalcError.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)"));
|
|
// A three or four argument call to something that is not a function at all.
|
|
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3)"));
|
|
try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3, 4)"));
|
|
}
|
|
|
|
// -- The AST is not the caller's problem --
|
|
//
|
|
// These use testing.allocator directly rather than testEval's arena, because an
|
|
// arena hides exactly the bug they guard against: evalStringInfo used to leak the
|
|
// whole parsed tree on every call, which an arena silently absorbs.
|
|
|
|
test "no leak: a successful evaluation releases the parsed tree" {
|
|
var env = Environment.init(testing.allocator, .standard);
|
|
defer env.deinit();
|
|
|
|
const sources = [_][]const u8{
|
|
"1 + 2 * 3",
|
|
"-(4 + 5)",
|
|
"max(1, min(2, 3))",
|
|
"sqrt(2) + factorial(10)",
|
|
"X = 7 * 6",
|
|
"X + 1",
|
|
"cagr(10000, 25000, 5) * 100",
|
|
"amort_interest(200000, 0.5, 360, 1)",
|
|
"0xFF and 0x0F",
|
|
};
|
|
for (sources) |source| {
|
|
var value = try evalString(&env, testing.allocator, source);
|
|
value.deinit();
|
|
}
|
|
}
|
|
|
|
test "no leak: a failed evaluation releases the parsed tree" {
|
|
var env = Environment.init(testing.allocator, .standard);
|
|
defer env.deinit();
|
|
|
|
const sources = [_][]const u8{
|
|
"2 +",
|
|
"(1 + 2",
|
|
"max(1, 2",
|
|
"1 / 0",
|
|
"unknownfn(1)",
|
|
"undefined_variable + 1",
|
|
"sqrt(-1)",
|
|
"cagr(1000, 2000, 0)",
|
|
};
|
|
for (sources) |source| {
|
|
if (evalString(&env, testing.allocator, source)) |value| {
|
|
var owned = value;
|
|
owned.deinit();
|
|
std.debug.print("expected an error for \"{s}\"\n", .{source});
|
|
return error.TestUnexpectedResult;
|
|
} else |_| {}
|
|
}
|
|
}
|
|
|
|
test "no leak: repeated evaluation does not accumulate" {
|
|
// A long interactive session is the case that made this visible: the TUI
|
|
// evaluates on every Enter and never frees anything itself.
|
|
var env = Environment.init(testing.allocator, .standard);
|
|
defer env.deinit();
|
|
|
|
var i: usize = 0;
|
|
while (i < 200) : (i += 1) {
|
|
var value = try evalString(&env, testing.allocator, "Ans + 1");
|
|
value.deinit();
|
|
}
|
|
}
|
|
|
|
// -- Function arguments versus grouped digits --
|
|
//
|
|
// The tokenizer used to treat any comma followed by a digit as a thousands
|
|
// separator, so a call written without spaces silently became a call with one
|
|
// merged argument. These are the end-to-end cases: what a user types, and what
|
|
// they get.
|
|
|
|
test "function arguments survive a comma with no space after it" {
|
|
// log(100, 10) is 2. The old rule lexed this as log(10010) and returned
|
|
// 4.0004, which is a wrong answer rather than an error.
|
|
try testing.expectApproxEqAbs(@as(f64, 2.0), try testEval("log(100,10)"), 1e-12);
|
|
try testing.expectEqual(@as(f64, 2.0), try testEval("max(1,2)"));
|
|
try testing.expectEqual(@as(f64, 1.0), try testEval("min(1,2)"));
|
|
try testing.expectApproxEqAbs(@as(f64, 0.2011244), try testEval("cagr(10000,25000,5)"), 1e-7);
|
|
try testing.expectApproxEqAbs(
|
|
@as(f64, -1199.10105),
|
|
try testEval("tvm_pmt(360,0.5,200000,0)"),
|
|
1e-5,
|
|
);
|
|
// Whitespace must not change the meaning.
|
|
try testing.expectEqual(try testEval("max(1, 2)"), try testEval("max(1,2)"));
|
|
try testing.expectEqual(try testEval("log(100, 10)"), try testEval("log(100,10)"));
|
|
}
|
|
|
|
test "grouped digits still work, inside and outside a call" {
|
|
try testing.expectEqual(@as(f64, 2000.0), try testEval("1,000 * 2"));
|
|
try testing.expectEqual(@as(f64, 1234568.0), try testEval("1,234,567 + 1"));
|
|
// A grouped argument is one argument.
|
|
try testing.expectEqual(@as(f64, 1000.0), try testEval("max(1,000, 500)"));
|
|
try testing.expectEqual(@as(f64, 1500.0), try testEval("min(1,500, 2,000)"));
|
|
}
|
|
|
|
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"));
|
|
}
|
|
|
|
test "a grouped literal past 2^53 is still exact" {
|
|
// The separators have to reach the exact re-parse, not just the f64 channel.
|
|
var env = Environment.init(testing.allocator, .standard);
|
|
defer env.deinit();
|
|
var value = try evalString(&env, testing.allocator, "9,007,199,254,740,993");
|
|
defer value.deinit();
|
|
try testing.expect(value.isExact());
|
|
const shown = try @import("formatter.zig").formatNumber(testing.allocator, value);
|
|
defer shown.deinit(testing.allocator);
|
|
try testing.expectEqualStrings("9007199254740993", shown.raw);
|
|
}
|
|
|
|
// -- Operands that do not fit a machine word --
|
|
//
|
|
// The bitwise operators project through f64 and then convert to an integer. That
|
|
// conversion used to be unchecked, so ordinary input aborted the process:
|
|
// `2^64 and 1` and `~1e30` both died with "integer part of floating point value
|
|
// out of bounds", and a NaN operand produced a garbage number instead.
|
|
|
|
test "bitwise operands outside i64 report overflow instead of aborting" {
|
|
const cases = [_][]const u8{
|
|
"2^64 and 1",
|
|
"1e30 and 1",
|
|
"1e30 or 1",
|
|
"1e30 xor 1",
|
|
"~1e30",
|
|
"~(2^1000)",
|
|
"1e30 rol 1",
|
|
"1e30 ror 1",
|
|
"1e30 << 1",
|
|
"1e30 >> 1",
|
|
"1 << 1e30",
|
|
"1 rol 1e30",
|
|
"0 - 1e30 and 1",
|
|
};
|
|
for (cases) |source| {
|
|
const result = testEval(source);
|
|
try testing.expectError(CalcError.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)"));
|
|
}
|
|
|
|
test "bitwise operators still work at the edges of the range" {
|
|
// Just inside i64: 2^63 - 1 as a float is 2^63, so use 2^62 for a clean case.
|
|
try testing.expectEqual(@as(f64, 0.0), try testEval("2^62 and 1"));
|
|
try testing.expectEqual(@as(f64, 15.0), try testEval("0xFF and 0x0F"));
|
|
try testing.expectEqual(@as(f64, 8.0), try testEval("1 << 3"));
|
|
try testing.expectEqual(@as(f64, -1.0), try testEval("~0"));
|
|
// Negative operands are fine; they are two's complement bit patterns.
|
|
try testing.expectEqual(@as(f64, -2.0), try testEval("~1"));
|
|
}
|