Arithmetic: drop divide-/modulo-by-zero short-circuit

The previous implementation intercepted `n / 0` and `n % 0` and returned
`.null`, on the rationale that `null` is friendlier for rule authors and
matches the engine's "missing value" convention. That's the last
remaining intentional deviation from `json-logic-js` in this file —
remove it for full spec parity.

`json-logic-js` delegates `/` and `%` to native JS arithmetic, which
follows IEEE 754: `n / 0` is `±Infinity` (sign matches the dividend),
`0 / 0` is `NaN`, and any `n % 0` is `NaN`. Swift's `Double` operators
and `truncatingRemainder(dividingBy:)` already produce these IEEE
values, so the fix is just dropping the divisor==0 short-circuit and
wrapping the result in `.float`.

Tests:
- Replace `testDivByZeroReturnsNull` with `testDivByZeroFollowsIeee754`
  pinning all three cases (`+Infinity`, `-Infinity`, `NaN`).
- Replace `testModByZeroReturnsNull` with `testModByZeroIsNan` covering
  `7 % 0` and `0 % 0`.
- Update the `1 / null` / `1 % null` cases inside
  `testSubDivAndModUseToNumberPerSpec` (divisor coerces to 0 →
  `+Infinity` / `NaN` instead of `.null`).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antonio PallaresandCursor committed 2026-05-27 10:27:32 +02:00
1 parent a1ad0bce09
commit e32933780b
2 files changed
+37 -43

No files matched your search

@@ -33,11 +33,10 @@ import Foundation
/// become `Double.nan` and propagate naturally — the final result is
/// `.float(nan)`, which `isTruthy` reports as falsy.
///
/// Division and modulo by zero return `.null` instead of `infinity` /
/// `nan`. JSON Logic JS produces the IEEE values, but for rule authoring
/// `null` is friendlier: it short-circuits comparisons in a predictable
/// way and matches the engine's "missing value" convention. This is the
/// one intentional deviation from the spec in this file.
/// Division and modulo by zero produce the IEEE 754 values (`±Infinity`
/// for `n / 0` with `n ≠ 0`, `NaN` for `0 / 0` and any `n % 0`), matching
/// `json-logic-js` exactly. Result is wrapped in `.float`, which means
/// `isTruthy` correctly reports `Infinity` as truthy and `NaN` as falsy.
enum ArithmeticOperators {
/// `{"+": [a, b, ...]}` — variadic sum. Each operand is coerced via
@@ -83,25 +82,18 @@ enum ArithmeticOperators {
}
/// `{"/": [a, b]}` — division. Operands are coerced via JS `Number()`
/// (`asNumber`). Division by zero returns `.null` (see type docs).
/// (`asNumber`). Division by zero follows IEEE 754: `n / 0` is
/// `±Infinity` (sign matches the dividend), `0 / 0` is `NaN`.
static func opDiv(args: Value, vars: Value) throws -> Value {
let (lhs, rhs) = try Operators.evalTwo(args, vars: vars, opName: "/")
let divisor = asDouble(rhs)
if divisor == 0.0 {
return .null
}
return .float(asDouble(lhs) / divisor)
return .float(asDouble(lhs) / asDouble(rhs))
}
/// `{"%": [a, b]}` — modulo. Operands are coerced via JS `Number()`
/// (`asNumber`). Modulo by zero returns `.null` (see type docs).
/// (`asNumber`). `n % 0` follows IEEE 754 and is `NaN`.
static func opMod(args: Value, vars: Value) throws -> Value {
let (lhs, rhs) = try Operators.evalTwo(args, vars: vars, opName: "%")
let divisor = asDouble(rhs)
if divisor == 0.0 {
return .null
}
return .float(asDouble(lhs).truncatingRemainder(dividingBy: divisor))
return .float(asDouble(lhs).truncatingRemainder(dividingBy: asDouble(rhs)))
}
/// `Number(value)`-style coercion for `-`, `/`, `%`. Falls back to
@@ -179,22 +179,26 @@ final class ArithmeticOperatorsTests: XCTestCase {
)
}
func testDivByZeroReturnsNull() throws {
// Both .int(0) and .float(0.0) divisors → .null
XCTAssertEqual(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .int(0))),
.null
/// `n / 0` follows IEEE 754: positive dividend → `+Infinity`, negative
/// dividend → `-Infinity`, `0 / 0` → `NaN`. Matches `json-logic-js`,
/// which delegates to native JS `/` (no short-circuit).
func testDivByZeroFollowsIeee754() throws {
// 1 / 0 → +Infinity (covers both .int(0) and .float(0.0) divisors).
let posIntZero = unwrapFloat(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .int(0)))
)
XCTAssertEqual(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .float(0.0))),
.null
XCTAssertEqual(posIntZero, .infinity)
let posFloatZero = unwrapFloat(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .float(0.0)))
)
// Even 0/0 returns .null (not NaN) — explicit short-circuit before
// arithmetic.
XCTAssertEqual(
try run(ArithmeticOperators.opDiv, args: arr(.int(0), .int(0))),
.null
XCTAssertEqual(posFloatZero, .infinity)
// -1 / 0 → -Infinity.
let neg = unwrapFloat(
try run(ArithmeticOperators.opDiv, args: arr(.int(-1), .int(0)))
)
XCTAssertEqual(neg, -.infinity)
// 0 / 0 → NaN.
assertNaN(try run(ArithmeticOperators.opDiv, args: arr(.int(0), .int(0))))
}
func testDivWrongArityIsTypeError() {
@@ -217,11 +221,11 @@ final class ArithmeticOperatorsTests: XCTestCase {
)
}
func testModByZeroReturnsNull() throws {
XCTAssertEqual(
try run(ArithmeticOperators.opMod, args: arr(.int(7), .int(0))),
.null
)
/// `n % 0` is always `NaN` per IEEE 754. Matches `json-logic-js`,
/// which delegates to native JS `%` (no short-circuit).
func testModByZeroIsNan() throws {
assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(7), .int(0))))
assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(0), .int(0))))
}
func testModWrongArityIsTypeError() {
@@ -297,16 +301,14 @@ final class ArithmeticOperatorsTests: XCTestCase {
.float(0.0)
)
// 1 / null and 1 % null → divisor coerces to 0 → null (engine's
// intentional div/mod-by-zero short-circuit; see type docs).
// 1 / null → divisor coerces to 0 → +Infinity (IEEE 754, see
// testDivByZeroFollowsIeee754 for the broader pinning).
XCTAssertEqual(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .null)),
.null
)
XCTAssertEqual(
try run(ArithmeticOperators.opMod, args: arr(.int(1), .null)),
.null
unwrapFloat(try run(ArithmeticOperators.opDiv, args: arr(.int(1), .null))),
.infinity
)
// 1 % null → divisor coerces to 0 → NaN.
assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(1), .null)))
// Bools coerce to 0 / 1 (Number(true) === 1, Number(false) === 0).
XCTAssertEqual(