mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +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> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
120 lines
5.1 KiB
Swift
120 lines
5.1 KiB
Swift
//
|
|
// StringArrayOperators.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension RulesEngine {
|
|
|
|
/// String + array operators: `in`, `cat`, `substr`, `merge`.
|
|
///
|
|
/// Behavior follows the JSON Logic JS reference (`json-logic-js`).
|
|
/// `substr` slices by Unicode code points, not UTF-16 code units —
|
|
/// matches Swift's `String.Character` semantics; differs from JS only
|
|
/// for surrogate-pair characters.
|
|
enum StringArrayOperators {
|
|
|
|
/// `{"in": [needle, haystack]}` — substring or array-membership
|
|
/// test. For a `.string` haystack, the needle is stringified and
|
|
/// the test is substring containment (mirrors JS
|
|
/// `String.prototype.indexOf`); when the haystack is falsy,
|
|
/// json-logic-js returns false immediately
|
|
/// (`if (!haystack) return false`), so an empty string never matches.
|
|
/// For an `.array` haystack, the test is strict element equality
|
|
/// (mirrors JS `Array.prototype.indexOf`, which uses `===`). Any
|
|
/// other haystack type returns `false`. `json-logic-js` implements
|
|
/// `in` as `function(a, b)` (needle, haystack); missing or extra
|
|
/// operands short-circuit to `false`.
|
|
static func opIn(args: Value, vars: Scope) throws -> Value {
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
let needle = evaluated.first ?? .null
|
|
let haystack = evaluated.indices.contains(1) ? evaluated[1] : .null
|
|
switch haystack {
|
|
case .string(let haystackString):
|
|
// json-logic-js: `if (!haystack || …) return false` — empty
|
|
// string is falsy, so `in` never matches regardless of needle.
|
|
if haystackString.isEmpty { return .bool(false) }
|
|
return .bool(jsStringContains(haystack: haystackString, needle: jsString(needle)))
|
|
case .array(let items):
|
|
return .bool(items.contains { strictEq(needle, $0) })
|
|
default:
|
|
return .bool(false)
|
|
}
|
|
}
|
|
|
|
/// `{"cat": [a, b, ...]}` — variadic string concatenation. Each
|
|
/// operand is rendered via [`jsArrayElementString`] (mirrors
|
|
/// `Array.prototype.join` on the argument list: `null` → `""`).
|
|
/// 0 args returns `""`.
|
|
static func opCat(args: Value, vars: Scope) throws -> Value {
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
let joined = evaluated.map(jsArrayElementString).joined()
|
|
return .string(joined)
|
|
}
|
|
|
|
/// `{"substr": [source, start]}` or
|
|
/// `{"substr": [source, start, length]}`. `source` is stringified.
|
|
/// Negative `start` counts from the end. A negative `length` drops
|
|
/// that many characters from the right of the substring that starts
|
|
/// at `start`. Code-point-based, not byte-based — see type docs.
|
|
/// `json-logic-js` declares `substr` as
|
|
/// `function(source, start, end)`, so a missing `start` defaults
|
|
/// to `0` and arguments past the third are silently ignored. A
|
|
/// missing `source` is `undefined`, which stringifies to
|
|
/// `"undefined"` (not `"null"`).
|
|
static func opSubstr(args: Value, vars: Scope) throws -> Value {
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
let source = evaluated.first ?? .undefined
|
|
let start = evaluated.indices.contains(1) ? evaluated[1] : .null
|
|
let length: Value? = evaluated.indices.contains(2) ? evaluated[2] : nil
|
|
|
|
let chars = Array(jsString(source))
|
|
let total = chars.count
|
|
|
|
// Non-numeric start coerces to 0 (matches JS `ToInteger`).
|
|
let startN = Operators.clampedInt(start.asNumber ?? 0)
|
|
let begin: Int
|
|
if startN < 0 {
|
|
begin = max(total + startN, 0)
|
|
} else {
|
|
begin = min(startN, total)
|
|
}
|
|
|
|
let afterStart = Array(chars[begin...])
|
|
|
|
let result: String
|
|
if let length = length {
|
|
let lenN = Operators.clampedInt(length.asNumber ?? 0)
|
|
let count: Int
|
|
if lenN < 0 {
|
|
count = max(afterStart.count + lenN, 0)
|
|
} else {
|
|
count = min(lenN, afterStart.count)
|
|
}
|
|
result = String(afterStart[..<count])
|
|
} else {
|
|
result = String(afterStart)
|
|
}
|
|
return .string(result)
|
|
}
|
|
|
|
/// `{"merge": [a, b, ...]}` — variadic, flattens one level. Array
|
|
/// operands are spliced in; non-array operands are appended as
|
|
/// single elements.
|
|
static func opMerge(args: Value, vars: Scope) throws -> Value {
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
var merged: [Value] = []
|
|
for item in evaluated {
|
|
if case .array(let inner) = item {
|
|
merged.append(contentsOf: inner)
|
|
} else {
|
|
merged.append(item)
|
|
}
|
|
}
|
|
return .array(merged)
|
|
}
|
|
}
|
|
}
|