Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement Chapter 7 #12

Merged
merged 11 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
Loading