├── .gitignore ├── favicon.ico ├── thumbnail.png ├── README.md ├── .github └── workflows │ └── main.yml ├── package.json ├── environment.test.js ├── LICENSE ├── simple.test.js ├── evaluator.test.js ├── index.js ├── simple.js ├── index.html └── evaluator.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .cache/ 3 | node_modules/ 4 | .vscode/ 5 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chidiwilliams/expression-evaluator/HEAD/favicon.ico -------------------------------------------------------------------------------- /thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chidiwilliams/expression-evaluator/HEAD/thumbnail.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # expression-evaluator 2 | 3 | Evaluates Math expressions. 4 | 5 | Supports: 6 | 7 | - Arithmetic operators: `+`, `-`, `*`, `/` 8 | - Brackets 9 | - Comparison operators: `<`, `>`, `==` 10 | - Variables 11 | - Custom functions 12 | 13 | Blog post: https://chidiwilliams.com/evaluator/ 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | gh-pages: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: '10.x' 14 | - run: npm install -g yarn 15 | - name: yarn install and build 16 | run: | 17 | yarn install --frozen-lockfile 18 | yarn run build 19 | - uses: crazy-max/ghaction-github-pages@v2 20 | with: 21 | target_branch: gh-pages 22 | build_dir: dist 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expression-evaluator", 3 | "version": "1.0.0", 4 | "main": "evaluator.js", 5 | "homepage": "http://chidiwilliams.github.io/expression-evaluator", 6 | "scripts": { 7 | "test": "node simple.test.js && node evaluator.test.js", 8 | "deploy": "yarn run build && gh-pages -d dist", 9 | "dev": "parcel index.html", 10 | "build": "parcel build index.html --public-url ." 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/chidiwilliams/expression-evaluator.git" 15 | }, 16 | "author": "Chidi Williams", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/chidiwilliams/expression-evaluator/issues" 20 | }, 21 | "devDependencies": { 22 | "gh-pages": "^2.2.0", 23 | "parcel": "^1.12.4", 24 | "parcel-plugin-ogimage": "^1.1.0", 25 | "parcel-plugin-static-files-copy": "^2.3.1" 26 | }, 27 | "dependencies": { 28 | "canvas-confetti": "^1.3.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /environment.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { evaluate } = require('./evaluator'); 3 | 4 | /** 5 | * These tests are in a different file because they update the state 6 | * of the environment and must be run in the specified order. 7 | */ 8 | const testCases = [ 9 | { 10 | input: 'SET(#FR, 45 + 50)', 11 | output: 95, 12 | message: 'setting the value of a variable', 13 | }, 14 | { 15 | input: 'SET(#FR, $FR + 5)', 16 | output: 100, 17 | message: 'updating value of already set variable', 18 | }, 19 | { 20 | input: '$FR * 10', 21 | output: 1000, 22 | message: 'accessing a set variable', 23 | }, 24 | ]; 25 | 26 | testCases.forEach((test) => { 27 | assertEqual(evaluate(test.input), test.output, `evaluator: ${test.message}`); 28 | }); 29 | 30 | function assertEqual(actual, expected, message) { 31 | assert.deepStrictEqual( 32 | actual, 33 | expected, 34 | `${message}: expected: ${expected}, got: ${actual}`, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chidi Williams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /simple.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { tokenize, toRPN, evalRPN, evaluate } = require('./simple'); 3 | 4 | const testCases = [ 5 | { 6 | input: '2 + 2', 7 | tokens: [2, '+', 2], 8 | rpn: [2, 2, '+'], 9 | output: 4, 10 | message: 'simple', 11 | }, 12 | { 13 | input: '0.3 * 0.95', 14 | tokens: [0.3, '*', 0.95], 15 | rpn: [0.3, 0.95, '*'], 16 | output: 0.285, 17 | message: 'with decimals', 18 | }, 19 | { 20 | input: '1 * 3 )', 21 | tokens: [1, '*', 3, ')'], 22 | rpn: [1, 3, '*'], 23 | output: 3, 24 | message: 'with unbalanced parantheses', 25 | }, 26 | { 27 | input: '24 + 5 * (6 - 3 ^ 2 ^ 2)', 28 | tokens: [24, '+', 5, '*', '(', 6, '-', 3, '^', 2, '^', 2, ')'], 29 | rpn: [24, 5, 6, 3, 2, '^', 2, '^', '-', '*', '+'], 30 | output: -351, 31 | message: 'with operators with different precedences', 32 | }, 33 | ]; 34 | 35 | testCases.forEach((test) => { 36 | assertEqual(tokenize(test.input), test.tokens, `tokenize: ${test.message}`); 37 | assertEqual(toRPN(test.tokens), test.rpn, `toRPN: ${test.message}`); 38 | assertEqual(evalRPN(test.rpn), test.output, `evalRPN: ${test.message}`); 39 | assertEqual(evaluate(test.input), test.output, `evaluator: ${test.message}`); 40 | }); 41 | 42 | function assertEqual(actual, expected, message) { 43 | assert.deepStrictEqual( 44 | actual, 45 | expected, 46 | `${message}: expected: ${expected}, got: ${actual}`, 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /evaluator.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { evalRPN, evaluate, toRPN, tokenize } = require('./evaluator'); 3 | require('./simple.test'); 4 | require('./environment.test'); 5 | 6 | const testCases = [ 7 | { 8 | input: 'MAX(50, 10)', 9 | tokens: ['MAX', '(', 50, ',', 10, ')'], 10 | rpn: [50, 10, 'MAX'], 11 | output: 50, 12 | message: 'with MAX function', 13 | }, 14 | { 15 | input: '54 + SQRT(49)', 16 | tokens: [54, '+', 'SQRT', '(', 49, ')'], 17 | rpn: [54, 49, 'SQRT', '+'], 18 | output: 61, 19 | message: 'with SQRT function', 20 | }, 21 | { 22 | input: '54 + SQRT(49) * 8', 23 | tokens: [54, '+', 'SQRT', '(', 49, ')', '*', 8], 24 | rpn: [54, 49, 'SQRT', 8, '*', '+'], 25 | output: 110, 26 | message: 'with function and operators with different precedences', 27 | }, 28 | { 29 | input: 'IF(54 < 3, 6, 9) + 20', 30 | tokens: ['IF', '(', 54, '<', 3, ',', 6, ',', 9, ')', '+', 20], 31 | rpn: [54, 3, '<', 6, 9, 'IF', 20, '+'], 32 | output: 29, 33 | message: 'with function and operators with different precedences', 34 | }, 35 | { 36 | input: '$PI * 78 + $E', 37 | tokens: ['$PI', '*', 78, '+', '$E'], 38 | rpn: ['$PI', 78, '*', '$E', '+'], 39 | output: 247.7625088084629, 40 | message: 'with predefined variable', 41 | }, 42 | ]; 43 | 44 | testCases.forEach((test) => { 45 | assertEqual(tokenize(test.input), test.tokens, `tokenize: ${test.message}`); 46 | assertEqual(toRPN(test.tokens), test.rpn, `toRPN: ${test.message}`); 47 | assertEqual(evalRPN(test.rpn), test.output, `evalRPN: ${test.message}`); 48 | assertEqual(evaluate(test.input), test.output, `evaluator: ${test.message}`); 49 | }); 50 | 51 | function assertEqual(actual, expected, message) { 52 | assert.deepStrictEqual( 53 | actual, 54 | expected, 55 | `${message}: expected: ${expected}, got: ${actual}`, 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import confetti from 'canvas-confetti'; 2 | import * as evaluator from './evaluator'; 3 | 4 | const inputElement = document.querySelector('#input'); 5 | const outputElement = document.querySelector('#output'); 6 | const envElement = document.querySelector('#env'); 7 | 8 | inputElement.addEventListener('input', (event) => { 9 | runEvaluator(event.target.value); 10 | }); 11 | 12 | function runEvaluator(expression) { 13 | try { 14 | const result = evaluator.evaluate(expression); 15 | outputElement.innerText = result ?? ''; 16 | outputElement.classList.remove('error'); 17 | 18 | if (result === Infinity) { 19 | runConfetti(); 20 | } 21 | } catch (error) { 22 | outputElement.innerText = error; 23 | outputElement.classList.add('error'); 24 | } 25 | 26 | const envStr = Object.entries(evaluator.environment) 27 | .map(([k, v]) => `${k}=${v}`) 28 | .join(', '); 29 | 30 | envElement.innerText = envStr; 31 | } 32 | 33 | // https://www.kirilv.com/canvas-confetti/#fireworks 34 | function runConfetti() { 35 | const duration = 5 * 1000; 36 | const animationEnd = Date.now() + duration; 37 | const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; 38 | 39 | function randomInRange(min, max) { 40 | return Math.random() * (max - min) + min; 41 | } 42 | 43 | const interval = setInterval(function () { 44 | const timeLeft = animationEnd - Date.now(); 45 | 46 | if (timeLeft <= 0) { 47 | return clearInterval(interval); 48 | } 49 | 50 | const particleCount = 50 * (timeLeft / duration); 51 | confetti( 52 | Object.assign({}, defaults, { 53 | particleCount, 54 | origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, 55 | }), 56 | ); 57 | confetti( 58 | Object.assign({}, defaults, { 59 | particleCount, 60 | origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, 61 | }), 62 | ); 63 | }, 250); 64 | } 65 | 66 | runEvaluator(inputElement.value); 67 | -------------------------------------------------------------------------------- /simple.js: -------------------------------------------------------------------------------- 1 | function tokenize(input) { 2 | let scanner = 0; 3 | const tokens = []; 4 | 5 | while (scanner < input.length) { 6 | const char = input[scanner]; 7 | 8 | if (/[0-9]/.test(char)) { 9 | let digits = ''; 10 | 11 | while (scanner < input.length && /[0-9\.]/.test(input[scanner])) { 12 | digits += input[scanner++]; 13 | } 14 | 15 | const number = parseFloat(digits); 16 | tokens.push(number); 17 | continue; 18 | } 19 | 20 | if (/[+\-/*()^]/.test(char)) { 21 | tokens.push(char); 22 | scanner++; 23 | continue; 24 | } 25 | 26 | if (char === ' ') { 27 | scanner++; 28 | continue; 29 | } 30 | 31 | throw new Error(`Invalid token ${char} at position ${scanner}`); 32 | } 33 | 34 | return tokens; 35 | } 36 | 37 | function toRPN(tokens) { 38 | const operators = []; 39 | const out = []; 40 | 41 | for (let i = 0; i < tokens.length; i++) { 42 | const token = tokens[i]; 43 | 44 | if (typeof token === 'number') { 45 | out.push(token); 46 | continue; 47 | } 48 | 49 | if (/[+\-/*<>=^]/.test(token)) { 50 | while (shouldUnwindOperatorStack(operators, token)) { 51 | out.push(operators.pop()); 52 | } 53 | operators.push(token); 54 | continue; 55 | } 56 | 57 | if (token === '(') { 58 | operators.push(token); 59 | continue; 60 | } 61 | 62 | if (token === ')') { 63 | while (operators.length > 0 && operators[operators.length - 1] !== '(') { 64 | out.push(operators.pop()); 65 | } 66 | operators.pop(); 67 | continue; 68 | } 69 | 70 | throw new Error(`Unparsed token ${token} at position ${i}`); 71 | } 72 | 73 | for (let i = operators.length - 1; i >= 0; i--) { 74 | out.push(operators[i]); 75 | } 76 | 77 | return out; 78 | } 79 | 80 | const precedence = { '^': 3, '*': 2, '/': 2, '+': 1, '-': 1 }; 81 | 82 | function shouldUnwindOperatorStack(operators, nextToken) { 83 | if (operators.length === 0) { 84 | return false; 85 | } 86 | 87 | const lastOperator = operators[operators.length - 1]; 88 | return precedence[lastOperator] >= precedence[nextToken]; 89 | } 90 | 91 | function evalRPN(rpn) { 92 | const stack = []; 93 | 94 | for (let i = 0; i < rpn.length; i++) { 95 | const token = rpn[i]; 96 | 97 | if (/[+\-/*^]/.test(token)) { 98 | stack.push(operate(token, stack)); 99 | continue; 100 | } 101 | 102 | // token is a number 103 | stack.push(token); 104 | } 105 | 106 | return stack.pop(); 107 | } 108 | 109 | function operate(operator, stack) { 110 | const a = stack.pop(); 111 | const b = stack.pop(); 112 | 113 | switch (operator) { 114 | case '+': 115 | return b + a; 116 | case '-': 117 | return b - a; 118 | case '*': 119 | return b * a; 120 | case '/': 121 | return b / a; 122 | case '^': 123 | return Math.pow(b, a); 124 | default: 125 | throw new Error(`Invalid operator: ${operator}`); 126 | } 127 | } 128 | 129 | function evaluate(input) { 130 | return evalRPN(toRPN(tokenize(input))); 131 | } 132 | 133 | module.exports = { 134 | tokenize, 135 | toRPN, 136 | evalRPN, 137 | evaluate, 138 | }; 139 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 38 | 42 | 43 |+, -, *, /,
129 | ^
130 | MAX(a, b),
134 | SQRT(a),
135 | IF(a, b, c),
138 | SET(#X, a)
141 | $PI,
145 | $E
146 |