mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-10 22:45:55 +05:45
* Compare strings by UTF-16 code unit in the rules engine A JS string is a sequence of UTF-16 code units and every string operation works on those units. Swift's String compares canonical equivalence over Unicode scalars, so <, <=, >, >=, ==, ===, in and rc.entries key ordering all had cases where a predicate evaluated one way on iOS and the other in JS and Kotlin. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the new fixture descriptions platform-neutral The fixture files are byte-identical across the iOS and Android engines, so a description that explains the contract in terms of one host language's string comparison does not read correctly in the other repository. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the characters under test in the UTF-16 fixture descriptions The predicates spell these strings as \uXXXX escapes so the exact code units are unambiguous, which leaves a reader unable to tell which character a fixture is about. Each description now shows the glyph and its code point, and spells out the surrogate pair where one is the point of the case. Co-authored-by: Cursor <cursoragent@cursor.com> * Route rc.sortBy string keys through jsStringPrecedes The operator open-coded the UTF-16 comparison because the shared helper did not exist yet when it landed. It does now, and the rationale lives in the helper's doc, so the operator no longer repeats it. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare object keys by UTF-16 code unit A Swift dictionary keys by canonical equivalence, so é (U+00E9) and e followed by a combining acute (U+0301) were one key: an object holding both spellings kept only one, and a var path spelled either way resolved against the other. JS and Kotlin keep the two apart, so a rule reading a non-ASCII key could answer differently per platform. Value.object now stores an ObjectValue, whose keys compare and hash by code unit, and the JSON parser iterates the NSDictionary rather than casting to [String: Any], which merges the keys before the engine sees them. Co-authored-by: Cursor <cursoragent@cursor.com> * Fold the ObjectValue.filter condition into a where clause SwiftLint's for_where rule, and it reads better. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
68 lines
2.8 KiB
Swift
68 lines
2.8 KiB
Swift
//
|
|
// LetOperator.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Name used in this operator's error messages.
|
|
private let operatorName = "rc.let"
|
|
|
|
extension RulesEngine {
|
|
|
|
/// `rc.let` — binds names that stay readable inside iteration.
|
|
enum LetOperator {
|
|
|
|
/// `{"rc.let": [{"name": expression, ...}, body]}` — evaluates `body`
|
|
/// with each name available to `var`.
|
|
///
|
|
/// Iteration replaces the whole scope with the current item, so a
|
|
/// predicate nested inside `some` or `reduce` cannot otherwise reach a
|
|
/// value from the enclosing level. Binding it first is what makes
|
|
/// comparing an item against something outside the loop expressible.
|
|
///
|
|
/// `var` reads the active data first and falls back to these names, so
|
|
/// a binding never masks a field the data actually has.
|
|
///
|
|
/// The declarations are a literal object, not an evaluated one: a
|
|
/// single-key object anywhere else in a predicate is an operator call,
|
|
/// which is why the argument is never evaluated as a whole. Every
|
|
/// declared expression is evaluated in the scope enclosing the
|
|
/// `rc.let`, so bindings cannot see each other and their order carries
|
|
/// no meaning; nest a second `rc.let` to build one from another.
|
|
///
|
|
/// Names must be non-empty and free of `.`, since a dotted name would
|
|
/// be unreachable behind path traversal. Anything else, including a
|
|
/// non-object declaration list or the wrong argument count, throws
|
|
/// `EvaluationError.typeMismatch`.
|
|
static func opLet(args: Value, vars: Scope) throws -> Value {
|
|
let raw = Operators.argsAsList(args)
|
|
|
|
try Operators.checkArity(raw.count, allowed: [2], operatorName: operatorName)
|
|
|
|
guard case .object(let declarations) = raw[0] else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected an object of bindings, "
|
|
+ "got \(raw[0])"
|
|
)
|
|
}
|
|
|
|
var bindings = ObjectValue()
|
|
// Sorted so that a predicate with two invalid names fails the same
|
|
// way on both engines.
|
|
for name in declarations.keys.sorted() {
|
|
guard !name.isEmpty, !name.contains(".") else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected a binding name that is "
|
|
+ "non-empty and free of '.', got '\(name)'"
|
|
)
|
|
}
|
|
bindings[name] = try Evaluator.evaluateValue(declarations[name] ?? .null, vars: vars)
|
|
}
|
|
|
|
return try Evaluator.evaluateValue(raw[1], vars: vars.binding(bindings))
|
|
}
|
|
}
|
|
}
|