Files
Antonio PallaresandCursor ad09c032c9 Add rc.rootVar operator for root scope in RulesEngine (#7432)
* 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>

* Add rc.rootVar operator for nested predicate root access

Introduce the rc.rootVar custom operator and shared resolveVar lookup,
with fixture coverage including falsy-item empty-path discrimination.

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 18:07:53 +02:00

168 lines
8.5 KiB
Swift

//
// IterationOperators.swift
//
// Created by Antonio Pallares.
//
import Foundation
extension RulesEngine {
/// Iteration operators: `some`, `all`, `none`, `map`, `filter`, `reduce`.
///
/// All six follow the JSON Logic JS reference (`json-logic-js`):
///
/// - **Shape** (`some` / `all` / `none` / `map` / `filter`):
/// `{"<op>": [arrayExpr, predicateExpr]}`. The first argument is
/// evaluated in the outer scope and must resolve to an array; anything
/// else is treated as an empty source. The second argument is a
/// literal template that is evaluated per-item with `vars` rebound to
/// the current item, with no parent-scope inheritance for `var`; the
/// preserved root scope remains available (`rc.rootVar` reads it).
/// - **Shape** (`reduce`):
/// `{"reduce": [arrayExpr, predicateExpr, initialAccumulator]}`. Both
/// the first and third arguments are evaluated in the outer scope.
/// The predicate is evaluated per-item with `vars` rebound to
/// `{"current": <item>, "accumulator": <acc>}`; `var` sees only that
/// object, but `rc.rootVar` still reaches the root.
///
/// **Empty- and non-array sources** per the JSON Logic JS spec:
/// - `some` / `all` return `false`.
/// - `none` returns `true`.
/// - `map` / `filter` return `[]`.
/// - `reduce` returns the initial accumulator unchanged.
enum IterationOperators {
/// `{"some": [arrayExpr, predicate]}` — `true` iff `predicate` is
/// truthy for at least one item. The array expression is evaluated in
/// the current scope; the predicate is re-evaluated per item with
/// `vars` rebound to that item (`var` reads the item; `rc.rootVar`
/// reads the root). Empty array or non-array source returns `false`.
/// Short-circuits on the first truthy result.
static func opSome(args: Value, vars: Scope) throws -> Value {
let (items, predicate) = try parseIterationArgs(args, vars: vars)
guard let items else { return .bool(false) }
for item in items {
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
if result.isTruthy { return .bool(true) }
}
return .bool(false)
}
/// `{"all": [arrayExpr, predicate]}` — `true` iff `predicate` is
/// truthy for every item. The array expression is evaluated in the
/// current scope; the predicate is re-evaluated per item with `vars`
/// rebound to that item (`var` reads the item; `rc.rootVar` reads the
/// root). Empty array returns `false` per the JSON Logic JS spec.
/// Non-array source returns `false`. Short-circuits on the first
/// non-truthy result.
static func opAll(args: Value, vars: Scope) throws -> Value {
let (items, predicate) = try parseIterationArgs(args, vars: vars)
guard let items, !items.isEmpty else { return .bool(false) }
for item in items {
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
if !result.isTruthy { return .bool(false) }
}
return .bool(true)
}
/// `{"none": [arrayExpr, predicate]}` — `true` iff `predicate` is
/// falsy for every item. Inverse of `some`. Short-circuits on the
/// first truthy item. Empty and non-array sources both return `true`,
/// matching the JS reference's `!Array.isArray(x) || !x.length` guard.
static func opNone(args: Value, vars: Scope) throws -> Value {
let (items, predicate) = try parseIterationArgs(args, vars: vars)
guard let items else { return .bool(true) }
for item in items {
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
if result.isTruthy { return .bool(false) }
}
return .bool(true)
}
/// `{"map": [arrayExpr, predicate]}` — apply `predicate` to each
/// item, return the new array of *raw* (non-truthy-coerced) results.
/// Empty or non-array source yields `[]`.
static func opMap(args: Value, vars: Scope) throws -> Value {
let (items, predicate) = try parseIterationArgs(args, vars: vars)
guard let items else { return .array([]) }
var results: [Value] = []
results.reserveCapacity(items.count)
for item in items {
results.append(try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item)))
}
return .array(results)
}
/// `{"filter": [arrayExpr, predicate]}` — return only items for
/// which `predicate` is truthy. Empty or non-array source yields
/// `[]`. The retained items are the *original* values, not the
/// predicate results.
static func opFilter(args: Value, vars: Scope) throws -> Value {
let (items, predicate) = try parseIterationArgs(args, vars: vars)
guard let items else { return .array([]) }
var results: [Value] = []
for item in items {
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
if result.isTruthy { results.append(item) }
}
return .array(results)
}
/// `{"reduce": [arrayExpr, predicate, initialAccumulator]}` — fold
/// over the array. The third argument is evaluated in the outer
/// scope to seed the accumulator, then the predicate is evaluated
/// once per item with `vars` rebound to
/// `{"current": item, "accumulator": acc}`. A non-array source
/// returns the seed unchanged. A missing predicate defaults to
/// `.undefined` (so it evaluates to `.undefined` per item, matching
/// `json-logic-js`'s `apply(undefined, …)`). A missing initial
/// accumulator defaults to `.null` — `json-logic-js` seeds the fold
/// with `typeof values[2] !== "undefined" ? values[2] : null`.
/// Arguments past the third are ignored.
static func opReduce(args: Value, vars: Scope) throws -> Value {
let raw = Operators.argsAsList(args)
let sourceArg: Value = raw.indices.contains(0) ? raw[0] : .null
let predicate: Value = raw.indices.contains(1) ? raw[1] : .undefined
let source = try Evaluator.evaluateValue(sourceArg, vars: vars)
var accumulator: Value = raw.indices.contains(2)
? try Evaluator.evaluateValue(raw[2], vars: vars)
: .null
guard case .array(let items) = source else { return accumulator }
for item in items {
let itemScope = vars.scoped(to: .object([
"current": item,
"accumulator": accumulator
]))
accumulator = try Evaluator.evaluateValue(predicate, vars: itemScope)
}
return accumulator
}
/// Parse `(items, predicate)` for the item-as-scope iteration
/// operators (`some`, `all`, `none`, `map`, `filter`). The source
/// argument is evaluated in the outer scope; the predicate template
/// is returned unevaluated so the caller can re-evaluate it per
/// item with the item as scope. `items` is `nil` when the source
/// does not resolve to an array, so callers can distinguish a
/// non-array source from a genuinely empty one (`some`/`all` treat
/// both as `false`, but `none`/`map`/`filter`/`reduce` need the
/// distinction). A missing predicate defaults to `.undefined` (so it
/// evaluates to `.undefined` per item, matching `json-logic-js`'s
/// `apply(undefined, …)`; observable via `map`'s raw results) and
/// arguments past the second are ignored, matching `json-logic-js`'s
/// `function(scopedData, scopedLogic)` signature.
private static func parseIterationArgs(
_ args: Value,
vars: Scope
) throws -> (items: [Value]?, predicate: Value) {
let raw = Operators.argsAsList(args)
let sourceArg: Value = raw.indices.contains(0) ? raw[0] : .null
let predicate: Value = raw.indices.contains(1) ? raw[1] : .undefined
let source = try Evaluator.evaluateValue(sourceArg, vars: vars)
guard case .array(let items) = source else { return (nil, predicate) }
return (items, predicate)
}
}
}