diff --git a/2kyu/evaluate-mathematical-expression/solution.ts b/2kyu/evaluate-mathematical-expression/solution.ts index 05a06d8..619a3f2 100644 --- a/2kyu/evaluate-mathematical-expression/solution.ts +++ b/2kyu/evaluate-mathematical-expression/solution.ts @@ -1,15 +1,12 @@ -type TokenType = 'number' | 'operator' | 'parenthesis'; -type TokenValue = number | '+' | '-' | '*' | '/' | '(' | ')'; +type TokenType = 'number' | 'operator' | 'parenthesis' | 'decimal'; +type TokenValue = number | '+' | '-' | '*' | '/' | '(' | ')' | '.'; -type Token = { - type: TokenType, - value: TokenValue, -} +type Token = NumberToken | OperatorToken | ParenthesisToken | DecimalToken; type OperatorChars = '+' | '-' | '*' | '/'; -type OperatorToken = Token & { +type OperatorToken = { type: 'operator', value: OperatorChars, } @@ -18,13 +15,13 @@ const isOperatorChar = (char: string): char is OperatorChars => { return char === '+' || char === '-' || char === '*' || char === '/'; } -type NumberToken = Token & { +type NumberToken = { type: 'number', - value: number, + value: string, } type ParenthesisChars = '(' | ')'; -type ParenthesisToken = Token & { +type ParenthesisToken = { type: 'parenthesis', value: ParenthesisChars, } @@ -33,7 +30,17 @@ const isParenthesisChar = (char: string): char is ParenthesisChars => { return char === '(' || char === ')'; } -const tokenize = (expression: string): Token[] => { +type DecimalChars = '.'; +type DecimalToken = { + type: 'decimal', + value: DecimalChars, +} + +const isDecimalChar = (char: string): char is DecimalChars => { + return char === '.'; +} + +export const tokenize = (expression: string): Token[] => { const tokens: Token[] = []; const chars = expression.split(''); let done = false; @@ -64,14 +71,21 @@ const tokenize = (expression: string): Token[] => { // number if (char.match(/\d/)) { let number = ''; - while (chars[i].match(/\d/)) { + while (chars[i] && chars[i].match(/\d/)) { number += chars[i]; i++; } - tokens.push({ type: 'number', value: parseInt(number) }); - } else { - throw new Error('Invalid character'); + tokens.push({ type: 'number', value: number }); + continue; } + // decimal + if (isDecimalChar(char)) { + tokens.push({ type: 'decimal', value: char }); + i++; + continue; + } + + throw new Error('Invalid character'); } return tokens; } @@ -85,8 +99,8 @@ type ASTNode = NumberNode | OperatorNode | GroupNode | NegationNode; class NumberNode extends ASTNodeCommon { private _value: number; - type = 'number'; - constructor(public numValue: number) { + type = 'numbernode'; + constructor(numValue: number) { super(); this._value = numValue; } @@ -94,17 +108,25 @@ class NumberNode extends ASTNodeCommon { get value() { return this._value; } + + static fromToken(token: NumberToken): NumberNode { + return new NumberNode(parseFloat(token.value)); + } + + static fromTokens(tokens: [NumberToken, DecimalToken, NumberToken]): NumberNode { + return new NumberNode(parseFloat(tokens[0].value + '.' + tokens[2].value)); + } } class OperatorNode extends ASTNodeCommon { - type = 'operator'; + type = 'operatornode'; constructor(public operator: OperatorChars, public left: ASTNode, public right: ASTNode) { super(); } - get value() { - const left = this.left.value; - const right = this.right.value; + get value(): number { + const left = this.left.value as number; + const right = this.right.value as number; switch (this.operator) { case '+': return left + right; @@ -121,21 +143,210 @@ class OperatorNode extends ASTNodeCommon { } class GroupNode extends ASTNodeCommon { - type = 'group'; - - constructor(public value: ASTNode) { + type = 'groupnode'; + + constructor(public child: ASTNode) { super(); } + get value(): number { + return this.child.value as number; + } + static fromTokens(tokens: (Token | ASTNode)[]): GroupNode { + const node = parse(tokens); + return new GroupNode(node); + } +} +class NegationNode extends ASTNodeCommon { + type = 'negationnode'; + + constructor(public child: NumberNode | GroupNode) { + super(); + } + + get value(): number { + return -(this.child.value as number); + } } -type NegationNode = { - type: 'negation', - value: ASTNode, +/** + * Transforms number tokens into NumberNodes + */ +function parseNumbers(tokens: (Token | ASTNode)[]): (Token | ASTNode)[] { + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + + if (token.type !== 'number') { + i++; + continue; + }; + + // check if it is a decimal + const nextToken = tokens[i + 1]; + const nextNextToken = tokens[i + 2]; + const nextTokenType = nextToken?.type; + const nextNextTokenType = nextNextToken?.type; + + if (nextTokenType === 'decimal' && nextNextTokenType === 'number') { + // it is a decimal number + const numberNode = NumberNode.fromTokens([ + token as NumberToken, + nextToken as DecimalToken, + nextNextToken as NumberToken + ]); + tokens.splice(i, 3, numberNode); + i += 3; + continue; + } else { + // it is a regular number + const numberNode = NumberNode.fromToken(token as NumberToken); + tokens.splice(i, 1, numberNode); + } + i++; + } + + return tokens; +} + +/** + * Matches highest level parenthesis and replaces them with GroupNodes. + * Only the highest level parenthesis are required because GroupNode will + * recursively parse the rest of the expression. + */ +function parseGroups(tokens: (Token | ASTNode)[]): (Token | ASTNode)[] { + // match highest level parenthesis + let i = 0; + const parenthesisPairs: [number, number][] = []; + const startParenthesis: number[] = []; + while (i < tokens.length) { + const token = tokens[i]; + + if (token.type !== 'parenthesis') { + i++; + continue; + }; + + // it is a parenthesis + if (token.value === '(') { + startParenthesis.push(i); + } else { + const start = startParenthesis.pop(); + if (startParenthesis.length === 0) { + if (start === undefined) { + throw new Error('Invalid parenthesis'); + } + parenthesisPairs.push([start, i]); + } + } + i++; + } + + // slice out parenthesis pairs and replace with GroupNode + for (const [start, end] of parenthesisPairs) { + const groupNode = GroupNode.fromTokens(tokens.slice(start + 1, end)); + tokens.splice(start, end - start + 1, groupNode); + } + + return tokens; +} + +function parseNegation(tokens: (Token | ASTNode)[]): (Token | ASTNode)[] { + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + + if (token.type !== 'operator') { + i++; + continue; + }; + + if (token.value === '-') { + const prevToken = tokens[i - 1] as Token; + if (!prevToken || isOperatorChar(prevToken.value)) { + // it is a negation + const child = tokens[i + 1] as NumberNode | GroupNode; + const negationNode = new NegationNode(child); + tokens.splice(i, 2, negationNode); + } + } + i++; + } + + return tokens; +} + +function parseMultDiv(tokens: (Token | ASTNode)[]): (Token | ASTNode)[] { + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + + if (token.type !== 'operator') { + i++; + continue; + }; + + if (token.value === '*' || token.value === '/') { + const left = tokens[i - 1] as ASTNode; + const right = tokens[i + 1] as ASTNode; + const operator = token.value as OperatorChars; + const operatorNode = new OperatorNode(operator, left, right); + tokens.splice(i - 1, 3, operatorNode); + } + i++; + } + + return tokens; +} + +function parseAddSub(tokens: (Token | ASTNode)[]): (Token | ASTNode)[] { + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + + if (token.type !== 'operator') { + i++; + continue; + }; + + if (token.value === '+' || token.value === '-') { + const left = tokens[i - 1] as ASTNode; + const right = tokens[i + 1] as ASTNode; + const operator = token.value as OperatorChars; + const operatorNode = new OperatorNode(operator, left, right); + tokens.splice(i - 1, 3, operatorNode); + } + i++; + } + + return tokens; +} + +export function parse(tokens: (Token | ASTNode)[]): ASTNode { + // array that we will save intermediate results in + const transform = [...tokens]; + + // first pass: parse groups (parenthesis) + parseGroups(transform); + + // second pass: parse numbers and decimals + parseNumbers(transform); + + // third pass: negation + parseNegation(transform); + + // fourth pass: multiplication and division + parseMultDiv(transform); + + // fifth pass: addition and subtraction + parseAddSub(transform); + + console.log(JSON.stringify(transform[0], null, 2)); + return transform[0] as ASTNode; } export function calc(expression: string): number { // evaluate `expression` and return result - return 0; + return parse(tokenize(expression)).value; } diff --git a/2kyu/evaluate-mathematical-expression/tests.ts b/2kyu/evaluate-mathematical-expression/tests.ts index 699560d..bc7d664 100644 --- a/2kyu/evaluate-mathematical-expression/tests.ts +++ b/2kyu/evaluate-mathematical-expression/tests.ts @@ -1,4 +1,4 @@ -import { calc } from './solution'; +import { calc, tokenize } from './solution'; import { expect } from "chai"; var tests: [string, number][] = [ @@ -13,12 +13,102 @@ var tests: [string, number][] = [ ['2 / (2 + 3) * 4.33 - -6', 7.732], ]; -describe("calc", function() { +describe("calc", function () { it("should evaluate correctly", () => { - tests.forEach(function(m) { + tests.forEach(function (m) { var x = calc(m[0]); var y = m[1]; expect(x).to.equal(y, 'Expected: "' + m[0] + '" to be ' + y + ' but got ' + x); }); }); }); + +describe("tokenize", function () { + it("should tokenize correctly", () => { + expect(tokenize('1 + 1')).to.deep.equal([{ + type: 'number', + value: '1' + }, { + type: 'operator', + value: '+' + }, { + type: 'number', + value: '1' + }]); + expect(tokenize('1 - 1')).to.deep.equal([{ + type: 'number', + value: '1' + }, { + type: 'operator', + value: '-' + }, { + type: 'number', + value: '1' + }]); + expect(tokenize('1 * 1')).to.deep.equal([{ + type: 'number', + value: '1' + }, { + type: 'operator', + value: '*' + }, { + type: 'number', + value: '1' + }]); + expect(tokenize('1 / 1')).to.deep.equal([{ + type: 'number', + value: '1' + }, { + type: 'operator', + value: '/' + }, { + type: 'number', + value: '1' + }]); + expect(tokenize('-3.1415')).to.deep.equal([{ + type: 'operator', + value: '-' + }, { + type: 'number', + value: '3' + }, { + type: 'decimal', + value: '.' + }, { + type: 'number', + value: '1415' + }]); + expect(tokenize('3.1415')).to.deep.equal([{ + type: 'number', + value: '3' + }, { + type: 'decimal', + value: '.' + }, { + type: 'number', + value: '1415' + }]); + expect(tokenize('(3 + 5) / 3')).to.deep.equal([{ + type: 'parenthesis', + value: '(' + }, { + type: 'number', + value: '3' + }, { + type: 'operator', + value: '+' + }, { + type: 'number', + value: '5' + }, { + type: 'parenthesis', + value: ')' + }, { + type: 'operator', + value: '/' + }, { + type: 'number', + value: '3' + }]); + }); +}); \ No newline at end of file