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.
318 lines
10 KiB
Swift
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
|
|
}
|
|
}
|