From ba7d12fbef9c34ac82a46303a5f08b01b275d360 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Wed, 5 Feb 2025 15:06:20 -0800 Subject: [PATCH] feat: always infer function call when args empty or have , --- docs/expressions/syntax.md | 7 +++-- src/expression/parse.js | 33 ++++++++++++++++----- test/unit-tests/expression/parse.test.js | 8 +++++ test/unit-tests/expression/security.test.js | 17 ++++++----- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/docs/expressions/syntax.md b/docs/expressions/syntax.md index 0ff0d21b0e..ac43e4fa2d 100644 --- a/docs/expressions/syntax.md +++ b/docs/expressions/syntax.md @@ -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: diff --git a/src/expression/parse.js b/src/expression/parse.js index 32faf04344..29c39424d6 100644 --- a/src/expression/parse.js +++ b/src/expression/parse.js @@ -1083,6 +1083,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ */ function parseImplicitMultiplication (state) { let node, last + const tokenStates = [] node = parseRule2(state) last = node @@ -1093,8 +1094,8 @@ 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' @@ -1102,6 +1103,24 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ // 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 } @@ -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 } } diff --git a/test/unit-tests/expression/parse.test.js b/test/unit-tests/expression/parse.test.js index 598a988b3d..a8b8053c77 100644 --- a/test/unit-tests/expression/parse.test.js +++ b/test/unit-tests/expression/parse.test.js @@ -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)) diff --git a/test/unit-tests/expression/security.test.js b/test/unit-tests/expression/security.test.js index 6ea71ffce4..804a69668c 100644 --- a/test/unit-tests/expression/security.test.js +++ b/test/unit-tests/expression/security.test.js @@ -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 () { @@ -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 () { @@ -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 () {