Skip to content
Merged
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
29 changes: 22 additions & 7 deletions src/expression/node/FunctionNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
* the arguments, typically a SymbolNode or AccessorNode
* @param {./Node[]} args
*/
constructor (fn, args) {
constructor (fn, args, optional) {
super()
if (typeof fn === 'string') {
fn = new SymbolNode(fn)
Expand All @@ -107,9 +107,14 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
throw new TypeError(
'Array containing Nodes expected for parameter "args"')
}
const optionalType = typeof optional
if (!(optionalType === 'undefined' || optionalType === 'boolean')) {
throw new TypeError('optional flag, if specified, must be boolean')
}

this.fn = fn
this.args = args || []
this.optional = !!optional
}

// readonly property name
Expand Down Expand Up @@ -137,7 +142,8 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
_compile (math, argNames) {
// compile arguments
const evalArgs = this.args.map((arg) => arg._compile(math, argNames))
const fromOptionalChaining = isAccessorNode(this.fn) && this.fn.optionalChaining
const fromOptionalChaining = this.optional ||
(isAccessorNode(this.fn) && this.fn.optionalChaining)

if (isSymbolNode(this.fn)) {
const name = this.fn.name
Expand All @@ -153,12 +159,14 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
value = scope.get(name)
} else if (name in math) {
value = getSafeProperty(math, name)
} else {
return FunctionNode.onUndefinedFunction(name)
}
if (typeof value === 'function') {
} else if (fromOptionalChaining) value = undefined
else return FunctionNode.onUndefinedFunction(name)

if (typeof value === 'function' ||
(fromOptionalChaining && value === undefined)) {
return value
}

throw new TypeError(
`'${name}' is not a function; its value is:\n ${strin(value)}`
)
Expand All @@ -185,17 +193,20 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
switch (evalArgs.length) {
case 0: return function evalFunctionNode (scope, args, context) {
const fn = resolveFn(scope)
if (fromOptionalChaining && fn === undefined) return undefined
return fn()
}
case 1: return function evalFunctionNode (scope, args, context) {
const fn = resolveFn(scope)
if (fromOptionalChaining && fn === undefined) return undefined
const evalArg0 = evalArgs[0]
return fn(
evalArg0(scope, args, context)
)
}
case 2: return function evalFunctionNode (scope, args, context) {
const fn = resolveFn(scope)
if (fromOptionalChaining && fn === undefined) return undefined
const evalArg0 = evalArgs[0]
const evalArg1 = evalArgs[1]
return fn(
Expand All @@ -205,6 +216,7 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
}
default: return function evalFunctionNode (scope, args, context) {
const fn = resolveFn(scope)
if (fromOptionalChaining && fn === undefined) return undefined
const values = evalArgs.map((evalArg) => evalArg(scope, args, context))
return fn(...values)
}
Expand All @@ -214,6 +226,7 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
const rawArgs = this.args
return function evalFunctionNode (scope, args, context) {
const fn = getSafeProperty(args, name)
if (fromOptionalChaining && fn === undefined) return undefined
if (typeof fn !== 'function') {
throw new TypeError(
`Argument '${name}' was not a function; received: ${strin(fn)}`
Expand Down Expand Up @@ -245,7 +258,8 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({
const object = evalObject(scope, args, context)

// Optional chaining: if the base object is nullish, short-circuit to undefined
if (fromOptionalChaining && object == null) {
if (fromOptionalChaining &&
(object == null || object[prop] === undefined)) {
return undefined
}

Expand All @@ -270,6 +284,7 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({

return function evalFunctionNode (scope, args, context) {
const fn = evalFn(scope, args, context)
if (fromOptionalChaining && fn === undefined) return undefined
if (typeof fn !== 'function') {
throw new TypeError(
`Expression '${fnExpr}' did not evaluate to a function; value is:` +
Expand Down
41 changes: 12 additions & 29 deletions src/expression/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,9 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
return new AssignmentNode(new SymbolNode(name), value)
} else if (isAccessorNode(node)) {
// parse a matrix subset assignment like 'A[1,2] = 4'
if (node.optionalChaining) {
throw createSyntaxError(state, 'Cannot assign to optional chain')
}
getTokenSkipNewline(state)
value = parseAssignment(state)
return new AssignmentNode(node.object, node.index, value)
Expand Down Expand Up @@ -1396,44 +1399,21 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
optional = true
// consume the '?.' token
getToken(state)

// Special case: property access via dot-notation following optional chaining (obj?.foo)
// After consuming '?.', the dot is already consumed as part of the token,
// so the next token is the property name itself. Handle that here.
const isPropertyNameAfterOptional = (!types || types.includes('.')) && (
state.tokenType === TOKENTYPE.SYMBOL ||
(state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)
)
if (isPropertyNameAfterOptional) {
params = []
params.push(new ConstantNode(state.token))
getToken(state)
const dotNotation = true
node = new AccessorNode(node, new IndexNode(params, dotNotation), true)
// Continue parsing, allowing more chaining after this accessor
continue
}
// Otherwise, fall through to allow patterns like obj?.[...]
}

// If the next token does not start an accessor, we're done
const hasNextAccessor =
(state.token === '(' || state.token === '[' || state.token === '.') &&
(!types || types.includes(state.token))

if (!hasNextAccessor) {
// A dangling '?.' without a following accessor is a syntax error
if (optional) {
throw createSyntaxError(state, 'Unexpected operator ?.')
}
if (!(optional || hasNextAccessor)) {
break
}

params = []

if (state.token === '(') {
if (isSymbolNode(node) || isAccessorNode(node)) {
// function invocation like fn(2, 3) or obj.fn(2, 3)
if (optional || isSymbolNode(node) || isAccessorNode(node)) {
// function invocation: fn(2, 3) or obj.fn(2, 3) or (anything)?.(2, 3)
openParams(state)
getToken(state)

Expand All @@ -1453,7 +1433,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
closeParams(state)
getToken(state)

node = new FunctionNode(node, params)
node = new FunctionNode(node, params, optional)
} else {
// implicit multiplication like (2+3)(4+5) or sqrt(2)(1+2)
// don't parse it here but let it be handled by parseImplicitMultiplication
Expand Down Expand Up @@ -1484,12 +1464,15 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({
node = new AccessorNode(node, new IndexNode(params), optional)
} else {
// dot notation like variable.prop
getToken(state)
// consume the `.` (if it was ?., already consumed):
if (!optional) getToken(state)

const isPropertyName = state.tokenType === TOKENTYPE.SYMBOL ||
(state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)
if (!isPropertyName) {
throw createSyntaxError(state, 'Property name expected after dot')
let message = 'Property name expected after '
message += optional ? 'optional chain' : 'dot'
throw createSyntaxError(state, message)
}

params.push(new ConstantNode(state.token))
Expand Down
54 changes: 54 additions & 0 deletions test/unit-tests/expression/parse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,8 @@ describe('parse', function () {
const res = parseAndEval('obj["b"] = 2', scope)
assert.strictEqual(res, 2)
assert.deepStrictEqual(scope, { obj: { a: 3, b: 2 } })
assert.deepStrictEqual(
parseAndEval('b = {}; b.a = 2; b').valueOf(), [{ a: 2 }])
})

it('should set a nested object property', function () {
Expand All @@ -965,6 +967,17 @@ describe('parse', function () {
assert.deepStrictEqual(scope, { obj: { foo: { bar: 2 } } })
})

it(
'should not set an object property through optional chaining',
function () {
assert.throws(
() => parseAndEval('obj = {a: 2}; obj?.b = 7'), SyntaxError)
assert.throws(
() => parseAndEval('obj = {a: 2}; obj?.["b"] = 7'), SyntaxError)
assert.throws(
() => parseAndEval('obj = {a: {}}; obj.a?.b = 7'), SyntaxError)
})

it('should throw an error when trying to apply a matrix index as object property', function () {
const scope = { a: {} }
assert.throws(function () {
Expand Down Expand Up @@ -1196,6 +1209,13 @@ describe('parse', function () {
assert.throws(function () { parseAndEval('obj.foo["bar"].baz', { obj: { foo: { bar: null } } }) }, TypeError)
})

it('should throw an error when using double-dot after optional chaining operator', function () {
// ?.. is not valid in JavaScript and should be rejected
assert.throws(function () { parseAndEval('{a: 3}?..a') }, /SyntaxError: Property name expected after optional chain \(char 9\)/)
assert.throws(function () { parseAndEval('obj?..foo', { obj: { foo: 2 } }) }, /SyntaxError: Property name expected after optional chain \(char 6\)/)
assert.throws(function () { parseAndEval('obj?.["a"]?..b', { obj: { a: { b: 2 } } }) }, /SyntaxError: Property name expected after optional chain \(char 13\)/)
})

it('should set an object property with dot notation', function () {
const scope = { obj: {} }
parseAndEval('obj.foo = 2', scope)
Expand Down Expand Up @@ -1471,6 +1491,40 @@ describe('parse', function () {
parseAndEval('2(x, 2) = x^2', scope)
}, SyntaxError)
})

it('should call functions via optional chaining', function () {
assert.strictEqual(parseAndEval('square?.(2)'), 4)
assert.deepStrictEqual(parseAndEval('f(x) = x+x; f?.(2)').valueOf(), [4])
assert.strictEqual(parseAndEval('(_(x) = x^x)?.(2)'), 4)
assert.strictEqual(parseAndEval('foo?.(2)', { foo: x => x * x }), 4)
assert.deepStrictEqual(
parseAndEval('f(x) = 4x/x; bar = {a: f}; bar.a?.(2)').valueOf(), [4])
})

it(
'should shortcircuit undefined functions via optional chaining',
function () {
assert.strictEqual(
parseAndEval('foo?.(2)', { foo: undefined }), undefined)
assert.strictEqual(parseAndEval('{a: 3}.foo?.(2)'), undefined)
assert.strictEqual(
parseAndEval('foo.bar?.(2)', { foo: {} }), undefined)
assert.deepStrictEqual(
parseAndEval('f(x) = undefined; f(0)?.(2)').valueOf(), [undefined])
assert.strictEqual(parseAndEval('(undefined)?.(2)'), undefined)
assert.strictEqual(parseAndEval('foo?.(2)'), undefined)
})

it('should throw with optional chain call on non-function', function () {
// I guess it is OK to consider this a syntax error since we know just
// by reading the expression that the function call can't succeed.
assert.throws(() => parseAndEval('7?.(2)'), SyntaxError)
assert.throws(() => parseAndEval('a = 7; a?.(2)'), TypeError)
assert.throws(() => parseAndEval('(3+4)?.(2)'), TypeError)
assert.throws(() => parseAndEval('add(3,4)?.(2)'), TypeError)
assert.throws(() => parseAndEval('{a: true}.a?.(2)'), Error)
assert.throws(() => parseAndEval('[3, 4]?.(2)'), TypeError)
})
})

describe('parentheses', function () {
Expand Down