Files
Antonio PallaresandCursor a9be1ba1d5 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>
2026-09-10 11:19:16 +02:00

74 lines
2.8 KiB
Swift

//
// Value+JSON.swift
//
// Created by Antonio Pallares.
//
import Foundation
/// Production JSON → `Value` parser. Converts the predicate JSON extracted
/// from the SDK artifact into the engine's typed `Value` tree. Used by
/// `RulesEngine.evaluate`; failures surface as `RulesEngine.EvaluationError.parse`.
extension RulesEngine.Value {
/// Parse a JSON string into a `Value`. `JSONSerialization` returns
/// numbers as `NSNumber`, so we use `CFNumber` type metadata to
/// distinguish booleans from ints from doubles — without that, a JSON
/// `true` and a JSON `1` both round-trip to `NSNumber` and lose their
/// type intent.
static func fromJSONString(_ input: String) throws -> RulesEngine.Value {
guard let data = input.data(using: .utf8) else {
throw RulesEngine.EvaluationError.parse(message: "non-UTF8 input")
}
let json: Any
do {
json = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw RulesEngine.EvaluationError.parse(message: error.localizedDescription)
}
return try Self.fromJSONObject(json)
}
/// Recursively convert a value produced by `JSONSerialization` (the
/// `Any` is one of `NSNull`, `NSNumber`, `String`, `[Any]`, or
/// `[String: Any]`). Throws `RulesEngine.EvaluationError.parse` if it
/// encounters anything else.
static func fromJSONObject(_ object: Any) throws -> RulesEngine.Value {
if object is NSNull {
return .null
}
if let number = object as? NSNumber {
switch number.jsonNumberKind {
case .boolean:
return .bool(number.boolValue)
case .integer:
return .int(number.int64Value)
case .floatingPoint:
return .float(number.doubleValue)
}
}
if let string = object as? String {
return .string(string)
}
if let array = object as? [Any] {
return .array(try array.map(Self.fromJSONObject))
}
// 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)
}
throw RulesEngine.EvaluationError.parse(
message: "unexpected JSONSerialization output of type \(type(of: object))"
)
}
}