From f44d51c219831df26fc877d748156ddf28e5bacb Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Oct 2026 11:48:28 -0700 Subject: [PATCH] allow for preview evaluations/partial results to show in other UI surfaces --- engine/src/evaluator.zig | 169 +++++++++++++++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 16 deletions(-) diff --git a/engine/src/evaluator.zig b/engine/src/evaluator.zig index 22c67cf..7cc7eed 100644 --- a/engine/src/evaluator.zig +++ b/engine/src/evaluator.zig @@ -155,11 +155,16 @@ pub const Environment = struct { /// 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: *Environment, scratch: Allocator, expr: *const Expr) Error!Number { +fn evalExact(env: *const Environment, scratch: Allocator, expr: *const Expr) Error!Number { switch (expr.*) { .number => |n| return literalToNumber(scratch, n), .string_literal => |text| { @@ -178,16 +183,13 @@ fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) Error!Num const value = env.getVar(name) orelse return Error.UnknownVariable; return try value.cloneWith(scratch); }, - .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. - if (Environment.isBuiltIn(a.name)) return Error.AssignmentToConstant; - const val = try evalExact(env, scratch, a.value); - // setVar copies, so storing an arena-allocated value is safe. What comes - // back is the arena's copy, not the environment's. - env.setVar(a.name, val) catch return Error.OutOfMemory; - return val; + .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); @@ -467,7 +469,7 @@ fn arityOf(builtin: Builtin) Arity { /// /// 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: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) Error!Number { +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; @@ -492,7 +494,7 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ /// 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: *Environment, + env: *const Environment, scratch: Allocator, builtin: Builtin, args: []const *Expr, @@ -653,7 +655,35 @@ pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) E /// 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 @@ -669,16 +699,45 @@ pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u defer arena.deinit(); const scratch = arena.allocator(); - const raw = try evalExact(env, scratch, expr); - const result = try raw.cloneWith(allocator); + const statement = try evaluateStatement(env, scratch, expr); + var result = try statement.value.cloneWith(allocator); + errdefer result.deinit(); - env.setAns(result) catch return Error.OutOfMemory; + 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.*) { @@ -1924,6 +1983,84 @@ test "a constant keeps its value, and a variable of another name still assigns" 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();