Skip to content

Commit 9aad1a0

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

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
```
@@ -672,7 +673,7 @@ at 1, when the end is undefined, the range will end at the end of the matrix.
672673

673674
There is a context variable `end` available as well to denote the end of the
674675
matrix. This variable cannot be used in multiple nested indices. In that case,
675-
`end` will be resolved as the end of the innermost matrix. To solve this,
676+
`end` will be resolved as the end of the innermost matrix. To solve this,
676677
resolving of the nested index needs to be split in two separate operations.
677678

678679
*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
@@ -1002,7 +1002,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
10021002
function parseAddSubtract (state) {
10031003
let node, name, fn, params
10041004

1005-
node = parseMultiplyDivideModulusPercentage(state)
1005+
node = parseMultiplyDivideModulus(state)
10061006

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

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

10281028
/**
1029-
* multiply, divide, modulus, percentage
1029+
* multiply, divide, modulus
10301030
* @return {Node} node
10311031
* @private
10321032
*/
1033-
function parseMultiplyDivideModulusPercentage (state) {
1033+
function parseMultiplyDivideModulus (state) {
10341034
let node, last, name, fn
10351035

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

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

@@ -1144,7 +1135,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
11441135
// Rewind once and build the "number / number" node; the symbol will be consumed later
11451136
Object.assign(state, tokenStates.pop())
11461137
tokenStates.pop()
1147-
last = parseUnary(state)
1138+
last = parseUnaryPercentage(state)
11481139
node = new OperatorNode('/', 'divide', [node, last])
11491140
} else {
11501141
// Not a match, so rewind
@@ -1165,6 +1156,30 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
11651156
return node
11661157
}
11671158

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

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

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

13801380
it('should parse % with division', function () {
1381-
approxEqual(parseAndEval('100/50%'), 0.02) // should be treated as ((100/50)%)
1382-
approxEqual(parseAndEval('100/50%*2'), 0.04) // should be treated as ((100/50)%))×2
1381+
approxEqual(parseAndEval('100/50%'), 200) // should be treated as 100/(50%)
1382+
approxEqual(parseAndEval('100/50%*2'), 400) // should be treated as (100/(50%))×2
13831383
approxEqual(parseAndEval('50%/100'), 0.005)
1384+
approxEqual(parseAndEval('50%(13)'), 11) // should be treated as 50 % (13)
13841385
assert.throws(function () { parseAndEval('50%(/100)') }, /Value expected/)
13851386
})
13861387

1387-
it('should parse % with addition', function () {
1388+
it('should parse unary % before division, binary % with division', function () {
1389+
approxEqual(parseAndEval('10/200%%3'), 2) // should be treated as (10/(200%))%3
1390+
})
1391+
1392+
it('should reject repeated unary percentage operators', function () {
1393+
assert.throws(function () { math.parse('17%%') }, /Unexpected operator %/)
1394+
assert.throws(function () { math.parse('17%%*5') }, /Unexpected operator %/)
1395+
assert.throws(function () { math.parse('10/200%%%3') }, /Unexpected operator %/)
1396+
})
1397+
1398+
it('should parse unary % with addition', function () {
13881399
approxEqual(parseAndEval('100+3%'), 103)
13891400
approxEqual(parseAndEval('3%+100'), 100.03)
13901401
})
13911402

1392-
it('should parse % with subtraction', function () {
1403+
it('should parse unary % with subtraction', function () {
13931404
approxEqual(parseAndEval('100-3%'), 97)
13941405
approxEqual(parseAndEval('3%-100'), -99.97)
13951406
})
@@ -1398,12 +1409,12 @@ describe('parse', function () {
13981409
approxEqual(parseAndEval('8 mod 3'), 2)
13991410
})
14001411

1401-
it('should give equal precedence to % and * operators', function () {
1412+
it('should give equal precedence to binary % and * operators', function () {
14021413
approxEqual(parseAndStringifyWithParens('10 % 3 * 2'), '(10 % 3) * 2')
14031414
approxEqual(parseAndStringifyWithParens('10 * 3 % 4'), '(10 * 3) % 4')
14041415
})
14051416

1406-
it('should give equal precedence to % and / operators', function () {
1417+
it('should give equal precedence to binary % and / operators', function () {
14071418
approxEqual(parseAndStringifyWithParens('10 % 4 / 2'), '(10 % 4) / 2')
14081419
approxEqual(parseAndStringifyWithParens('10 / 2 % 3'), '(10 / 2) % 3')
14091420
})
@@ -1418,12 +1429,12 @@ describe('parse', function () {
14181429
approxEqual(parseAndStringifyWithParens('8 / 3 mod 2'), '(8 / 3) mod 2')
14191430
})
14201431

1421-
it('should give equal precedence to % and .* operators', function () {
1432+
it('should give equal precedence to binary % and .* operators', function () {
14221433
approxEqual(parseAndStringifyWithParens('10 % 3 .* 2'), '(10 % 3) .* 2')
14231434
approxEqual(parseAndStringifyWithParens('10 .* 3 % 4'), '(10 .* 3) % 4')
14241435
})
14251436

1426-
it('should give equal precedence to % and ./ operators', function () {
1437+
it('should give equal precedence to binary % and ./ operators', function () {
14271438
approxEqual(parseAndStringifyWithParens('10 % 4 ./ 2'), '(10 % 4) ./ 2')
14281439
approxEqual(parseAndStringifyWithParens('10 ./ 2 % 3'), '(10 ./ 2) % 3')
14291440
})

0 commit comments

Comments
 (0)