Skip to content

Commit

Permalink
feat: Implement Chapter 7 (#12)
Browse files Browse the repository at this point in the history
Implementation of Chapter 7: [Evaluating
Expressions](https://craftinginterpreters.com/evaluating-expressions.html).
  • Loading branch information
danvk authored Jan 14, 2024
1 parent ded12e0 commit d39a37d
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 16 deletions.
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"gravlax",
"Vanderkam",
"endregion",
"autofix"
"autofix",
"quickfix"
]
}
2 changes: 2 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": ["src/index.ts!"],
// See https://github.com/webpro/knip/issues/450
"exclude": ["classMembers"],
"ignoreExportsUsedInFile": true,
"project": ["src/**/*.ts!"]
}
108 changes: 108 additions & 0 deletions src/interpreter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";

import { Interpreter, stringify } from "./interpreter.js";
import { parse } from "./parser.js";
import { Scanner } from "./scanner.js";

function parseText(text: string) {
return parse(new Scanner(text).scanTokens());
}

function evaluate(text: string) {
const expr = parseText(text);
return expr && new Interpreter().evaluate(expr);
}

describe("interpreter", () => {
it("should evaluate an arithmetic expression", () => {
expect(evaluate("1 + 2 * 3")).toEqual(7);
expect(evaluate("3 - 1 / 2")).toEqual(2.5);
});

it("should evaluate comparison operators", () => {
expect(evaluate("12 > 6")).toBe(true);
expect(evaluate("12 > 12")).toBe(false);
expect(evaluate("12 == 12")).toBe(true);
expect(evaluate("12 != 12")).toBe(false);
expect(evaluate("0 == nil")).toBe(false);
expect(evaluate("nil == nil")).toBe(true);
expect(evaluate("2 >= 2")).toBe(true);
expect(evaluate("2 >= 3")).toBe(false);
expect(evaluate("2 <= 3")).toBe(true);
expect(evaluate("2 < 3")).toBe(true);
});

it("should evaluate unary operators", () => {
expect(evaluate("-12")).toBe(-12);
expect(evaluate("-(1 + 2)")).toBe(-3);
expect(evaluate("!nil")).toBe(true);
expect(evaluate("!!nil")).toBe(false);
});

it("should concatenate strings", () => {
expect(evaluate(`"hello" + " " + "world"`)).toEqual("hello world");
});

it("should evaluate truthiness", () => {
expect(evaluate("!true")).toEqual(false);
expect(evaluate("!12")).toEqual(false);
expect(evaluate("!!nil")).toEqual(false);
expect(evaluate("!!0")).toEqual(true);
expect(evaluate(`!!""`)).toEqual(true);
});

it("should report an error on mixed + operands", () => {
expect(() => evaluate(`"12" + 13`)).toThrowError(
"Operands must be two numbers or two strings.",
);
});

it("should report an error on non-numeric operands", () => {
expect(() => evaluate(`"12" / 13`)).toThrowError(
"Operand must be a number.",
);
});

it("should interpret and stringify output", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const expr = parseText("1 + 2");
expect(expr).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
new Interpreter().interpret(expr!);
expect(log).toHaveBeenCalledWith("3");
});

it("should interpret and report an error", () => {
const error = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const expr = parseText("1 - nil");
expect(expr).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
new Interpreter().interpret(expr!);
expect(error).toHaveBeenCalledWith("Operand must be a number.\n[line 1]");
});
});

describe("stringify", () => {
it("should stringify a null value", () => {
expect(stringify(null)).toEqual("nil");
});

it("should stringify a numbers", () => {
expect(stringify(123)).toEqual("123");
expect(stringify(-123)).toEqual("-123");
expect(stringify(1.25)).toEqual("1.25");
expect(stringify(-0.125)).toEqual("-0.125");
});

it("should stringify booleans", () => {
expect(stringify(true)).toEqual("true");
expect(stringify(false)).toEqual("false");
});

it("should stringify strings", () => {
expect(stringify("")).toEqual(``);
expect(stringify("hello")).toEqual(`hello`);
});
});
152 changes: 152 additions & 0 deletions src/interpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
Binary,
Expr,
ExpressionVisitor,
Grouping,
Literal,
Unary,
visitExpr,
} from "./ast.js";
import { runtimeError } from "./main.js";
import { Token } from "./token.js";

// XXX using eslint quickfix to implement this interface did not work at all.

export class Interpreter implements ExpressionVisitor<unknown> {
binary(expr: Binary): unknown {
const left = this.evaluate(expr.left);
const right = this.evaluate(expr.right);
const { operator } = expr;
switch (operator.type) {
case "-":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left - right;
case "/":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left / right;
case "*":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left * right;
case "+":
// This looks kinda funny!
if (typeof left === "number" && typeof right === "number") {
return left + right;
} else if (typeof left === "string" && typeof right === "string") {
return left + right;
}
throw new RuntimeError(
operator,
"Operands must be two numbers or two strings.",
);

case ">":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left > right;
case ">=":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left >= right;
case "<":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left < right;
case "<=":
checkNumberOperand(operator, left);
checkNumberOperand(operator, right);
return left <= right;
case "==":
return isEqual(left, right);
case "!=":
return !isEqual(left, right);
}

return null;
}

evaluate(expr: Expr): unknown {
return visitExpr(expr, this);
}

grouping(expr: Grouping): unknown {
return this.evaluate(expr.expr);
}

interpret(expr: Expr): void {
try {
const value = this.evaluate(expr);
console.log(stringify(value));
} catch (e) {
if (e instanceof RuntimeError) {
runtimeError(e);
}
}
}

literal(expr: Literal): unknown {
return expr.value;
}

unary(expr: Unary): unknown {
const right = this.evaluate(expr.right);
switch (expr.operator.type) {
case "-":
checkNumberOperand(expr.operator, right);
return -right;
case "!":
return !isTruthy(right);
}
}
}

export class RuntimeError extends Error {
token: Token;
constructor(token: Token, message: string) {
super(message);
this.token = token;
}
}

function checkNumberOperand(
operator: Token,
operand: unknown,
): asserts operand is number {
if (typeof operand === "number") {
return;
}
throw new RuntimeError(operator, "Operand must be a number.");
}

// TODO: would be nice if this worked!
// function checkNumberOperands(
// operator: Token,
// operands: [unknown, unknown],
// ): asserts operands is [number, number] {
// const [left, right] = operands;
// checkNumberOperand(operator, left);
// checkNumberOperand(operator, right);
// }

export function isTruthy(val: unknown): boolean {
if (val === null) {
return false;
}
if (typeof val === "boolean") {
return val;
}
return true;
}

export function isEqual(a: unknown, b: unknown): boolean {
return a === b;
}

export function stringify(val: unknown): string {
if (val === null) {
return "nil";
}
return String(val);
}
40 changes: 25 additions & 15 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import * as fs from "node:fs/promises";
import { createInterface } from "node:readline";

import { visitExpr } from "./ast.js";
import { astPrinter } from "./ast-printer.js";
import { Interpreter, RuntimeError } from "./interpreter.js";
import { parse } from "./parser.js";
import { Scanner } from "./scanner.js";
import { Token } from "./token.js";
Expand All @@ -11,21 +10,30 @@ export function add(a: number, b: number) {
return a + b;
}

export async function runFile(path: string) {
export async function runFile(interpreter: Interpreter, path: string) {
const contents = await fs.readFile(path, "utf-8");
run(contents);
run(interpreter, contents);
if (hadError) {
// eslint-disable-next-line n/no-process-exit
process.exit(65);
}
if (hadRuntimeError) {
// eslint-disable-next-line n/no-process-exit
process.exit(70);
}
}

export async function runPrompt() {
export async function runPrompt(interpreter: Interpreter) {
process.stdout.write("> ");
for await (const line of createInterface({ input: process.stdin })) {
run(line);
run(interpreter, line);
hadError = false;
process.stdout.write("> ");
}
}

let hadError = false;
let hadRuntimeError = false;

export function error(line: number, message: string) {
report(line, "", message);
Expand All @@ -37,20 +45,24 @@ export function errorOnToken(token: Token, message: string) {
report(token.line, ` at '${token.lexeme}'`, message);
}
}
export function runtimeError(error: RuntimeError) {
console.error(`${error.message}\n[line ${error.token.line}]`);
hadRuntimeError = true;
}

function report(line: number, where: string, message: string) {
console.error(`[line ${line}] Error${where}: ${message}`);
}

function run(contents: string): void {
function run(interpreter: Interpreter, contents: string): void {
const scanner = new Scanner(contents);
const tokens = scanner.scanTokens();
const expr = parse(tokens);
if (hadError || !expr) {
return;
}

console.log(visitExpr(expr, astPrinter));
interpreter.interpret(expr);
}

export async function main() {
Expand All @@ -59,14 +71,12 @@ export async function main() {
console.error("Usage:", args[1], "[script]");
// eslint-disable-next-line n/no-process-exit
process.exit(64);
} else if (args.length == 1) {
await runFile(args[0]);
} else {
await runPrompt();
}

if (hadError) {
// eslint-disable-next-line n/no-process-exit
process.exit(65);
const interpreter = new Interpreter();
if (args.length == 1) {
await runFile(interpreter, args[0]);
} else {
await runPrompt(interpreter);
}
}

0 comments on commit d39a37d

Please sign in to comment.