Skip to content

Commit 4f748d2

Browse files
kiprobinsonknackgwhitney
authored andcommitted
feat: give unary % operator (percentage) higher precedence than binary % operator (modulus) (#3432)
1 parent f404dab commit 4f748d2

File tree

3 files changed

+56
-29
lines changed

3 files changed

+56
-29
lines changed

docs/expressions/syntax.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,9 @@ Operators | Description
114114
`!` | Factorial
115115
`^`, `.^` | Exponentiation
116116
`+`, `-`, `~`, `not` | Unary plus, unary minus, bitwise not, logical not
117+
`%` | Unary percentage
117118
See section below | Implicit multiplication
118-
`*`, `/`, `.*`, `./`,`%`, `mod` | Multiply, divide , percentage, modulus
119+
`*`, `/`, `.*`, `./`,`%`, `mod` | Multiply, divide, modulus
119120
`+`, `-` | Add, subtract
120121
`:` | Range
121122
`to`, `in` | Unit conversion
@@ -134,8 +135,8 @@ See section below | Implicit multiplication
134135
`\n`, `;` | Statement separators
135136

136137
Lazy evaluation is used where logically possible for bitwise and logical
137-
operators. In the following example, the value of `x` will not even be
138-
evaluated because it cannot effect the final result:
138+
operators. In the following example, the value of `x` will not even be
139+
evaluated because it cannot effect the final result:
139140
```js
140141
math.evaluate('false and x') // false, no matter what x equals
141142
```
@@ -652,7 +653,7 @@ at 1, when the end is undefined, the range will end at the end of the matrix.
652653

653654
There is a context variable `end` available as well to denote the end of the
654655
matrix. This variable cannot be used in multiple nested indices. In that case,
655-
`end` will be resolved as the end of the innermost matrix. To solve this,
656+
`end` will be resolved as the end of the innermost matrix. To solve this,
656657
resolving of the nested index needs to be split in two separate operations.
657658

658659
*IMPORTANT: matrix indexes and ranges work differently from the math.js indexes

src/expression/parse.js

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
10011001
function parseAddSubtract (state) {
10021002
let node, name, fn, params
10031003

1004-
node = parseMultiplyDivideModulusPercentage(state)
1004+
node = parseMultiplyDivideModulus(state)
10051005

10061006
const operators = {
10071007
'+': 'add',
@@ -1012,7 +1012,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
10121012
fn = operators[name]
10131013

10141014
getTokenSkipNewline(state)
1015-
const rightNode = parseMultiplyDivideModulusPercentage(state)
1015+
const rightNode = parseMultiplyDivideModulus(state)
10161016
if (rightNode.isPercentage) {
10171017
params = [node, new OperatorNode('*', 'multiply', [node, rightNode])]
10181018
} else {
@@ -1025,11 +1025,11 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
10251025
}
10261026

10271027
/**
1028-
* multiply, divide, modulus, percentage
1028+
* multiply, divide, modulus
10291029
* @return {Node} node
10301030
* @private
10311031
*/
1032-
function parseMultiplyDivideModulusPercentage (state) {
1032+
function parseMultiplyDivideModulus (state) {
10331033
let node, last, name, fn
10341034

10351035
node = parseImplicitMultiplication(state)
@@ -1053,17 +1053,8 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
10531053
getTokenSkipNewline(state)
10541054

10551055
if (name === '%' && state.tokenType === TOKENTYPE.DELIMITER && state.token !== '(') {
1056-
// If the expression contains only %, then treat that as /100
1057-
if (state.token !== '' && operators[state.token]) {
1058-
const left = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true)
1059-
name = state.token
1060-
fn = operators[name]
1061-
getTokenSkipNewline(state)
1062-
last = parseImplicitMultiplication(state)
1063-
1064-
node = new OperatorNode(name, fn, [left, last])
1065-
} else { node = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true) }
1066-
// return node
1056+
// This % cannot be interpreted as a modulus, and it wasn't handled by parseUnaryPostfix
1057+
throw createSyntaxError(state, 'Unexpected operator %')
10671058
} else {
10681059
last = parseImplicitMultiplication(state)
10691060
node = new OperatorNode(name, fn, [node, last])
@@ -1120,7 +1111,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
11201111
* @private
11211112
*/
11221113
function parseRule2 (state) {
1123-
let node = parseUnary(state)
1114+
let node = parseUnaryPercentage(state)
11241115
let last = node
11251116
const tokenStates = []
11261117

@@ -1143,7 +1134,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
11431134
// Rewind once and build the "number / number" node; the symbol will be consumed later
11441135
Object.assign(state, tokenStates.pop())
11451136
tokenStates.pop()
1146-
last = parseUnary(state)
1137+
last = parseUnaryPercentage(state)
11471138
node = new OperatorNode('/', 'divide', [node, last])
11481139
} else {
11491140
// Not a match, so rewind
@@ -1164,6 +1155,30 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
11641155
return node
11651156
}
11661157

1158+
/**
1159+
* Unary percentage operator (treated as `value / 100`)
1160+
* @return {Node} node
1161+
* @private
1162+
*/
1163+
function parseUnaryPercentage (state) {
1164+
let node = parseUnary(state)
1165+
1166+
if (state.token === '%') {
1167+
const previousState = Object.assign({}, state)
1168+
getTokenSkipNewline(state)
1169+
1170+
if (state.tokenType === TOKENTYPE.DELIMITER && state.token !== '(') {
1171+
// This is unary postfix %, then treat that as /100
1172+
node = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true)
1173+
} else {
1174+
// Not a match, so rewind
1175+
Object.assign(state, previousState)
1176+
}
1177+
}
1178+
1179+
return node
1180+
}
1181+
11671182
/**
11681183
* Unary plus and minus, and logical and bitwise not
11691184
* @return {Node} node

test/unit-tests/expression/parse.test.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,18 +1370,29 @@ describe('parse', function () {
13701370
})
13711371

13721372
it('should parse % with division', function () {
1373-
approxEqual(parseAndEval('100/50%'), 0.02) // should be treated as ((100/50)%)
1374-
approxEqual(parseAndEval('100/50%*2'), 0.04) // should be treated as ((100/50)%))×2
1373+
approxEqual(parseAndEval('100/50%'), 200) // should be treated as 100/(50%)
1374+
approxEqual(parseAndEval('100/50%*2'), 400) // should be treated as (100/(50%))×2
13751375
approxEqual(parseAndEval('50%/100'), 0.005)
1376+
approxEqual(parseAndEval('50%(13)'), 11) // should be treated as 50 % (13)
13761377
assert.throws(function () { parseAndEval('50%(/100)') }, /Value expected/)
13771378
})
13781379

1379-
it('should parse % with addition', function () {
1380+
it('should parse unary % before division, binary % with division', function () {
1381+
approxEqual(parseAndEval('10/200%%3'), 2) // should be treated as (10/(200%))%3
1382+
})
1383+
1384+
it('should reject repeated unary percentage operators', function () {
1385+
assert.throws(function () { math.parse('17%%') }, /Unexpected operator %/)
1386+
assert.throws(function () { math.parse('17%%*5') }, /Unexpected operator %/)
1387+
assert.throws(function () { math.parse('10/200%%%3') }, /Unexpected operator %/)
1388+
})
1389+
1390+
it('should parse unary % with addition', function () {
13801391
approxEqual(parseAndEval('100+3%'), 103)
13811392
approxEqual(parseAndEval('3%+100'), 100.03)
13821393
})
13831394

1384-
it('should parse % with subtraction', function () {
1395+
it('should parse unary % with subtraction', function () {
13851396
approxEqual(parseAndEval('100-3%'), 97)
13861397
approxEqual(parseAndEval('3%-100'), -99.97)
13871398
})
@@ -1390,12 +1401,12 @@ describe('parse', function () {
13901401
approxEqual(parseAndEval('8 mod 3'), 2)
13911402
})
13921403

1393-
it('should give equal precedence to % and * operators', function () {
1404+
it('should give equal precedence to binary % and * operators', function () {
13941405
approxEqual(parseAndStringifyWithParens('10 % 3 * 2'), '(10 % 3) * 2')
13951406
approxEqual(parseAndStringifyWithParens('10 * 3 % 4'), '(10 * 3) % 4')
13961407
})
13971408

1398-
it('should give equal precedence to % and / operators', function () {
1409+
it('should give equal precedence to binary % and / operators', function () {
13991410
approxEqual(parseAndStringifyWithParens('10 % 4 / 2'), '(10 % 4) / 2')
14001411
approxEqual(parseAndStringifyWithParens('10 / 2 % 3'), '(10 / 2) % 3')
14011412
})
@@ -1410,12 +1421,12 @@ describe('parse', function () {
14101421
approxEqual(parseAndStringifyWithParens('8 / 3 mod 2'), '(8 / 3) mod 2')
14111422
})
14121423

1413-
it('should give equal precedence to % and .* operators', function () {
1424+
it('should give equal precedence to binary % and .* operators', function () {
14141425
approxEqual(parseAndStringifyWithParens('10 % 3 .* 2'), '(10 % 3) .* 2')
14151426
approxEqual(parseAndStringifyWithParens('10 .* 3 % 4'), '(10 .* 3) % 4')
14161427
})
14171428

1418-
it('should give equal precedence to % and ./ operators', function () {
1429+
it('should give equal precedence to binary % and ./ operators', function () {
14191430
approxEqual(parseAndStringifyWithParens('10 % 4 ./ 2'), '(10 % 4) ./ 2')
14201431
approxEqual(parseAndStringifyWithParens('10 ./ 2 % 3'), '(10 ./ 2) % 3')
14211432
})

0 commit comments

Comments
 (0)