PureXML/Sources/XPath/CompilerPaths.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

318 lines
10 KiB
Swift

extension PureXML.XPath.Compiler {
typealias Expression = PureXML.XPath.Expression
typealias Step = PureXML.XPath.Step
typealias Axis = PureXML.XPath.Axis
typealias NodeTest = PureXML.XPath.NodeTest
typealias QueryError = PureXML.XPath.QueryError
// MARK: Paths and primaries
mutating func parsePath() throws -> Expression {
skipSpace()
guard try isPrimaryStart() else {
let (absolute, steps) = try parseLocationPath()
return .path(absolute: absolute, steps: Self.fuseDescendantSteps(steps))
}
let primary = try parsePrimary()
let predicates = try parsePredicates()
var steps: [Step] = []
skipSpace()
if peek() == "/" {
advance()
if consume("/") {
steps.append(Self.descendantOrSelfStep())
}
try steps.append(parseStep())
try parseTrailingSteps(into: &steps)
}
if predicates.isEmpty, steps.isEmpty {
return primary
}
return .filter(primary: primary, predicates: predicates, steps: Self.fuseDescendantSteps(steps))
}
private mutating func isPrimaryStart() throws -> Bool {
guard let character = peek() else { return false }
if character == "$" || character == "(" || character == "\"" || character == "'" {
return true
}
if character.isNumber {
return true
}
if character == ".", peek(1)?.isNumber == true {
return true
}
return functionCallAhead()
}
private mutating func functionCallAhead() -> Bool {
let save = index
let word = parseName()
skipSpace()
let isCall = peek() == "("
index = save
return isCall && !word.isEmpty && !Self.isNodeType(word)
}
private static func isNodeType(_ name: String) -> Bool {
["node", "text", "comment", "processing-instruction"].contains(name)
}
private mutating func parsePrimary() throws -> Expression {
skipSpace()
if peek() == "$" {
advance()
return .variable(parseName())
}
if consume("(") {
let expression = try parseExpression()
skipSpace()
try expect(")")
return expression
}
if let quote = peek(), quote == "\"" || quote == "'" {
return try .string(parseStringLiteral())
}
if let character = peek(), character.isNumber || character == "." {
return .number(parseNumberLiteral())
}
return try parseFunctionCall()
}
private mutating func parseFunctionCall() throws -> Expression {
let name = parseName()
skipSpace()
try expect("(")
var arguments: [Expression] = []
skipSpace()
if peek() != ")" {
try arguments.append(parseExpression())
while skipSpaceThenComma() {
try arguments.append(parseExpression())
}
}
skipSpace()
try expect(")")
return .function(name: name, arguments: arguments)
}
private mutating func skipSpaceThenComma() -> Bool {
skipSpace()
return consume(",")
}
// MARK: Location paths
static func descendantOrSelfStep() -> Step {
Step(axis: .descendantOrSelf, test: .node, predicates: [])
}
private mutating func parseLocationPath() throws -> (absolute: Bool, steps: [Step]) {
var absolute = false
var steps: [Step] = []
if consume("/") {
absolute = true
if consume("/") {
steps.append(Self.descendantOrSelfStep())
try steps.append(parseStep())
} else if startsStep() {
try steps.append(parseStep())
} else {
// The relative part after `/` is optional, so a lone `/` is the
// root node even when a `)`, `]`, `|`, or an operator follows it
// rather than the end of the expression (e.g. `count(/)`).
return (true, [])
}
} else {
try steps.append(parseStep())
}
try parseTrailingSteps(into: &steps)
return (absolute, steps)
}
mutating func parseTrailingSteps(into steps: inout [Step]) throws {
while true {
skipSpace()
guard peek() == "/" else { return }
advance()
if consume("/") {
steps.append(Self.descendantOrSelfStep())
}
try steps.append(parseStep())
}
}
/// Whether the next token begins a location step (an axis, `@`, `.`/`..`, a
/// `*` wildcard, or a name/node-test), as opposed to ending the path (`)`,
/// `]`, `|`, an operator, or the end). Used to recognize a lone `/` as root.
private mutating func startsStep() -> Bool {
skipSpace()
guard let character = peek() else { return false }
return character.isLetter || character == "_" || character == "@" || character == "." || character == "*"
}
private mutating func parseStep() throws -> Step {
skipSpace()
if matches("..") {
advance(by: 2)
return try Step(axis: .parent, test: .node, predicates: parsePredicates())
}
if peek() == ".", peek(1) != "." {
advance()
return try Step(axis: .selfAxis, test: .node, predicates: parsePredicates())
}
let axis = try parseAxis()
let test = try parseNodeTest()
return try Step(axis: axis, test: test, predicates: parsePredicates())
}
private mutating func parseAxis() throws -> Axis {
if consume("@") {
return .attribute
}
let save = index
let word = parseName()
// XPath 1.0 allows whitespace around the `::` axis separator (a child
// `::` sub is the child axis). Skip it before the test; on no match the
// reset to `save` (before the name) undoes the skip, so a bare name step
// is unaffected.
skipSpace()
if !word.isEmpty, matches("::") {
advance(by: 2)
skipSpace()
return try Self.namedAxis(word)
}
index = save
return .child
}
private static func namedAxis(_ name: String) throws -> Axis {
if let axis = verticalAxis(name) ?? lateralAxis(name) {
return axis
}
throw QueryError.unsupportedAxis(name)
}
private static func verticalAxis(_ name: String) -> Axis? {
switch name {
case "child": .child
case "descendant": .descendant
case "descendant-or-self": .descendantOrSelf
case "parent": .parent
case "ancestor": .ancestor
case "ancestor-or-self": .ancestorOrSelf
case "self": .selfAxis
default: nil
}
}
private static func lateralAxis(_ name: String) -> Axis? {
switch name {
case "following-sibling": .followingSibling
case "preceding-sibling": .precedingSibling
case "following": .following
case "preceding": .preceding
case "attribute": .attribute
case "namespace": .namespace
default: nil
}
}
private mutating func parseNodeTest() throws -> NodeTest {
skipSpace()
if peek() == "*" {
advance()
return .wildcard
}
var name = parseName()
guard !name.isEmpty else { throw QueryError.expectedNodeTest }
if name.hasSuffix(":"), peek() == "*" {
// The NCName:* form: any name in the prefix's namespace.
advance()
name += "*"
return .name(name)
}
guard peek() == "(" else {
return .name(name)
}
return try parseNodeTypeTest(name)
}
private mutating func parseNodeTypeTest(_ name: String) throws -> NodeTest {
advance()
skipSpace()
if name == "processing-instruction", peek() != ")" {
let target = try parseStringLiteral()
skipSpace()
try expect(")")
return .processingInstruction(target: target)
}
try expect(")")
switch name {
case "text": return .text
case "node": return .node
case "comment": return .comment
case "processing-instruction": return .processingInstruction(target: nil)
default: throw QueryError.unexpectedToken("\(name)()")
}
}
private mutating func parsePredicates() throws -> [Expression] {
var predicates: [Expression] = []
while true {
skipSpace()
guard peek() == "[" else { return predicates }
advance()
try predicates.append(parseExpression())
skipSpace()
guard consume("]") else { throw QueryError.unterminatedPredicate }
}
}
// MARK: Tokens
mutating func parseStringLiteral() throws -> String {
guard let quote = peek(), quote == "\"" || quote == "'" else {
throw QueryError.unsupportedPredicate("expected a quoted value")
}
advance()
var value = ""
while let character = peek(), character != quote {
value.append(character)
advance()
}
guard peek() == quote else { throw QueryError.unsupportedPredicate("unterminated value") }
advance()
return value
}
private mutating func parseNumberLiteral() -> Double {
var text = ""
while let character = peek(), character.isNumber {
text.append(character)
advance()
}
if peek() == "." {
text.append(".")
advance()
while let character = peek(), character.isNumber {
text.append(character)
advance()
}
}
return Double(text) ?? .nan
}
mutating func parseName() -> String {
var name = ""
while let character = peek(), character.isXMLNameContinuation {
if character == ":", peek(1) == ":" {
break
}
name.append(character)
advance()
}
return name
}
}