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> * 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>
103 lines
3.0 KiB
Swift
103 lines
3.0 KiB
Swift
//
|
|
// ObjectValue.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension RulesEngine {
|
|
|
|
/// The storage behind `Value.object`, keyed by UTF-16 code unit.
|
|
///
|
|
/// A Swift `[String: Value]` keys by canonical equivalence, so `é`
|
|
/// (U+00E9) and `e` followed by a combining acute (U+0301) are one key: an
|
|
/// object holding both spellings silently keeps one, and a path spelled
|
|
/// either way resolves against the other. JS and Kotlin compare keys by
|
|
/// code unit and keep the two apart, which is what `jsStringEquals`
|
|
/// already does everywhere else the engine compares strings.
|
|
struct ObjectValue {
|
|
|
|
private var storage: [ObjectKey: Value]
|
|
|
|
init() {
|
|
self.storage = [:]
|
|
}
|
|
|
|
/// Widens a Swift dictionary. Keys it already merged stay merged, so
|
|
/// callers that must keep every spelling build an `ObjectValue` from
|
|
/// the start.
|
|
init(_ dictionary: [String: Value]) {
|
|
self.storage = .init(minimumCapacity: dictionary.count)
|
|
for (key, value) in dictionary {
|
|
self[key] = value
|
|
}
|
|
}
|
|
|
|
subscript(key: String) -> Value? {
|
|
get { self.storage[ObjectKey(string: key)] }
|
|
set { self.storage[ObjectKey(string: key)] = newValue }
|
|
}
|
|
|
|
var count: Int { self.storage.count }
|
|
var isEmpty: Bool { self.storage.isEmpty }
|
|
var keys: [String] { self.storage.keys.map(\.string) }
|
|
|
|
var first: (key: String, value: Value)? {
|
|
self.storage.first.map { ($0.key.string, $0.value) }
|
|
}
|
|
|
|
/// Shadows the `Sequence` overload, which would hand back an array of
|
|
/// pairs rather than an object, the way `Dictionary.filter` does.
|
|
func filter(
|
|
_ isIncluded: ((key: String, value: Value)) throws -> Bool
|
|
) rethrows -> Self {
|
|
var result = Self()
|
|
for element in self where try isIncluded(element) {
|
|
result[element.key] = element.value
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RulesEngine.ObjectValue: Equatable, Hashable, Sendable {}
|
|
|
|
extension RulesEngine.ObjectValue: Sequence {
|
|
|
|
func makeIterator() -> AnyIterator<(key: String, value: RulesEngine.Value)> {
|
|
var iterator = self.storage.makeIterator()
|
|
return AnyIterator {
|
|
iterator.next().map { ($0.key.string, $0.value) }
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RulesEngine.ObjectValue: ExpressibleByDictionaryLiteral {
|
|
|
|
typealias Key = String
|
|
typealias Value = RulesEngine.Value
|
|
|
|
init(dictionaryLiteral elements: (String, RulesEngine.Value)...) {
|
|
self.init()
|
|
for (key, value) in elements {
|
|
self[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ObjectKey: Hashable {
|
|
|
|
let string: String
|
|
|
|
static func == (lhs: ObjectKey, rhs: ObjectKey) -> Bool {
|
|
RulesEngine.jsStringEquals(lhs.string, rhs.string)
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
for unit in string.utf16 {
|
|
hasher.combine(unit)
|
|
}
|
|
}
|
|
}
|