Skip to content

Commit 20f200f

Browse files
authored
Version v2.1.0 (#10)
* Implement it using 'm' * Use full word 'max' as token * Use function names as variables * Make it explicitly case insensitive * Convert to generalised 'function' * Add error states * Increment minor version * Update readme * Add test for empty call of max * Update readme again * Generalise getVariables for future functions * Update and add tests * Use `isFunctionToken` and add comment * Make commas fully not operators
1 parent 2c3cd3a commit 20f200f

File tree

6 files changed

+148
-13
lines changed

6 files changed

+148
-13
lines changed

README.md

+12
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,15 @@ import { getVariables } from '@beyondessential/arithmetic';
4646
const variables = getVariables('(-a * b - 1) / (c + 3)');
4747
console.log(variables); // ['a', 'b', 'c']
4848
```
49+
50+
## formulaText operators
51+
Note: All operators are case **in**sensitive.
52+
Operator | Example | Description
53+
-|-|-
54+
`+` | `1 + 1` | Addition
55+
`-` | `1 - 1` | Subtraction
56+
`*` or `x` | `1 * 1` or `1 x 1` | Multiplication
57+
`/` | `1 / 1` | Division
58+
`()`| `1 / (1 + 1)` | Brackets
59+
`-` | `-1` | Unary minus
60+
`max` | `max(1, 2, 3)` | Takes the maximum value of it's arguments. `-Infinity` if given none

__tests__/arithmetic.test.js

+77-2
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('Arithmetic', () => {
6565
});
6666

6767
describe('unary minus (for eg, -1)', () => {
68-
it('should handle multiplying by a negative', () => {
68+
it('should handle dividing by a negative', () => {
6969
const result = runArithmetic('4 / - 2');
7070
expect(result).toEqual(4 / -2);
7171
});
@@ -75,7 +75,7 @@ describe('Arithmetic', () => {
7575
expect(result).toEqual(4 * -2 * -2);
7676
});
7777

78-
it('should handle multiplying by a double negative', () => {
78+
it('should handle dividing by a double negative', () => {
7979
const result = runArithmetic('4 / - - 2');
8080
expect(result).toEqual(4 / 2);
8181
});
@@ -94,6 +94,44 @@ describe('Arithmetic', () => {
9494
const result = runArithmetic('1+1 - - 5');
9595
expect(result).toEqual(1 + 1 - -5);
9696
});
97+
98+
it('should handle negating a function call', () => {
99+
const result = runArithmetic('1 - -max(1, 2)');
100+
expect(result).toEqual(1 - -Math.max(1, 2));
101+
});
102+
});
103+
104+
describe('max function', () => {
105+
const { max } = Math;
106+
it('should handle max with no arguments', () => {
107+
const result = runArithmetic('max()');
108+
expect(result).toEqual(-Infinity);
109+
});
110+
111+
it('should handle max of one number', () => {
112+
const result = runArithmetic('max(15)');
113+
expect(result).toEqual(max(15));
114+
});
115+
116+
it('should handle max of two numbers', () => {
117+
const result = runArithmetic('max(15, 20)');
118+
expect(result).toEqual(max(15, 20));
119+
});
120+
121+
it('should handle a more complex expression', () => {
122+
const result = runArithmetic('max(1, 3 - 2, -100) / 2');
123+
expect(result).toEqual(max(1, 3 - 2, -100) / 2);
124+
});
125+
126+
it('should handle a yet more complex expression', () => {
127+
const result = runArithmetic('max(max(), (-max(15, 3 -2, -100) / 2 + 1 - max(3/2)) /2, -10)');
128+
expect(result).toEqual(max(-Infinity, (-max(15, 3 - 2, -100) / 2 + 1 - max(3 / 2)) / 2, -10));
129+
});
130+
131+
it('should be caps insensitive', () => {
132+
const result = runArithmetic('maX(15)');
133+
expect(result).toEqual(max(15));
134+
});
97135
});
98136

99137
describe('substituting values', () => {
@@ -103,6 +141,9 @@ describe('Arithmetic', () => {
103141
pi: 3.14159,
104142
sins: 7,
105143
negative: -5,
144+
theLetter_A: 65,
145+
theLetter_a: 97,
146+
max: 100,
106147
};
107148

108149
it('should handle simple value substitution', () => {
@@ -115,10 +156,25 @@ describe('Arithmetic', () => {
115156
expect(result).toEqual(10 - 5);
116157
});
117158

159+
it('should handle substituting based on capitalization', () => {
160+
const result = runArithmetic('theLetter_A - theLetter_a', VALUES);
161+
expect(result).toEqual(65 - 97);
162+
});
163+
118164
it('should handle a more complicated case', () => {
119165
const result = runArithmetic('-eyes * (sins - pi * 3) / negative + (sins + 1)', VALUES);
120166
expect(result).toEqual((-2 * (7 - 3.14159 * 3)) / -5 + (7 + 1));
121167
});
168+
169+
it('should handle substitution of a value which is a function name', () => {
170+
const result = runArithmetic('fingers + max', VALUES);
171+
expect(result).toEqual(10 + 100);
172+
});
173+
174+
it('should be able to substitute values into a function with the same name', () => {
175+
const result = runArithmetic('max(max, eyes)', VALUES);
176+
expect(result).toEqual(Math.max(100, 10));
177+
});
122178
});
123179

124180
describe('errors', () => {
@@ -135,6 +191,25 @@ describe('Arithmetic', () => {
135191
expect(() => runArithmetic('4 * 1 + 2)')).toThrow();
136192
});
137193

194+
it('should fail on incorrect function call', () => {
195+
expect(() => runArithmetic('max(')).toThrow();
196+
expect(() => runArithmetic('max())')).toThrow();
197+
expect(() => runArithmetic('max(()')).toThrow();
198+
expect(() => runArithmetic('max((1, 2), 3)')).toThrow();
199+
expect(() => runArithmetic('max(3, (1, 2))')).toThrow();
200+
expect(() => runArithmetic('max(, 3)')).toThrow();
201+
expect(() => runArithmetic('max(3, )')).toThrow();
202+
expect(() => runArithmetic('max(3,,2)')).toThrow();
203+
expect(() => runArithmetic('max(3-,2)')).toThrow();
204+
});
205+
206+
it('should fail on incorrect comma usage', () => {
207+
expect(() => runArithmetic('1 + , 1')).toThrow();
208+
expect(() => runArithmetic('1 , 1')).toThrow();
209+
expect(() => runArithmetic('1 + (1, 1)')).toThrow();
210+
expect(() => runArithmetic(',')).toThrow();
211+
});
212+
138213
it('should fail if a substitution is not numeric', () => {
139214
expect(() => runArithmetic('check + 1', { check: 'check' })).toThrow();
140215
expect(() => runArithmetic('check + 1', { check: '+' })).toThrow();

__tests__/symbols.test.js

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ describe('getVariables()', () => {
1818
['no whitespace', 'a+b', ['a', 'b']],
1919
['same variable multiple times', 'a * b + a', ['a', 'b']],
2020
['all operators', '(a + -b) / ((2 * c) - 3 x d)', ['a', 'b', 'c', 'd']],
21+
['formula includes functions', 'max(1, 2, a)', ['a']],
22+
['variable with function name', 'max(1, 2, a, max)', ['a', 'max']],
2123
];
2224

2325
it.each(testData)('%s', (_, formula, expected) => {

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@beyondessential/arithmetic",
3-
"version": "2.0.0",
3+
"version": "2.1.0",
44
"description": "Utility to evaluate BODMAS arithmetic formulas",
55
"keywords": [
66
"arithmetic",

src/arithmetic.js

+46-8
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
// that, don't sweat! There're unit tests and you can validate that things
2323
// work over there.
2424

25-
import { isOperator, getPrecedence } from './symbols';
25+
import {
26+
isOperator, getPrecedence, FUNCTION_NAMES, isFunctionToken,
27+
} from './symbols';
2628

27-
const unaryRegex = /(^|[*x/+\-u])-/g;
29+
const unaryRegex = /(^|[*x/+\-u,(])-/g;
2830
function replaceUnaryMinus(text) {
2931
const replaced = text.replace(unaryRegex, (match, p1) => `${p1}u`);
3032
if (replaced !== text) {
@@ -47,7 +49,7 @@ function shouldPopOperator(token, topOfStack) {
4749
return true;
4850
}
4951

50-
const tokenizer = /([+\-*/()ux])/g;
52+
const tokenizer = new RegExp(`(${FUNCTION_NAMES.join('|')}|[+\\-*/()ux,])`, 'g');
5153

5254
function shuntingYard(text) {
5355
const stack = [];
@@ -64,18 +66,32 @@ function shuntingYard(text) {
6466
queue.push(stack.shift());
6567
}
6668
stack.unshift(token);
69+
if (isFunctionToken(token)) queue.push('END_ARGS');
6770
continue;
6871
}
6972
if (token === '(') {
7073
stack.unshift(token);
7174
continue;
7275
}
76+
if (token === ',') {
77+
while (stack.length && stack[0] !== '(') {
78+
queue.push(stack.shift());
79+
}
80+
if (!(stack[0] === '(' && isFunctionToken(stack[1]))) {
81+
// A comma is ONLY valid when the next thing in the stack is the function call
82+
throw new Error('Incorrect function call');
83+
}
84+
continue;
85+
}
7386
if (token === ')') {
7487
while (stack.length > 0 && stack[0] !== '(') {
7588
queue.push(stack.shift());
7689
}
7790
if (stack[0] === '(') {
7891
stack.shift();
92+
if (isFunctionToken(stack[0])) {
93+
queue.push(stack.shift());
94+
}
7995
} else {
8096
throw new Error('Unmatched parenthesis');
8197
}
@@ -88,7 +104,7 @@ function shuntingYard(text) {
88104
continue;
89105
}
90106

91-
throw new Error('Unrecognised token');
107+
throw new Error(`Unrecognised token: ${token}`);
92108
}
93109

94110
while (stack.length > 0) {
@@ -109,11 +125,23 @@ function processQueue(queue) {
109125

110126
// alias just in case
111127
x: () => operations['*'](),
128+
129+
// functions
130+
max: () => {
131+
let val = stack.pop();
132+
let max = -Infinity;
133+
while (val !== 'END_ARGS') {
134+
max = Math.max(max, val);
135+
if (stack.length === 0) throw new Error('No END_ARGS for function "max"');
136+
val = stack.pop();
137+
}
138+
return max;
139+
},
112140
};
113141

114142
while (queue.length > 0) {
115143
const item = queue.shift();
116-
if (typeof item === 'number') {
144+
if (typeof item === 'number' || item === 'END_ARGS') {
117145
stack.push(item);
118146
continue;
119147
}
@@ -129,26 +157,36 @@ function processQueue(queue) {
129157

130158
const noWhitespace = /\s/g;
131159

160+
// Names with a '(' after them are function calls, not variables
161+
// e.g. max(5 + max)
162+
// the first max would not be replaced
163+
const buildVariableReplacer = (key) => new RegExp(`${key}(?!\\s*\\()`, 'g');
164+
132165
export function runArithmetic(formulaText, values = {}) {
133166
// first replace variables with their actual values
134167
// (we do this here rather than treating the variable names as tokens,
135168
// so that the tokeniser doesn't get confused by variable names with
136169
// u and x in them)
137170
let valuedText = formulaText;
138-
Object.entries(values).map(([key, value]) => {
171+
Object.entries(values).forEach(([key, value]) => {
139172
if (Number.isNaN(parseFloat(value))) {
140173
throw new Error('Invalid value substitution');
141174
}
142175

143-
valuedText = valuedText.replace(new RegExp(key, 'g'), value);
176+
valuedText = valuedText.replace(buildVariableReplacer(key), value);
144177
});
145178

146179
// strip out all whitespace
147180
const strippedText = valuedText.replace(noWhitespace, '');
148181

182+
if (strippedText.match(/([(,],)|(,\))/g)) throw new Error('Leading or trailing comma detected');
183+
184+
// functions are case insensitive
185+
const lowercaseText = strippedText.toLowerCase();
186+
149187
// then replace the unary minus with a 'u' so we can
150188
// handle it differently to subtraction in the tokeniser
151-
const replacedText = replaceUnaryMinus(strippedText);
189+
const replacedText = replaceUnaryMinus(lowercaseText);
152190

153191
// then create a postfix queue using the shunting yard algorithm
154192
const queue = shuntingYard(replacedText);

src/symbols.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
export const FUNCTION_NAMES = ['max'];
9+
10+
export const isFunctionToken = (token) => FUNCTION_NAMES.includes(token);
11+
812
export function isOperator(token) {
9-
return ['+', '-', '/', '*', 'x', 'u'].includes(token);
13+
return ['+', '-', '/', '*', 'x', 'u'].includes(token) || isFunctionToken(token);
1014
}
1115

1216
export function getPrecedence(operator) {
17+
if (isFunctionToken(operator)) return 5;
18+
1319
switch (operator) {
1420
case 'u':
1521
return 4;
@@ -29,8 +35,10 @@ export function getVariables(formulaText) {
2935
const variables = formulaText
3036
// Replace the alternate multiplication symbol 'x' with a non-alphanumeric character
3137
.replace(/(^|\W)x(\W|$)/, ' ')
38+
// Replace functions with a non-alphanumeric character
39+
.replace(new RegExp(`${FUNCTION_NAMES.join('|')}\\s*\\(`, 'g'), ' ')
3240
.split(/[+-/*() ]/g)
33-
.filter(v => v !== '' && Number.isNaN(Number(v)));
41+
.filter((v) => v !== '' && Number.isNaN(Number(v)));
3442

3543
return [...new Set(variables)];
3644
}

0 commit comments

Comments
 (0)