Skip to main content
  1. Books/
  2. From Interpreter To Compiler In Zig/
Chapter 11

Functions

20 mins
On this page
Table of Contents

This is the largest chapter in the compiler section. We add first-class functions with local bindings and arguments, which requires changes to nearly every layer: objects, opcodes, the compiler, the symbol table, and the VM.

The chapter is split into three parts that build on each other: simple functions (no arguments, no locals), local bindings inside functions, and function arguments.


Part 1: simple functions
#

New object type (CompiledFunction)
#

Add this struct to object.zig. A compiled function carries its own bytecode, plus metadata the VM needs to set up the call frame:

pub const CompiledFunction = struct {
    instructions: []const u8, // The function's own bytecode stream.
    num_locals: usize,        // How many local bindings this function defines.
    num_parameters: usize,    // How many parameters this function expects.
};

Add a compiled_function variant to the existing Object union in object.zig:

pub const Object = union(enum) {
    integer: i64,
    boolean: bool,
    string: []const u8,
    array: []const Object,
    hash: HashPair,
    null,
    compiled_function: CompiledFunction,
    // ... more later
};

New opcodes
#

Opcode Operand Description
op_call u8 (num args) Call the function sitting below the arguments on stack
op_return_value (none) Return the top-of-stack value to the caller
op_return (none) Return without a value (pushes Null)

Add these variants to the Opcode enum in code.zig:

    op_call,
    op_return_value,
    op_return,

Add these entries to the lookup function in code.zig, alongside the existing opcode definitions:

.op_call => .{ .name = "OpCall", .operand_widths = &[_]u8{1} },
.op_return_value => .{ .name = "OpReturnValue", .operand_widths = &[_]u8{} },
.op_return => .{ .name = "OpReturn", .operand_widths = &[_]u8{} },

Compilation scopes
#

The top-level program and each function literal produce their own instruction stream. The compiler manages this with a stack of scopes. Add this struct to compiler.zig (above the Compiler struct):

const CompilationScope = struct {
    instructions: std.ArrayList(u8),           // The instruction stream being built for this scope.
    last_instruction: ?EmittedInstruction,     // The most recently emitted instruction.
    previous_instruction: ?EmittedInstruction, // The instruction before `last_instruction`.
};

Add these two fields to the existing Compiler struct in compiler.zig:

scopes: std.ArrayList(CompilationScope), // Stack of compilation scopes. Index 0 is the top-level (main) scope.
scope_index: usize,                      // Index of the currently active scope inside `scopes`.

Update init. Replace the old field initializers for instructions, last_instruction, and previous_instruction with the new scopes and scope_index fields. The main scope is pushed into scopes at creation time:

pub fn init(allocator: std.mem.Allocator) Compiler {
    var scopes: std.ArrayList(CompilationScope) = .empty;
    // The main (top-level) scope is always at index 0.
    scopes.append(allocator, .{
        .instructions = .empty,
        .last_instruction = null,
        .previous_instruction = null,
    }) catch unreachable;

    return .{
        .constants = .empty,
        .allocator = allocator,
        .symbol_table = SymbolTable.init(allocator),
        .scopes = scopes,
        .scope_index = 0,
    };
}

The old instructions, last_instruction, and previous_instruction fields are removed from the Compiler struct. They now live inside CompilationScope. Every access goes through self.currentScope() (shown below).

Update deinit to clean up all scopes:

pub fn deinit(self: *Compiler) void {
    for (self.scopes.items) |*scope| {
        scope.instructions.deinit(self.allocator);
    }
    self.scopes.deinit(self.allocator);
    self.constants.deinit(self.allocator);
    self.symbol_table.store.deinit();
}

Update bytecode to return the current scope’s instructions instead of the old field:

pub fn bytecode(self: *Compiler) Bytecode {
    return .{
        .instructions = self.currentInstructions(),
        .constants = self.constants.items,
    };
}

Add these two methods to the Compiler struct in compiler.zig:

/// Push a fresh compilation scope and make it current.
fn enterScope(self: *Compiler) !void {
    const scope = CompilationScope{
        .instructions = .empty,
        .last_instruction = null,
        .previous_instruction = null,
    };
    try self.scopes.append(self.allocator, scope);
    self.scope_index += 1;

    // Save the current symbol table on the heap so the enclosed table
    // can point back to it via `outer`.
    const outer = try self.allocator.create(SymbolTable);
    outer.* = self.symbol_table;
    self.symbol_table = SymbolTable.initEnclosed(self.allocator, outer);
}

/// Pop the current scope and return its instruction bytes.
fn leaveScope(self: *Compiler) ![]const u8 {
    const scope = &self.scopes.items[self.scope_index];
    const ins = try scope.instructions.toOwnedSlice(self.allocator);
    self.scopes.items.len -= 1;
    self.scope_index -= 1;

    // Restore the enclosing symbol table and free the heap copy.
    const outer = self.symbol_table.outer.?;
    self.symbol_table.store.deinit();
    self.symbol_table = outer.*;
    self.allocator.destroy(outer);

    return ins;
}

All existing helpers that touch instructions, last_instruction, or previous_instruction must now go through the current scope. Add these two accessor methods to the Compiler struct:

/// Return a mutable pointer to the current scope.
fn currentScope(self: *Compiler) *CompilationScope {
    return &self.scopes.items[self.scope_index];
}

/// Return the current instruction stream as a slice.
fn currentInstructions(self: *Compiler) []const u8 {
    return self.scopes.items[self.scope_index].instructions.items;
}

Now update every method that previously accessed self.instructions, self.last_instruction, or self.previous_instruction to go through self.currentScope() instead.

Updated emit:

fn emit(self: *Compiler, op: code.Opcode, operands: []const usize) !usize {
    const instruction = try code.make(self.allocator, op, operands);
    defer self.allocator.free(instruction);

    const scope = self.currentScope();
    const pos = scope.instructions.items.len;
    try scope.instructions.appendSlice(self.allocator, instruction);

    scope.previous_instruction = scope.last_instruction;
    scope.last_instruction = .{ .opcode = op, .position = pos };

    return pos;
}

Updated removeLastPop:

fn removeLastPop(self: *Compiler) void {
    const scope = self.currentScope();
    if (scope.last_instruction) |last| {
        if (last.opcode == .op_pop) {
            scope.instructions.items.len = last.position;
            scope.last_instruction = scope.previous_instruction;
        }
    }
}

Updated lastInstructionIs:

fn lastInstructionIs(self: *Compiler, op: code.Opcode) bool {
    const scope = self.currentScope();
    if (scope.last_instruction) |last| {
        return last.opcode == op;
    }
    return false;
}

Updated changeOperand:

fn changeOperand(self: *Compiler, op_pos: usize, operand: usize) void {
    const scope = self.currentScope();
    std.mem.writeInt(
        u16,
        scope.instructions.items[op_pos + 1 ..][0..2],
        @intCast(operand),
        .big,
    );
}

Compiling return statements
#

The return_statement case in compileStatement was a placeholder until now. Update it in compiler.zig:

.return_statement => |rs| {
    try self.compileExpression(rs.value);
    _ = try self.emit(.op_return_value, &[_]usize{});
},

Compiling function literals
#

Add this case to compileExpression in compiler.zig:

.function_literal => |fl| {
    try self.enterScope();

    // Compile every statement in the function body.
    for (fl.body.statements) |stmt| try self.compileStatement(stmt);

    // Implicit return: if the last instruction is OpPop (an expression
    // statement whose value was discarded), replace it with OpReturnValue
    // so the value is returned instead.
    if (self.lastInstructionIs(.op_pop)) self.replaceLastPopWithReturn();

    // If there is still no return, emit a bare OpReturn (returns Null).
    if (!self.lastInstructionIs(.op_return_value)) _ = try self.emit(.op_return, &[_]usize{});

    const num_locals = self.symbol_table.num_definitions;
    const instructions = try self.leaveScope();

    const compiled_fn = object.Object{ .compiled_function = .{
        .instructions = instructions,
        .num_locals = num_locals,
        .num_parameters = fl.parameters.len,
    } };
    const const_idx = try self.addConstant(compiled_fn);
    _ = try self.emit(.op_constant, &[_]usize{const_idx});
},

Add this method to the Compiler struct. It overwrites the last OpPop byte with OpReturnValue:

fn replaceLastPopWithReturn(self: *Compiler) void {
    const scope = self.currentScope();
    const last_pos = scope.last_instruction.?.position;
    scope.instructions.items[last_pos] = @intFromEnum(code.Opcode.op_return_value);
    scope.last_instruction.?.opcode = .op_return_value;
}

Add this case to compileExpression in compiler.zig. After adding both .function_literal and .call, all Expression variants are handled. Remove the else => {} catch-all that was there from previous chapters (Zig will error on unreachable else prongs):

.call => |ce| {
    // Compile the function expression (pushes the function onto the stack).
    try self.compileExpression(ce.function.*);

    // Compile each argument left-to-right (pushes them in order).
    for (ce.arguments) |arg| {
        try self.compileExpression(arg);
    }

    _ = try self.emit(.op_call, &[_]usize{ce.arguments.len});
},

VM frames
#

Add this import at the top of vm.zig (alongside the existing imports):

const CompiledFunction = object.CompiledFunction;

Add this struct at module level of vm.zig (above the VM struct):

pub const Frame = struct {
    /// The compiled function being executed.
    fn_obj: CompiledFunction,
    /// Instruction pointer within this frame's instructions.
    ip: usize,
    /// Stack index where this frame's locals begin.
    base_pointer: usize,

    /// Return the instructions for this frame.
    pub fn instructions(self: *const Frame) []const u8 {
        return self.fn_obj.instructions;
    }
};

Replace the VM struct in vm.zig with this updated version that adds the frame stack. The old instructions field is removed; instructions now come from the current frame:

const MAX_FRAMES = 1024;

pub const VM = struct {
    constants: []const object.Object,

    stack: [STACK_SIZE]?object.Object,
    sp: usize,

    globals: [GLOBALS_SIZE]?object.Object,
    allocator: std.mem.Allocator,

    frames: [MAX_FRAMES]?Frame,
    frame_index: usize,

    /// Return a mutable pointer to the current frame.
    pub fn currentFrame(self: *VM) *Frame {
        // self.frames is [MAX_FRAMES]?Frame. Indexing through *VM gives a
        // pointer to the slot; .? unwraps the optional in-place, giving
        // a mutable reference to the Frame payload.
        return &self.frames[self.frame_index - 1].?;
    }

    /// Push a new frame onto the call stack.
    pub fn pushFrame(self: *VM, frame: Frame) void {
        self.frames[self.frame_index] = frame;
        self.frame_index += 1;
    }

    /// Pop the current frame and return it.
    pub fn popFrame(self: *VM) Frame {
        self.frame_index -= 1;
        const f = self.frames[self.frame_index].?;
        self.frames[self.frame_index] = null;
        return f;
    }

    // ...
};

At initialization, wrap the top-level bytecode in a CompiledFunction and push it as frame 0:

pub fn init(allocator: std.mem.Allocator, bc: compiler.Bytecode) VM {
    var vm = VM{
        .constants = bc.constants,
        .stack = [_]?object.Object{null} ** STACK_SIZE,
        .sp = 0,
        .globals = [_]?object.Object{null} ** GLOBALS_SIZE,
        .allocator = allocator,
        .frames = [_]?Frame{null} ** MAX_FRAMES,
        .frame_index = 0,
    };

    // The top-level program is frame 0.
    const main_fn = CompiledFunction{
        .instructions = bc.instructions,
        .num_locals = 0,
        .num_parameters = 0,
    };

    const main_frame = Frame{
        .fn_obj = main_fn,
        .ip = 0,
        .base_pointer = 0,
    };
    vm.pushFrame(main_frame);

    return vm;
}

The main loop
#

Replace the run method on the VM struct. The old run method used a local ip variable and self.instructions. Now the instruction pointer lives inside each frame (frame.ip), and instructions come from frame.instructions(). Every existing opcode handler must be updated to use frame.ip and frame.instructions() instead of local variables.

Here is the complete updated run method. Every opcode from previous chapters is included, with ip replaced by frame.ip and self.instructions replaced by ins:

pub fn run(self: *VM) !void {
    while (self.currentFrame().ip < self.currentFrame().instructions().len) {
        const frame = self.currentFrame();
        const ip = frame.ip;
        const ins = frame.instructions();
        const op: code.Opcode = @enumFromInt(ins[ip]);

        switch (op) {
            .op_constant => {
                const const_idx = std.mem.readInt(
                    u16,
                    ins[ip + 1 ..][0..2],
                    .big,
                );
                frame.ip += 3;
                try self.push(self.constants[const_idx]);
            },
            .op_jump_not_truthy => {
                const target = std.mem.readInt(
                    u16,
                    ins[ip + 1 ..][0..2],
                    .big,
                );
                frame.ip += 3;
                const condition = self.pop();
                if (!isTruthy(condition)) {
                    frame.ip = target;
                }
            },
            .op_jump => {
                const target = std.mem.readInt(
                    u16,
                    ins[ip + 1 ..][0..2],
                    .big,
                );
                frame.ip = target;
            },
            .op_null => {
                try self.push(.{ .null = .{} });
                frame.ip += 1;
            },
            .op_add, .op_sub, .op_mul, .op_div => {
                try self.executeBinaryOperation(op);
                frame.ip += 1;
            },
            .op_pop => {
                _ = self.pop();
                frame.ip += 1;
            },
            .op_true => {
                try self.push(.{ .boolean = .{ .value = true } });
                frame.ip += 1;
            },
            .op_false => {
                try self.push(.{ .boolean = .{ .value = false } });
                frame.ip += 1;
            },
            .op_equal, .op_not_equal, .op_greater_than => {
                try self.executeComparison(op);
                frame.ip += 1;
            },
            .op_minus => {
                try self.executeMinus();
                frame.ip += 1;
            },
            .op_bang => {
                try self.executeBang();
                frame.ip += 1;
            },
            .op_set_global => {
                const global_idx = std.mem.readInt(u16, ins[ip + 1 ..][0..2], .big);
                frame.ip += 3;
                self.globals[global_idx] = self.pop();
            },
            .op_get_global => {
                const global_idx = std.mem.readInt(u16, ins[ip + 1 ..][0..2], .big);
                frame.ip += 3;
                try self.push(self.globals[global_idx].?);
            },
            .op_array => {
                const count = std.mem.readInt(u16, ins[ip + 1 ..][0..2], .big);
                frame.ip += 3;

                const start = self.sp - count;
                const elements = try self.allocator.alloc(object.Object, count);
                for (0..count) |i| {
                    elements[i] = self.stack[start + i].?;
                }
                self.sp = start;

                try self.push(.{ .array = .{ .elements = elements } });
            },
            .op_hash => {
                const count = std.mem.readInt(u16, ins[ip + 1 ..][0..2], .big);
                frame.ip += 3;

                var pairs = std.AutoHashMap(object.HashKey, object.HashPair).init(self.allocator);

                const start = self.sp - count;
                var i: usize = start;
                while (i < self.sp) : (i += 2) {
                    const key = self.stack[i].?;
                    const value = self.stack[i + 1].?;

                    const hash_key = key.hashKey() orelse return error.UnhashableKey;
                    try pairs.put(hash_key, .{ .key = key, .value = value });
                }

                self.sp = start;
                try self.push(.{ .hash = .{ .pairs = pairs } });
            },
            .op_index => {
                frame.ip += 1;
                const index = self.pop();
                const left = self.pop();

                if (left == .array and index == .integer) {
                    try self.executeArrayIndex(left, index);
                } else if (left == .hash) {
                    try self.executeHashIndex(left, index);
                } else {
                    return error.IndexNotSupported;
                }
            },
            .op_call => {
                const num_args: usize = @intCast(ins[ip + 1]);
                frame.ip += 2;

                const callee = self.stack[self.sp - 1 - num_args].?;
                switch (callee) {
                    .compiled_function => |func| {
                        if (num_args != func.num_parameters) {
                            return error.WrongArgumentCount;
                        }
                        const new_frame = Frame{
                            .fn_obj = func,
                            .ip = 0,
                            .base_pointer = self.sp - num_args,
                        };
                        self.pushFrame(new_frame);
                        // Reserve stack slots for locals.
                        self.sp = new_frame.base_pointer + func.num_locals;
                    },
                    else => return error.CallingNonFunction,
                }
                // Do not fall through, the new frame starts at ip 0.
                continue;
            },
            .op_return_value => {
                const return_value = self.pop();
                const old_frame = self.popFrame();
                // -1 pops the function object itself off the caller's stack.
                self.sp = old_frame.base_pointer - 1;
                try self.push(return_value);
                continue;
            },
            .op_return => {
                const old_frame = self.popFrame();
                self.sp = old_frame.base_pointer - 1;
                try self.push(.{ .null = .{} });
                continue;
            },
            .op_set_local => {
                const local_index: usize = @intCast(ins[ip + 1]);
                frame.ip += 2;
                self.stack[frame.base_pointer + local_index] = self.pop();
                continue;
            },
            .op_get_local => {
                const local_index: usize = @intCast(ins[ip + 1]);
                frame.ip += 2;
                try self.push(self.stack[frame.base_pointer + local_index].?);
                continue;
            },
        }
    }
}

Important: the old run method had a default ip += 1 at the bottom of the loop or used per-case ip management. With frames, there is no default increment. Every case must explicitly advance frame.ip (or continue to skip it for call/return).


Part 2: local bindings
#

New opcodes
#

Opcode Operand Description
op_set_local u8 (index) Pop value, store at base_pointer + index
op_get_local u8 (index) Push value from base_pointer + index

Add these variants to the Opcode enum in code.zig:

    op_set_local,
    op_get_local,

Add these entries to the lookup function in code.zig:

.op_set_local => .{ .name = "OpSetLocal", .operand_widths = &[_]u8{1} },
.op_get_local => .{ .name = "OpGetLocal", .operand_widths = &[_]u8{1} },

Symbol table (scopes and nesting)
#

Chapter 9 defined the symbol table with only a global scope and no nesting. Now we need to add local scope, an outer pointer for scope chaining, and update define and resolve.

Update SymbolScope in symbol_table.zig to add the local variant:

pub const SymbolScope = enum {
    global,
    local,
    // builtin comes in chapter 12, free and function in chapter 13
};

Update SymbolTable in symbol_table.zig to add the outer field, initEnclosed, and update define/resolve:

pub const SymbolTable = struct {
    allocator: std.mem.Allocator,
    outer: ?*SymbolTable,  // NEW — null for the global scope
    store: std.StringHashMap(Symbol),
    num_definitions: usize,

    pub fn init(allocator: std.mem.Allocator) SymbolTable {
        return .{
            .allocator = allocator,
            .outer = null,
            .store = std.StringHashMap(Symbol).init(allocator),
            .num_definitions = 0,
        };
    }

    pub fn deinit(self: *SymbolTable) void {
        self.store.deinit();
    }

    /// Create an enclosed (child) symbol table that can look up names in
    /// the parent when they are not found locally.
    pub fn initEnclosed(allocator: std.mem.Allocator, outer: *SymbolTable) SymbolTable {
        var st = init(allocator);
        st.outer = outer;
        return st;
    }

    /// Define a new symbol. If `outer` is non-null, the symbol gets `local`
    /// scope; otherwise `global`.
    pub fn define(self: *SymbolTable, name: []const u8) !Symbol {
        const scope: SymbolScope = if (self.outer != null) .local else .global;
        const sym = Symbol{
            .name = name,
            .scope = scope,
            .index = self.num_definitions,
        };
        try self.store.put(name, sym);
        self.num_definitions += 1;
        return sym;
    }

    /// Resolve a name. Check locally first, then walk `outer` chain.
    pub fn resolve(self: *SymbolTable, name: []const u8) ?Symbol {
        if (self.store.get(name)) |sym| {
            return sym;
        }

        if (self.outer) |outer| {
            return outer.resolve(name);
        }

        return null;
    }
};

Compiler changes
#

enterScope and leaveScope (shown in Part 1) already create/restore enclosed symbol tables. Now update the compiler to handle local scope.

Update loadSymbol in compiler.zig to add the .local case (chapter 9 only had .global):

fn loadSymbol(self: *Compiler, sym: Symbol) CompileError!void {
    switch (sym.scope) {
        .global => _ = try self.emit(.op_get_global, &[_]usize{sym.index}),
        .local => _ = try self.emit(.op_get_local, &[_]usize{sym.index}),
    }
}

Update the let_statement case in compileStatement to add the .local branch (chapter 9 only emitted op_set_global):

.let_statement => |ls| {
    try self.compileExpression(ls.value);
    const sym = try self.symbol_table.define(ls.name);
    switch (sym.scope) {
        .global => _ = try self.emit(.op_set_global, &[_]usize{sym.index}),
        .local => _ = try self.emit(.op_set_local, &[_]usize{sym.index}),
    }
},

The identifier case in compileExpression (from chapter 9) already uses loadSymbol. No changes needed there.

VM changes
#

Locals live on the stack starting at frame.base_pointer. The op_call handler already reserves space by advancing sp past num_locals.

The op_set_local and op_get_local handlers are already included in the complete run method shown in Part 1. No additional VM changes needed here.

Stack layout during a call
#

For let add = fn(a, b) { let c = a + b; c; }; add(3, 4);:

Index  | Contents after OpCall   | After locals allocated
-------+-------------------------+------------------------
  0    | add (CompiledFunction)  | add
  1    | 3      (arg 0 = a)      | 3      <- base_pointer
  2    | 4      (arg 1 = b)      | 4
  3    | (empty)                 | (slot for c, local 2)
  4    |                         | <- sp

base_pointer is 1 (where arguments start). num_locals is 3 (a, b, c). sp is set to base_pointer + num_locals = 4.


Part 3: arguments
#

Arguments are simply the first N local bindings. The caller pushes them onto the stack before emitting op_call. The callee accesses them through op_get_local with indices 0 through N-1.

Compiler changes
#

Update the .function_literal case in compileExpression (in compiler.zig). The only change from Part 1 is the loop that defines each parameter as a local symbol before compiling the body:

.function_literal => |fl| {
    try self.enterScope();

    // Define parameters as the first locals (index 0, 1, ...).
    for (fl.parameters) |param| _ = try self.symbol_table.define(param.value);

    // Compile the body.
    for (fl.body.statements) |stmt| try self.compileStatement(stmt);

    // Handle implicit/explicit returns (same as before).
    if (self.lastInstructionIs(.op_pop)) self.replaceLastPopWithReturn();
    if (!self.lastInstructionIs(.op_return_value)) _ = try self.emit(.op_return, &[_]usize{});

    const num_locals = self.symbol_table.num_definitions;
    const instructions = try self.leaveScope();

    const compiled_fn = object.Object{ .compiled_function = .{
        .instructions = instructions,
        .num_locals = num_locals,
        .num_parameters = fl.parameters.len,
    } };

    const const_idx = try self.addConstant(compiled_fn);
    _ = try self.emit(.op_constant, &[_]usize{const_idx});
},

No changes to call expression compilation – the arguments are already pushed in order, and op_call already carries the argument count.

VM argument validation
#

The op_call handler already checks num_args != func.num_parameters. That’s all that’s needed. The arguments are already sitting at base_pointer + 0 through base_pointer + num_args - 1, exactly where op_get_local expects them.


Tests
#

Compiler tests
#

Update existing compiler tests. The Compiler struct no longer has an instructions field; instructions now live inside compilation scopes and are accessed via bytecode(). In compiler_test.zig, make two changes to the chapter 8 tests:

  1. Change const comp to var comp (because bytecode() takes *Compiler)
  2. Change comp.instructions.items to comp.bytecode().instructions

Then add this new test. It checks that a function with an explicit return compiles to a CompiledFunction constant with the right instruction sequence:

test "compiler: function literal with return" {
    const allocator = std.testing.allocator;
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();

    var comp = try testCompile(arena_state.allocator(), "fn() { return 5 + 10; }");

    // Expected top-level instructions:
    //   OpConstant 2   (the compiled function)
    //   OpPop
    const expected_top = [_][]const u8{
        try code.make(allocator, .op_constant, &[_]usize{2}),
        try code.make(allocator, .op_pop, &[_]usize{}),
    };
    defer for (expected_top) |instr| allocator.free(instr);

    const expected = try concatInstructions(allocator, &expected_top);
    defer allocator.free(expected);
    try std.testing.expectEqualSlices(u8, expected, comp.bytecode().instructions);

    // Constants: 5, 10, CompiledFunction
    try std.testing.expectEqual(@as(usize, 3), comp.constants.items.len);
    try std.testing.expectEqual(@as(i64, 5), comp.constants.items[0].integer.value);
    try std.testing.expectEqual(@as(i64, 10), comp.constants.items[1].integer.value);

    // Verify the compiled function's instructions.
    const func = comp.constants.items[2].compiled_function;
    const expected_fn_instrs = [_][]const u8{
        try code.make(allocator, .op_constant, &[_]usize{0}),
        try code.make(allocator, .op_constant, &[_]usize{1}),
        try code.make(allocator, .op_add, &[_]usize{}),
        try code.make(allocator, .op_return_value, &[_]usize{}),
    };
    defer for (expected_fn_instrs) |instr| allocator.free(instr);

    const expected_fn = try concatInstructions(allocator, &expected_fn_instrs);
    defer allocator.free(expected_fn);
    try std.testing.expectEqualSlices(u8, expected_fn, func.instructions);
}

VM tests
#

Add these tests to vm_test.zig. They use the existing testVMRun and testExpectedValue helpers from chapter 8.

test "vm: calling functions without arguments" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: ExpectedValue }{
        .{
            .input = "let fivePlusTen = fn() { 5 + 10; }; fivePlusTen();",
            .expected = .{ .integer = 15 },
        },
        .{
            .input =
                \\let one = fn() { 1; };
                \\let two = fn() { 2; };
                \\one() + two();
            ,
            .expected = .{ .integer = 3 },
        },
    };

    for (tests) |t| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, t.input);
        try testExpectedValue(result, t.expected);
    }
}

test "vm: calling functions with return statement" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: ExpectedValue }{
        .{
            .input = "let earlyReturn = fn() { return 99; 100; }; earlyReturn();",
            .expected = .{ .integer = 99 },
        },
        .{
            .input =
                \\let earlyReturn = fn() { return 99; return 100; };
                \\earlyReturn();
            ,
            .expected = .{ .integer = 99 },
        },
    };

    for (tests) |t| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, t.input);
        try testExpectedValue(result, t.expected);
    }
}

test "vm: calling functions without return value" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: ExpectedValue }{
        .{
            .input = "let noReturn = fn() { }; noReturn();",
            .expected = .{ .null = {} },
        },
        .{
            .input =
                \\let noReturn = fn() { };
                \\let noReturnTwo = fn() { noReturn(); };
                \\noReturn();
                \\noReturnTwo();
            ,
            .expected = .{ .null = {} },
        },
    };

    for (tests) |t| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, t.input);
        try testExpectedValue(result, t.expected);
    }
}

test "vm: first-class functions" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    const result = try testVMRun(&arena,
        \\let returnsOne = fn() { 1; };
        \\let returnsOneReturner = fn() { returnsOne; };
        \\returnsOneReturner()();
    );
    try testExpectedValue(result, .{ .integer = 1 });
}

test "vm: local bindings" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: ExpectedValue }{
        .{
            .input =
                \\let oneAndTwo = fn() { let one = 1; let two = 2; one + two; };
                \\oneAndTwo();
            ,
            .expected = .{ .integer = 3 },
        },
        .{
            .input =
                \\let oneAndTwo = fn() { let one = 1; let two = 2; one + two; };
                \\let threeAndFour = fn() { let three = 3; let four = 4; three + four; };
                \\oneAndTwo() + threeAndFour();
            ,
            .expected = .{ .integer = 10 },
        },
        .{
            .input =
                \\let firstFoobar = fn() { let foobar = 50; foobar; };
                \\let secondFoobar = fn() { let foobar = 100; foobar; };
                \\firstFoobar() + secondFoobar();
            ,
            .expected = .{ .integer = 150 },
        },
        .{
            .input =
                \\let globalSeed = 50;
                \\let minusOne = fn() {
                \\    let num = 1;
                \\    globalSeed - num;
                \\};
                \\let minusTwo = fn() {
                \\    let num = 2;
                \\    globalSeed - num;
                \\};
                \\minusOne() + minusTwo();
            ,
            .expected = .{ .integer = 97 },
        },
    };

    for (tests) |t| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, t.input);
        try testExpectedValue(result, t.expected);
    }
}

test "vm: calling functions with arguments" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: ExpectedValue }{
        .{
            .input = "let identity = fn(a) { a; }; identity(4);",
            .expected = .{ .integer = 4 },
        },
        .{
            .input = "let sum = fn(a, b) { a + b; }; sum(1, 2);",
            .expected = .{ .integer = 3 },
        },
        .{
            .input =
                \\let sum = fn(a, b) {
                \\    let c = a + b;
                \\    c;
                \\};
                \\sum(1, 2);
            ,
            .expected = .{ .integer = 3 },
        },
        .{
            .input =
                \\let sum = fn(a, b) {
                \\    let c = a + b;
                \\    c;
                \\};
                \\sum(1, 2) + sum(3, 4);
            ,
            .expected = .{ .integer = 10 },
        },
        .{
            .input =
                \\let sum = fn(a, b) {
                \\    let c = a + b;
                \\    c;
                \\};
                \\let outer = fn() {
                \\    sum(1, 2) + sum(3, 4);
                \\};
                \\outer();
            ,
            .expected = .{ .integer = 10 },
        },
        .{
            .input =
                \\let sum = fn(a, b) {
                \\    let c = a + b;
                \\    c;
                \\};
                \\sum(1, sum(2, 3));
            ,
            .expected = .{ .integer = 6 },
        },
    };

    for (tests) |t| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, t.input);
        try testExpectedValue(result, t.expected);
    }
}

test "vm: wrong number of arguments" {
    const allocator = std.testing.allocator;

    // These should return error.WrongArgumentCount from vm.run().
    const tests = [_][]const u8{
        "let noArg = fn() { 24; }; noArg(1);",
        "let twoArgs = fn(a, b) { a + b; }; twoArgs(1);",
    };

    for (tests) |input| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = testVMRun(&arena, input);
        try std.testing.expectError(error.WrongArgumentCount, result);
    }
}

Symbol table tests
#

Add these tests inline in symbol_table.zig.

const testing = std.testing;

test "resolve local" {
    var global = SymbolTable.init(testing.allocator);
    defer global.store.deinit();
    _ = try global.define("a");
    _ = try global.define("b");

    var local = SymbolTable.initEnclosed(testing.allocator, &global);
    defer local.store.deinit();
    _ = try local.define("c");
    _ = try local.define("d");

    const expected = [_]Symbol{
        .{ .name = "a", .scope = .global, .index = 0 },
        .{ .name = "b", .scope = .global, .index = 1 },
        .{ .name = "c", .scope = .local, .index = 0 },
        .{ .name = "d", .scope = .local, .index = 1 },
    };

    for (expected) |sym| {
        const resolved = local.resolve(sym.name) orelse {
            std.debug.print("name {s} not resolvable\n", .{sym.name});
            return error.TestFailed;
        };
        try testing.expectEqualStrings(sym.name, resolved.name);
        try testing.expectEqual(sym.scope, resolved.scope);
        try testing.expectEqual(sym.index, resolved.index);
    }
}

test "resolve nested local" {
    var global = SymbolTable.init(testing.allocator);
    defer global.store.deinit();
    _ = try global.define("a");
    _ = try global.define("b");

    var first_local = SymbolTable.initEnclosed(testing.allocator, &global);
    defer first_local.store.deinit();
    _ = try first_local.define("c");
    _ = try first_local.define("d");

    var second_local = SymbolTable.initEnclosed(testing.allocator, &first_local);
    defer second_local.store.deinit();
    _ = try second_local.define("e");
    _ = try second_local.define("f");

    // From second_local: a, b are global; c, d are resolved from first_local
    // (still "local" scope in the book's model); e, f are local.
    const resolved_e = second_local.resolve("e").?;
    try testing.expectEqual(SymbolScope.local, resolved_e.scope);
    try testing.expectEqual(@as(usize, 0), resolved_e.index);

    const resolved_a = second_local.resolve("a").?;
    try testing.expectEqual(SymbolScope.global, resolved_a.scope);
    try testing.expectEqual(@as(usize, 0), resolved_a.index);
}

Verify it works
#

Run zig build test. No output means all tests passed.