Ikem 0b84ce264d
feat: add nullish coalescing operator support (#3497)
Resolves #3353.

* test: enhance nullish coalescing tests for arrays, matrices, and conditional expressions

* docs: add nullish coalescing operator precedence details and update syntax documentation
---------

Co-authored-by: Jos de Jong <wjosdejong@gmail.com>
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
2025-09-17 19:49:15 -04:00

283 lines
14 KiB
JavaScript

import assert from 'assert'
import { approxEqual } from '../../../../tools/approx.js'
import math from '../../../../src/defaultInstance.js'
import { createMap } from '../../../../src/utils/map.js'
const Complex = math.Complex
const Unit = math.Unit
const ResultSet = math.ResultSet
describe('evaluate', function () {
it('should evaluate expressions', function () {
approxEqual(math.evaluate('(2+3)/4'), 1.25)
assert.deepStrictEqual(math.evaluate('sqrt(-4)'), new Complex(0, 2))
})
it('should evaluate a list of expressions', function () {
assert.deepStrictEqual(math.evaluate(['1+2', '3+4', '5+6']), [3, 7, 11])
assert.deepStrictEqual(math.evaluate(['a=3', 'b=4', 'a*b']), [3, 4, 12])
assert.deepStrictEqual(math.evaluate(math.matrix(['a=3', 'b=4', 'a*b'])), math.matrix([3, 4, 12]))
assert.deepStrictEqual(math.evaluate(['a=3', 'b=4', 'a*b']), [3, 4, 12])
})
it('should evaluate a series of expressions', function () {
assert.deepStrictEqual(math.evaluate('a=3\nb=4\na*b'), new ResultSet([3, 4, 12]))
assert.deepStrictEqual(math.evaluate('f(x) = a * x; a=2; f(4)'), new ResultSet([8]))
assert.deepStrictEqual(math.evaluate('b = 43; b * 4'), new ResultSet([172]))
})
it('should throw an error if wrong number of arguments', function () {
assert.throws(function () { math.evaluate() }, /TypeError: Too few arguments/)
assert.throws(function () { math.evaluate('', {}, 3) }, /TypeError: Too many arguments/)
})
it('should throw an error with a unit', function () {
assert.throws(function () { math.evaluate(new Unit(5, 'cm')) }, /TypeError: Unexpected type of argument/)
})
it('should throw an error with a complex number', function () {
assert.throws(function () { math.evaluate(new Complex(2, 3)) }, /TypeError: Unexpected type of argument/)
})
it('should evaluate a boolean', function () {
// TODO: this is odd. Boolean is turned in to string and then evaluated
assert.strictEqual(math.evaluate(true), true)
})
it('should handle the given scope', function () {
const scope = {
a: 3,
b: 4
}
assert.deepStrictEqual(math.evaluate('a*b', scope), 12)
assert.deepStrictEqual(math.evaluate('c=5', scope), 5)
assert.deepStrictEqual(math.format(math.evaluate('f(x) = x^a', scope)), 'f(x)')
assert.deepStrictEqual(Object.keys(scope).length, 4)
assert.deepStrictEqual(scope.a, 3)
assert.deepStrictEqual(scope.b, 4)
assert.deepStrictEqual(scope.c, 5)
assert.deepStrictEqual(typeof scope.f, 'function')
assert.strictEqual(scope.f(3), 27)
scope.a = 2
assert.strictEqual(scope.f(3), 9)
scope.hello = function (name) {
return 'hello, ' + name + '!'
}
assert.deepStrictEqual(math.evaluate('hello("jos")', scope), 'hello, jos!')
})
it('should handle the given Map scope', function () {
const scope = createMap({
a: 3,
b: 4
})
assert.deepStrictEqual(math.evaluate('a*b', scope), 12)
assert.deepStrictEqual(math.evaluate('c=5', scope), 5)
})
it('should LaTeX evaluate', function () {
const expr1 = math.parse('evaluate(expr)')
const expr2 = math.parse('evaluate(expr,scope)')
assert.strictEqual(expr1.toTex(), '\\mathrm{evaluate}\\left( expr\\right)')
assert.strictEqual(expr2.toTex(), '\\mathrm{evaluate}\\left( expr, scope\\right)')
})
describe('nullish coalescing operator', function () {
it('should handle basic nullish coalescing', function () {
assert.strictEqual(math.evaluate('null ?? 42'), 42)
assert.strictEqual(math.evaluate('undefined ?? 42'), 42)
assert.strictEqual(math.evaluate('0 ?? 42'), 0)
assert.strictEqual(math.evaluate('nullish(null, 42)'), 42)
assert.strictEqual(math.evaluate('false ?? 42'), false)
assert.strictEqual(math.evaluate('"" ?? 42'), '')
assert(isNaN(math.evaluate('NaN ?? 42'))) // Should return NaN, not 42
})
it('should handle nullish coalescing with variables', function () {
const scope1 = {}
assert.throws(() => math.evaluate('x ?? 42', scope1), /Undefined symbol x/)
assert.throws(() => math.evaluate('nullish(x, 42)', scope1), /Undefined symbol x/)
const scope2 = { x: null }
assert.strictEqual(math.evaluate('x ?? 42', scope2), 42)
assert.strictEqual(math.evaluate('nullish(x, 42)', scope2), 42)
const scope3 = { x: 0 }
assert.strictEqual(math.evaluate('x ?? 42', scope3), 0)
assert.strictEqual(math.evaluate('nullish(x, 42)', scope3), 0)
const scope4 = { x: undefined }
assert.strictEqual(math.evaluate('x ?? 42', scope4), 42)
assert.strictEqual(math.evaluate('nullish(x, 42)', scope4), 42)
const scope5 = { x: 5 }
assert.strictEqual(math.evaluate('x ?? 42', scope5), 5)
assert.strictEqual(math.evaluate('nullish(x, 42)', scope5), 5)
})
it('should handle chained nullish coalescing', function () {
assert.strictEqual(math.evaluate('null ?? undefined ?? 42'), 42)
assert.strictEqual(math.evaluate('nullish(null, undefined ?? 42)'), 42)
assert.strictEqual(math.evaluate('null ?? 10 ?? 42'), 10)
assert.strictEqual(math.evaluate('5 ?? null ?? 42'), 5)
assert.strictEqual(math.evaluate('nullish(5, null ?? 42)'), 5)
assert.strictEqual(math.evaluate('null ?? null ?? null ?? 99'), 99)
assert.strictEqual(math.evaluate('nullish(null ?? null, null ?? 99)'), 99)
})
it('should handle nullish coalescing with correct precedence', function () {
// ?? has higher precedence than arithmetic and logical operators
assert.strictEqual(math.evaluate('1 + null ?? 2'), 3) // 1 + (null ?? 2)
assert.strictEqual(math.evaluate('2 * null ?? 3'), 6) // 2 * (null ?? 3)
// ?? has higher precedence than exponentiation
assert.strictEqual(math.evaluate('2 ^ null ?? 3'), 8) // 2 ^ (null ?? 3)
assert.strictEqual(math.evaluate('null ?? false or true'), true) // (null ?? false) or true
assert.strictEqual(math.evaluate('true or null ?? 42'), true) // true or (null ?? 42)
assert.strictEqual(math.evaluate('false and null ?? 42'), false) // false and (null ?? 42)
assert.strictEqual(math.evaluate('true xor null ?? 42'), false) // true xor (null ?? 42)
// Parentheses can override precedence
assert.throws(() => math.evaluate('(1 + null) ?? 2'), /TypeError: Unexpected type of argument/)
assert.throws(() => math.evaluate('(2 * null) ?? 3'), /TypeError: Unexpected type of argument/)
assert.throws(() => math.evaluate('(2 ^ null) ?? 3'), /TypeError: Unexpected type of argument/)
assert.strictEqual(math.evaluate('2 * (null ?? 3)'), 6)
})
it('should handle nullish coalescing with higher precedence than exponentiation', function () {
// These tests specifically verify that ?? has higher precedence than ^
assert.strictEqual(math.evaluate('5 ?? 2 ^ 3'), 125) // (5 ?? 2) ^ 3 = 5 ^ 3 = 125
assert.strictEqual(math.evaluate('5 ?? (2 ^ 3)'), 5)
assert.strictEqual(math.evaluate('3 ^ null ?? 2 ^ 2'), 81) // 3 ^ (null ?? 2) ^ 2 = 3 ^ 2 ^ 2 = 3 ^ 4 = 81
assert.strictEqual(math.evaluate('false ?? 3 ^ 2'), 0) // false is not nullish, so (false ?? 3) ^ 2 = false ^ 2 = 0 ^ 2 = 0
})
it('should handle nullish coalescing precedence with dot power and left-hand operators', function () {
// dot power .^ should behave like ^ for scalars and bind lower than ??
assert.strictEqual(math.evaluate('2 .^ null ?? 3'), 8) // 2 .^ (null ?? 3)
assert.strictEqual(math.evaluate('5 ?? 2 .^ 3'), 125) // (5 ?? 2) .^ 3
// left-hand operators like factorial bind tighter than ??
assert.strictEqual(math.evaluate('5! ?? 2'), 120) // (5!) ?? 2
assert.strictEqual(math.evaluate('null ?? 3!'), 6) // null ?? (3!)
assert.strictEqual(math.evaluate('(null ?? 3)!'), 6) // parentheses with left-hand op
})
it('should handle nullish coalescing with scope lookup', function () {
const scope = { a: null, b: 5, c: 0 }
assert.strictEqual(math.evaluate('a ?? b * 2', scope), 10) // null ?? (5 * 2)
assert.strictEqual(math.evaluate('c ?? b * 2', scope), 0) // 0 ?? (5 * 2) = 0
assert.strictEqual(math.evaluate('(a ?? b) * 2', scope), 10) // (null ?? 5) * 2 = 10
// d is undefined, would throw error without fallback
assert.throws(() => math.evaluate('d ?? 0', scope), /Undefined symbol d/)
})
it('should handle nullish coalescing with strings', function () {
assert.strictEqual(math.evaluate('null ?? "hello"'), 'hello')
assert.strictEqual(math.evaluate('"world" ?? "hello"'), 'world')
assert.strictEqual(math.evaluate('"" ?? "hello"'), '') // empty string is not nullish
})
it('should handle nullish coalescing with matrices and arrays as operands', function () {
// Test arrays as operands
assert.deepStrictEqual(math.evaluate('null ?? [1, 2, 3]'), math.matrix([1, 2, 3]))
assert.deepStrictEqual(math.evaluate('[1, 2] ?? [3, 4]'), math.matrix([1, 2])) // Neither 1 nor 2 is nullish
assert.deepStrictEqual(math.evaluate('undefined ?? [5, 6]'), math.matrix([5, 6]))
assert.deepStrictEqual(math.evaluate('[null, null] ?? [7, 8]'), math.matrix([7, 8])) // Both null elements are nullish, so use [7, 8]
// Test matrices as operands
const matrix1 = math.matrix([1, 2])
assert.deepStrictEqual(math.evaluate('null ?? matrix([1, 2])'), matrix1)
assert.deepStrictEqual(math.evaluate('matrix([1, 2]) ?? matrix([3, 4])'), matrix1) // Neither 1 nor 2 is nullish
assert.deepStrictEqual(math.evaluate('undefined ?? matrix([5, 6])'), math.matrix([5, 6]))
// Test mixed arrays and matrices
assert.deepStrictEqual(math.evaluate('null ?? matrix([1, 2])'), matrix1)
assert.deepStrictEqual(math.evaluate('[1, 2] ?? matrix([3, 4])'), math.matrix([1, 2]))
assert.deepStrictEqual(math.evaluate('[null, 5] ?? 42'), math.matrix([42, 5]))
assert.deepStrictEqual(math.evaluate('[null, 5] ?? [1, 2]'), math.matrix([1, 5]))
// Test arrays/matrices containing expressions
assert.deepStrictEqual(math.evaluate(['null ?? 1', '2 ?? null', 'null ?? null ?? 3']), [1, 2, 3])
assert.deepStrictEqual(math.evaluate(math.matrix(['null ?? 1', '2 ?? null'])), math.matrix([1, 2]))
// Test shape mismatch with empty array
assert.throws(() => math.evaluate('[] ?? [7, 8]'), /RangeError/)
assert.throws(() => math.evaluate('[1] ?? [7, 8]'), /RangeError/)
})
it('should handle nullish coalescing with function calls', function () {
const scope = {
getValue: function () { return null },
getDefault: function () { return 42 }
}
assert.strictEqual(math.evaluate('getValue() ?? getDefault()', scope), 42)
const scope2 = {
getValue: function () { return 10 },
getDefault: function () { return 42 }
}
assert.strictEqual(math.evaluate('getValue() ?? getDefault()', scope2), 10)
})
it('should handle nullish function with arrays', function () {
assert.deepStrictEqual(math.evaluate('nullish(null, [1, 2, 3])'), math.matrix([1, 2, 3]))
assert.deepStrictEqual(math.evaluate('nullish([1, 2], [3, 4])'), math.matrix([1, 2]))
assert.deepStrictEqual(math.evaluate('nullish([null, 5], 42)'), math.matrix([42, 5]))
assert.deepStrictEqual(math.evaluate('nullish([null, 5], [1, 2])'), math.matrix([1, 5]))
})
it('should handle nullish coalescing with conditional expressions and correct precedence', function () {
// ?? has higher precedence than conditional (?:), so these test cases show the difference
assert.strictEqual(math.evaluate('5 ?? null ? 1 : 2'), 1) // (5 ?? null) ? 1 : 2 = 5 ? 1 : 2 = 1
assert.strictEqual(math.evaluate('null ?? 0 ? 1 : 2'), 2) // (null ?? 0) ? 1 : 2 = 0 ? 1 : 2 = 2
assert.strictEqual(math.evaluate('undefined ?? true ? 1 : 2'), 1) // (undefined ?? true) ? 1 : 2 = true ? 1 : 2 = 1
assert.strictEqual(math.evaluate('(5 ?? null) ? 1 : 2'), 1) // Explicit precedence
assert.strictEqual(math.evaluate('5 ?? (null ? 1 : 2)'), 5) // Different precedence
})
it('should short-circuit evaluation of the right-hand side when left is not nullish', function () {
// RHS throws if evaluated; must not be called
const scope = {
boom: function () { throw new Error('RHS evaluated unexpectedly') }
}
assert.strictEqual(math.evaluate('5 ?? boom()', scope), 5)
assert.strictEqual(math.evaluate('0 ?? boom()', scope), 0)
assert.strictEqual(math.evaluate('false ?? boom()', scope), false)
assert.strictEqual(math.evaluate('"" ?? boom()', scope), '')
assert.throws(() => math.evaluate('null ?? boom()', scope))
assert.throws(() => math.evaluate('undefined ?? boom()', scope))
})
it('should evaluate the right-hand side when left is nullish', function () {
let count = 0
const scope = {
inc: function () { count++; return 7 }
}
assert.strictEqual(math.evaluate('null ?? inc()', scope), 7)
assert.strictEqual(count, 1)
assert.strictEqual(math.evaluate('undefined ?? inc()', scope), 7)
assert.strictEqual(count, 2)
})
it('should not short-circuit for collections (element-wise evaluation requires RHS)', function () {
// When left is a collection, element-wise nullish requires evaluating RHS
let called = 0
const scope = {
getDefault: function () {
called++
return math.matrix([1, 2])
}
}
const res = math.evaluate('matrix([null, 5]) ?? getDefault()', scope)
assert.deepStrictEqual(res, math.matrix([1, 5]))
assert.strictEqual(called, 1)
})
})
})