import Foundation public struct RuleEngine: Sendable { private let parser: RuleExpressionParser private let jsonPath: JSONPathEvaluator private let regex: RegexEvaluator private let html: HTMLRuleEvaluator private let javaScript: JavaScriptRuleEvaluator public init( parser: RuleExpressionParser = .init(), jsonPath: JSONPathEvaluator = .init(), regex: RegexEvaluator = .init(), html: HTMLRuleEvaluator = .init(), javaScript: JavaScriptRuleEvaluator = .init() ) { self.parser = parser self.jsonPath = jsonPath self.regex = regex self.html = html self.javaScript = javaScript } public func evaluate( _ rule: String, in input: JSONValue, listMode: Bool = false, context: RuleExecutionContext = .init() ) async -> RuleEvaluation { var context = context if context.content == nil { context.content = input } if let pipeline = splitJavaScriptPipeline(rule) { return await evaluatePipeline(pipeline, input: input, listMode: listMode, context: context) } do { let extracted = try extractPutRules(rule) if !extracted.puts.isEmpty { var nextContext = context var diagnostics: [RuleDiagnostic] = [] for key in extracted.puts.keys.sorted() { guard let valueRule = extracted.puts[key] else { continue } let value = await evaluate(valueRule, in: input, context: nextContext) diagnostics += value.diagnostics nextContext.variables.merge(value.variables) { _, new in new } nextContext.variables[key] = value.strings.joined(separator: "\n") } if extracted.rule.isEmpty { return .init(values: [input], diagnostics: diagnostics, variables: nextContext.variables) } var result = await evaluate(extracted.rule, in: input, listMode: listMode, context: nextContext) result.diagnostics = diagnostics + result.diagnostics result.variables = nextContext.variables.merging(result.variables) { _, new in new } return result } let rendered = try await renderDynamicRule(extracted.rule, result: input, context: context) if rendered.captureLiteral { return literalEvaluation(rendered.rule, context: rendered.context) } if rendered.context.variables != context.variables { var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context) result.variables = rendered.context.variables.merging(result.variables) { _, new in new } return result } return await evaluatePrepared(rendered.rule, in: input, context: rendered.context) } catch let error as RuleEngineError { return .init(diagnostics: [error.diagnostic], variables: context.variables) } catch { return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables) } } private func evaluatePrepared( _ rule: String, in input: JSONValue, context: RuleExecutionContext ) async -> RuleEvaluation { do { let plan = try parser.parse(rule, inputLooksLikeJSON: true) var results: [[JSONValue]] = [] var diagnostics: [RuleDiagnostic] = [] for branch in plan.branches { do { let values = try await evaluate(branch, in: input, context: context) results.append(values) if plan.combination == .fallback, !values.isEmpty { break } } catch let error as RuleEngineError { diagnostics.append(error.diagnostic) results.append([]) } catch { diagnostics.append(.init( severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: branch.expression )) results.append([]) } } return .init( values: combine(results, with: plan.combination), diagnostics: diagnostics, variables: context.variables ) } catch let error as RuleEngineError { return .init(diagnostics: [error.diagnostic], variables: context.variables) } catch { return .init(diagnostics: [.init( severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule )], variables: context.variables) } } public func evaluateJSONPath(_ rule: String, in input: JSONValue) throws -> [JSONValue] { try jsonPath.evaluate(path: rule, in: input) } public func evaluate( _ rule: String, inText input: String, baseURL: String = "", listMode: Bool = false, context: RuleExecutionContext = .init() ) async -> RuleEvaluation { var context = context if context.baseURL.isEmpty { context.baseURL = baseURL } if context.content == nil { context.content = .string(input) } if let pipeline = splitJavaScriptPipeline(rule) { return await evaluatePipeline(pipeline, input: .string(input), listMode: listMode, context: context) } do { let extracted = try extractPutRules(rule) if !extracted.puts.isEmpty { var nextContext = context var diagnostics: [RuleDiagnostic] = [] for key in extracted.puts.keys.sorted() { guard let valueRule = extracted.puts[key] else { continue } let value = await evaluate(valueRule, inText: input, baseURL: baseURL, listMode: false, context: nextContext) diagnostics += value.diagnostics nextContext.variables.merge(value.variables) { _, new in new } nextContext.variables[key] = value.strings.joined(separator: "\n") } if extracted.rule.isEmpty { return .init(values: [.string(input)], diagnostics: diagnostics, variables: nextContext.variables) } var result = await evaluate(extracted.rule, inText: input, baseURL: baseURL, listMode: listMode, context: nextContext) result.diagnostics = diagnostics + result.diagnostics result.variables = nextContext.variables.merging(result.variables) { _, new in new } return result } let rendered = try await renderDynamicRule(extracted.rule, result: .string(input), context: context) if rendered.captureLiteral { return literalEvaluation(rendered.rule, context: rendered.context) } if rendered.context.variables != context.variables { var result = await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context) result.variables = rendered.context.variables.merging(result.variables) { _, new in new } return result } return await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context) } catch let error as RuleEngineError { return .init(diagnostics: [error.diagnostic], variables: context.variables) } catch { return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables) } } private func evaluatePrepared( _ rule: String, inText input: String, baseURL: String, listMode: Bool, context: RuleExecutionContext ) async -> RuleEvaluation { if rule.hasPrefix(":"), rule.count > 1 { do { let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty } guard !patterns.isEmpty else { return .init(variables: context.variables) } var stage = input var captures: [[String]] = [] for (index, pattern) in patterns.enumerated() { captures = try regex.captureGroups(pattern: pattern, input: stage) if index + 1 < patterns.count { stage = captures.compactMap(\.first).joined() } } return .init( values: captures.map { .array($0.map(JSONValue.string)) }, variables: context.variables ) } catch let error as RuleEngineError { return .init(diagnostics: [error.diagnostic], variables: context.variables) } catch { return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables) } } do { let plan = try parser.parse(rule, inputLooksLikeJSON: (try? JSONValue.decode(data: Data(input.utf8))) != nil) var results: [[JSONValue]] = [] var diagnostics: [RuleDiagnostic] = [] for branch in plan.branches { do { let strings = try await evaluateTextBranch( branch, input: input, baseURL: baseURL, listMode: listMode, context: context ) results.append(strings.map(JSONValue.string)) if plan.combination == .fallback, !strings.isEmpty { break } } catch let error as RuleEngineError { diagnostics.append(error.diagnostic) results.append([]) } } return .init( values: combine(results, with: plan.combination), diagnostics: diagnostics, variables: context.variables ) } catch let error as RuleEngineError { return .init(diagnostics: [error.diagnostic], variables: context.variables) } catch { return .init(diagnostics: [.init( severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule )], variables: context.variables) } } private func evaluate( _ branch: RuleBranch, in input: JSONValue, context: RuleExecutionContext ) async throws -> [JSONValue] { try Task.checkCancellation() let rawValues: [JSONValue] if branch.expression.isEmpty { return try [JSONValue.string(regex.replacing( pattern: branch.replacePattern ?? "", in: input.stringValue, with: branch.replacement, firstOnly: branch.replaceFirst ))] } switch branch.mode { case .jsonPath: let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" rawValues = try jsonPath.evaluate(path: path, in: input) case .regex: let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue) rawValues = matches.map { .array($0.map(JSONValue.string)) } case .css: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "CSS selector") case .xpath: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "XPath") case .javaScript: rawValues = [try await javaScript.evaluateAsync( script: branch.expression, result: input, executionContext: context ).value] case .webJavaScript: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") } guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues } return try rawValues.map { .string(try regex.replacing( pattern: pattern, in: $0.stringValue, with: branch.replacement, firstOnly: branch.replaceFirst )) } } private func evaluateTextBranch( _ branch: RuleBranch, input: String, baseURL: String, listMode: Bool, context: RuleExecutionContext ) async throws -> [String] { try Task.checkCancellation() let raw: [String] if branch.expression.isEmpty { return try [regex.replacing( pattern: branch.replacePattern ?? "", in: input, with: branch.replacement, firstOnly: branch.replaceFirst )] } switch branch.mode { case .css: raw = try listMode ? html.selectCSSFragments(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment) : html.extractCSS(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment) case .xpath: raw = try listMode ? html.selectXPathFragments(branch.expression, html: input, isFragment: context.isHTMLFragment) : html.extractXPath(branch.expression, html: input, isFragment: context.isHTMLFragment) case .jsonPath: let root: JSONValue do { root = try JSONValue.decode(data: Data(input.utf8)) } catch { throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON") } let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue) case .regex: raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? "" } case .javaScript: raw = [try await javaScript.evaluateAsync( script: branch.expression, result: .string(input), executionContext: context ).value.stringValue] case .webJavaScript: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") } guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw } return try raw.map { try regex.replacing( pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst ) } } private func combine(_ results: [[JSONValue]], with combination: RuleCombination) -> [JSONValue] { switch combination { case .single, .concatenate: return results.flatMap { $0 } case .fallback: return results.first(where: { !$0.isEmpty }) ?? [] case .interleave: guard let firstCount = results.first?.count else { return [] } return (0 ..< firstCount).flatMap { index in results.compactMap { $0.indices.contains(index) ? $0[index] : nil } } } } private func evaluatePipeline( _ pipeline: JavaScriptPipeline, input: JSONValue, listMode: Bool, context: RuleExecutionContext ) async -> RuleEvaluation { var execution = context let initial: RuleEvaluation if pipeline.prefix.isEmpty { initial = .init(values: [input], variables: context.variables) } else { initial = await evaluateValue(pipeline.prefix, input: input, listMode: listMode, context: context) } execution.variables = initial.variables var diagnostics = initial.diagnostics let scriptInput: JSONValue if pipeline.prefix.isEmpty { scriptInput = input } else if listMode { if initial.values.count == 1, case .array = initial.values[0] { scriptInput = initial.values[0] } else { scriptInput = .array(initial.values) } } else { scriptInput = .string(initial.strings.joined(separator: "\n")) } do { let rendered = try await renderDynamicRule(pipeline.script, result: scriptInput, context: execution) let result = try await javaScript.evaluateAsync( script: rendered.rule, result: scriptInput, executionContext: rendered.context ) execution.variables = result.variables guard !pipeline.suffix.isEmpty else { return .init(values: result.value == .null ? [] : [result.value], diagnostics: diagnostics, variables: execution.variables) } execution.isHTMLFragment = false var next = await evaluateValue(pipeline.suffix, input: result.value, listMode: listMode, context: execution) next.diagnostics = diagnostics + next.diagnostics return next } catch let error as RuleEngineError { diagnostics.append(error.diagnostic) } catch { diagnostics.append(.init(severity: .error, code: "rule.javascript", message: error.localizedDescription, rule: pipeline.script)) } return .init(diagnostics: diagnostics, variables: execution.variables) } private func evaluateValue(_ rule: String, input: JSONValue, listMode: Bool, context: RuleExecutionContext) async -> RuleEvaluation { if case .string(let text) = input { return await evaluate(rule, inText: text, baseURL: context.baseURL, listMode: listMode, context: context) } return await evaluate(rule, in: input, listMode: listMode, context: context) } private func splitJavaScriptPipeline(_ rule: String) -> JavaScriptPipeline? { guard let pattern = try? NSRegularExpression(pattern: #"([\s\S]*?)|@js:([\s\S]*)"#, options: .caseInsensitive), let match = pattern.firstMatch(in: rule, range: NSRange(rule.startIndex..., in: rule)), let full = Range(match.range, in: rule), let script = Range(match.range(at: match.range(at: 2).location == NSNotFound ? 1 : 2), in: rule) else { return nil } return .init( prefix: String(rule[.. (rule: String, puts: [String: String]) { let expression = try NSRegularExpression(pattern: #"@put:(\{[^}]+?\})"#, options: .caseInsensitive) let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source)) guard !matches.isEmpty else { return (source, [:]) } var puts: [String: String] = [:] var rule = source for match in matches.reversed() { guard let fullRange = Range(match.range(at: 0), in: source), let jsonRange = Range(match.range(at: 1), in: source) else { continue } if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(String(source[jsonRange]), quoteUnquotedValues: true) { puts.merge(object.mapValues(\.stringValue)) { _, new in new } } if let currentRange = Range(match.range(at: 0), in: rule) { rule.removeSubrange(currentRange) } else { rule = rule.replacingOccurrences(of: String(source[fullRange]), with: "") } } return (rule.trimmingCharacters(in: .whitespacesAndNewlines), puts) } private func renderDynamicRule( _ source: String, result: JSONValue, context: RuleExecutionContext ) async throws -> (rule: String, context: RuleExecutionContext, captureLiteral: Bool) { var source = source var captureLiteral = false if case .array(let captures) = result { let captureExpression = try NSRegularExpression(pattern: #"\$(\d{1,2})"#) let captureEnd = source.range(of: "##")?.lowerBound ?? source.endIndex let matches = captureExpression.matches(in: source, range: NSRange(source.startIndex.. RuleEvaluation { let components = source.components(separatedBy: "##") var value = components.first ?? "" if components.count > 1, !components[1].isEmpty { value = (try? regex.replacing( pattern: components[1], in: value, with: components.count > 2 ? components[2] : "", firstOnly: components.count > 3 )) ?? value } return .init(values: [.string(value)], variables: context.variables) } private struct JavaScriptPipeline { var prefix: String var script: String var suffix: String } }