Files
purchases-ios/RulesEngineInternal/Operators/ArithmeticOperators.swift
T

105 lines
4.8 KiB
Swift
Raw Normal View History

//
// ArithmeticOperators.swift
//
// Created by Antonio Pallares.
//
import Foundation
/// Arithmetic operators: `+`, `-`, `*`, `/`, `%`.
///
/// All operators return `.float(Double)` regardless of operand types. JSON
/// Logic in JS coerces every operand to a number before arithmetic, so
/// preserving an `.int` result would be a per-call decision with no spec
/// support. `looseEq` and `strictEq` already bridge `.int(n) ↔
/// .float(n.0)`, so callers comparing an arithmetic result to an integer
/// literal still get the expected answer.
///
/// `json-logic-js` is asymmetric about *which* JS coercion it uses, and we
/// faithfully replicate that asymmetry:
///
/// - `+` and `*` go through `parseFloat(value)` — `value` is stringified
/// first, then the longest numeric prefix is parsed. So `null`, bools,
/// the empty string, and `[1,2]` (which stringifies to `"1,2"`) all
/// yield `NaN`, while `"3.14abc"` parses as `3.14`. See `jsParseFloat`.
/// - `-`, `/`, `%` use native JS arithmetic which calls `Number(value)`
/// (a.k.a. `ToNumber`) — bool / null / empty-string become `0`, and
/// arrays / objects coerce via `ToPrimitive("number")` → `toString` →
/// recurse. So `[]` → `0`, `[1]` → `1`, `[1,2]` → `NaN`. See
/// `Value.asNumber`.
///
/// Operands that can't be coerced (`.object`, multi-element arrays,
/// unparseable strings, anything that hits `parseFloat`'s strict cases)
/// become `Double.nan` and propagate naturally — the final result is
/// `.float(nan)`, which `isTruthy` reports as falsy.
///
/// 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
/// JS `parseFloat`. The 1-arg form acts as a numeric cast
/// (`{"+": ["3.14"]}` → `3.14`, but `{"+": [true]}` → `NaN` because
/// `parseFloat("true")` is `NaN`). 0 arguments is a `.typeMismatch`.
static func opAdd(args: Value, vars: Value) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
guard !evaluated.isEmpty else {
throw RuleError.typeMismatch(message: "operator '+' requires at least 1 argument")
}
let sum = evaluated.reduce(0.0) { $0 + jsParseFloat($1) }
return .float(sum)
}
/// `{"*": [a, b, ...]}` — variadic product. Each operand is coerced
/// via JS `parseFloat` (same rules as `+`). 0 arguments is a
/// `.typeMismatch`.
static func opMul(args: Value, vars: Value) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
guard !evaluated.isEmpty else {
throw RuleError.typeMismatch(message: "operator '*' requires at least 1 argument")
}
let product = evaluated.reduce(1.0) { $0 * jsParseFloat($1) }
return .float(product)
}
/// `{"-": [a]}` — unary negation. `{"-": [a, b]}` — subtraction.
/// Operands are coerced via JS `Number()` (`asNumber`). Other arities
/// are a `.typeMismatch`.
static func opSub(args: Value, vars: Value) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
switch evaluated.count {
case 1:
return .float(-asDouble(evaluated[0]))
case 2:
return .float(asDouble(evaluated[0]) - asDouble(evaluated[1]))
default:
throw RuleError.typeMismatch(
message: "operator '-' expects 1 or 2 arguments, got \(evaluated.count)"
)
}
}
/// `{"/": [a, b]}` — division. Operands are coerced via JS `Number()`
/// (`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: "/")
return .float(asDouble(lhs) / asDouble(rhs))
}
/// `{"%": [a, b]}` — modulo. Operands are coerced via JS `Number()`
/// (`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: "%")
return .float(asDouble(lhs).truncatingRemainder(dividingBy: asDouble(rhs)))
}
/// `Number(value)`-style coercion for `-`, `/`, `%`. Falls back to
/// `nan` so arithmetic propagates the failure without raising an
/// error. `+` and `*` use `jsParseFloat` instead — see type docs.
private static func asDouble(_ value: Value) -> Double {
value.asNumber ?? .nan
}
}