diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2207cb..9d96d3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: - name: Lint run: bunx biome check . + + - name: Test + run: bun test - name: Build run: bun run build \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index e4b1180..ca61e7b 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1,3 @@ -bun run build \ No newline at end of file +[ -n "$CI" ] && exit 0 +bun run build +bun test \ No newline at end of file diff --git a/package.json b/package.json index 94a1b86..088063a 100644 --- a/package.json +++ b/package.json @@ -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}": [ diff --git a/tests/interpreter.test.ts b/tests/interpreter.test.ts new file mode 100644 index 0000000..12e46ca --- /dev/null +++ b/tests/interpreter.test.ts @@ -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; + +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"]); + }); +}); diff --git a/tests/lexer.test.ts b/tests/lexer.test.ts new file mode 100644 index 0000000..ab23991 --- /dev/null +++ b/tests/lexer.test.ts @@ -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 }); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..6e6c6c3 --- /dev/null +++ b/tests/parser.test.ts @@ -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"); + }); +});