diff --git a/lark-sandbox/calculator.lark b/lark-sandbox/calculator.lark new file mode 100644 index 0000000..c1375de --- /dev/null +++ b/lark-sandbox/calculator.lark @@ -0,0 +1,22 @@ +expression : term ((PLUS | MINUS) term)* + +term : factor ((MULTIPLY | DIVIDE) factor)* + +factor : (PLUS | MINUS) factor + | power + +power : primary (POWER factor)* + +primary : NUMBER + | "(" expression ")" + +PLUS : "+" +MINUS : "-" +MULTIPLY : "*" +DIVIDE : "/" +POWER : "**" + +%import common.WS_INLINE +%import common.NUMBER + +%ignore WS_INLINE diff --git a/lark-sandbox/calculator_test.py b/lark-sandbox/calculator_test.py new file mode 100644 index 0000000..85b1516 --- /dev/null +++ b/lark-sandbox/calculator_test.py @@ -0,0 +1,12 @@ +import os + +from lark import Lark + +grammar = open(os.path.join(os.path.dirname(__file__), "calculator.lark"), "r").read() +parser = Lark(grammar, start="expression") + + +def test_calculator(): + ast = parser.parse("(1 + 2) * 3 - -4 ** 5") + assert ast + print(ast.pretty()) diff --git a/lark-sandbox/setup.cfg b/lark-sandbox/setup.cfg index 77f7877..e9c84f5 100644 --- a/lark-sandbox/setup.cfg +++ b/lark-sandbox/setup.cfg @@ -19,7 +19,6 @@ addopts = # Print all `print(...)` statements in the console --capture=no # pytest-cov: - --cov=app --cov-report=term:skip-covered --cov-report=html --cov-report=xml diff --git a/src/Calculator/Expression.ts b/src/Calculator/Expression.ts new file mode 100644 index 0000000..1d7c2ec --- /dev/null +++ b/src/Calculator/Expression.ts @@ -0,0 +1,34 @@ +import { BinaryOperator, UnaryOperator } from "./TokenType"; + +export abstract class Expression {} + +export class BinaryOperation extends Expression { + constructor( + public readonly left: Expression, + public readonly operator: BinaryOperator, + public readonly right: Expression + ) { + super(); + } +} + +export class UnaryOperation extends Expression { + constructor( + public readonly operator: UnaryOperator, + public readonly child: Expression + ) { + super(); + } +} + +export class VariableAccess extends Expression { + constructor(public readonly name: string) { + super(); + } +} + +export class NumberLiteral extends Expression { + constructor(public readonly value: number) { + super(); + } +} diff --git a/src/Calculator/Interpreter.test.ts b/src/Calculator/Interpreter.test.ts new file mode 100644 index 0000000..813276f --- /dev/null +++ b/src/Calculator/Interpreter.test.ts @@ -0,0 +1,41 @@ +import { UndefinedVariableError, ZeroDivisionError } from "./errors"; +import { Interpreter, SymbolTable } from "./Interpreter"; +import { Parser } from "./Parser"; +import { Tokenizer } from "./Tokenizer"; + +describe("Interpreter", () => { + it("evaluates", () => { + const tokens = new Tokenizer("1 + 2 * 3").tokenize(); + const expression = new Parser(tokens).parse(); + const interpreter = new Interpreter(expression); + + expect(interpreter.evaluate()).toBe(7); + }); + + it("evaluates with variables", () => { + const tokens = new Tokenizer("1 + foo + bar").tokenize(); + const expression = new Parser(tokens).parse(); + const variables: SymbolTable = { foo: 2, bar: 3 }; + const interpreter = new Interpreter(expression, variables); + + expect(interpreter.evaluate()).toBe(6); + }); + + it("raises an error when variable is not defined", () => { + const tokens = new Tokenizer("1 + foo + bar").tokenize(); + const expression = new Parser(tokens).parse(); + const interpreter = new Interpreter(expression); + + expect(() => interpreter.evaluate()).toThrow(UndefinedVariableError); + expect(() => interpreter.evaluate()).toThrow("Variable foo is not defined"); + }); + + it("raises an error on division by zero", () => { + const tokens = new Tokenizer("1/0").tokenize(); + const expression = new Parser(tokens).parse(); + const interpreter = new Interpreter(expression); + + expect(() => interpreter.evaluate()).toThrow(ZeroDivisionError); + expect(() => interpreter.evaluate()).toThrow("Division by zero"); + }); +}); diff --git a/src/Calculator/Interpreter.ts b/src/Calculator/Interpreter.ts new file mode 100644 index 0000000..86f6db2 --- /dev/null +++ b/src/Calculator/Interpreter.ts @@ -0,0 +1,94 @@ +import { UndefinedVariableError, ZeroDivisionError } from "./errors"; +import { + BinaryOperation, + Expression, + NumberLiteral, + UnaryOperation, + VariableAccess, +} from "./Expression"; + +export type SymbolTable = Record; + +export class Interpreter { + constructor( + private readonly expression: Expression, + private readonly symbolTable: SymbolTable = {} + ) {} + + public evaluate(): number { + return this.visitExpression(this.expression); + } + + private visitExpression(expression: Expression): number { + if (expression instanceof BinaryOperation) { + return this.visitBinaryOperation(expression); + } + + if (expression instanceof UnaryOperation) { + return this.visitUnaryOperation(expression); + } + + if (expression instanceof VariableAccess) { + return this.visitVariableAccess(expression); + } + + if (expression instanceof NumberLiteral) { + return this.visitNumberLiteral(expression); + } + + return 0; + } + + private visitBinaryOperation(expression: BinaryOperation): number { + const left = this.visitExpression(expression.left); + const right = this.visitExpression(expression.right); + + switch (expression.operator) { + case "+": + return left + right; + case "-": + return left + right; + case "*": + return left * right; + case "/": { + if (right === 0) { + throw new ZeroDivisionError(); + } + + return left / right; + } + case "**": + return left ** right; + default: + throw new Error(`Unsupported binary operator ${expression.operator}`); + } + } + + private visitUnaryOperation(expression: UnaryOperation) { + const value = this.visitExpression(expression.child); + + switch (expression.operator) { + case "+": + return value; + case "-": + return value * -1; + default: + throw new Error(`Unsupported unary operator ${expression.operator}`); + } + } + + private visitVariableAccess(expression: VariableAccess): number { + const name = expression.name; + const value = this.symbolTable[name]; + + if (value === undefined) { + throw new UndefinedVariableError(name); + } + + return value; + } + + private visitNumberLiteral(expression: NumberLiteral) { + return expression.value; + } +} diff --git a/src/Calculator/Parser.test.ts b/src/Calculator/Parser.test.ts new file mode 100644 index 0000000..8678365 --- /dev/null +++ b/src/Calculator/Parser.test.ts @@ -0,0 +1,54 @@ +import { IllegalTokenError } from "./errors"; +import { + BinaryOperation, + NumberLiteral, + UnaryOperation, + VariableAccess, +} from "./Expression"; +import { Parser } from "./Parser"; +import { Tokenizer } from "./Tokenizer"; + +describe("Parser", () => { + it("generates a valid AST", () => { + const tokens = new Tokenizer("(1.5 + 2) * 3 - -foo ** 5").tokenize(); + const expression = new Parser(tokens).parse(); + + expect(expression).toEqual( + new BinaryOperation( + new BinaryOperation( + new BinaryOperation( + new NumberLiteral(1.5), + "+", + new NumberLiteral(2) + ), + "*", + new NumberLiteral(3) + ), + "-", + new UnaryOperation( + "-", + new BinaryOperation( + new VariableAccess("foo"), + "**", + new NumberLiteral(5) + ) + ) + ) + ); + }); + + it.each` + input | error + ${"(1 + 2 *"} | ${"Unexpected 'eof' at position 8"} + ${"** **"} | ${"Unexpected '**' at position 0"} + ${"1 2"} | ${"Expected 'eof' but got 'number' at position 2"} + `( + "throws error when the given input has invalid syntax", + ({ input, error }) => { + const tokens = new Tokenizer(input).tokenize(); + + expect(() => new Parser(tokens).parse()).toThrow(IllegalTokenError); + expect(() => new Parser(tokens).parse()).toThrow(error); + } + ); +}); diff --git a/src/Calculator/Parser.ts b/src/Calculator/Parser.ts new file mode 100644 index 0000000..9733037 --- /dev/null +++ b/src/Calculator/Parser.ts @@ -0,0 +1,126 @@ +import { IllegalTokenError } from "./errors"; +import { + Expression, + BinaryOperation, + NumberLiteral, + UnaryOperation, + VariableAccess, +} from "./Expression"; +import { Token } from "./Token"; +import { TokenType } from "./TokenType"; + +export class Parser { + private position = 0; + + constructor(private tokens: Token[]) {} + + parse(): Expression { + const expression = this.expression(); + this.consume("eof"); + + return expression; + } + + // term ((PLUS | MINUS) term)* + private expression(): Expression { + let left = this.term(); + + while (this.currentToken.type === "+" || this.currentToken.type === "-") { + const operator = this.currentToken.type; + this.consume(operator); + + left = new BinaryOperation(left, operator, this.term()); + } + + return left; + } + + // factor ((MULTIPLY | DIVIDE) factor)* + private term(): Expression { + let left = this.factor(); + + while (this.currentToken.type === "*" || this.currentToken.type === "/") { + const operator = this.currentToken.type; + this.consume(operator); + + left = new BinaryOperation(left, operator, this.factor()); + } + + return left; + } + + // : (PLUS | MINUS) factor + // | power + private factor(): Expression { + if (this.currentToken.type === "+" || this.currentToken.type === "-") { + const operator = this.currentToken.type; + this.consume(operator); + + return new UnaryOperation(operator, this.factor()); + } + + return this.power(); + } + + // primary (POWER factor)* + private power(): Expression { + let left = this.primary(); + + while (this.currentToken.type === "**") { + this.consume("**"); + left = new BinaryOperation(left, "**", this.factor()); + } + + return left; + } + + // : NUMBER + // | IDENTIFIER + // | group + private primary(): Expression { + if (this.currentToken.type === "number") { + const value = this.currentToken.value as number; + this.consume("number"); + + return new NumberLiteral(value); + } + + if (this.currentToken.type === "identifier") { + const name = this.currentToken.value as string; + this.consume("identifier"); + + return new VariableAccess(name); + } + + if (this.currentToken.type === "(") { + return this.group(); + } + + throw new IllegalTokenError(this.currentToken); + } + + // "(" expression ")" + private group(): Expression { + this.consume("("); + const expression = this.expression(); + this.consume(")"); + + return expression; + } + + private get currentToken(): Token { + return this.tokens[this.position]; + } + + private consume(tokenType: TokenType): void { + if (this.currentToken.type !== tokenType) { + throw new IllegalTokenError(this.currentToken, tokenType); + } + + this.advance(); + } + + private advance() { + this.position += 1; + } +} diff --git a/src/Calculator/Token.ts b/src/Calculator/Token.ts new file mode 100644 index 0000000..c257b6a --- /dev/null +++ b/src/Calculator/Token.ts @@ -0,0 +1,9 @@ +import { TokenType } from "./TokenType"; + +export class Token { + constructor( + public type: TokenType, + public position: number, + public value?: string | number + ) {} +} diff --git a/src/Calculator/TokenType.ts b/src/Calculator/TokenType.ts new file mode 100644 index 0000000..1cdef71 --- /dev/null +++ b/src/Calculator/TokenType.ts @@ -0,0 +1,14 @@ +export type BinaryOperator = "+" | "-" | "*" | "/" | "**"; + +export type UnaryOperator = "+" | "-"; + +export type Delimiter = "(" | ")"; + +export type Literal = "number" | "identifier"; + +export type TokenType = + | BinaryOperator + | UnaryOperator + | Delimiter + | Literal + | "eof"; diff --git a/src/Calculator/Tokenizer.test.ts b/src/Calculator/Tokenizer.test.ts new file mode 100644 index 0000000..6fbc55e --- /dev/null +++ b/src/Calculator/Tokenizer.test.ts @@ -0,0 +1,27 @@ +import { Token } from "./Token"; +import { Tokenizer } from "./Tokenizer"; + +describe("Tokenizer", () => { + it("tokenizes the expression", () => { + const tokens = new Tokenizer("(1 + 12) / fooBar ** 12.34").tokenize(); + + expect(tokens).toEqual([ + new Token("(", 0), + new Token("number", 1, 1), + new Token("+", 3), + new Token("number", 5, 12), + new Token(")", 7), + new Token("/", 9), + new Token("identifier", 11, "fooBar"), + new Token("**", 18), + new Token("number", 21, 12.34), + new Token("eof", 26), + ]); + }); + + it("raises error on unrecognized character", () => { + expect(() => new Tokenizer("123 % 123").tokenize()).toThrow( + "Unrecognized character '%' at 4" + ); + }); +}); diff --git a/src/Calculator/Tokenizer.ts b/src/Calculator/Tokenizer.ts new file mode 100644 index 0000000..4b14ea6 --- /dev/null +++ b/src/Calculator/Tokenizer.ts @@ -0,0 +1,107 @@ +import { IllegalCharacterError, SyntaxError } from "./errors"; +import { Token } from "./Token"; +import { TokenType } from "./TokenType"; + +type Pattern = string | RegExp; +type Rule = (match: string) => undefined | Token; + +export class Tokenizer { + private position = -1; + + private rules: [Pattern, Rule][] = []; + + constructor(private expression: string) { + this.rule(/^\s+/, () => this.skip()); + + // Operators + + this.rule("+", () => this.accept("+")); + this.rule("-", () => this.accept("-")); + this.rule("**", () => this.accept("**")); + this.rule("*", () => this.accept("*")); + this.rule("/", () => this.accept("/")); + + // Delimiters + + this.rule("(", () => this.accept("(")); + this.rule(")", () => this.accept(")")); + + // Literals + + this.rule(/^\d+(\.\d+)?/, (match) => + this.accept("number", parseFloat(match)) + ); + + this.rule(/^\w+/, (match) => this.accept("identifier", match)); + } + + tokenize(): Token[] { + this.position = 0; + + const tokens: Token[] = []; + + while (!this.isEnd()) { + const rest = this.expression.slice(this.position); + + let noMatchFound = true; + + for (const [pattern, rule] of this.rules) { + const matchedText = this.test(pattern, rest); + + if (matchedText) { + noMatchFound = false; + + const token = rule && rule(matchedText); + if (token) { + tokens.push(token); + } + + this.position += matchedText.length; + break; + } + } + + if (noMatchFound) { + throw new IllegalCharacterError( + this.expression[this.position], + this.position + ); + } + } + + tokens.push(new Token("eof", this.position)); + + return tokens; + } + + private test(pattern: Pattern, text: string): string | undefined { + if (typeof pattern === "string") { + if (text.startsWith(pattern)) { + return pattern; + } + } else { + const match = text.match(pattern); + if (match) { + return match[0]; + } + } + + return undefined; + } + + private isEnd(): boolean { + return this.position >= this.expression.length; + } + + private rule(pattern: Pattern, rule: Rule): void { + this.rules.push([pattern, rule]); + } + + private accept(type: TokenType, value?: string | number): Token { + return new Token(type, this.position, value); + } + + private skip() { + return undefined; + } +} diff --git a/src/Calculator/errors.ts b/src/Calculator/errors.ts new file mode 100644 index 0000000..66e0e4c --- /dev/null +++ b/src/Calculator/errors.ts @@ -0,0 +1,39 @@ +import { Token } from "./Token"; +import { TokenType } from "./TokenType"; + +export abstract class SyntaxError extends Error { + protected constructor(message: string, public position: number) { + super(message); + } +} + +export class IllegalCharacterError extends SyntaxError { + constructor(character: string, position: number) { + super(`Unrecognized character '${character}' at ${position}`, position); + } +} + +export class IllegalTokenError extends SyntaxError { + constructor(currentToken: Token, expectedType?: TokenType) { + super( + expectedType + ? `Expected '${expectedType}' but got '${currentToken.type}' at position ${currentToken.position}` + : `Unexpected '${currentToken.type}' at position ${currentToken.position}`, + currentToken.position + ); + } +} + +class RuntimeError extends Error {} + +export class UndefinedVariableError extends RuntimeError { + constructor(name: string) { + super(`Variable ${name} is not defined`); + } +} + +export class ZeroDivisionError extends RuntimeError { + constructor() { + super("Division by zero"); + } +} diff --git a/src/Calculator/repl.ts b/src/Calculator/repl.ts new file mode 100644 index 0000000..bb342da --- /dev/null +++ b/src/Calculator/repl.ts @@ -0,0 +1,42 @@ +import * as readline from "readline"; + +import { SyntaxError } from "./errors"; +import { SymbolTable, Interpreter } from "./Interpreter"; +import { Parser } from "./Parser"; +import { Tokenizer } from "./Tokenizer"; + +const PROMPT = "> "; + +const scanner = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: PROMPT, +}); + +const symbolTable: SymbolTable = { + one: 1, + two: 2, + three: 3, +}; + +console.log("Predefined variables:", symbolTable); + +scanner.prompt(); + +scanner.on("line", (line) => { + const input = line.trim(); + + try { + const lexer = new Tokenizer(input).tokenize(); + const ast = new Parser(lexer).parse(); + const result = new Interpreter(ast, symbolTable).evaluate(); + console.log(result); + } catch (error) { + if (error instanceof SyntaxError) { + console.error("^".padStart(PROMPT.length + error.position + 1, " ")); + } + console.error(error.message); + } + + scanner.prompt(); +});