Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: always infer function call when args empty or have comma #3378

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/expressions/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,8 +755,11 @@ math.evaluate('(1+2)(3+4)') // 21
```

Parentheses are parsed as a function call when there is a symbol or accessor on
the left hand side, like `sqrt(4)` or `obj.method(4)`. In other cases the
parentheses are interpreted as an implicit multiplication.
the left hand side, like `sqrt(4)` or `obj.method(4)`, when the argument
list is empty, like `(f()=1)()`, or when the argument list contains a comma,
like `(f(x,y) = 2x-y)(3,1)`. To enable calling a unary function this way, you
may add a final comma in the argument list, as in `(f(x)=(x+1)x/2)(3,)`. In all
other cases the parentheses are interpreted as an implicit multiplication.

Math.js will always evaluate implicit multiplication before explicit multiplication `*`, so that the expression `x * y z` is parsed as `x * (y * z)`. Math.js also gives implicit multiplication higher precedence than division, *except* when the division matches the pattern `[unaryPrefixOp]?[number] / [number] [symbol]` or `[unaryPrefixOp]?[number] / [number] [left paren]`. In that special case, the division is evaluated first:

Expand Down
33 changes: 26 additions & 7 deletions src/expression/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
*/
function parseImplicitMultiplication (state) {
let node, last
const tokenStates = []

node = parseRule2(state)
last = node
Expand All @@ -1093,15 +1094,33 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
(state.token === 'in' && isOperatorNode(node) && node.fn === 'unaryMinus' && isConstantNode(node.args[0])) ||
(state.tokenType === TOKENTYPE.NUMBER &&
!isConstantNode(last) &&
(!isOperatorNode(last) || last.op === '!')) ||
(state.token === '(')) {
(!isOperatorNode(last) || last.op === '!'))
) {
// parse implicit multiplication
//
// symbol: implicit multiplication like '2a', '(2+3)a', 'a b'
// number: implicit multiplication like '(2+3)2'
// parenthesis: implicit multiplication like '2(3+4)', '(3+4)(1+2)'
last = parseRule2(state)
node = new OperatorNode('*', 'multiply', [node, last], true /* implicit */)
} else if (state.token === '(') {
// parse implicit multiplication like '2(3+4)', '(3+4)(1+2)'
// but might also be function call like '(compose(f, g))(1, 2)'
// Function application forced by presence of comma
tokenStates.push(Object.assign({}, state))
try {
last = parseRule2(state)
node = new OperatorNode('*', 'multiply', [node, last], true /* implicit */)
} catch (e) {
Object.assign(state, tokenStates.pop())
if (e instanceof SyntaxError) {
const dummy = new SymbolNode('_TEMP_')
const dummyCall = parseAccessors(state, dummy)
node = new FunctionNode(node, dummyCall.args)
} else {
throw e
}
}
} else {
break
}
Expand Down Expand Up @@ -1368,13 +1387,13 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
openParams(state)
getToken(state)

if (state.token !== ')') {
while (state.token !== ')') { // eslint-disable-line no-unmodified-loop-condition
params.push(parseAssignment(state))

// parse a list with parameters
while (state.token === ',') { // eslint-disable-line no-unmodified-loop-condition
if (state.token === ',') {
// parse a list with parameters
getToken(state)
params.push(parseAssignment(state))
} else {
break
}
}

Expand Down
8 changes: 8 additions & 0 deletions test/unit-tests/expression/parse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,14 @@ describe('parse', function () {
assert.deepStrictEqual(parseAndEval('sqrt(4)(1+2)(2)'), 12)
})

it('should allow calling a function-valued expression', function () {
assert.deepStrictEqual(parseAndEval('(f()=7)()'), 7)
assert.deepStrictEqual(parseAndEval('(f(x)=x+3)(4,)'), 7)
assert.deepStrictEqual(parseAndEval('(f(x,y)=x+y)(4,3)'), 7)
assert.deepStrictEqual(parseAndEval('(f(x,y)=x+y)(4,3,)'), 7)
assert.deepStrictEqual(parseAndEval('(f(g)=(_(x)=g(x)+1))(floor,)(6.5,)'), 7)
})

it('should invoke a function on an object with the right context', function () {
approxEqual(parseAndEval('(2.54 cm).toNumeric("inch")'), 1)
assert.deepStrictEqual(parseAndEval('bignumber(2).plus(3)'), math.bignumber(5))
Expand Down
17 changes: 10 additions & 7 deletions test/unit-tests/expression/security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,9 @@ describe('security', function () {

it('should not allow replacing validateSafeMethod with a local variant', function () {
assert.throws(function () {
math.evaluate("evaluate(\"f(validateSafeMethod)=cos.constructor(\\\"return evaluate\\\")()\")(evaluate(\"f(x,y)=0\"))(\"console.log('hacked...')\")")
}, /SyntaxError: Value expected/)
const hack = "evaluate(\"f(validateSafeMethod)=cos.constructor(\\\"return evaluate\\\")()\")(evaluate(\"f(x,y)=0\"),)(\"console.log('hacked...')\",)"
math.evaluate(hack)
}, /Error: No access .*constructor/)
})

it('should not allow abusing toString', function () {
Expand All @@ -259,14 +260,16 @@ describe('security', function () {

it('should not allow creating a bad FunctionAssignmentNode', function () {
assert.throws(function () {
math.evaluate("badNode={isNode:true,type:\"FunctionAssignmentNode\",expr:parse(\"1\"),types:{join:evaluate(\"f(a)=\\\"\\\"\")},params:{\"forEach\":evaluate(\"f(x)=1\"),\"join\":evaluate(\"f(x)=\\\"){return evaluate;}});return fn;})())}});return fn;})());}};//\\\"\")}};parse(\"f()=x\").map(evaluate(\"f(a,b,c)=badNode\",{\"badNode\":badNode})).compile().evaluate()()()(\"console.log('hacked...')\")")
}, /SyntaxError: Value expected/)
math.evaluate("badNode={isNode:true,constructor:{prototype:{isNode:true}},type:\"FunctionAssignmentNode\",expr:parse(\"1\"),types:{join:evaluate(\"f(a)=\\\"\\\"\")},params:{\"forEach\":evaluate(\"f(x)=1\"),\"join\":evaluate(\"f(x)=\\\"){return evaluate;}});return fn;})())}});return fn;})());}};//\\\"\")}};parse(\"f()=x\").map(evaluate(\"f(a,b,c)=badNode\",{\"badNode\":badNode})).compile().evaluate()()()(\"console.log('hacked...')\")")
}, /Error: No access to property "isNode"/)
})

// FIXME: Appears to be identical to prior test; is it supposed to be
// testing something else?
it('should not allow creating a bad OperatorNode (1)', function () {
assert.throws(function () {
math.evaluate("badNode={isNode:true,type:\"FunctionAssignmentNode\",expr:parse(\"1\"),types:{join:evaluate(\"f(a)=\\\"\\\"\")},params:{\"forEach\":evaluate(\"f(x)=1\"),\"join\":evaluate(\"f(x)=\\\"){return evaluate;}});return fn;})())}});return fn;})());}};//\\\"\")}};parse(\"f()=x\").map(evaluate(\"f(a,b,c)=badNode\",{\"badNode\":badNode})).compile().evaluate()()()(\"console.log('hacked...')\")")
}, /SyntaxError: Value expected/)
math.evaluate("badNode={isNode:true,constructor:{prototype:{isNode:true}},type:\"FunctionAssignmentNode\",expr:parse(\"1\"),types:{join:evaluate(\"f(a)=\\\"\\\"\")},params:{\"forEach\":evaluate(\"f(x)=1\"),\"join\":evaluate(\"f(x)=\\\"){return evaluate;}});return fn;})())}});return fn;})());}};//\\\"\")}};parse(\"f()=x\").map(evaluate(\"f(a,b,c)=badNode\",{\"badNode\":badNode})).compile().evaluate()()()(\"console.log('hacked...')\")")
}, /Error: No access to property "isNode"/)
})

it('should not allow creating a bad OperatorNode (2)', function () {
Expand Down Expand Up @@ -316,7 +319,7 @@ describe('security', function () {
'evilMath=x.create().done();' +
'evilMath.import({"_compile":f(a,b,c)="evaluate","isNode":f()=true}); ' +
"parse(\"(1)\").map(g(a,b,c)=evilMath.chain()).compile().evaluate()(\"console.log('hacked...')\")")
}, /SyntaxError: Value expected/)
}, /Error: Undefined symbol Chain/)
})

it('should not allow passing a function name containg bad contents', function () {
Expand Down