Skip to main content
  1. Books/
  2. Monkey Interpreter In Zig/
Chapter 4

Extending the Interpreter

7 mins
On this page
Table of Contents

Almost done with part 1. This chapter completes the interpreter by adding built-in functions (builtins.zig), wiring them into the evaluator, and building the REPL (main.zig).

REPL is a Read Evaluate Print Loop, something similar to the Python interactive shell. The code will produce a prompt and allow the user to write a line of Monkey. It’ll read the input from the user, then lex/parse/evaluate, display the resulting object, then loop back to step 1.


Step 1: create builtins.zig
#

This module defines the six Monkey built-in functions: len, first, last, rest, push, puts. We’ll put tese in src/builtins.zig.

const std = @import("std");
const object = @import("object.zig");

pub const BuiltinFn = object.BuiltinFn;

pub fn lookup(name: []const u8) ?BuiltinFn {
    const map = std.StaticStringMap(BuiltinFn).initComptime(.{
        .{ "len", &builtinLen },
        .{ "first", &builtinFirst },
        .{ "last", &builtinLast },
        .{ "rest", &builtinRest },
        .{ "push", &builtinPush },
        .{ "puts", &builtinPuts },
    });

    return map.get(name);
}

pub const builtin_names = [_][]const u8{ "len", "puts", "first", "last", "rest", "push" };

fn builtinLen(allocator: std.mem.Allocator, args: []const object.Object) object.Object {
    _ = allocator;
    if (args.len != 1) return .{ .err = .{ .message = "wrong number of arguments to `len`" } };

    return switch (args[0]) {
        .string => |s| .{ .integer = .{ .value = @intCast(s.value.len) } },
        .array => |a| .{ .integer = .{ .value = @intCast(a.elements.len) } },
        else => .{ .err = .{ .message = "argument to `len` not supported" } },
    };
}

fn builtinFirst(allocator: std.mem.Allocator, args: []const object.Object) object.Object {
    _ = allocator;
    if (args.len != 1) return .{ .err = .{ .message = "wrong number of arguments to `first`" } };

    return switch (args[0]) {
        .array => |a| {
            if (a.elements.len > 0) return a.elements[0];
            return .{ .null = .{} };
        },
        else => .{ .err = .{ .message = "argument to `first` must be ARRAY" } },
    };
}

fn builtinLast(allocator: std.mem.Allocator, args: []const object.Object) object.Object {
    _ = allocator;
    if (args.len != 1) return .{ .err = .{ .message = "wrong number of arguments to `last`" } };

    return switch (args[0]) {
        .array => |a| {
            if (a.elements.len > 0) return a.elements[a.elements.len - 1];
            return .{ .null = .{} };
        },
        else => .{ .err = .{ .message = "argument to `last` must be ARRAY" } },
    };
}

fn builtinRest(allocator: std.mem.Allocator, args: []const object.Object) object.Object {
    if (args.len != 1) return .{ .err = .{ .message = "wrong number of arguments to `rest`" } };

    return switch (args[0]) {
        .array => |a| {
            if (a.elements.len == 0) return .{ .null = .{} };
            const new_elements = allocator.dupe(object.Object, a.elements[1..]) catch {
                return .{ .err = .{ .message = "allocation failed in `rest`" } };
            };
            return .{ .array = .{ .elements = new_elements } };
        },
        else => .{ .err = .{ .message = "argument to `rest` must be ARRAY" } },
    };
}

fn builtinPush(allocator: std.mem.Allocator, args: []const object.Object) object.Object {
    if (args.len != 2) return .{ .err = .{ .message = "wrong number of arguments to `push`" } };

    return switch (args[0]) {
        .array => |a| {
            const new_elements = allocator.alloc(object.Object, a.elements.len + 1) catch {
                return .{ .err = .{ .message = "allocation failed in `push`" } };
            };
            @memcpy(new_elements[0..a.elements.len], a.elements);
            new_elements[a.elements.len] = args[1];
            return .{ .array = .{ .elements = new_elements } };
        },
        else => .{ .err = .{ .message = "first argument to `push` must be ARRAY" } },
    };
}

fn builtinPuts(allocator: std.mem.Allocator, args: []const object.Object) object.Object {
    for (args) |arg| {
        const s = arg.inspect(allocator) catch {
            return .{ .err = .{ .message = "allocation failed in `puts`" } };
        };
        std.debug.print("{s}\n", .{s});
    }

    return .{ .null = .{} };
}

Step 2: update evaluator.zig
#

Now that builtins.zig exists, update evaluator.zig to import it and use it for identifier resolution.

const std = @import("std");
const ast = @import("ast.zig");
const object = @import("object.zig");
const Environment = @import("environment.zig").Environment;
const builtins = @import("builtins.zig");

Step 2a: update evalIdentifier
#

In chapter 3, evalIdentifier only checked the environment. Now add the builtins lookup as a fallback.

fn evalIdentifier(allocator: std.mem.Allocator, id: ast.Identifier, env: *Environment) EvalError!object.Object {
    if (env.get(id.value)) |val| return val;
    if (builtins.lookup(id.value)) |func| return .{ .builtin = .{ .func = func } };

    return newError(allocator, "identifier not found: {s}", .{id.value});
}

The lookup order matters! Environment first, then builtins. This means a user can shadow a builtin with let len = 5;.

Step 2b: add the builtin tests
#

Add these tests to evaluator_test.zig. They use the same testEval helper from chapter 3.

test "builtin len" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected_int: ?i64, expected_err: ?[]const u8 }{
        .{ .input = "len(\"\")", .expected_int = 0, .expected_err = null },
        .{ .input = "len(\"four\")", .expected_int = 4, .expected_err = null },
        .{ .input = "len(\"hello world\")", .expected_int = 11, .expected_err = null },
        .{ .input = "len([1, 2, 3])", .expected_int = 3, .expected_err = null },
        .{ .input = "len(1)", .expected_int = null, .expected_err = "argument to `len` not supported" },
    };

    for (tests) |tt| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testEval(arena.allocator(), tt.input);

        if (tt.expected_int) |expected| try std.testing.expectEqual(expected, result.integer.value);
        if (tt.expected_err) |expected| try std.testing.expectEqualStrings(expected, result.err.message);
    }
}

test "builtin array functions" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var result = try testEval(arena.allocator(), "first([1, 2, 3])");
    try std.testing.expectEqual(@as(i64, 1), result.integer.value);

    result = try testEval(arena.allocator(), "last([1, 2, 3])");
    try std.testing.expectEqual(@as(i64, 3), result.integer.value);

    result = try testEval(arena.allocator(), "rest([1, 2, 3])");
    try std.testing.expectEqual(@as(usize, 2), result.array.elements.len);
    try std.testing.expectEqual(@as(i64, 2), result.array.elements[0].integer.value);
    try std.testing.expectEqual(@as(i64, 3), result.array.elements[1].integer.value);

    result = try testEval(arena.allocator(), "push([1, 2], 3)");
    try std.testing.expectEqual(@as(usize, 3), result.array.elements.len);
    try std.testing.expectEqual(@as(i64, 3), result.array.elements[2].integer.value);
}

Step 3: create main.zig
#

Finally, the REPL reads a line, lexes, parses, evaluates, and prints the result.

const std = @import("std");
const Lexer = @import("lexer.zig").Lexer;
const Parser = @import("parser.zig").Parser;
const evaluator = @import("evaluator.zig");
const Environment = @import("environment.zig").Environment;

const PROMPT = ">> ";

const MONKEY_FACE =
    \\            __,__
    \\   .--.  .-"     "-.  .--.
    \\  / .. \/  .-. .-.  \/ .. \
    \\ | |  '|  /   Y   \  |'  | |
    \\ | \   \  \ 0 | 0 /  /   / |
    \\  \ '- ,\.-"""""""-./, -' /
    \\   ''-' /_   ^ ^   _\ '-''
    \\       |  \._   _./  |
    \\       \   \ '~' /   /
    \\        '._ '-=-' _.'
    \\           '-----'
;

fn readLine(allocator: std.mem.Allocator) !?[]u8 {
    var line: std.ArrayList(u8) = .empty;
    errdefer line.deinit(allocator);

    while (true) {
        var buf: [1]u8 = undefined;
        const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch return null;
        if (n == 0) {
            // EOF
            if (line.items.len == 0) return null;
            return try line.toOwnedSlice(allocator);
        }

        if (buf[0] == '\n') return try line.toOwnedSlice(allocator);
        try line.append(allocator, buf[0]);
    }
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var env = Environment.init(allocator);

    std.debug.print("Welcome to the Monkey programming language!\n", .{});
    std.debug.print("Feel free to type in commands\n", .{});

    while (true) {
        std.debug.print("{s}", .{PROMPT});

        const line = try readLine(allocator) orelse break; // EOF (Ctrl-D)
        defer allocator.free(line);

        // Use an arena for per-line allocations (parser, AST, intermediate objects)
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();

        var l = Lexer.init(line);
        var p = Parser.init(arena.allocator(), &l);
        const program = p.parseProgram() catch |err| {
            std.debug.print("Parse error: {}\n", .{err});
            continue;
        };

        if (p.errors.items.len > 0) {
            std.debug.print("{s}", .{MONKEY_FACE});
            std.debug.print("\nWoops! We ran into some monkey business here!\n", .{});
            std.debug.print(" parser errors:\n", .{});
            for (p.errors.items) |err| std.debug.print("\t{s}\n", .{err});
            continue;
        }

        const result = evaluator.evalProgram(arena.allocator(), program, &env) catch |err| {
            std.debug.print("Eval error: {}\n", .{err});
            continue;
        };

        const output = result.inspect(arena.allocator()) catch continue;
        std.debug.print("{s}\n", .{output});
    }

    std.debug.print("\nGoodbye!\n", .{});
}

Verify it works
#

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

Then build and run the REPL with zig build run. Go ahead and try a few expressions to confirm everything works, then press Ctrl-D to exit.

zig build run

Try it out:

>> let x = 5
5
>> let add = fn(a, b) { a + b; }
fn(...) { ... }
>> add(x, 10)
15
>> len("hello")
5
>> push([1, 2], 3)
[1, 2, 3]

In chapter 5 we cover the theory behind compilers and virtual machines, and from chapter 6 onward we replace the interpreter with a bytecode compiler and stack-based VM. At this point you’ve built a working interpreter. You can stop here if you’re learning is complete. The next chapter begins building the compiler.