Making a Language: The Evaluator
Building Spark's evaluator — 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.
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.