Compare object keys by UTF-16 code unit in the rules engine (#7562)

* 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>
This commit is contained in:
Antonio PallaresandCursor authored and GitHub committed 2026-09-10 11:19:16 +02:00
1 parent 812c2dd37e
commit a9be1ba1d5
17 files changed
+175 -29

No files matched your search

+2 -2
View File
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/EmergeTools/SnapshotPreviews.git",
"state" : {
"revision" : "d42446f0439217941a4e3a2ca58a643c1ac328c4",
"version" : "0.14.1"
"revision" : "4bd6eed8a0d663a2fab9ffd3f45fd566f762c62c",
"version" : "0.18.0"
}
},
{
+4
View File
@@ -79,6 +79,7 @@
0882A0238A0B15DCAA63DCA5 /* WorkflowManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA47C656406D58B636FD82D /* WorkflowManagerTests.swift */; };
0DCCBCD6C7CA32B59407A391 /* IterationOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB697245B7ED4A8855FF027E /* IterationOperators.swift */; };
7A8B9C0D1E2F304152637481 /* Scope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B9C0D1E2F304152637480 /* Scope.swift */; };
7A8B9C0D1E2F304152637491 /* ObjectValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B9C0D1E2F304152637490 /* ObjectValue.swift */; };
A1B2C3D4E5F60718293A4B5E /* CustomOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* CustomOperators.swift */; };
C3D4E5F60718293A4B5C03E /* EntriesOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F60718293A4B5C03D /* EntriesOperators.swift */; };
D4E5F60718293A4B5C03D02 /* CaseOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60718293A4B5C03D01 /* CaseOperators.swift */; };
@@ -3160,6 +3161,7 @@
AAE17E010000000000000004 /* RewardVerificationStatusResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardVerificationStatusResponseTests.swift; sourceTree = "<group>"; };
AB697245B7ED4A8855FF027E /* IterationOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IterationOperators.swift; sourceTree = "<group>"; };
7A8B9C0D1E2F304152637480 /* Scope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Scope.swift; sourceTree = "<group>"; };
7A8B9C0D1E2F304152637490 /* ObjectValue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ObjectValue.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5D /* CustomOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomOperators.swift; sourceTree = "<group>"; };
C3D4E5F60718293A4B5C03D /* EntriesOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EntriesOperators.swift; sourceTree = "<group>"; };
D4E5F60718293A4B5C03D01 /* CaseOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CaseOperators.swift; sourceTree = "<group>"; };
@@ -6842,6 +6844,7 @@
E32EE3749710C439BCB9AFD6 /* RulesEngine.swift */,
2F7428736E4FABCF7AD45798 /* RulesEngineEvaluate.swift */,
7A8B9C0D1E2F304152637480 /* Scope.swift */,
7A8B9C0D1E2F304152637490 /* ObjectValue.swift */,
DF4D7487E0B0348ED8ECCBD3 /* Value+JSON.swift */,
BDE6FC116D66BBEFEE662547 /* Value.swift */,
);
@@ -8323,6 +8326,7 @@
2DF8D9550354ECBAE400C547 /* EvaluationError.swift in Sources */,
6A24585F01C6C2310AECC0B0 /* Evaluator.swift in Sources */,
7A8B9C0D1E2F304152637481 /* Scope.swift in Sources */,
7A8B9C0D1E2F304152637491 /* ObjectValue.swift in Sources */,
A1B2C3D4E5F60718293A4B5E /* CustomOperators.swift in Sources */,
C3D4E5F60718293A4B5C03E /* EntriesOperators.swift in Sources */,
D4E5F60718293A4B5C03D02 /* CaseOperators.swift in Sources */,
+5 -5
View File
@@ -16,7 +16,7 @@ import Foundation
struct DimensionSnapshot: Equatable, Sendable {
let values: [String: RulesEngine.Value]
let values: RulesEngine.ObjectValue
let evaluationDate: Date
}
@@ -58,7 +58,7 @@ struct DimensionResolver: Sendable {
) async throws -> DimensionSnapshot {
let appUserID = self.currentAppUserIDProvider()
let date = self.dateProvider.now()
var values: [String: RulesEngine.Value] = [
var values: RulesEngine.ObjectValue = [
Self.evaluatedAtKey: .int(Int64(date.timeIntervalSince1970 * 1_000))
]
@@ -107,7 +107,7 @@ struct DimensionResolver: Sendable {
private static func addPerEvaluationValues(
_ dimensions: [String: DimensionValue],
root: String,
to values: inout [String: RulesEngine.Value]
to values: inout RulesEngine.ObjectValue
) {
let converted = DimensionValueConverter.convert(dimensions, parentPath: root)
if !converted.isEmpty {
@@ -125,8 +125,8 @@ private enum DimensionValueConverter {
static func convert(
_ dimensions: [String: DimensionValue],
parentPath: String
) -> [String: RulesEngine.Value] {
return dimensions.reduce(into: [:]) { result, dimension in
) -> RulesEngine.ObjectValue {
return dimensions.reduce(into: RulesEngine.ObjectValue()) { result, dimension in
let (name, value) = dimension
guard Self.isValidName(name) else {
Logger.warn(Strings.localRules.invalidDimensionName(name, parentPath: parentPath))
@@ -86,7 +86,7 @@ extension RulesEngine {
)
}
var result: [String: Value] = [:]
var result = ObjectValue()
for entry in entries {
guard case .array(let pair) = entry, pair.count == 2 else {
throw EvaluationError.typeMismatch(
@@ -48,7 +48,7 @@ extension RulesEngine {
)
}
var bindings: [String: Value] = [:]
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() {
+1 -1
View File
@@ -26,7 +26,7 @@ extension RulesEngine {
/// etc.).
/// - Returns: `true` when the predicate evaluates to a truthy value per
/// JSON Logic rules.
static func evaluate(predicate: Value, variables: [String: Value]) throws -> Bool {
static func evaluate(predicate: Value, variables: ObjectValue) throws -> Bool {
let scope = Scope(root: .object(variables))
let result = try evaluateValue(predicate, vars: scope)
return result.isTruthy
+102
View File
@@ -0,0 +1,102 @@
//
// 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)
}
}
}
@@ -18,7 +18,7 @@ extension RulesEngine {
/// an `EvaluationError` when parsing or evaluation fails.
static func evaluate(
predicate: String,
variables: [String: Value]
variables: ObjectValue
) -> Result<Bool, EvaluationError> {
do {
let predicateValue = try Value.fromJSONString(predicate)
+9 -9
View File
@@ -21,12 +21,12 @@ extension RulesEngine {
/// Names bound by enclosing `rc.let` calls. Unlike `current`, these
/// survive iteration, which is what lets an inner predicate still see
/// a value captured outside the loop.
let bindings: [String: Value]
let bindings: ObjectValue
init(root: Value) {
self.root = root
self.current = root
self.bindings = [:]
self.bindings = ObjectValue()
}
func scoped(to current: Value) -> Scope {
@@ -35,15 +35,15 @@ extension RulesEngine {
/// Adds names visible from here down. An inner `rc.let` reusing a name
/// shadows the outer one.
func binding(_ names: [String: Value]) -> Scope {
Scope(
current: current,
root: root,
bindings: bindings.merging(names) { _, inner in inner }
)
func binding(_ names: ObjectValue) -> Scope {
var merged = bindings
for (name, value) in names {
merged[name] = value
}
return Scope(current: current, root: root, bindings: merged)
}
private init(current: Value, root: Value, bindings: [String: Value]) {
private init(current: Value, root: Value, bindings: ObjectValue) {
self.current = current
self.root = root
self.bindings = bindings
+8 -3
View File
@@ -53,10 +53,15 @@ extension RulesEngine.Value {
if let array = object as? [Any] {
return .array(try array.map(Self.fromJSONObject))
}
if let dict = object as? [String: Any] {
var result: [String: RulesEngine.Value] = [:]
result.reserveCapacity(dict.count)
// Iterated as an `NSDictionary` because casting to `[String: Any]`
// bridges into a Swift dictionary, which merges keys that differ only
// in how the same character is spelled.
if let dict = object as? NSDictionary {
var result = RulesEngine.ObjectValue()
for (key, value) in dict {
guard let key = key as? String else {
throw RulesEngine.EvaluationError.parse(message: "object key is not a string")
}
result[key] = try Self.fromJSONObject(value)
}
return .object(result)
+1 -1
View File
@@ -30,7 +30,7 @@ extension RulesEngine {
/// A JSON array.
case array([Value])
/// A JSON object.
case object([String: Value])
case object(ObjectValue)
}
}
@@ -82,7 +82,7 @@ private extension DimensionScopeTests {
private extension DimensionScopeTests {
static let expectedValues: [String: RulesEngine.Value] = [
static let expectedValues: RulesEngine.ObjectValue = [
"evaluated_at": .int(1_700_000_000_000),
"app_version": .string("1.2.3"),
"platform": .string("ios"),
@@ -28,8 +28,8 @@ enum PredicateConformanceRunner {
/// Fixture-declared variables take precedence over the reserved
/// constants on a name collision.
private static func scope(for fixture: PredicateConformanceFixtureCase) -> [String: Value] {
reservedConstants.merging(fixture.variables) { _, fixtureValue in fixtureValue }
private static func scope(for fixture: PredicateConformanceFixtureCase) -> RulesEngine.ObjectValue {
.init(reservedConstants.merging(fixture.variables) { _, fixtureValue in fixtureValue })
}
static func run(_ fixture: PredicateConformanceFixtureCase) throws {
@@ -36,7 +36,7 @@ extension RulesEngine.Value: Decodable {
} else if let array = try? container.decode([RulesEngine.Value].self) {
self = .array(array)
} else if let object = try? container.decode([String: RulesEngine.Value].self) {
self = .object(object)
self = .object(RulesEngine.ObjectValue(object))
} else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
@@ -32,7 +32,7 @@ struct PredicateFixtureTests {
/// fixtures are added or removed.
@Test
func fixtureCountMatchesExpected() {
let expectedCount = 676
let expectedCount = 681
#expect(
Self.fixtures.count == expectedCount,
"Expected \(expectedCount) fixtures, loaded \(Self.fixtures.count)"
@@ -162,6 +162,27 @@
"predicate": {"some": [[{"rc.entries": [{"var": "o"}]}], {"and": [{"===": [{"var": "0.0"}, "\ud83d\ude00"]}, {"===": [{"var": "1.0"}, "\uff21"]}]}]},
"variables": {"o": {"\uff21": 1, "\ud83d\ude00": 2}},
"expected": true
},
{
"id": "from_entries_keeps_two_spellings_of_the_same_character_apart",
"description": "One key is é (U+00E9), the other is é spelled e (U+0065) followed by a combining acute accent (U+0301). They render alike but are different UTF-16 code unit sequences, so the object has two entries",
"predicate": {"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["e\u0301", 2]]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 2]},
"variables": {},
"expected": true
},
{
"id": "from_entries_pairs_each_spelling_with_its_own_value",
"description": "The decomposed spelling é (U+0065 U+0301) keeps its own value instead of being folded into the precomposed é (U+00E9)",
"predicate": {"some": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["e\u0301", 2]]]}}, {"and": [{"===": [{"var": "0"}, "e\u0301"]}, {"===": [{"var": "1"}, 2]}]}]},
"variables": {},
"expected": true
},
{
"id": "from_entries_still_merges_a_repeated_identical_key",
"description": "Two entries spelled with the same code units are one key, the last value winning, as in JS",
"predicate": {"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["\u00e9", 2]]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 1]},
"variables": {},
"expected": true
}
]
}
@@ -180,6 +180,20 @@
"predicate": { "===": [{ "var": ["missing_key", { "and": [] }] }, null] },
"variables": {},
"expected": true
},
{
"id": "var_path_must_match_the_key_spelling",
"description": "The key spells café with e (U+0065) followed by a combining acute accent (U+0301); the path spells the same glyph as é (U+00E9). Object keys are compared as UTF-16 code units, so the path does not resolve and the default applies",
"predicate": { "===": [{ "var": ["caf\u00e9", "fallback"] }, "fallback"] },
"variables": { "cafe\u0301": "sub" },
"expected": true
},
{
"id": "var_resolves_when_the_path_matches_the_key_spelling",
"description": "Same key as var_path_must_match_the_key_spelling, café spelled e (U+0065) + combining acute accent (U+0301), with a path spelled the same way",
"predicate": { "===": [{ "var": ["cafe\u0301", "fallback"] }, "sub"] },
"variables": { "cafe\u0301": "sub" },
"expected": true
}
]
}