Files
Antonio PallaresandCursor 2c5f57a83c Share one arity check across the strict operators (#7571)
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>
2026-09-02 13:09:59 +02:00

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