Files
purchases-ios/Sources/RulesEngine/Operators/StringArrayOperators.swift
T
Antonio PallaresandCursor e6a0d9531c Compare strings by UTF-16 code unit in the rules engine (#7518)
* 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>
2026-09-01 10:51:39 +02:00

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)
}
}
}