import Foundation public enum RuleMode: String, Codable, Sendable { case css case xpath case jsonPath case regex case javaScript case webJavaScript } public enum RuleCombination: String, Codable, Sendable { case single case concatenate = "&&" case fallback = "||" case interleave = "%%" } public struct RuleBranch: Codable, Hashable, Sendable { public var mode: RuleMode public var expression: String public var replacePattern: String? public var replacement: String public var replaceFirst: Bool public init( mode: RuleMode, expression: String, replacePattern: String? = nil, replacement: String = "", replaceFirst: Bool = false ) { self.mode = mode self.expression = expression self.replacePattern = replacePattern self.replacement = replacement self.replaceFirst = replaceFirst } } public struct RulePlan: Codable, Hashable, Sendable { public var source: String public var combination: RuleCombination public var branches: [RuleBranch] } public struct RuleExpressionParser: Sendable { public init() {} public func parse(_ source: String, inputLooksLikeJSON: Bool = false) throws -> RulePlan { let normalized = source.trimmingCharacters(in: .newlines) guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空") } let split = try splitAtTopLevel(normalized) let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) } return .init(source: source, combination: split.combination, branches: branches) } private func parseBranch(_ source: String, inputLooksLikeJSON: Bool) throws -> RuleBranch { var value = String(source.drop(while: { $0.isWhitespace })) var mode: RuleMode if value.lowercased().hasPrefix("@css:") { mode = .css value.removeFirst(5) } else if value.hasPrefix("@@") { mode = .css value.removeFirst(2) } else if value.lowercased().hasPrefix("@xpath:") { mode = .xpath value.removeFirst(7) } else if value.lowercased().hasPrefix("@json:") { mode = .jsonPath value.removeFirst(6) } else if value.lowercased().hasPrefix("@js:") { mode = .javaScript value.removeFirst(4) } else if value.lowercased().hasPrefix("") && value.lowercased().hasSuffix("") { mode = .javaScript value = String(value.dropFirst(4).dropLast(5)) } else if value.lowercased().hasPrefix("") && value.lowercased().hasSuffix("") { mode = .webJavaScript value = String(value.dropFirst(7).dropLast(8)) } else if value.hasPrefix(":") { mode = .regex value.removeFirst() } else if value.hasPrefix("$.") || value.hasPrefix("$[") || inputLooksLikeJSON { mode = .jsonPath } else if value.hasPrefix("/") || value.hasPrefix("./") { mode = .xpath } else { mode = .css } let replacement = value.components(separatedBy: "##") let expression = replacement[0].trimmingCharacters(in: .whitespacesAndNewlines) guard !expression.isEmpty || replacement.count > 1 else { throw RuleEngineError.malformedRule(rule: source, detail: "规则主体不能为空") } return .init( mode: mode, expression: expression, replacePattern: replacement.count > 1 ? replacement[1] : nil, replacement: replacement.count > 2 ? replacement[2] : "", replaceFirst: replacement.count > 3 ) } private func splitAtTopLevel(_ source: String) throws -> (parts: [String], combination: RuleCombination) { var quote: Character? var escaped = false var roundDepth = 0 var squareDepth = 0 var curlyDepth = 0 var found: RuleCombination? var parts: [String] = [] var partStart = source.startIndex var index = source.startIndex while index < source.endIndex { let character = source[index] if escaped { escaped = false index = source.index(after: index) continue } if character == "\\" { escaped = true index = source.index(after: index) continue } if let activeQuote = quote { if character == activeQuote { quote = nil } index = source.index(after: index) continue } if character == "\"" || character == "'" { quote = character index = source.index(after: index) continue } switch character { case "(": roundDepth += 1 case ")": roundDepth -= 1 case "[": squareDepth += 1 case "]": squareDepth -= 1 case "{": curlyDepth += 1 case "}": curlyDepth -= 1 default: break } if roundDepth < 0 || squareDepth < 0 || curlyDepth < 0 { throw RuleEngineError.malformedRule(rule: source, detail: "规则括号未平衡") } if roundDepth == 0, squareDepth == 0, curlyDepth == 0 { let remainder = source[index...] let candidate: RuleCombination? = if remainder.hasPrefix("&&") { .concatenate } else if remainder.hasPrefix("||") { .fallback } else if remainder.hasPrefix("%%") { .interleave } else { nil } if let candidate { if found == nil { found = candidate } if found == candidate { parts.append(String(source[partStart..