We extend the compiler and VM to handle all expression types: infix operators (+, -, *, /, ==, !=, >, <), boolean literals (true, false), and prefix operators (-, !). Follow these steps in order.
New opcodes #
Extend the Opcode enum in code.zig:
pub const Opcode = enum(u8) {
op_constant = 0,
op_add = 1,
op_pop = 2,
op_sub = 3,
op_mul = 4,
op_div = 5,
op_true = 6,
op_false = 7,
op_equal = 8,
op_not_equal = 9,
op_greater_than = 10,
op_minus = 11,
op_bang = 12,
};
All new opcodes have zero operands. Update lookup:
pub fn lookup(op: Opcode) Definition {
return switch (op) {
.op_constant => .{ .name = "OpConstant", .operand_widths = &[_]u8{2} },
.op_add => .{ .name = "OpAdd", .operand_widths = &[_]u8{} },
.op_pop => .{ .name = "OpPop", .operand_widths = &[_]u8{} },
.op_sub => .{ .name = "OpSub", .operand_widths = &[_]u8{} },
.op_mul => .{ .name = "OpMul", .operand_widths = &[_]u8{} },
.op_div => .{ .name = "OpDiv", .operand_widths = &[_]u8{} },
.op_true => .{ .name = "OpTrue", .operand_widths = &[_]u8{} },
.op_false => .{ .name = "OpFalse", .operand_widths = &[_]u8{} },
.op_equal => .{ .name = "OpEqual", .operand_widths = &[_]u8{} },
.op_not_equal => .{ .name = "OpNotEqual", .operand_widths = &[_]u8{} },
.op_greater_than => .{ .name = "OpGreaterThan", .operand_widths = &[_]u8{} },
.op_minus => .{ .name = "OpMinus", .operand_widths = &[_]u8{} },
.op_bang => .{ .name = "OpBang", .operand_widths = &[_]u8{} },
};
}
The less-than trick #
There is no OpLessThan. The compiler handles a < b by reordering the operands and using OpGreaterThan. Instead of a < b, it compiles b > a. One fewer opcode to worry about in the VM.
Step 2: update compiler.zig
#
Update compileExpression in compiler.zig. Replace the existing .infix case (which only handled +) and add new cases for .boolean and .prefix.
Infix expressions #
Replace the .infix case in compileExpression with:
.infix => |inf| {
// Special case: < (less than) — swap operands, use greater-than
if (std.mem.eql(u8, inf.operator, "<")) {
try self.compileExpression(inf.right.*); // compile right FIRST
try self.compileExpression(inf.left.*); // then left
_ = try self.emit(.op_greater_than, &[_]usize{});
return;
}
// Normal case: compile left, then right
try self.compileExpression(inf.left.*);
try self.compileExpression(inf.right.*);
if (std.mem.eql(u8, inf.operator, "+")) {
_ = try self.emit(.op_add, &[_]usize{});
} else if (std.mem.eql(u8, inf.operator, "-")) {
_ = try self.emit(.op_sub, &[_]usize{});
} else if (std.mem.eql(u8, inf.operator, "*")) {
_ = try self.emit(.op_mul, &[_]usize{});
} else if (std.mem.eql(u8, inf.operator, "/")) {
_ = try self.emit(.op_div, &[_]usize{});
} else if (std.mem.eql(u8, inf.operator, "==")) {
_ = try self.emit(.op_equal, &[_]usize{});
} else if (std.mem.eql(u8, inf.operator, "!=")) {
_ = try self.emit(.op_not_equal, &[_]usize{});
} else if (std.mem.eql(u8, inf.operator, ">")) {
_ = try self.emit(.op_greater_than, &[_]usize{});
} else {
return error.UnknownOperator;
}
},
Booleans #
Add this case to compileExpression in compiler.zig:
.boolean => |b| {
if (b.value) {
_ = try self.emit(.op_true, &[_]usize{});
} else {
_ = try self.emit(.op_false, &[_]usize{});
}
},
Booleans are not stored in the constant pool. They get dedicated opcodes (OpTrue, OpFalse) because there are only two possible values.
Prefix expressions #
Add this case to compileExpression in compiler.zig:
.prefix => |p| {
try self.compileExpression(p.right.*);
if (std.mem.eql(u8, p.operator, "-")) {
_ = try self.emit(.op_minus, &[_]usize{});
} else if (std.mem.eql(u8, p.operator, "!")) {
_ = try self.emit(.op_bang, &[_]usize{});
} else {
return error.UnknownOperator;
}
},
Step 3: update vm.zig
#
Constant boolean objects #
Add these at the top of vm.zig (module-level, outside the VM struct):
const TRUE = object.Object{ .boolean = .{ .value = true } };
const FALSE = object.Object{ .boolean = .{ .value = false } };
const NULL = object.Object{ .null = .{} };
Arithmetic operations #
Add this method to the VM struct:
fn executeBinaryOperation(self: *VM, op: code.Opcode) !void {
const right = self.pop();
const left = self.pop();
if (left == .integer and right == .integer) {
const result = switch (op) {
.op_add => left.integer.value + right.integer.value,
.op_sub => left.integer.value - right.integer.value,
.op_mul => left.integer.value * right.integer.value,
.op_div => @divTrunc(left.integer.value, right.integer.value),
else => return error.UnknownOperator,
};
try self.push(.{ .integer = .{ .value = result } });
return;
}
return error.UnsupportedTypes;
}
Comparison operations #
Add this method to the VM struct:
fn executeComparison(self: *VM, op: code.Opcode) !void {
const right = self.pop();
const left = self.pop();
if (left == .integer and right == .integer) {
const result = switch (op) {
.op_equal => left.integer.value == right.integer.value,
.op_not_equal => left.integer.value != right.integer.value,
.op_greater_than => left.integer.value > right.integer.value,
else => return error.UnknownOperator,
};
try self.push(if (result) TRUE else FALSE);
return;
}
if (left == .boolean and right == .boolean) {
const result = switch (op) {
.op_equal => left.boolean.value == right.boolean.value,
.op_not_equal => left.boolean.value != right.boolean.value,
else => return error.UnknownOperator,
};
try self.push(if (result) TRUE else FALSE);
return;
}
return error.UnsupportedTypes;
}
Prefix operations #
Add these methods to the VM struct:
fn executeMinus(self: *VM) !void {
const operand = self.pop();
if (operand != .integer) return error.UnsupportedType;
try self.push(.{ .integer = .{ .value = -operand.integer.value } });
}
fn executeBang(self: *VM) !void {
const operand = self.pop();
switch (operand) {
.boolean => |b| try self.push(if (b.value) FALSE else TRUE),
.null => try self.push(TRUE),
else => try self.push(FALSE),
}
}
Updated run loop #
Replace the run method in the VM struct with this expanded version that handles all new opcodes:
pub fn run(self: *VM) !void {
var ip: usize = 0;
while (ip < self.instructions.len) {
const op: code.Opcode = @enumFromInt(self.instructions[ip]);
switch (op) {
.op_constant => {
const idx = std.mem.readInt(u16, self.instructions[ip + 1 ..][0..2], .big);
ip += 3;
try self.push(self.constants[idx]);
},
.op_add, .op_sub, .op_mul, .op_div => {
try self.executeBinaryOperation(op);
ip += 1;
},
.op_equal, .op_not_equal, .op_greater_than => {
try self.executeComparison(op);
ip += 1;
},
.op_true => {
try self.push(TRUE);
ip += 1;
},
.op_false => {
try self.push(FALSE);
ip += 1;
},
.op_minus => {
try self.executeMinus();
ip += 1;
},
.op_bang => {
try self.executeBang();
ip += 1;
},
.op_pop => {
_ = self.pop();
ip += 1;
},
}
}
}
Step 4: update tests in vm_test.zig
#
Replace the “integer arithmetic” test from chapter 6 with this expanded version, and add the boolean test. These use the same testVMRun helper.
test "integer arithmetic" {
const allocator = std.testing.allocator;
const tests = [_]struct { input: []const u8, expected: i64 }{
.{ .input = "1", .expected = 1 },
.{ .input = "2", .expected = 2 },
.{ .input = "1 + 2", .expected = 3 },
.{ .input = "1 - 2", .expected = -1 },
.{ .input = "1 * 2", .expected = 2 },
.{ .input = "4 / 2", .expected = 2 },
.{ .input = "50 / 2 * 2 + 10 - 5", .expected = 55 },
.{ .input = "5 + 5 + 5 + 5 - 10", .expected = 10 },
.{ .input = "2 * 2 * 2 * 2 * 2", .expected = 32 },
.{ .input = "5 * 2 + 10", .expected = 20 },
.{ .input = "5 + 2 * 10", .expected = 25 },
.{ .input = "5 * (2 + 10)", .expected = 60 },
.{ .input = "-5", .expected = -5 },
.{ .input = "-10", .expected = -10 },
.{ .input = "-50 + 100 + -50", .expected = 0 },
.{ .input = "(5 + 10 * 2 + 15 / 3) * 2 + -10", .expected = 50 },
};
for (tests) |tt| {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const result = try testVMRun(&arena, tt.input);
try std.testing.expectEqual(tt.expected, result.integer.value);
}
}
test "boolean expressions" {
const allocator = std.testing.allocator;
const tests = [_]struct { input: []const u8, expected: bool }{
.{ .input = "true", .expected = true },
.{ .input = "false", .expected = false },
.{ .input = "1 < 2", .expected = true },
.{ .input = "1 > 2", .expected = false },
.{ .input = "1 < 1", .expected = false },
.{ .input = "1 > 1", .expected = false },
.{ .input = "1 == 1", .expected = true },
.{ .input = "1 != 1", .expected = false },
.{ .input = "1 == 2", .expected = false },
.{ .input = "1 != 2", .expected = true },
.{ .input = "true == true", .expected = true },
.{ .input = "false == false", .expected = true },
.{ .input = "true == false", .expected = false },
.{ .input = "true != false", .expected = true },
.{ .input = "(1 < 2) == true", .expected = true },
.{ .input = "(1 < 2) == false", .expected = false },
.{ .input = "(1 > 2) == true", .expected = false },
.{ .input = "(1 > 2) == false", .expected = true },
.{ .input = "!true", .expected = false },
.{ .input = "!false", .expected = true },
.{ .input = "!5", .expected = false },
.{ .input = "!!true", .expected = true },
.{ .input = "!!false", .expected = false },
.{ .input = "!!5", .expected = true },
};
for (tests) |tt| {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const result = try testVMRun(&arena, tt.input);
try std.testing.expectEqual(tt.expected, result.boolean.value);
}
}
Verify it works #
Run zig build test. No output means all tests passed.
In chapter 8 we add conditional expressions (if/else) with jump instructions, the first time the compiler needs to emit instructions whose targets aren’t known yet.