Files
Antonio PallaresandCursor 2deacfd190 Add Scope plumbing and custom operator extension point (#7433)
* Add Scope plumbing and custom operator extension point

Thread evaluation scope through the rules engine and delegate unknown
operators to an empty CustomOperators dispatcher, without changing behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix project.pbxproj: restore RewardedAdTrackingMetadata entries

The RulesEngine registration edit accidentally dropped four
RewardedAdTrackingMetadata.swift entries from the Xcode project.
Restore them and normalize CustomOperators identifiers to 24 hex chars.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Promote firstArgEvaluated to shared Operators helpers

Move the unary-arg helper from LogicOperators to Operators so custom
operators can use the same json-logic-js spread convention.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address review comments on custom operator extension point

Drop the cross-engine registration note from CustomOperators, and restore
the single-line var extra-args warning that was reflowed without changing
its text.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 08:04:21 +00:00

96 lines
4.4 KiB
Swift

//
// ArithmeticOperators.swift
//
// Created by Antonio Pallares.
//
import Foundation
extension RulesEngine {
/// Arithmetic operators: `+`, `-`, `*`, `/`, `%`.
///
/// All operators return `.float(Double)`. `json-logic-js` is asymmetric
/// about which JS coercion each operator uses, and we replicate that:
///
/// - `+` and `*` go through `parseFloat(value)` — `value` is stringified
/// first, then the longest numeric prefix is parsed. `null`, bools,
/// the empty string, and `[1,2]` all yield `NaN`; `"3.14abc"` parses
/// as `3.14`. See `jsParseFloat`.
/// - `-`, `/`, `%` use native JS arithmetic which calls `Number(value)`
/// — bool / null / empty-string become `0`, arrays / objects coerce
/// via `ToPrimitive("number")` → `toString` → recurse. `[]` → `0`,
/// `[1]` → `1`, `[1,2]` → `NaN`. See `Value.asNumber`.
///
/// 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`.
enum ArithmeticOperators {
/// `{"+": [a, b, ...]}` — variadic sum, seeded with `0`. 0 arguments
/// returns `0`. Each operand is coerced via JS `parseFloat`.
static func opAdd(args: Value, vars: Scope) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
let sum = evaluated.reduce(0.0) { $0 + jsParseFloat($1) }
return .float(sum)
}
/// `{"*": [a, b, ...]}` — variadic product, no seed (matches
/// `Array.prototype.reduce` without an initial value). The 1-arg form
/// returns the operand unchanged (no `parseFloat` coercion). 0
/// arguments is a `.typeMismatch` to mirror `[].reduce(fn)` throwing.
static func opMul(args: Value, vars: Scope) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
guard let head = evaluated.first else {
throw RulesEngine.EvaluationError.typeMismatch(message: "operator '*' requires at least 1 argument")
}
guard evaluated.count > 1 else { return head }
let product = evaluated.dropFirst().reduce(jsParseFloat(head)) { $0 * jsParseFloat($1) }
return .float(product)
}
/// `{"-": [a]}` — unary negation. `{"-": [a, b]}` — subtraction.
/// `{"-": [a, b, ...]}` ignores extra operands. `{"-": []}` returns
/// `NaN` (mirroring JS `-undefined`). Operands are coerced via JS
/// `Number()` (`asNumber`).
static func opSub(args: Value, vars: Scope) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
let lhs = evaluated.first.map(asDouble) ?? .nan
if evaluated.count >= 2 {
return .float(lhs - asDouble(evaluated[1]))
}
return .float(-lhs)
}
/// `{"/": [a, b]}` — division. Extra operands are ignored; missing
/// operands resolve to `NaN` (mirroring JS `undefined / x`). Division
/// by zero follows IEEE 754: `n / 0` is `±Infinity`, `0 / 0` is `NaN`.
static func opDiv(args: Value, vars: Scope) throws -> Value {
let (lhs, rhs) = try evalDivisorPair(args, vars: vars)
return .float(lhs / rhs)
}
/// `{"%": [a, b]}` — modulo. Same arity / coercion rules as `/`;
/// `n % 0` follows IEEE 754 and is `NaN`.
static func opMod(args: Value, vars: Scope) throws -> Value {
let (lhs, rhs) = try evalDivisorPair(args, vars: vars)
return .float(lhs.truncatingRemainder(dividingBy: rhs))
}
/// Evaluate two operands into `Double`, defaulting missing operands
/// to `NaN` (mirroring JS `undefined`). Extra operands are ignored.
private static func evalDivisorPair(_ args: Value, vars: Scope) throws -> (Double, Double) {
let evaluated = try Operators.evalArgs(args, vars: vars)
let lhs = evaluated.first.map(asDouble) ?? .nan
let rhs = evaluated.count >= 2 ? asDouble(evaluated[1]) : .nan
return (lhs, rhs)
}
/// `Number(value)`-style coercion for `-`, `/`, `%`. Falls back to
/// `nan` for non-numeric operands. `+` and `*` use `jsParseFloat`.
private static func asDouble(_ value: Value) -> Double {
value.asNumber ?? .nan
}
}
}