mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
Seven operators hand-wrote the same count check and the same message. Move it to Operators, next to evalArgs, whose result it always checks. Messages are unchanged, so no fixture moves. Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
6.9 KiB
Swift
166 lines
6.9 KiB
Swift
//
|
|
// RegexOperators.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension RulesEngine {
|
|
|
|
/// Regular expression operators.
|
|
///
|
|
/// Patterns are compiled by `NSRegularExpression`, whose syntax is
|
|
/// documented at
|
|
/// https://developer.apple.com/documentation/foundation/nsregularexpression
|
|
///
|
|
/// No operator takes flags. Write `[aA]` for a case-insensitive letter.
|
|
enum RegexOperators {
|
|
|
|
/// `{"rc.regexMatch": [input, pattern]}` — whether the pattern occurs
|
|
/// anywhere in the input. Anchor with `^` and `$` for a whole-string
|
|
/// test.
|
|
///
|
|
/// Both operands must be strings and the pattern must compile,
|
|
/// otherwise `EvaluationError.typeMismatch`.
|
|
static func opRegexMatch(args: Value, vars: Scope) throws -> Value {
|
|
let operatorName = "rc.regexMatch"
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
try Operators.checkArity(evaluated.count, allowed: [2], operatorName: operatorName)
|
|
|
|
let operands = try Self.operands(evaluated, operatorName: operatorName)
|
|
return .bool(Self.firstMatch(of: operands.regex, in: operands.input) != nil)
|
|
}
|
|
|
|
/// `{"rc.regexExtract": [input, pattern]}` or
|
|
/// `{"rc.regexExtract": [input, pattern, group]}` — the text of the
|
|
/// first match, or of one of its capture groups. Group `0`, the
|
|
/// default, is the whole match.
|
|
///
|
|
/// Returns `null` when the pattern does not match, and when the group
|
|
/// exists but took no part in the match, as group 1 of `(a)|(b)` does
|
|
/// against `"b"`.
|
|
///
|
|
/// A group number the pattern does not have is a lowering bug and
|
|
/// throws `EvaluationError.typeMismatch`, as do non-string operands, a
|
|
/// pattern that does not compile, and a fractional group.
|
|
static func opRegexExtract(args: Value, vars: Scope) throws -> Value {
|
|
let operatorName = "rc.regexExtract"
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
try Operators.checkArity(evaluated.count, allowed: [2, 3], operatorName: operatorName)
|
|
|
|
let operands = try Self.operands(evaluated, operatorName: operatorName)
|
|
let group = try Self.group(evaluated.count == 3 ? evaluated[2] : nil,
|
|
operatorName: operatorName)
|
|
|
|
guard group <= operands.regex.numberOfCaptureGroups else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' asked for group \(group) of a pattern "
|
|
+ "with \(operands.regex.numberOfCaptureGroups) capture group(s)"
|
|
)
|
|
}
|
|
|
|
guard let match = Self.firstMatch(of: operands.regex, in: operands.input),
|
|
let range = Range(match.range(at: group), in: operands.input) else {
|
|
return .null
|
|
}
|
|
|
|
return .string(String(operands.input[range]))
|
|
}
|
|
|
|
/// `{"rc.regexReplace": [input, pattern, replacement]}` — every match
|
|
/// replaced, left to right.
|
|
///
|
|
/// The replacement is literal text: `$1` and `$&` are not
|
|
/// backreferences. Build a replacement out of captures with
|
|
/// `rc.regexExtract` and `cat` instead.
|
|
///
|
|
/// All operands must be strings and the pattern must compile,
|
|
/// otherwise `EvaluationError.typeMismatch`.
|
|
static func opRegexReplace(args: Value, vars: Scope) throws -> Value {
|
|
let operatorName = "rc.regexReplace"
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
try Operators.checkArity(evaluated.count, allowed: [3], operatorName: operatorName)
|
|
|
|
let operands = try Self.operands(evaluated, operatorName: operatorName)
|
|
|
|
guard case .string(let replacement) = evaluated[2] else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected a string replacement, "
|
|
+ "got \(evaluated[2])"
|
|
)
|
|
}
|
|
|
|
let input = operands.input
|
|
return .string(
|
|
operands.regex.stringByReplacingMatches(
|
|
in: input,
|
|
range: NSRange(input.startIndex..., in: input),
|
|
withTemplate: NSRegularExpression.escapedTemplate(for: replacement)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Type-checks the `[input, pattern]` pair every regex operator starts
|
|
/// with, and compiles the pattern.
|
|
static func operands(
|
|
_ evaluated: [Value],
|
|
operatorName: String
|
|
) throws -> (input: String, regex: NSRegularExpression) {
|
|
guard let first = evaluated[safe: 0], let second = evaluated[safe: 1] else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expects at least 2 arguments, "
|
|
+ "got \(evaluated.count)"
|
|
)
|
|
}
|
|
|
|
guard case .string(let input) = first else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected a string to match against, "
|
|
+ "got \(first)"
|
|
)
|
|
}
|
|
|
|
guard case .string(let pattern) = second else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected a string pattern, "
|
|
+ "got \(second)"
|
|
)
|
|
}
|
|
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' could not compile pattern '\(pattern)'"
|
|
)
|
|
}
|
|
|
|
return (input, regex)
|
|
}
|
|
|
|
static func firstMatch(
|
|
of regex: NSRegularExpression,
|
|
in input: String
|
|
) -> NSTextCheckingResult? {
|
|
regex.firstMatch(in: input, range: NSRange(input.startIndex..., in: input))
|
|
}
|
|
|
|
/// Reads the optional group argument, defaulting to the whole match.
|
|
private static func group(_ value: Value?, operatorName: String) throws -> Int {
|
|
switch value {
|
|
case .none:
|
|
return 0
|
|
case .int(let number) where number >= 0:
|
|
return Int(clamping: number)
|
|
case .float(let number)
|
|
where number >= 0 && number.truncatingRemainder(dividingBy: 1) == 0:
|
|
return Operators.clampedInt(number)
|
|
case .some(let other):
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected a whole, non-negative group "
|
|
+ "number, got \(other)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|