allow for preview evaluations/partial results to show in other UI surfaces

This commit is contained in:
Emil Lerch 2026-10-03 11:48:28 -07:00
parent d36cf386ac
commit f44d51c219
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -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();