//! 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 Integer = @import("Integer.zig"); /// 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, /// A known function called with a number of arguments it does not take. Was /// reported as `UnknownFunction`, so `log(2)` complained about the name. WrongArgumentCount, UnknownVariable, /// A name the environment answers itself, used as an assignment target. AssignmentToConstant, DomainError, Overflow, } || Parser.Error || number_mod.Error || bitwise.Error || financial.Error; const Parser = @import("Parser.zig"); const number_mod = @import("number.zig"); const Number = number_mod.Number; const bitwise = @import("bitwise.zig"); const financial = @import("financial.zig"); /// The built-in constants. Inexact by nature: pi, e and tau are irrational and have /// no rational representation. /// /// One list, consulted by both `getVar` and `isBuiltIn`, so a name cannot be readable /// as a constant and writable as a variable at the same time. const constants = [_]struct { name: []const u8, value: f64 }{ .{ .name = "pi", .value = math.pi }, .{ .name = "e", .value = math.e }, .{ .name = "tau", .value = math.tau }, }; fn constantValue(name: []const u8) ?f64 { for (constants) |c| { if (std.mem.eql(u8, name, c.name)) return c.value; } return null; } /// `Ans` is spelled either way, and is the environment's own, so it is a built-in /// name too even though its value is not a constant. fn isAnsName(name: []const u8) bool { return std.mem.eql(u8, name, "Ans") or std.mem.eql(u8, name, "ans"); } /// Evaluation environment holding variables and the last answer. /// /// 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. /// /// It holds no mode and no programmer configuration. It used to hold both and read /// neither: the caller chooses between `evalString` and `evalProgrammerString`, and /// standard mode's integer type is fixed (see `standardInt`). The TUI was writing /// a mode into this on every mode change, into a field nothing consulted. pub const Environment = struct { allocator: Allocator, variables: std.StringHashMap(Number), ans: Number, pub fn init(allocator: Allocator) Environment { return .{ .allocator = allocator, .variables = std.StringHashMap(Number).init(allocator), // Starts inexact so that `init` cannot fail; the first evaluation // replaces it. .ans = Number.fromFloat(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. pub fn getVar(self: *const Environment, name: []const u8) ?Number { if (constantValue(name)) |value| return Number.fromFloat(value); if (isAnsName(name)) return self.ans; return self.variables.get(name); } /// True for a name this environment answers itself. Such a name cannot be /// assigned: `getVar` checks the constants and `Ans` before the variable map, so /// a stored value of the same name would never be read again. pub fn isBuiltIn(name: []const u8) bool { return constantValue(name) != null or isAnsName(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. /// /// The exact evaluation core. Produces a `Number`, staying exact until an /// operation forces the float fallback (see design.md 2.7.4). /// /// The environment is `const`: this walk reads variables and `Ans` and never stores /// anything. Storing is the statement's job, done once at the root by /// `evalStringInfo`, which is what lets `previewStringInfo` share this walk and be /// unable to change the session rather than merely careful not to (design 6.5). /// /// There used to be a `pub fn evaluate` above this that ran the same walk and /// collapsed the result to `f64`. It was the last f64-returning expression API in /// the engine, and by the time both frontends had moved to `evalString` nothing /// called it. fn evalExact(env: *const 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 Error.InvalidNumber; packed_value = (packed_value << 8) | byte; } 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 Error.UnknownVariable; return try value.cloneWith(scratch); }, .assignment => { // The parser produces an assignment only as a whole statement (Task 5.19), // and `evaluateStatement` unwraps that one before this walk starts. Reaching // one here means a tree the parser cannot build; an error rather than // `unreachable`, because a hand-built tree is a caller's bug, not undefined // behaviour in a release build. return Error.UnexpectedToken; }, .unary => |u| { const operand = try evalExact(env, scratch, u.operand); return switch (u.op) { .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 // `env.programmer_config.bit_width`, so `tally --bits 8 '~0'` gave // 255 in standard mode while the shifts alongside it ignored the // setting entirely. .bitwise_not => blk: { const projected = try standardOperand(scratch, operand); break :blk try fromStandardInt( scratch, bitwise.not(projected.value), projected.lossless, ); }, }; }, .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. /// /// Both bases parse the literal's own source text, because that is the only /// lossless form of it: `0.1` cannot be recovered from the nearest f64, and /// `0x1FFFFFFFFFFFFFFFF` does not fit the u64 the tokenizer used to precompute. /// /// Failures propagate. This used to swallow every `Number.parse` error and fall /// back to a float, so `1e100001` printed `inf` instead of reporting that the /// exponent was out of range, and the exact tier silently became the inexact one. fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) Error!Number { if (n.base == .decimal) return Number.parse(scratch, n.text); // A non-decimal literal is a bit pattern, so it is an integer of at most the // 128 bits `Integer` holds. Wider than that is `Overflow`, not a float. return Number.fromInt(scratch, try n.base.parseDigits(n.text)); } /// Evaluate a binary operation. fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) Error!Number { return switch (op) { .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` // resolves the operator at comptime, so an operator added to `BinaryOp` // that `bitwise.fromBinaryOp` does not know is a compile error here. inline else => |fixed_op| blk: { const l = try standardOperand(scratch, left); const r = try standardOperand(scratch, right); const result = try bitwise.apply(comptime bitwise.fromBinaryOp(fixed_op).?, l.value, r.value); break :blk try fromStandardInt(scratch, result, l.lossless and r.lossless); }, }; } /// An operand of a fixed-width operation, and whether getting it here cost anything. /// /// The projection is lossless when the operand is already an exact integer that fits /// the width, which is the common case (`0xFF`, `2^62`, `-8`). Otherwise the only /// representation available is an f64, and the result inherits that: `Number`'s /// contagion rule says exact means no rounding anywhere in the value's history, so a /// result computed from a rounded operand must not come back exact. const StandardOperand = struct { value: Integer, lossless: bool, }; /// Project an operand onto standard mode's integer type: 64-bit two's complement, /// fixed (FR-2.3), which is also `Integer`'s default. /// /// Standard mode does not consult the programmer-mode width. A width other than 64 /// is what programmer mode is for, and pretending otherwise is how `~` came to /// honour the setting while the shifts beside it did not. /// /// 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 the width is a reportable /// error, not a crash. fn standardOperand(scratch: Allocator, n: Number) Error!StandardOperand { // An exact integer goes in as itself. Through f64 it would not: `2^53 + 1` is // not representable, so `(2^53 + 1) and -1` used to answer 2^53. if (n.asExactInt(i64)) |exact| { return .{ .value = .{ .raw = @as(u64, @bitCast(exact)) }, .lossless = true }; } return .{ .value = try standardInt(n.toFloat(scratch)), .lossless = false }; } /// The f64 projection, for an operand with no exact integer form. fn standardInt(value: f64) Error!Integer { 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 Error.Overflow; } const bits: u64 = @bitCast(@as(i64, @intFromFloat(value))); return .{ .raw = @as(u128, bits) }; } /// Read a fixed-width result back as a number, signed, since standard mode is /// signed. /// /// Exact when nothing was rounded on the way in. The result used to go back through /// f64 unconditionally, which lost the answer for values above 2^53: `2^62 or 1` /// reported 4.611686018427388e18 rather than 4611686018427387905. fn fromStandardInt(scratch: Allocator, value: Integer, lossless: bool) Error!Number { if (lossless) return Number.fromInt(scratch, value.signedValue()); return Number.fromFloat(@floatFromInt(value.signedValue())); } /// Every built-in function the language has. /// /// The names are the enum's, so `std.meta.stringToEnum` is the lookup and there is no /// hand-written list of strings to drift. Nothing in the engine can name a function /// that is not here, and `builtinNames` below lets a test walk the set against /// FR-5.7. /// /// This replaced a chain of `if (mem.eql(u8, name, ...))` blocks grouped by argument /// count, spread across `evalFunction`, `evalSingleArgFn` and `evalFinancialFn`. That /// shape could not tell a wrong name from a wrong argument count: `log(2)` matched /// nothing in the one-argument group and came out as "unknown function", about a /// function that exists. const Builtin = enum { // Exact where the operand allows it. abs, floor, ceil, round, sqrt, factorial, max, min, // Float-only by nature: these escape the rationals (design.md 2.7.4). sin, cos, tan, asin, acos, atan, cbrt, exp, ln, log2, log10, /// Arity-overloaded: `log(x)` is log10, `log(x, base)` is the general form. log, atan2, apy, // Financial (FR-5.7), inexact by construction: every formula here needs a // non-integer power or a logarithm. cagr, fv, pv, compound_rate, compound_years, tvm_fv, tvm_pv, tvm_pmt, tvm_n, tvm_rate, amort_payment, amort_total_interest, amort_total_paid, amort_interest, amort_principal, amort_balance, rand, }; /// How many arguments a built-in takes. `min` and `max` differ only where a trailing /// argument is optional. const Arity = struct { min: u8, max: u8 }; /// The widest argument list any built-in takes, which is what the float projection /// buffer is sized for. const max_args = 4; /// Every built-in that is not a financial function, in declaration order: the general /// maths surface, for the help screens. /// /// Derived rather than listed, because a hand-written copy went stale. The TUI's help /// overlay named seventeen functions and the engine had answered to twenty-one for /// some time: `log2`, `log10`, `cbrt` and `atan2` were missing, so the only way to /// discover them was to read this file. /// /// The financial half is `financial.expression_functions`, which carries signatures /// and summaries because those functions need them. These take a number and return a /// number, so a name is the whole story. /// /// `rand` is deliberately absent: it is a placeholder that returns 0, and a help /// screen should not advertise a function that does not do what its name says. pub const math_function_names = blk: { // 38 built-ins crossed with 17 financial names, each comparison a loop over bytes. @setEvalBranchQuota(4000); var count: usize = 0; for (@typeInfo(Builtin).@"enum".fields) |field| { if (isMathBuiltin(field.name)) count += 1; } var names: [count][]const u8 = undefined; var i: usize = 0; for (@typeInfo(Builtin).@"enum".fields) |field| { if (isMathBuiltin(field.name)) { names[i] = field.name; i += 1; } } break :blk names; }; /// Whether a built-in belongs in the general list rather than the financial one. fn isMathBuiltin(comptime name: []const u8) bool { if (std.mem.eql(u8, name, "rand")) return false; for (financial.expression_functions) |doc| { if (std.mem.eql(u8, name, doc.name)) return false; } return true; } fn arityOf(builtin: Builtin) Arity { return switch (builtin) { .rand => .{ .min = 0, .max = 0 }, .abs, .floor, .ceil, .round, .sqrt, .factorial, .sin, .cos, .tan, .asin, .acos, .atan, .cbrt, .exp, .ln, .log2, .log10, => .{ .min = 1, .max = 1 }, // log10 with one argument, general with two. .log => .{ .min = 1, .max = 2 }, .max, .min, .atan2, .apy => .{ .min = 2, .max = 2 }, .cagr, .amort_payment, .amort_total_interest, .amort_total_paid => .{ .min = 3, .max = 3 }, // The compounding frequency defaults to annual when it is left off. .fv, .pv, .compound_rate, .compound_years => .{ .min = 3, .max = max_args }, .tvm_fv, .tvm_pv, .tvm_pmt, .tvm_n, .tvm_rate, .amort_interest, .amort_principal, .amort_balance, => .{ .min = max_args, .max = max_args }, }; } /// Evaluate a built-in function call. /// /// The name and the argument count are checked before anything is evaluated, so a /// misspelled name and a miscounted argument list are different errors. fn evalFunction(env: *const Environment, scratch: Allocator, name: []const u8, args: []const *Expr) Error!Number { const builtin = std.meta.stringToEnum(Builtin, name) orelse return Error.UnknownFunction; const arity = arityOf(builtin); if (args.len < arity.min or args.len > arity.max) return Error.WrongArgumentCount; switch (builtin) { // These have exact implementations, so their operands stay `Number`. .abs, .floor, .ceil, .round, .sqrt, .factorial, .max, .min => { return exactBuiltin(env, scratch, builtin, args); }, else => {}, } // Everything else has no exact form, so the operands collapse to f64 once, here. var values: [max_args]f64 = undefined; for (args, 0..) |arg, i| { var value = try evalExact(env, scratch, arg); values[i] = value.toFloat(scratch); } return Number.fromFloat(try floatBuiltin(builtin, values[0..args.len])); } /// The built-ins with an exact implementation: an exact operand gives an exact /// result, so `sqrt(4)` is 2 and `factorial(171)` is every one of its 310 digits. fn exactBuiltin( env: *const Environment, scratch: Allocator, builtin: Builtin, args: []const *Expr, ) Error!Number { const x = try evalExact(env, scratch, args[0]); return switch (builtin) { .abs => Number.abs(scratch, x), .floor => Number.floor(scratch, x), .ceil => Number.ceil(scratch, x), .round => Number.round(scratch, x), // The negative-input rule lives in Number.sqrt, which raises NegativeRoot. // That name reaches the user instead of being flattened into "domain error". .sqrt => Number.sqrt(scratch, x), // Null means the argument was negative or fractional, which is a domain // error, not an unknown function. .factorial => (try Number.factorial(scratch, x)) orelse Error.DomainError, .max, .min => blk: { const y = try evalExact(env, scratch, args[1]); break :blk if (builtin == .max) Number.max(scratch, x, y) else Number.min(scratch, x, y); }, // `evalFunction` routes only the group above here. else => unreachable, }; } /// The built-ins with no exact form, over operands already collapsed to f64. /// /// `a.len` is within the arity `evalFunction` checked, so an optional trailing /// argument is the only thing that varies. fn floatBuiltin(builtin: Builtin, a: []const f64) Error!f64 { // The compounding frequency the compound functions take optionally. const per_year: f64 = if (a.len == max_args) a[max_args - 1] else 1; return switch (builtin) { .sin => @sin(a[0]), .cos => @cos(a[0]), .tan => @tan(a[0]), .asin => if (a[0] < -1 or a[0] > 1) Error.DomainError else math.asin(a[0]), .acos => if (a[0] < -1 or a[0] > 1) Error.DomainError else math.acos(a[0]), .atan => math.atan(a[0]), .cbrt => math.cbrt(a[0]), .exp => @exp(a[0]), // A logarithm of a non-positive value has no real result. These used to // return -inf or NaN, while the two-argument `log` reported it properly. .ln => if (a[0] <= 0) Error.DomainError else @log(a[0]), .log2 => if (a[0] <= 0) Error.DomainError else @log2(a[0]), .log10 => if (a[0] <= 0) Error.DomainError else @log10(a[0]), .log => blk: { if (a[0] <= 0) break :blk Error.DomainError; if (a.len == 1) break :blk @log10(a[0]); if (a[1] <= 0 or a[1] == 1) break :blk Error.DomainError; break :blk @log(a[0]) / @log(a[1]); }, .atan2 => math.atan2(a[0], a[1]), // apy(nominal_rate, compounds_per_year): the effective annual rate, so a // nominal rate can be compared against one. .apy => financial.effectiveAnnualRate(a[0], a[1]), // cagr(start, end, periods) -> growth rate as a fraction. .cagr => financial.cagr(a[0], a[1], a[2]), // fv(pv, annual_rate_percent, years[, per_year]) and its inverse. The rate is // NOMINAL; use apy() to convert. .fv => financial.compoundFutureValue(a[0], a[1], a[2], per_year), .pv => financial.compoundPresentValue(a[0], a[1], a[2], per_year), // The same relationship solved for its other two variables. .compound_rate => financial.compoundRate(a[0], a[1], a[2], per_year), .compound_years => financial.compoundPeriods(a[0], a[1], a[2], per_year), // 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. .tvm_fv => (try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .payment = a[3], })).value, .tvm_pv => (try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .payment = a[2], .future_value = a[3], })).value, .tvm_pmt => (try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .future_value = a[3], })).value, .tvm_n => (try financial.solveTvm(.{ .rate = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3], })).value, .tvm_rate => (try financial.solveTvm(.{ .periods = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3], })).value, // Amortization: (principal, rate_per_period_percent, periods[, period]). .amort_payment => financial.amortizationPayment(try loan(a)), .amort_total_interest => (try financial.amortizationTotals(try loan(a))).interest, .amort_total_paid => (try financial.amortizationTotals(try loan(a))).paid, .amort_interest => (try amortRow(a)).interest, .amort_principal => (try amortRow(a)).principal, .amort_balance => (try amortRow(a)).balance, // Not truly random in a pure engine, but useful as a placeholder. .rand => 0, // `evalFunction` sends the exact group to `exactBuiltin` before collapsing // anything to a float, so none of it arrives here. .abs, .floor, .ceil, .round, .sqrt, .factorial, .max, .min => unreachable, }; } /// The loan an amortization built-in describes: principal, rate per period, periods. fn loan(a: []const f64) Error!financial.AmortizationParams { return .{ .principal = a[0], .rate = a[1], .periods = try periodCount(a[2]) }; } /// One row of an amortization schedule, for the built-ins that report a single /// period. fn amortRow(a: []const f64) Error!financial.AmortizationEntry { return financial.amortizationEntry(try loan(a), try periodCount(a[3])); } /// A whole period count or 1-based period index, validated. 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 Error.DomainError; } return @intFromFloat(value); } /// 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) 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. /// /// Commits: an assignment stores its variable, and the value becomes `Ans`. pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u8) Error!EvalInfo { return evalSource(.commit, env, allocator, source); } /// What `evalStringInfo` would answer, with nothing stored (design 6.5). /// /// For a screen that answers as the user types. An assignment previews as the value /// it would store and stores nothing; `Ans` keeps the last committed answer. The /// environment is `const`, so that is a property of the signature rather than of /// this function's care. pub fn previewStringInfo(env: *const Environment, allocator: Allocator, source: []const u8) Error!EvalInfo { return evalSource(.preview, env, allocator, source); } /// Whether an evaluation is allowed to change the environment. const Effect = enum { commit, preview }; /// The environment pointer each effect is given. A preview gets a `*const`, so the /// commit branch below is not merely skipped for it but could not compile. fn EnvFor(comptime effect: Effect) type { return switch (effect) { .commit => *Environment, .preview => *const Environment, }; } fn evalSource(comptime effect: Effect, env: EnvFor(effect), 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 // 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.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 statement = try evaluateStatement(env, scratch, expr); var result = try statement.value.cloneWith(allocator); errdefer result.deinit(); if (effect == .commit) { // Both copy, so storing an arena-allocated value is safe. The variable is // stored only once the value is known to have been copied out, so a failed // copy no longer leaves an assignment half-made. if (statement.assigns) |name| env.setVar(name, statement.value) catch return Error.OutOfMemory; env.setAns(result) catch return Error.OutOfMemory; } return .{ .value = result, .has_nondecimal_literal = hasNonDecimalLiteral(expr), }; } /// A whole input's value, and the variable it would store if it is an assignment. const Statement = struct { value: Number, /// Points into the source text; `setVar` copies it. assigns: ?[]const u8, }; /// Evaluate the root of a tree, which is the only place an assignment can be. fn evaluateStatement(env: *const Environment, scratch: Allocator, expr: *const Expr) Error!Statement { switch (expr.*) { .assignment => |a| { // A built-in name is answered by `getVar` before the variable map, so // storing one would be write-only: `pi = 3` used to return 3 and leave // pi untouched, with nothing to tell the user the name had not taken. // Checked here rather than at the store, so a preview reports it too. if (Environment.isBuiltIn(a.name)) return Error.AssignmentToConstant; return .{ .value = try evalExact(env, scratch, a.value), .assigns = a.name }; }, else => return .{ .value = try evalExact(env, scratch, expr), .assigns = null }, } } /// 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); 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); 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(Error.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(Error.UnknownFunction, result); } test "eval unknown variable" { const result = testEval("xyz"); try testing.expectError(Error.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); 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); 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 "standard mode: the fixed-width operators use one implementation with programmer mode" { // FR-2.12: an operator means the same thing in both modes. Standard mode is // 64-bit signed (FR-2.3), so the same expression evaluated through the // evaluator and through programmer.zig has to agree. Note that this compares // the two real paths: `testEvalProgrammer` only sets the mode flag and still // runs the evaluator, so it would have compared one implementation with itself. const programmer_mod = @import("programmer.zig"); const shared = [_][]const u8{ "0xF0 and 0x0F", "0xF0 or 0x0F", "5 xor 3", "1 << 10", "1 << 63", "1 << 64", "1024 >> 4", "1024 >>> 4", "0 - 8 >> 1", "1 rol 4", "1 rol 65", "16 ror 4", "~0", "~5", }; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const alloc = arena.allocator(); for (shared) |source| { const standard = try testEval(source); const prog = try programmer_mod.evalProgrammerString(alloc, source, .{ .width = .bits64, .signedness = .signed, }); try testing.expectEqual(@as(i128, @intFromFloat(standard)), prog.signedValue()); } } test "standard mode: >> is arithmetic and >>> is logical" { // This is the case that used to differ: standard mode had only a logical shift, // so `-8 >> 1` was 9223372036854775804 rather than -4. try testing.expectEqual(@as(f64, -4.0), try testEval("0 - 8 >> 1")); try testing.expectEqual(@as(f64, -1.0), try testEval("0 - 1 >> 1")); try testing.expectEqual(@as(f64, -64.0), try testEval("0 - 128 >> 1")); // The zero-filling variant is still available and still huge. try testing.expect(try testEval("0 - 8 >>> 1") > 9.0e18); // Non-negative values shift identically either way. try testing.expectEqual(try testEval("1024 >> 4"), try testEval("1024 >>> 4")); } test "standard mode: a shift runs to completion instead of wrapping the distance" { // The distance used to be reduced modulo 64, so `1 << 64` was `1 << 0`. try testing.expectEqual(@as(f64, 0.0), try testEval("1 << 64")); try testing.expectEqual(@as(f64, 0.0), try testEval("1 << 65")); try testing.expectEqual(@as(f64, 0.0), try testEval("1 << 1000")); try testing.expectEqual(@as(f64, 0.0), try testEval("1024 >>> 64")); // A negative value shifted all the way out is all sign bits, which is -1. try testing.expectEqual(@as(f64, -1.0), try testEval("0 - 8 >> 64")); // One place short of the width still keeps a bit: 1 << 63 is the sign bit. try testing.expect(try testEval("1 << 63") < 0.0); } 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(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" { try testing.expectEqual(@as(f64, 1.0), try testEval("1 rol 64")); try testing.expectEqual(@as(f64, 2.0), try testEval("1 rol 65")); try testing.expectEqual(@as(f64, 1.0), try testEval("1 ror 64")); } test "standard mode: its integer type is fixed at 64-bit signed" { // `~` used to read `env.programmer_config.bit_width` while the shifts beside it // ignored it, so `--bits 8` changed one operator and not the others. The // environment no longer carries a width to disagree about: there is nothing to // set here, which is the point. var env = Environment.init(testing.allocator); defer env.deinit(); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const alloc = arena.allocator(); var not_zero = try evalString(&env, alloc, "~0"); defer not_zero.deinit(); try testing.expectEqual(@as(f64, -1.0), not_zero.toFloat(alloc)); var shifted = try evalString(&env, alloc, "1 << 10"); defer shifted.deinit(); try testing.expectEqual(@as(f64, 1024.0), shifted.toFloat(alloc)); const projected = try standardInt(1); try testing.expectEqual(@as(u8, 64), projected.width.bits()); try testing.expectEqual(Integer.Signedness.signed, projected.signedness); } 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(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(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(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(Error.UnknownFunction, testEval("nope(1)")); // A known name with too many arguments is an arity error, not a name error: // this line used to expect UnknownFunction, which was the bug. try testing.expectError(Error.WrongArgumentCount, testEval("asin(1, 2)")); } test "the functions themselves still work inside their domains" { try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("asin(0)"), 1e-15); try testing.expectApproxEqAbs(math.pi / 2.0, try testEval("acos(0)"), 1e-15); try testing.expectEqual(@as(f64, 2.0), try testEval("log10(100)")); try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("ln(e)"), 1e-15); try testing.expectEqual(@as(f64, 3.0), try testEval("log2(8)")); try testing.expectEqual(@as(f64, 120.0), try testEval("factorial(5)")); try testing.expectEqual(@as(f64, 12.0), try testEval("sqrt(144)")); } test "tan, atan and cbrt compute what they say" { // These had no test at all. The old dispatch hid it: `mem.eql(u8, name, "tan")` // ran on every single-argument call, so the line counted as covered even when // `@tan` never did. One switch arm per function is what surfaced the gap. try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("tan(pi / 4)"), 1e-15); try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("tan(0)"), 1e-15); try testing.expectApproxEqAbs(math.pi / 4.0, try testEval("atan(1)"), 1e-15); try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("atan(0)"), 1e-15); try testing.expectEqual(@as(f64, 3.0), try testEval("cbrt(27)")); // The cube root of a negative value is real, unlike the square root. try testing.expectEqual(@as(f64, -2.0), try testEval("cbrt(0 - 8)")); } 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); 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); 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); 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(Error.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(Error.UnknownFunction, result); } test "eval unknown three-arg function" { const result = testEval("bogus(1, 2, 3)"); try testing.expectError(Error.UnknownFunction, result); } test "eval unknown two-arg function" { const result = testEval("bogus(1, 2)"); try testing.expectError(Error.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: 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) -- // // 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); 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 == .exact); const shown = try result.exact.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 == .exact); // The exact form is a fraction, which no float could express. const frac = try result.exact.toFractionString(arena.allocator()); try testing.expectEqualStrings("1/3", frac); // And its decimal rendering is correctly reported as approximate. const shown = try result.exact.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 == .exact); const shown = try result.exact.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 != .exact); const p = try testEvalNumber(&arena, "pi"); try testing.expect(p != .exact); const r = try testEvalNumber(&arena, "sqrt(2)"); try testing.expect(r != .exact); // But a perfect square stays exact. const q = try testEvalNumber(&arena, "sqrt(144)"); try testing.expect(q == .exact); } 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 != .exact); } 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); defer env.deinit(); const assigned = try evalString(&env, a, "X = 0.1"); try testing.expect(assigned == .exact); const sum = try evalString(&env, a, "X + 0.2"); try testing.expect(sum == .exact); const shown = try sum.exact.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); defer env.deinit(); _ = try evalString(&env, a, "1/3"); const doubled = try evalString(&env, a, "Ans * 3"); try testing.expect(doubled == .exact); const shown = try doubled.exact.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); 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); 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(Error.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); defer env.deinit(); const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)"); try testing.expect(result != .exact); } test "financial: bad arguments are domain errors, not wrong answers" { // Zero periods. try testing.expectError(Error.DomainError, testEval("cagr(1000, 2000, 0)")); // A fractional period count cannot index an amortization schedule. try testing.expectError(Error.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)")); // Period past the end of the schedule. try testing.expectError(Error.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)")); // Payments that never retire the loan. try testing.expectError(Error.DomainError, testEval("amort_payment(0, 0.5, 360)")); // Period counts outside the schedule bounds. 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 "a known function with the wrong argument count says so" { // These used to be reported as "unknown function", which sent the user looking // for a typo in a name that was spelled correctly. The name and the arity are // separate checks now, and the arity comes from one table. try testing.expectError(Error.WrongArgumentCount, testEval("cagr(10000, 25000)")); try testing.expectError(Error.WrongArgumentCount, testEval("tvm_pmt(360, 0.5, 200000)")); try testing.expectError(Error.WrongArgumentCount, testEval("amort_payment(200000, 0.5, 360, 1)")); try testing.expectError(Error.WrongArgumentCount, testEval("sqrt(4, 9)")); try testing.expectError(Error.WrongArgumentCount, testEval("max(3)")); try testing.expectError(Error.WrongArgumentCount, testEval("sin()")); try testing.expectError(Error.WrongArgumentCount, testEval("rand(1)")); // log is the one built-in that takes either count, so both are fine and three // is not. try testing.expectEqual(@as(f64, 2), try testEval("log(100)")); try testing.expectEqual(@as(f64, 2), try testEval("log(100, 10)")); try testing.expectError(Error.WrongArgumentCount, testEval("log(100, 10, 1)")); // A name that is not a function at all is still an unknown function, at any // argument count. try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3)")); try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3, 4)")); try testing.expectError(Error.UnknownFunction, testEval("nope()")); } test "every built-in is reachable by name at its own arity" { // Walks the enum, so a built-in added without a `floatBuiltin` or // `exactBuiltin` arm cannot pass unnoticed, and neither can one whose arity // table entry disagrees with what the implementation reads. inline for (@typeInfo(Builtin).@"enum".fields) |field| { const builtin = @field(Builtin, field.name); const arity = arityOf(builtin); // A call with one argument too many is always an arity error, never an // unknown function: proof the name resolved. var source = std.ArrayList(u8).empty; defer source.deinit(testing.allocator); try source.appendSlice(testing.allocator, field.name); try source.append(testing.allocator, '('); for (0..arity.max + 1) |i| { if (i > 0) try source.appendSlice(testing.allocator, ", "); try source.append(testing.allocator, '1'); } try source.append(testing.allocator, ')'); try testing.expectError(Error.WrongArgumentCount, testEval(source.items)); } } test "every name in the help lists is a built-in, and every built-in is in one of them" { // The two lists the help screens render, plus the one deliberate exclusion, have // to add up to the dispatcher. A built-in added without a decision about which // list it belongs to fails here rather than being invisible to the user. inline for (@typeInfo(Builtin).@"enum".fields) |field| { const in_math = for (math_function_names) |name| { if (std.mem.eql(u8, name, field.name)) break true; } else false; const in_financial = for (financial.expression_functions) |doc| { if (std.mem.eql(u8, doc.name, field.name)) break true; } else false; const excluded = comptime std.mem.eql(u8, field.name, "rand"); if (@intFromBool(in_math) + @intFromBool(in_financial) + @intFromBool(excluded) != 1) { std.debug.print("'{s}': maths={} financial={} excluded={}\n", .{ field.name, in_math, in_financial, excluded, }); return error.BuiltinUnclassified; } } for (math_function_names) |name| { if (std.meta.stringToEnum(Builtin, name) == null) { std.debug.print("help lists '{s}', which no built-in answers\n", .{name}); return error.UnknownFunction; } } } test "the help table's financial functions dispatch at the arity they advertise" { // `financial.expression_functions` is what both frontends' help screens render. // The names and signatures there are only correct if the evaluator agrees, and // nothing else checks it: `Builtin` is private, so this is the one place the two // can be compared. for (financial.expression_functions) |doc| { const builtin = std.meta.stringToEnum(Builtin, doc.name) orelse { std.debug.print("help table lists '{s}', which no built-in answers\n", .{doc.name}); return error.UndocumentedFunction; }; const arity = arityOf(builtin); try testing.expectEqual(arity.min, doc.requiredArgs()); try testing.expectEqual(arity.max, doc.maxArgs()); } } // -- 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); 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); 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); 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(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" { // The separators have to reach the exact re-parse, not just the f64 channel. var env = Environment.init(testing.allocator); defer env.deinit(); var value = try evalString(&env, testing.allocator, "9,007,199,254,740,993"); defer value.deinit(); try testing.expect(value == .exact); const shown = try value.render(testing.allocator, .{ .fraction_digits = 20, .scientific_below_exponent = 15, .max_integer_digits = null, .significant_digits = 17, .separators = false, }); defer shown.deinit(testing.allocator); try testing.expectEqualStrings("9007199254740993", shown.text); } // -- 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(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(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" { // 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")); } // -- Built-in names are not assignment targets -- test "assigning to a constant is an error, not a silent no-op" { // `getVar` answers these before the variable map, so storing one wrote to a slot // nothing would ever read: `pi = 3` returned 3 and left pi alone (open item 10). for ([_][]const u8{ "pi = 3", "e = 1", "tau = 0", "Ans = 5", "ans = 5" }) |source| { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const alloc = arena.allocator(); var env = Environment.init(alloc); defer env.deinit(); try testing.expectError(Error.AssignmentToConstant, evalString(&env, alloc, source)); } } test "a constant keeps its value, and a variable of another name still assigns" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const alloc = arena.allocator(); var env = Environment.init(alloc); defer env.deinit(); try testing.expectError(Error.AssignmentToConstant, evalString(&env, alloc, "pi = 3")); var still_pi = try evalString(&env, alloc, "pi"); defer still_pi.deinit(); try testing.expectApproxEqAbs(math.pi, still_pi.toFloat(alloc), 1e-15); var assigned = try evalString(&env, alloc, "radius = 3"); defer assigned.deinit(); try testing.expectEqual(@as(f64, 3), assigned.toFloat(alloc)); } // -- Preview: the same answer, nothing stored (design 6.5) -- /// Preview `source` and return its value as a float, freeing everything it allocated. fn previewFloat(env: *const Environment, source: []const u8) !f64 { var info = try previewStringInfo(env, testing.allocator, source); defer info.value.deinit(); return info.value.toFloat(testing.allocator); } fn commitFloat(env: *Environment, source: []const u8) !f64 { var value = try evalString(env, testing.allocator, source); defer value.deinit(); return value.toFloat(testing.allocator); } test "a previewed assignment shows its value and stores nothing" { var env = Environment.init(testing.allocator); defer env.deinit(); // What a live result shows while `x = 7` is being typed. try testing.expectEqual(@as(f64, 7), try previewFloat(&env, "x = 7")); try testing.expectEqual(@as(u32, 0), env.variables.count()); try testing.expectError(Error.UnknownVariable, previewFloat(&env, "x")); // Committing it is what stores it, and a preview can then read it. try testing.expectEqual(@as(f64, 7), try commitFloat(&env, "x = 7")); try testing.expectEqual(@as(f64, 42), try previewFloat(&env, "x * 6")); // A previewed reassignment leaves the committed value alone. try testing.expectEqual(@as(f64, 100), try previewFloat(&env, "x = 100")); try testing.expectEqual(@as(f64, 7), try commitFloat(&env, "x")); } test "a preview leaves Ans as the last committed answer" { var env = Environment.init(testing.allocator); defer env.deinit(); try testing.expectEqual(@as(f64, 5), try commitFloat(&env, "2 + 3")); // Every keystroke of `Ans + 100` is previewed; none of them is the new `Ans`. try testing.expectEqual(@as(f64, 105), try previewFloat(&env, "Ans + 100")); try testing.expectEqual(@as(f64, 105), try previewFloat(&env, "Ans + 100")); try testing.expectEqual(@as(f64, 5), try commitFloat(&env, "Ans")); } test "a preview reports the errors committing would" { var env = Environment.init(testing.allocator); defer env.deinit(); // The built-in check happens before the store, so a preview sees it too. try testing.expectError(Error.AssignmentToConstant, previewFloat(&env, "pi = 3")); // An unfinished expression is an error, which a live result shows as nothing. try testing.expectError(Error.UnexpectedEnd, previewFloat(&env, "2 +")); try testing.expectError(Error.DivisionByZero, previewFloat(&env, "1 / 0")); } test "a preview keeps the multi-base metadata" { var env = Environment.init(testing.allocator); defer env.deinit(); var info = try previewStringInfo(&env, testing.allocator, "0xFF + 1"); defer info.value.deinit(); try testing.expect(info.has_nondecimal_literal); } test "an assignment below the root is refused rather than evaluated" { // The parser cannot build this tree (assignment is a statement), so only a // hand-built one reaches the walk. It must not be able to store from there, // and it cannot: the walk takes a `*const Environment`. var env = Environment.init(testing.allocator); defer env.deinit(); var value: Expr = .{ .number = .{ .base = .decimal, .text = "1" } }; const nested: Expr = .{ .assignment = .{ .name = "x", .value = &value } }; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); try testing.expectError(Error.UnexpectedToken, evalExact(&env, arena.allocator(), &nested)); } test "isBuiltIn covers exactly the names getVar answers itself" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); var env = Environment.init(arena.allocator()); defer env.deinit(); for ([_][]const u8{ "pi", "e", "tau", "Ans", "ans" }) |name| { try testing.expect(Environment.isBuiltIn(name)); try testing.expect(env.getVar(name) != null); } for ([_][]const u8{ "x", "PI", "Pi", "answer", "tauon" }) |name| { try testing.expect(!Environment.isBuiltIn(name)); try testing.expect(env.getVar(name) == null); } } // -- The fixed-width operators keep exact operands exact -- // // Both ends of the projection used to go through f64: an exact integer operand was // rounded on the way in, and the i64 result was widened on the way out. Past 2^53 // that loses the answer outright. test "a fixed-width result above 2^53 is exact, not a rounded float" { // 2^62 or 1 = 4611686018427387905, which no f64 holds. It came out as // 4.611686018427388e18. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const alloc = arena.allocator(); var env = Environment.init(alloc); defer env.deinit(); var result = try evalString(&env, alloc, "2^62 or 1"); defer result.deinit(); try testing.expect(result == .exact); try testing.expectEqual(@as(?i128, 4611686018427387905), result.asExactInt(i128)); // The operand side of the same problem: 2^53 + 1 is not representable as an // f64, so masking it with -1 used to answer 2^53. var operand = try evalString(&env, alloc, "(2^53 + 1) and -1"); defer operand.deinit(); try testing.expect(operand == .exact); try testing.expectEqual(@as(?i128, 9007199254740993), operand.asExactInt(i128)); } test "an operand with no exact integer form keeps the result inexact" { // Contagion: a rounded operand cannot produce an exact result, whatever the // operator does with it. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const alloc = arena.allocator(); var env = Environment.init(alloc); defer env.deinit(); for ([_][]const u8{ "0.5 and 1", "pi and 1", "1.5 << 2", "~2.5" }) |source| { var result = try evalString(&env, alloc, source); defer result.deinit(); try testing.expect(result != .exact); } // And an exact fraction is not an exact integer, so it takes the float path too. var third = try evalString(&env, alloc, "(1/3) or 0"); defer third.deinit(); try testing.expect(third != .exact); } test "the width bounds still hold on the exact path" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const alloc = arena.allocator(); var env = Environment.init(alloc); defer env.deinit(); // 2^64 has an exact integer form, but not one that fits the 64-bit width, so it // falls to the f64 path and is reported as an overflow rather than truncated. try testing.expectError(Error.Overflow, evalString(&env, alloc, "2^64 and 1")); try testing.expectError(Error.Overflow, evalString(&env, alloc, "~1e30")); // The last value the width holds, and the first it does not. var max = try evalString(&env, alloc, "(2^63 - 1) and -1"); defer max.deinit(); try testing.expectEqual(@as(?i128, 9223372036854775807), max.asExactInt(i128)); try testing.expectError(Error.Overflow, evalString(&env, alloc, "2^63 and -1")); }