Skip to content
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: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:

- name: Lint
run: bunx biome check .

- name: Test
run: bun test

- name: Build
run: bun run build
4 changes: 3 additions & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
bun run build
[ -n "$CI" ] && exit 0
bun run build
bun test
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"release": "bun run build && changeset publish",
"version-packages": "changeset version",
"build": "tsup",
"check": "biome check --write ."
"check": "biome check --write .",
"test": "bun test"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,json,css,md}": [
Expand Down
97 changes: 97 additions & 0 deletions tests/interpreter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, spyOn, test } from "bun:test";
import { Interpreter } from "../src/interpreter";
import { Lexer } from "../src/lexer";
import { Parser } from "../src/parser";

let output: string[];
let logSpy: ReturnType<typeof spyOn>;

function run(source: string): string[] {
output = [];
logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => {
output.push(args.map(String).join(" "));
});

const tokens = new Lexer(source).tokenize();
const ast = new Parser(tokens).parse();
new Interpreter().run(ast);

logSpy.mockRestore();
return output;
}

describe("Interpreter", () => {
test("declares and prints a variable", () => {
const result = run("new x = 42;\nprint(x);");
expect(result).toEqual(["42"]);
});

test("prints a number literal", () => {
const result = run("print(10);");
expect(result).toEqual(["10"]);
});

test("evaluates addition", () => {
const result = run("new a = 10;\nnew b = 20;\nprint(a + b);");
expect(result).toEqual(["30"]);
});

test("evaluates subtraction", () => {
const result = run("new a = 50;\nnew b = 8;\nprint(a - b);");
expect(result).toEqual(["42"]);
});

test("evaluates chained operations", () => {
const result = run("print(10 + 20 - 5);");
expect(result).toEqual(["25"]);
});

test("reassigns variables", () => {
const result = run("new x = 1;\nx = 99;\nprint(x);");
expect(result).toEqual(["99"]);
});

test("reassigns with expression", () => {
const result = run("new x = 10;\nx = x + 5;\nprint(x);");
expect(result).toEqual(["15"]);
});

test("handles multiple prints", () => {
const result = run(
"new a = 1;\nnew b = 2;\nnew c = 3;\nprint(a);\nprint(b);\nprint(c);",
);
expect(result).toEqual(["1", "2", "3"]);
});

test("throws on duplicate declaration", () => {
expect(() => run("new x = 1;\nnew x = 2;")).toThrow(
"Variable 'x' is already declared",
);
});

test("throws on undeclared variable in print", () => {
expect(() => run("print(x);")).toThrow("Variable 'x' is not declared");
});

test("throws on assignment to undeclared variable", () => {
expect(() => run("x = 10;")).toThrow("Variable 'x' is not declared");
});

test("throws on undeclared variable in expression", () => {
expect(() => run("new x = 1;\nprint(x + y);")).toThrow(
"Variable 'y' is not declared",
);
});

test("full program", () => {
const result = run(`
new a = 10;
new b = 20;
new c = a + b;
print(c);
a = 5;
print(a - b);
`);
expect(result).toEqual(["30", "-15"]);
});
});
113 changes: 113 additions & 0 deletions tests/lexer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test";
import { Lexer, TokenType } from "../src/lexer";

describe("Lexer", () => {
test("tokenizes number", () => {
const tokens = new Lexer("51").tokenize();
expect(tokens[0]).toMatchObject({ type: TokenType.Number, value: "51" });
expect(tokens[1]).toMatchObject({ type: TokenType.EOF });
});

test("tokenizes identifier", () => {
const tokens = new Lexer("foo").tokenize();
expect(tokens[0]).toMatchObject({
type: TokenType.Identifier,
value: "foo",
});
});

test("tokenizes keywords", () => {
const tokens = new Lexer("new print").tokenize();
expect(tokens[0]).toMatchObject({ type: TokenType.New });
expect(tokens[1]).toMatchObject({ type: TokenType.Print });
});

test("tokenizes operators and delimiters", () => {
const tokens = new Lexer("= + - ; ( )").tokenize();
expect(tokens.map((t) => t.type)).toEqual([
TokenType.Equals,
TokenType.Plus,
TokenType.Minus,
TokenType.Semicolon,
TokenType.LParen,
TokenType.RParen,
TokenType.EOF,
]);
});

test("tokenizes variable declaration", () => {
const tokens = new Lexer("new x = 10;").tokenize();
expect(tokens.map((t) => t.type)).toEqual([
TokenType.New,
TokenType.Identifier,
TokenType.Equals,
TokenType.Number,
TokenType.Semicolon,
TokenType.EOF,
]);
});

test("skips single-line comments", () => {
const tokens = new Lexer("// this is a comment\n42").tokenize();
expect(tokens[0]).toMatchObject({ type: TokenType.Number, value: "42" });
});

test("skips inline comments", () => {
const tokens = new Lexer("new x = 10; // my var").tokenize();
expect(tokens).toHaveLength(6); // new, x, =, 10, ;, EOF
});

test("tracks line numbers", () => {
const tokens = new Lexer("new x = 10;\nnew y = 20;").tokenize();
const y = tokens.find((t) => t.value === "y");
expect(y?.line).toBe(2);
});

test("tracks column numbers", () => {
const tokens = new Lexer("new x = 10;").tokenize();
expect(tokens[0]?.column).toBe(1); // new
expect(tokens[1]?.column).toBe(5); // x
expect(tokens[2]?.column).toBe(7); // =
expect(tokens[3]?.column).toBe(9); // 10
});

test("handles identifiers with underscores", () => {
const tokens = new Lexer("my_var _private").tokenize();
expect(tokens[0]).toMatchObject({
type: TokenType.Identifier,
value: "my_var",
});
expect(tokens[1]).toMatchObject({
type: TokenType.Identifier,
value: "_private",
});
});

test("handles identifiers with numbers", () => {
const tokens = new Lexer("x1 var2name").tokenize();
expect(tokens[0]).toMatchObject({
type: TokenType.Identifier,
value: "x1",
});
expect(tokens[1]).toMatchObject({
type: TokenType.Identifier,
value: "var2name",
});
});

test("throws on unexpected character", () => {
expect(() => new Lexer("@").tokenize()).toThrow("Unexpected character '@'");
});

test("handles empty input", () => {
const tokens = new Lexer("").tokenize();
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({ type: TokenType.EOF });
});

test("handles whitespace only", () => {
const tokens = new Lexer(" \t\n \n").tokenize();
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({ type: TokenType.EOF });
});
});
133 changes: 133 additions & 0 deletions tests/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, test } from "bun:test";
import { Lexer } from "../src/lexer";
import type { Program } from "../src/parser";
import { Parser } from "../src/parser";

function parse(source: string): Program {
const tokens = new Lexer(source).tokenize();
return new Parser(tokens).parse();
}

describe("Parser", () => {
test("parses variable declaration", () => {
const ast = parse("new x = 10;");
expect(ast.body).toHaveLength(1);
expect(ast.body[0]).toMatchObject({
type: "VariableDeclaration",
name: "x",
value: { type: "NumberLiteral", value: 10 },
});
});

test("parses assignment", () => {
const ast = parse("new x = 1;\nx = 5;");
expect(ast.body[1]).toMatchObject({
type: "Assignment",
name: "x",
value: { type: "NumberLiteral", value: 5 },
});
});

test("parses print statement", () => {
const ast = parse("print(42);");
expect(ast.body[0]).toMatchObject({
type: "PrintStatement",
value: { type: "NumberLiteral", value: 42 },
});
});

test("parses binary expression with addition", () => {
const ast = parse("new x = 1 + 2;");
expect(ast.body[0]).toMatchObject({
type: "VariableDeclaration",
name: "x",
value: {
type: "BinaryExpression",
operator: "+",
left: { type: "NumberLiteral", value: 1 },
right: { type: "NumberLiteral", value: 2 },
},
});
});

test("parses binary expression with subtraction", () => {
const ast = parse("new x = 10 - 3;");
expect(ast.body[0]).toMatchObject({
type: "VariableDeclaration",
name: "x",
value: {
type: "BinaryExpression",
operator: "-",
left: { type: "NumberLiteral", value: 10 },
right: { type: "NumberLiteral", value: 3 },
},
});
});

test("parses chained binary expressions left-to-right", () => {
const ast = parse("new x = 1 + 2 - 3;");
// Should be ((1 + 2) - 3)
expect(ast.body[0]).toMatchObject({
type: "VariableDeclaration",
name: "x",
value: {
type: "BinaryExpression",
operator: "-",
left: {
type: "BinaryExpression",
operator: "+",
left: { type: "NumberLiteral", value: 1 },
right: { type: "NumberLiteral", value: 2 },
},
right: { type: "NumberLiteral", value: 3 },
},
});
});

test("parses identifier in expression", () => {
const ast = parse("new x = 1;\nnew y = x + 2;");
expect(ast.body[1]).toMatchObject({
type: "VariableDeclaration",
name: "y",
value: {
type: "BinaryExpression",
operator: "+",
left: { type: "Identifier", name: "x" },
right: { type: "NumberLiteral", value: 2 },
},
});
});

test("parses print with expression", () => {
const ast = parse("print(1 + 2);");
expect(ast.body[0]).toMatchObject({
type: "PrintStatement",
value: {
type: "BinaryExpression",
operator: "+",
left: { type: "NumberLiteral", value: 1 },
right: { type: "NumberLiteral", value: 2 },
},
});
});

test("parses multiple statements", () => {
const ast = parse("new a = 1;\nnew b = 2;\nprint(a + b);");
expect(ast.body).toHaveLength(3);
expect(ast.body[0]?.type).toBe("VariableDeclaration");
expect(ast.body[1]?.type).toBe("VariableDeclaration");
expect(ast.body[2]?.type).toBe("PrintStatement");
});

test("throws on missing semicolon", () => {
expect(() => parse("new x = 10")).toThrow("Expected Semicolon");
});

test("throws on missing equals in declaration", () => {
expect(() => parse("new x 10;")).toThrow("Expected Equals");
});

test("throws on unexpected token", () => {
expect(() => parse("= 10;")).toThrow("Unexpected token");
});
});
Loading