mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
497 lines
21 KiB
Swift
497 lines
21 KiB
Swift
import Foundation
|
|
|
|
public struct JSONPathEvaluator: Sendable {
|
|
private enum Selector: Equatable {
|
|
case key(String)
|
|
case index(Int)
|
|
case wildcard
|
|
}
|
|
|
|
private enum Segment: Equatable {
|
|
case child(Selector)
|
|
case recursive(Selector)
|
|
case union([Selector])
|
|
case slice(start: Int?, end: Int?, step: Int)
|
|
case filter(String)
|
|
}
|
|
|
|
public init() {}
|
|
|
|
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
|
|
let segments = try parse(path)
|
|
var current = [root]
|
|
for segment in segments {
|
|
try Task.checkCancellation()
|
|
switch segment {
|
|
case .child(let selector):
|
|
current = current.flatMap { select(selector, from: $0) }
|
|
case .recursive(let selector):
|
|
current = current.flatMap { recursivelySelect(selector, from: $0) }
|
|
case .union(let selectors):
|
|
current = current.flatMap { node in selectors.flatMap { select($0, from: node) } }
|
|
case .slice(let start, let end, let step):
|
|
current = current.flatMap { slice($0, start: start, end: end, step: step) }
|
|
case .filter(let predicate):
|
|
var filtered: [JSONValue] = []
|
|
for node in current {
|
|
guard case .array(let values) = node else { continue }
|
|
for value in values where try matches(predicate, candidate: value, root: root) {
|
|
filtered.append(value)
|
|
}
|
|
}
|
|
current = filtered
|
|
}
|
|
}
|
|
return current
|
|
}
|
|
|
|
private func parse(_ path: String) throws -> [Segment] {
|
|
let rule = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard rule.first == "$" else {
|
|
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 必须以 $ 开始")
|
|
}
|
|
var segments: [Segment] = []
|
|
var index = rule.index(after: rule.startIndex)
|
|
while index < rule.endIndex {
|
|
let character = rule[index]
|
|
if character == "." {
|
|
let next = rule.index(after: index)
|
|
guard next < rule.endIndex else {
|
|
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
|
|
}
|
|
if rule[next] == "." {
|
|
index = rule.index(after: next)
|
|
let parsed = try parseDotSelector(rule, from: index, original: path)
|
|
segments.append(.recursive(parsed.selector))
|
|
index = parsed.next
|
|
} else {
|
|
index = next
|
|
let parsed = try parseDotSelector(rule, from: index, original: path)
|
|
segments.append(.child(parsed.selector))
|
|
index = parsed.next
|
|
}
|
|
} else if character == "[" {
|
|
let parsed = try parseBracket(rule, from: index, original: path)
|
|
segments.append(parsed.segment)
|
|
index = parsed.next
|
|
} else {
|
|
throw RuleEngineError.malformedRule(
|
|
rule: path,
|
|
detail: "JSONPath 在 \(rule.distance(from: rule.startIndex, to: index)) 处包含意外字符"
|
|
)
|
|
}
|
|
}
|
|
return segments
|
|
}
|
|
|
|
private func parseDotSelector(
|
|
_ rule: String,
|
|
from start: String.Index,
|
|
original: String
|
|
) throws -> (selector: Selector, next: String.Index) {
|
|
if rule[start] == "*" {
|
|
return (.wildcard, rule.index(after: start))
|
|
}
|
|
var end = start
|
|
while end < rule.endIndex, rule[end] != ".", rule[end] != "[" {
|
|
end = rule.index(after: end)
|
|
}
|
|
let key = String(rule[start..<end])
|
|
guard !key.isEmpty else {
|
|
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 属性名不能为空")
|
|
}
|
|
return (.key(key), end)
|
|
}
|
|
|
|
private func parseBracket(
|
|
_ rule: String,
|
|
from open: String.Index,
|
|
original: String
|
|
) throws -> (segment: Segment, next: String.Index) {
|
|
var index = rule.index(after: open)
|
|
var quote: Character?
|
|
var escaped = false
|
|
var depth = 1
|
|
while index < rule.endIndex {
|
|
let character = rule[index]
|
|
if escaped {
|
|
escaped = false
|
|
} else if character == "\\" {
|
|
escaped = true
|
|
} else if let activeQuote = quote {
|
|
if character == activeQuote { quote = nil }
|
|
} else if character == "\"" || character == "'" {
|
|
quote = character
|
|
} else if character == "[" {
|
|
depth += 1
|
|
} else if character == "]" {
|
|
depth -= 1
|
|
guard depth == 0 else {
|
|
index = rule.index(after: index)
|
|
continue
|
|
}
|
|
let content = String(rule[rule.index(after: open)..<index])
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let segment = try parseBracketContent(content, original: original)
|
|
return (segment, rule.index(after: index))
|
|
}
|
|
index = rule.index(after: index)
|
|
}
|
|
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号未闭合")
|
|
}
|
|
|
|
private func parseBracketContent(_ content: String, original: String) throws -> Segment {
|
|
guard !content.isEmpty else {
|
|
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号不能为空")
|
|
}
|
|
if content.hasPrefix("?(") && content.hasSuffix(")") {
|
|
let predicate = String(content.dropFirst(2).dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !predicate.isEmpty else {
|
|
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath filter 不能为空")
|
|
}
|
|
return .filter(predicate)
|
|
}
|
|
if content == "*" { return .child(.wildcard) }
|
|
if content.contains(":") {
|
|
let values = content.split(separator: ":", omittingEmptySubsequences: false)
|
|
guard (2 ... 3).contains(values.count),
|
|
let step = values.count == 3 ? Int(values[2]) : 1,
|
|
step != 0 else {
|
|
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath slice 格式无效")
|
|
}
|
|
return .slice(
|
|
start: values[0].isEmpty ? nil : Int(values[0]),
|
|
end: values[1].isEmpty ? nil : Int(values[1]),
|
|
step: step
|
|
)
|
|
}
|
|
let parts = splitCommaSeparated(content)
|
|
let selectors = try parts.map { try parseSelector($0, original: original) }
|
|
return selectors.count == 1 ? .child(selectors[0]) : .union(selectors)
|
|
}
|
|
|
|
private func splitCommaSeparated(_ content: String) -> [String] {
|
|
var parts: [String] = []
|
|
var quote: Character?
|
|
var escaped = false
|
|
var start = content.startIndex
|
|
var index = start
|
|
while index < content.endIndex {
|
|
let character = content[index]
|
|
if escaped {
|
|
escaped = false
|
|
} else if character == "\\" {
|
|
escaped = true
|
|
} else if let activeQuote = quote {
|
|
if character == activeQuote { quote = nil }
|
|
} else if character == "\"" || character == "'" {
|
|
quote = character
|
|
} else if character == "," {
|
|
parts.append(String(content[start..<index]).trimmingCharacters(in: .whitespaces))
|
|
start = content.index(after: index)
|
|
}
|
|
index = content.index(after: index)
|
|
}
|
|
parts.append(String(content[start...]).trimmingCharacters(in: .whitespaces))
|
|
return parts
|
|
}
|
|
|
|
private func parseSelector(_ source: String, original: String) throws -> Selector {
|
|
if source == "*" { return .wildcard }
|
|
if let index = Int(source) { return .index(index) }
|
|
if source.count >= 2,
|
|
let first = source.first,
|
|
first == "\"" || first == "'",
|
|
source.last == first {
|
|
let key = String(source.dropFirst().dropLast())
|
|
.replacingOccurrences(of: "\\\(first)", with: String(first))
|
|
.replacingOccurrences(of: "\\\\", with: "\\")
|
|
return .key(key)
|
|
}
|
|
throw RuleEngineError.malformedRule(rule: original, detail: "不支持的 JSONPath 选择器:\(source)")
|
|
}
|
|
|
|
private func select(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
|
|
switch (selector, value) {
|
|
case (.key(let key), .object(let object)):
|
|
return object[key].map { [$0] } ?? []
|
|
case (.index(let index), .array(let array)):
|
|
let resolved = index >= 0 ? index : array.count + index
|
|
return array.indices.contains(resolved) ? [array[resolved]] : []
|
|
case (.wildcard, .object(let object)):
|
|
return object.keys.sorted().compactMap { object[$0] }
|
|
case (.wildcard, .array(let array)):
|
|
return array
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
private func recursivelySelect(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
|
|
var result = select(selector, from: value)
|
|
switch value {
|
|
case .object(let object):
|
|
for key in object.keys.sorted() {
|
|
if let child = object[key] { result += recursivelySelect(selector, from: child) }
|
|
}
|
|
case .array(let array):
|
|
for child in array { result += recursivelySelect(selector, from: child) }
|
|
default:
|
|
break
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func slice(_ value: JSONValue, start: Int?, end: Int?, step: Int) -> [JSONValue] {
|
|
guard case .array(let array) = value, !array.isEmpty else { return [] }
|
|
let count = array.count
|
|
let defaultStart = step > 0 ? 0 : count - 1
|
|
let defaultEnd = step > 0 ? count : -1
|
|
var cursor = normalize(start ?? defaultStart, count: count, allowEnd: true)
|
|
let boundary = end == nil && step < 0 ? -1 : normalize(end ?? defaultEnd, count: count, allowEnd: true)
|
|
var result: [JSONValue] = []
|
|
while step > 0 ? cursor < boundary : cursor > boundary {
|
|
if array.indices.contains(cursor) { result.append(array[cursor]) }
|
|
cursor += step
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func normalize(_ index: Int, count: Int, allowEnd: Bool) -> Int {
|
|
let value = index < 0 ? count + index : index
|
|
return min(max(value, 0), allowEnd ? count : count - 1)
|
|
}
|
|
|
|
private func matches(_ source: String, candidate: JSONValue, root: JSONValue) throws -> Bool {
|
|
let predicate = stripOuterParentheses(source.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
if let parts = splitLogical(predicate, operator: "||"), parts.count > 1 {
|
|
for part in parts where try matches(part, candidate: candidate, root: root) { return true }
|
|
return false
|
|
}
|
|
if let parts = splitLogical(predicate, operator: "&&"), parts.count > 1 {
|
|
for part in parts where !(try matches(part, candidate: candidate, root: root)) { return false }
|
|
return true
|
|
}
|
|
if predicate.hasPrefix("!") && !predicate.hasPrefix("!=") {
|
|
return try !matches(String(predicate.dropFirst()), candidate: candidate, root: root)
|
|
}
|
|
if let comparison = findComparison(predicate) {
|
|
let lhs = try resolveOperand(String(predicate[..<comparison.range.lowerBound]), candidate: candidate, root: root)
|
|
let rhs = try resolveOperand(String(predicate[comparison.range.upperBound...]), candidate: candidate, root: root)
|
|
guard !lhs.isEmpty, !rhs.isEmpty else { return false }
|
|
return try lhs.contains { left in
|
|
try rhs.contains { right in try compare(left, right, operation: comparison.operation) }
|
|
}
|
|
}
|
|
let values = try resolveOperand(predicate, candidate: candidate, root: root)
|
|
return values.contains(where: isTruthy)
|
|
}
|
|
|
|
private func splitLogical(_ source: String, operator operation: String) -> [String]? {
|
|
var parts: [String] = []
|
|
var quote: Character?
|
|
var regex = false
|
|
var escaped = false
|
|
var roundDepth = 0
|
|
var squareDepth = 0
|
|
var start = source.startIndex
|
|
var index = start
|
|
while index < source.endIndex {
|
|
let character = source[index]
|
|
if escaped {
|
|
escaped = false
|
|
} else if character == "\\" {
|
|
escaped = true
|
|
} else if let activeQuote = quote {
|
|
if character == activeQuote { quote = nil }
|
|
} else if character == "\"" || character == "'" {
|
|
quote = character
|
|
} else if character == "/" {
|
|
regex.toggle()
|
|
} else if !regex {
|
|
if character == "(" { roundDepth += 1 }
|
|
if character == ")" { roundDepth -= 1 }
|
|
if character == "[" { squareDepth += 1 }
|
|
if character == "]" { squareDepth -= 1 }
|
|
if roundDepth == 0, squareDepth == 0, source[index...].hasPrefix(operation) {
|
|
parts.append(String(source[start..<index]).trimmingCharacters(in: .whitespaces))
|
|
index = source.index(index, offsetBy: operation.count)
|
|
start = index
|
|
continue
|
|
}
|
|
}
|
|
index = source.index(after: index)
|
|
}
|
|
guard !parts.isEmpty else { return nil }
|
|
parts.append(String(source[start...]).trimmingCharacters(in: .whitespaces))
|
|
return parts
|
|
}
|
|
|
|
private func stripOuterParentheses(_ source: String) -> String {
|
|
var value = source
|
|
while value.first == "(", value.last == ")", outerParenthesesEncloseAll(value) {
|
|
value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
return value
|
|
}
|
|
|
|
private func outerParenthesesEncloseAll(_ source: String) -> Bool {
|
|
var depth = 0
|
|
var quote: Character?
|
|
var escaped = false
|
|
for index in source.indices {
|
|
let character = source[index]
|
|
if escaped { escaped = false; continue }
|
|
if character == "\\" { escaped = true; continue }
|
|
if let activeQuote = quote {
|
|
if character == activeQuote { quote = nil }
|
|
continue
|
|
}
|
|
if character == "\"" || character == "'" { quote = character; continue }
|
|
if character == "(" { depth += 1 }
|
|
if character == ")" {
|
|
depth -= 1
|
|
if depth == 0 && index != source.index(before: source.endIndex) { return false }
|
|
}
|
|
}
|
|
return depth == 0
|
|
}
|
|
|
|
private func findComparison(_ source: String) -> (range: Range<String.Index>, operation: String)? {
|
|
let operations = ["==", "!=", ">=", "<=", "=~", ">", "<"]
|
|
var quote: Character?
|
|
var escaped = false
|
|
var roundDepth = 0
|
|
var squareDepth = 0
|
|
var index = source.startIndex
|
|
while index < source.endIndex {
|
|
let character = source[index]
|
|
if escaped {
|
|
escaped = false
|
|
} else if character == "\\" {
|
|
escaped = true
|
|
} else if let activeQuote = quote {
|
|
if character == activeQuote { quote = nil }
|
|
} else if character == "\"" || character == "'" {
|
|
quote = character
|
|
} else {
|
|
if character == "(" { roundDepth += 1 }
|
|
if character == ")" { roundDepth -= 1 }
|
|
if character == "[" { squareDepth += 1 }
|
|
if character == "]" { squareDepth -= 1 }
|
|
if roundDepth == 0, squareDepth == 0 {
|
|
for operation in operations where source[index...].hasPrefix(operation) {
|
|
return (index..<source.index(index, offsetBy: operation.count), operation)
|
|
}
|
|
}
|
|
}
|
|
index = source.index(after: index)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func resolveOperand(_ source: String, candidate: JSONValue, root: JSONValue) throws -> [JSONValue] {
|
|
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty else {
|
|
throw RuleEngineError.malformedRule(rule: source, detail: "JSONPath filter 比较值不能为空")
|
|
}
|
|
if value.first == "@" || value.first == "$" {
|
|
return try resolvePath(value, base: value.first == "@" ? candidate : root)
|
|
}
|
|
if value == "true" { return [.bool(true)] }
|
|
if value == "false" { return [.bool(false)] }
|
|
if value == "null" { return [.null] }
|
|
if let number = Double(value) { return [.number(number)] }
|
|
if let first = value.first, (first == "\"" || first == "'"), value.last == first {
|
|
let inner = String(value.dropFirst().dropLast())
|
|
.replacingOccurrences(of: "\\\(first)", with: String(first))
|
|
.replacingOccurrences(of: "\\n", with: "\n")
|
|
.replacingOccurrences(of: "\\\\", with: "\\")
|
|
return [.string(inner)]
|
|
}
|
|
if value.first == "/", value.dropFirst().lastIndex(of: "/") != nil {
|
|
return [.string(value)]
|
|
}
|
|
throw RuleEngineError.malformedRule(rule: source, detail: "不支持的 JSONPath filter 操作数:\(value)")
|
|
}
|
|
|
|
private func resolvePath(_ path: String, base: JSONValue) throws -> [JSONValue] {
|
|
var current = [base]
|
|
var index = path.index(after: path.startIndex)
|
|
while index < path.endIndex {
|
|
if path[index] == "." {
|
|
index = path.index(after: index)
|
|
var end = index
|
|
while end < path.endIndex, path[end] != ".", path[end] != "[" { end = path.index(after: end) }
|
|
let key = String(path[index..<end])
|
|
guard !key.isEmpty else { throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径属性为空") }
|
|
current = current.flatMap { select(.key(key), from: $0) }
|
|
index = end
|
|
} else if path[index] == "[" {
|
|
guard let close = path[index...].firstIndex(of: "]") else {
|
|
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径方括号未闭合")
|
|
}
|
|
let token = String(path[path.index(after: index)..<close]).trimmingCharacters(in: .whitespaces)
|
|
let selector = try parseSelector(token, original: path)
|
|
current = current.flatMap { select(selector, from: $0) }
|
|
index = path.index(after: close)
|
|
} else {
|
|
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径包含意外字符")
|
|
}
|
|
}
|
|
return current
|
|
}
|
|
|
|
private func compare(_ lhs: JSONValue, _ rhs: JSONValue, operation: String) throws -> Bool {
|
|
if operation == "=~" {
|
|
let literal = rhs.stringValue
|
|
guard literal.first == "/", let closing = literal.dropFirst().lastIndex(of: "/") else {
|
|
throw RuleEngineError.malformedRule(rule: literal, detail: "filter 正则必须写成 /pattern/flags")
|
|
}
|
|
let pattern = String(literal[literal.index(after: literal.startIndex)..<closing])
|
|
guard pattern.count <= 1_024 else {
|
|
throw RuleEngineError.unsupportedRule(rule: literal, feature: "过长 JSONPath filter 正则")
|
|
}
|
|
let flags = literal[literal.index(after: closing)...]
|
|
let options: NSRegularExpression.Options = flags.contains("i") ? .caseInsensitive : []
|
|
let regex = try NSRegularExpression(pattern: pattern, options: options)
|
|
let range = NSRange(lhs.stringValue.startIndex..., in: lhs.stringValue)
|
|
return regex.firstMatch(in: lhs.stringValue, range: range) != nil
|
|
}
|
|
if operation == "==" || operation == "!=" {
|
|
let equal: Bool
|
|
switch (lhs, rhs) {
|
|
case (.number(let left), .number(let right)): equal = left == right
|
|
case (.bool(let left), .bool(let right)): equal = left == right
|
|
case (.null, .null): equal = true
|
|
default: equal = lhs.stringValue == rhs.stringValue
|
|
}
|
|
return operation == "==" ? equal : !equal
|
|
}
|
|
let ordering: ComparisonResult
|
|
if case .number(let left) = lhs, case .number(let right) = rhs {
|
|
ordering = left < right ? .orderedAscending : left > right ? .orderedDescending : .orderedSame
|
|
} else {
|
|
ordering = lhs.stringValue.compare(rhs.stringValue, options: .numeric)
|
|
}
|
|
return switch operation {
|
|
case ">": ordering == .orderedDescending
|
|
case ">=": ordering != .orderedAscending
|
|
case "<": ordering == .orderedAscending
|
|
case "<=": ordering != .orderedDescending
|
|
default: false
|
|
}
|
|
}
|
|
|
|
private func isTruthy(_ value: JSONValue) -> Bool {
|
|
switch value {
|
|
case .null: false
|
|
case .bool(let value): value
|
|
case .number(let value): value != 0
|
|
case .string(let value): !value.isEmpty
|
|
case .array(let value): !value.isEmpty
|
|
case .object(let value): !value.isEmpty
|
|
}
|
|
}
|
|
}
|