Making a Language: The Evaluator
Building Spark's evaluator, covering environments, closures, operator overloading, return signals, and error reporting.
In the previous part, we built the parser that turns tokens into an AST. Now we get to the fun part: making it actually run.
Environments: Managing Scope
Variables don't float in space. They live in environments. An environment is just a map of names to values, with a pointer to the parent scope.
1export function createEnvironment(
2 parent: Environment | null = null,
3): Environment {
4 return { variables: new Map(), parent };
5}Creates a fresh scope. Pass a parent and the new environment links to it. Variable lookups will walk up that chain.
1export function defineVar(
2 env: Environment,
3 name: string,
4 value: RuntimeValue,
5): RuntimeValue {
6 env.variables.set(name, value);
7 return value;
8}Sticks a variable into the current environment. No chain-walking; let always binds in the innermost scope.
1export function getVar(
2 env: Environment,
3 name: string,
4 line: number,
5 col: number,
6): RuntimeValue {
7 let current: Environment | null = env;
8 while (current) {
9 const value = current.variables.get(name);
10 if (value !== undefined) return value;
11 current = current.parent;
12 }
13 throw new RuntimeError(`Undefined variable "${name}"`, line, col);
14}Walks up from the current environment through each parent until it finds the variable. If it reaches the top with no match, it throws a RuntimeError with source position. There is no silent undefined nonsense.
1export function setVar(
2 env: Environment,
3 name: string,
4 value: RuntimeValue,
5 line: number,
6 col: number,
7): RuntimeValue {
8 let current: Environment | null = env;
9 while (current) {
10 if (current.variables.has(name)) {
11 current.variables.set(name, value);
12 return value;
13 }
14 current = current.parent;
15 }
16 throw new RuntimeError(`Undefined variable "${name}"`, line, col);
17}Same walk-up logic as getVar, but for reassignment. It only writes to the scope where the variable was originally defined. Shadows are respected, not overwritten.
The evaluate Function
1export function evaluate(program: Program): {
2 output: string[];
3 error?: string;
4} {
5 const output: string[] = [];
6 const env = createEnvironment();
7
8 defineVar(env, "say", {
9 type: "native",
10 name: "say",
11 fn: (args) => {
12 const str = args.map(formatValue).join(" ");
13 output.push(str);
14 return { type: "null" };
15 },
16 });
17
18 try {
19 for (const stmt of program.body) {
20 evaluateStatement(stmt, env);
21 }
22 } catch (e) {
23 if (e instanceof RuntimeError) {
24 return { output, error: e.format(originalSource) };
25 }
26 throw e;
27 }
28
29 return { output };
30}The entry point. It creates the global environment, registers the built-in say function (which captures output by closure), then runs every statement in the program. If any RuntimeError escapes, it's caught and formatted with source context so the process never hard-crashes.
1let originalSource = "";
2
3export function setSource(source: string) {
4 originalSource = source;
5}The parser calls setSource before evaluation so runtime errors can display the offending line with a pointer.
Statement Evaluation
1function evaluateStatement(stmt: Statement, env: Environment): RuntimeValue {
2 switch (stmt.kind) {
3 case "LetStatement":
4 return evaluateLet(stmt, env);
5 case "IfStatement":
6 return evaluateIf(stmt, env);
7 case "FunctionDeclaration":
8 return evaluateFn(stmt, env);
9 case "ReturnStatement":
10 throw new ReturnSignal(evaluateExpressionOrNull(stmt.value, env));
11 case "ExpressionStatement":
12 return evaluateExpression(stmt.expression, env);
13 default:
14 return { type: "null" };
15 }
16}Dispatches on the AST node's kind. Each statement type has its own handler. ReturnStatement doesn't return. Instead it throws a signal that unwinds the call stack.
1function evaluateLet(stmt: LetStatement, env: Environment): RuntimeValue {
2 const value = evaluateExpression(stmt.value, env);
3 return defineVar(env, stmt.name, value);
4}
5
6function evaluateIf(stmt: IfStatement, env: Environment): RuntimeValue {
7 const condition = evaluateExpression(stmt.condition, env);
8 const boolVal = isTruthy(condition);
9
10 if (boolVal) {
11 return evaluateBlock(stmt.consequent, env);
12 } else if (stmt.alternate) {
13 if (
14 stmt.alternate.length === 1 &&
15 stmt.alternate[0].kind === "IfStatement"
16 ) {
17 return evaluateIf(stmt.alternate[0] as IfStatement, env);
18 }
19 return evaluateBlock(stmt.alternate, env);
20 }
21
22 return { type: "null" };
23}
24
25function evaluateFn(
26 stmt: FunctionDeclaration,
27 env: Environment,
28): RuntimeValue {
29 const fn: RuntimeValue = {
30 type: "function",
31 name: stmt.name,
32 params: stmt.params,
33 body: stmt.body,
34 closure: env,
35 };
36 return defineVar(env, stmt.name, fn);
37}evaluateLetevaluates the initializer and binds the result in the current scope.evaluateIfchecks truthiness (not strict boolean), then runs the matching branch.else ifchains are handled by checking if the alternate is a singleIfStatementand recursing.evaluateFncreates a function object that captures the current environment as itsclosure. This is what enables lexical scoping.
1function evaluateBlock(stmts: Statement[], env: Environment): RuntimeValue {
2 const blockEnv = createEnvironment(env);
3 let result: RuntimeValue = { type: "null" };
4 for (const stmt of stmts) {
5 try {
6 result = evaluateStatement(stmt, blockEnv);
7 } catch (e) {
8 if (e instanceof ReturnSignal) throw e;
9 throw e;
10 }
11 }
12 return result;
13}
14
15class ReturnSignal {
16 constructor(public value: RuntimeValue) {}
17}A block creates a child scope and runs each statement in order. The ReturnSignal class is the mechanism for return. It is thrown by ReturnStatement and caught by the function call handler. evaluateBlock re-throws it so it propagates up through nested blocks.
Expression Evaluation
1function evaluateExpression(expr: Expression, env: Environment): RuntimeValue {
2 switch (expr.kind) {
3 case "NumberLiteral":
4 return { type: "number", value: expr.value };
5 case "StringLiteral":
6 return { type: "string", value: expr.value };
7 case "BoolLiteral":
8 return { type: "boolean", value: expr.value };
9 case "Identifier":
10 return getVar(env, expr.name, 0, 0);
11 case "BinaryExpression":
12 return evaluateBinary(expr, env);
13 case "ArrayLiteral":
14 return evaluateArray(expr, env);
15 case "IndexExpression":
16 return evaluateIndex(expr, env);
17 case "CallExpression":
18 return evaluateCall(expr, env);
19 case "Assignment":
20 return evaluateAssignment(expr, env);
21 default:
22 return { type: "null" };
23 }
24}Literals become runtime values directly. Identifiers look up variables, binary expressions recurse into evaluateBinary, calls dispatch to evaluateCall, and so on. Same pattern as evaluateStatement: one handler per kind.
Binary Operators
1function evaluateBinary(expr: BinaryExpression, env: Environment): RuntimeValue {
2 if (expr.operator === "!") {
3 const right = evaluateExpression(expr.right, env);
4 return { type: "boolean", value: !isTruthy(right) };
5 }
6 // ...evaluate left/right, then switch on operator...The ! (not) operator is special. It is an example of unary in practice, stored as binary in the AST. It negates truthiness rather than requiring a boolean, so !0 is yes and ![] is no.
1 case "..":
2 if (left.type === "number" && right.type === "number") {
3 const arr: RuntimeValue[] = [];
4 for (let i = left.value; i <= right.value; i++) {
5 arr.push({ type: "number", value: i });
6 }
7 return { type: "array", value: arr };
8 }
9 throw new RuntimeError("Range requires numbers", 0, 0);The range operator .. (inspired by Rust) creates an array of consecutive numbers. 1..5 evaluates to [1, 2, 3, 4, 5].
1 case "+": {
2 if (left.type === "number" && right.type === "number")
3 return { type: "number", value: left.value + right.value };
4 if (left.type === "string" && right.type === "string")
5 return { type: "string", value: left.value + right.value };
6 if (left.type === "array" && right.type === "array")
7 return { type: "array", value: [...left.value, ...right.value] };
8 return { type: "string", value: formatValue(left) + formatValue(right) };
9 }The + operator is overloaded: adds numbers, concatenates strings, merges arrays, and coerces mixed types to strings. Other operators like -, *, /, and comparisons all enforce numeric types.
and and or use short-circuit evaluation. a and b evaluates a first; if it's falsy, it returns a without evaluating b. a or b evaluates a first; if it's truthy, it returns a without evaluating b. This means say("hi") or 5 never calls say because or short-circuits on the first truthy value.
1function evaluateCall(expr: CallExpression, env: Environment): RuntimeValue {
2 const callee = evaluateExpression(expr.callee, env);
3
4 if (callee.type === "native") {
5 const args = expr.args.map((a) => evaluateExpression(a, env));
6 return callee.fn(args);
7 }
8
9 if (callee.type === "function") {
10 const args = expr.args.map((a) => evaluateExpression(a, env));
11 const fnEnv = createEnvironment(callee.closure);
12 callee.params.forEach((param, i) => {
13 defineVar(fnEnv, param, args[i] ?? { type: "null" });
14 });
15 try {
16 return evaluateBlock(callee.body, fnEnv);
17 } catch (e) {
18 if (e instanceof ReturnSignal) return e.value;
19 throw e;
20 }
21 }
22
23 throw new RuntimeError("Cannot call non-function", 0, 0);
24}Native functions (like say) call directly into JavaScript. User-defined functions create a new environment chained to their closure (not the call site), bind arguments to parameter names, then run the body. The try/catch intercepts ReturnSignal to extract the return value.
Truthiness and Equality
1function isTruthy(val: RuntimeValue): boolean {
2 switch (val.type) {
3 case "null":
4 return false;
5 case "boolean":
6 return val.value;
7 case "number":
8 return val.value !== 0;
9 case "string":
10 return val.value !== "";
11 case "array":
12 return val.value.length > 0;
13 default:
14 return true;
15 }
16}Zero, empty string, empty array, and null are falsy. Everything else, including functions, is truthy. This lets you write when (items) instead of when (items.length > 0).
1export function formatValue(val: RuntimeValue): string {
2 switch (val.type) {
3 case "null":
4 return "null";
5 case "number":
6 return String(val.value);
7 case "string":
8 return val.value;
9 case "boolean":
10 return val.value ? "yes" : "no";
11 case "array":
12 return `[${val.value.map(formatValue).join(", ")}]`;
13 case "function":
14 return `<func ${val.name}>`;
15 case "native":
16 return `<native ${val.name}>`;
17 }
18}Controls how values appear in say output. Booleans display as yes/no instead of true/false. A small language personality choice. Arrays are recursively expanded.
In the final part, we'll wire everything up, run some complete Spark programs, and explore how you can extend the language yourself.