Files
purchases-ios/Sources/RulesEngine/CustomOperators/SliceOperator.swift
T
Antonio PallaresandCursor 0712031229 Add rc.slice custom operator (#7505)
* Add rc.sortBy custom operator

Orders an array by a key computed per item, ascending, with ties keeping
their input order. Keys must be all strings or all numbers so the two
engines never have to invent a cross-type ordering.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add rc.slice custom operator

Takes a run of elements out of an array, with the start and length
semantics substr already uses for strings. Pairs with rc.sortBy to
express top-N without a reduce carrying a compound accumulator.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Exempt the custom operator dispatcher from cyclomatic complexity

The tenth operator pushed the switch past the limit. Operators.dispatch
carries the same exemption for the same reason.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reject non-finite rc.sortBy keys

A NaN key compares false in both directions, so every pair tied and the
input came back unsorted with no error. Negating a string key to attempt
a descending sort produced exactly that.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Note ascending order in the rc.sortBy summary

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the rc.sortBy doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the rc.sortBy stability comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the dispatcher comment above the lint annotation

Co-authored-by: Cursor <cursoragent@cursor.com>

* Scope the substr hint to string input in rc.slice

Co-authored-by: Cursor <cursoragent@cursor.com>

* Pin rc.slice index and source type strictness in fixtures

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reach the float index branch through arithmetic in rc.slice fixtures

Co-authored-by: Cursor <cursoragent@cursor.com>

* Order rc.sortBy string keys by UTF-16 code unit

Swift's < orders by Unicode scalar and treats canonically equivalent
strings as equal, while JS and Kotlin order by UTF-16 code unit. The two
disagree once a scalar above the surrogate range meets an astral one, so
the same rule would sort differently on iOS and Android.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the rc.sortBy ordering fixtures 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>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 06:52:53 +00:00

79 lines
3.1 KiB
Swift

//
// SliceOperator.swift
//
// Created by Antonio Pallares.
//
import Foundation
/// Name used in this operator's error messages.
private let operatorName = "rc.slice"
extension RulesEngine {
/// `rc.slice` — takes a run of elements out of an array.
enum SliceOperator {
/// `{"rc.slice": [array, start]}` or
/// `{"rc.slice": [array, start, length]}`.
///
/// Start and length mean what they mean in `substr`, the string operator
/// already in the engine: a negative `start` counts from the end, a
/// negative `length` drops that many elements from the right, and both
/// clamp to the array instead of failing. Strings keep using `substr`.
///
/// Indices must be whole numbers — `.float` is accepted because all
/// arithmetic returns it, so `length - 1` is an ordinary way to reach the
/// last element. A fractional or non-numeric index throws
/// `EvaluationError.typeMismatch`, as does a non-array operand.
static func opSlice(args: Value, vars: Scope) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
guard evaluated.count == 2 || evaluated.count == 3 else {
throw EvaluationError.typeMismatch(
message: "operator '\(operatorName)' expects 2 or 3 arguments, "
+ "got \(evaluated.count)"
)
}
guard case .array(let items) = evaluated[0] else {
var hint = ""
if case .string = evaluated[0] {
hint = "; strings use 'substr'"
}
throw EvaluationError.typeMismatch(
message: "operator '\(operatorName)' expected an array to slice, "
+ "got \(evaluated[0])\(hint)"
)
}
let start = try Self.index(evaluated[1], argument: "start")
let begin = start < 0 ? max(items.count + start, 0) : min(start, items.count)
let remaining = Array(items[begin...])
guard evaluated.count == 3 else { return .array(remaining) }
let length = try Self.index(evaluated[2], argument: "length")
let count = length < 0
? max(remaining.count + length, 0)
: min(length, remaining.count)
return .array(Array(remaining[..<count]))
}
/// Reads a whole-number index, rejecting anything that is not one.
private static func index(_ value: Value, argument: String) throws -> Int {
switch value {
case .int(let number):
return Int(clamping: number)
case .float(let number) where number.truncatingRemainder(dividingBy: 1) == 0:
return Operators.clampedInt(number)
default:
throw EvaluationError.typeMismatch(
message: "operator '\(operatorName)' expected a whole number "
+ "\(argument), got \(value)"
)
}
}
}
}