diff --git a/CHANGELOG.md b/CHANGELOG.md index 241b690..5133744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XPath query support (a practical subset). `PureXML.XPath.Query` compiles a + location path and evaluates it over a node; `PureXML.xpath(_:over:)` is the + one-shot convenience. Supports the forward axes (child, descendant `//`, self + `.`, attribute `@`), the node tests (name, `*`, `text()`, `node()`, + `comment()`), and the predicates `[n]`, `[@a]`, `[@a='v']`, `[child]`, + `[child='v']`. Returns a `Selection` node-set with `element` and `stringValue` + accessors. Upward/sibling axes and the full expression language are out of + scope (the model has no parent pointers). - Optional DTD with internal general entities, off by default. With `Limits(allowDoctype: true)` the parser reads a `` internal subset, honors internal `` declarations, and expands them in text diff --git a/README.md b/README.md index 3718cd8..70dc967 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ flowchart TB ByteStream["Streaming byte decode"]:::done DTD["Optional DTD + entities"]:::done Validation["Schema validation"]:::todo - XPath["XPath 1.0"]:::todo + XPath["XPath 1.0 subset"]:::done Model --> Stream Stream --> Escape Escape --> Limits diff --git a/Sources/API.swift b/Sources/API.swift index 73be113..4114634 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -64,6 +64,12 @@ public extension PureXML { return Parsing.EventReader(pulling: { decoder.next() }, limits: limits) } + /// Compiles and evaluates an XPath query over a node, returning the selected + /// node-set. Compile once with ``XPath/Query`` to reuse a query. + static func xpath(_ path: String, over node: Model.Node) throws -> [XPath.Selection] { + try XPath.Query(path).evaluate(over: node) + } + /// Serializes a ``Model/Node`` tree into XML with the selected options. static func serialize( _ node: Model.Node, diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index cd49c39..6b6e1aa 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -20,4 +20,7 @@ public enum PureXML { /// XML stream types. public enum Stream {} + + /// XPath query types. + public enum XPath {} } diff --git a/Sources/XPath/Axis.swift b/Sources/XPath/Axis.swift new file mode 100644 index 0000000..f8c44f9 --- /dev/null +++ b/Sources/XPath/Axis.swift @@ -0,0 +1,15 @@ +extension PureXML.XPath { + /// The supported XPath axes. The model is a value tree without parent + /// pointers, so only the forward axes are available; upward axes (parent, + /// ancestor) and sibling axes are intentionally out of scope. + enum Axis: Equatable { + /// Immediate children (the default axis). + case child + /// All descendants at any depth (reached with `//`). + case descendant + /// The context node itself (`.`). + case selfNode + /// Attributes of the context element (`@`). + case attribute + } +} diff --git a/Sources/XPath/Compiler.swift b/Sources/XPath/Compiler.swift new file mode 100644 index 0000000..6475e30 --- /dev/null +++ b/Sources/XPath/Compiler.swift @@ -0,0 +1,192 @@ +extension PureXML.XPath { + /// Compiles an XPath location-path string (the supported subset) into a list + /// of steps. A small recursive-descent parser over the characters. + struct Compiler { + private let chars: [Character] + private var index = 0 + + private init(_ path: String) { + chars = Array(path) + } + + static func compile(_ path: String) throws -> (absolute: Bool, steps: [Step]) { + var compiler = Compiler(path) + return try compiler.parse() + } + + private mutating func parse() throws -> (absolute: Bool, steps: [Step]) { + skipSpace() + guard !isAtEnd else { throw QueryError.empty } + var absolute = false + var steps: [Step] = [] + + if peek() == "/" { + absolute = true + advance() + if peek() == "/" { + advance() + try steps.append(parseStep(axisOverride: .descendant)) + } else if isAtEnd { + return (true, []) + } else { + try steps.append(parseStep(axisOverride: nil)) + } + } else { + try steps.append(parseStep(axisOverride: nil)) + } + + while true { + skipSpace() + guard peek() == "/" else { + if isAtEnd { break } + throw QueryError.unexpectedToken(String(peek() ?? " ")) + } + advance() + if peek() == "/" { + advance() + try steps.append(parseStep(axisOverride: .descendant)) + } else { + try steps.append(parseStep(axisOverride: nil)) + } + } + return (absolute, steps) + } + + private mutating func parseStep(axisOverride: Axis?) throws -> Step { + skipSpace() + if peek() == "." { + advance() + if peek() == "." { + throw QueryError.unsupportedAxis("..") + } + return try Step(axis: .selfNode, test: .node, predicates: parsePredicates()) + } + var axis = axisOverride ?? .child + if peek() == "@" { + advance() + axis = .attribute + } + let test = try parseNodeTest() + return try Step(axis: axis, test: test, predicates: parsePredicates()) + } + + private mutating func parseNodeTest() throws -> NodeTest { + if peek() == "*" { + advance() + return .wildcard + } + let name = parseName() + guard !name.isEmpty else { throw QueryError.expectedNodeTest } + if peek() == "(" { + advance() + skipSpace() + guard peek() == ")" else { throw QueryError.unexpectedToken(String(peek() ?? " ")) } + advance() + switch name { + case "text": return .text + case "node": return .node + case "comment": return .comment + default: throw QueryError.unexpectedToken("\(name)()") + } + } + return .name(name) + } + + private mutating func parsePredicates() throws -> [Predicate] { + var predicates: [Predicate] = [] + while peek() == "[" { + advance() + try predicates.append(parsePredicate()) + } + return predicates + } + + private mutating func parsePredicate() throws -> Predicate { + skipSpace() + let predicate: Predicate + if let digit = peek(), digit.isNumber { + predicate = .position(parseNumber()) + } else if peek() == "@" { + advance() + let name = parseName() + predicate = try parseEquality( + makeEquals: { .attributeEquals(name: name, value: $0) }, + makeExists: { .hasAttribute(name) }, + ) + } else { + let name = parseName() + guard !name.isEmpty else { throw QueryError.unsupportedPredicate("empty") } + predicate = try parseEquality( + makeEquals: { .childEquals(name: name, value: $0) }, + makeExists: { .hasChild(name) }, + ) + } + skipSpace() + guard peek() == "]" else { throw QueryError.unterminatedPredicate } + advance() + return predicate + } + + private mutating func parseEquality( + makeEquals: (String) -> Predicate, + makeExists: () -> Predicate, + ) throws -> Predicate { + skipSpace() + guard peek() == "=" else { return makeExists() } + advance() + skipSpace() + return try makeEquals(parseLiteral()) + } + + private mutating func parseLiteral() 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 parseName() -> String { + var name = "" + while let character = peek(), character.isXMLNameContinuation { + name.append(character) + advance() + } + return name + } + + private mutating func parseNumber() -> Int { + var digits = "" + while let character = peek(), character.isNumber { + digits.append(character) + advance() + } + return Int(digits) ?? 0 + } + + private var isAtEnd: Bool { + index >= chars.count + } + + private func peek() -> Character? { + index < chars.count ? chars[index] : nil + } + + private mutating func advance() { + if index < chars.count { index += 1 } + } + + private mutating func skipSpace() { + while let character = peek(), character == " " || character == "\t" { + advance() + } + } + } +} diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift new file mode 100644 index 0000000..4c7dd64 --- /dev/null +++ b/Sources/XPath/Evaluator.swift @@ -0,0 +1,143 @@ +extension PureXML.XPath { + /// Evaluates compiled steps over a node, returning the selected node-set in + /// document order. The provided node is treated as the starting context (and, + /// for an absolute path, as the root). + enum Evaluator { + static func evaluate( + steps: [Step], + over node: PureXML.Model.Node, + ) throws -> [Selection] { + guard !steps.isEmpty else { return [.node(node)] } + var context: [PureXML.Model.Node] = [node] + for (offset, step) in steps.enumerated() { + if step.axis == .attribute { + guard offset == steps.count - 1 else { throw QueryError.attributeStepNotLast } + return attributeSelections(step, context: context) + } + var next: [PureXML.Model.Node] = [] + for contextNode in context { + let candidates = candidateNodes(step, contextNode) + next.append(contentsOf: applyPredicates(step.predicates, to: candidates)) + } + context = next + } + return context.map { .node($0) } + } + + private static func candidateNodes(_ step: Step, _ contextNode: PureXML.Model.Node) -> [PureXML.Model.Node] { + let pool: [PureXML.Model.Node] = switch step.axis { + case .child: children(of: contextNode) + case .descendant: subtree(of: contextNode) + case .selfNode: [contextNode] + case .attribute: [] + } + return pool.filter { matches($0, step.test) } + } + + private static func attributeSelections(_ step: Step, context: [PureXML.Model.Node]) -> [Selection] { + var result: [Selection] = [] + for contextNode in context { + guard case let .element(element) = contextNode else { continue } + var attributes = element.attributes.filter { attributeMatches($0, step.test) } + for predicate in step.predicates { + if case let .position(index) = predicate { + attributes = (index >= 1 && index <= attributes.count) ? [attributes[index - 1]] : [] + } + } + result.append(contentsOf: attributes.map { .attribute($0) }) + } + return result + } + + private static func applyPredicates( + _ predicates: [Predicate], + to nodes: [PureXML.Model.Node], + ) -> [PureXML.Model.Node] { + var result = nodes + for predicate in predicates { + switch predicate { + case let .position(index): + result = (index >= 1 && index <= result.count) ? [result[index - 1]] : [] + case let .hasAttribute(name): + result = result.filter { attributeValue($0, name) != nil } + case let .attributeEquals(name, value): + result = result.filter { attributeValue($0, name) == value } + case let .hasChild(name): + result = result.filter { childElement($0, name) != nil } + case let .childEquals(name, value): + result = result.filter { childElementText($0, name) == value } + } + } + return result + } + + private static func children(of node: PureXML.Model.Node) -> [PureXML.Model.Node] { + switch node { + case let .element(element): element.children + case let .document(nodes): nodes + default: [] + } + } + + private static func subtree(of node: PureXML.Model.Node) -> [PureXML.Model.Node] { + var result = [node] + for child in children(of: node) { + result.append(contentsOf: subtree(of: child)) + } + return result + } + + private static func matches(_ node: PureXML.Model.Node, _ test: NodeTest) -> Bool { + switch test { + case let .name(name): + guard case let .element(element) = node else { return false } + return element.name.description == name || element.name.localName == name + case .wildcard: + if case .element = node { return true } + return false + case .text: + if case .text = node { return true } + if case .cdata = node { return true } + return false + case .node: + return true + case .comment: + if case .comment = node { return true } + return false + } + } + + private static func attributeMatches(_ attribute: PureXML.Model.Attribute, _ test: NodeTest) -> Bool { + switch test { + case let .name(name): attribute.name.description == name || attribute.name.localName == name + case .wildcard: true + default: false + } + } + + private static func attributeValue(_ node: PureXML.Model.Node, _ name: String) -> String? { + guard case let .element(element) = node else { return nil } + return element.attributes.first { $0.name.description == name || $0.name.localName == name }?.value + } + + private static func childElementNode(_ node: PureXML.Model.Node, _ name: String) -> PureXML.Model.Node? { + guard case let .element(element) = node else { return nil } + return element.children.first { child in + if case let .element(inner) = child { + return inner.name.description == name || inner.name.localName == name + } + return false + } + } + + private static func childElement(_ node: PureXML.Model.Node, _ name: String) -> PureXML.Model.Element? { + guard case let .element(found) = childElementNode(node, name) else { return nil } + return found + } + + private static func childElementText(_ node: PureXML.Model.Node, _ name: String) -> String? { + guard let child = childElementNode(node, name) else { return nil } + return Selection.node(child).stringValue + } + } +} diff --git a/Sources/XPath/NodeTest.swift b/Sources/XPath/NodeTest.swift new file mode 100644 index 0000000..a7def4c --- /dev/null +++ b/Sources/XPath/NodeTest.swift @@ -0,0 +1,15 @@ +extension PureXML.XPath { + /// The node test of a location step: which nodes on the axis qualify. + enum NodeTest: Equatable { + /// A local-name match (`book`). + case name(String) + /// The `*` wildcard (any element, or any attribute on the attribute axis). + case wildcard + /// `text()`: character-data and CDATA nodes. + case text + /// `node()`: any node. + case node + /// `comment()`: comment nodes. + case comment + } +} diff --git a/Sources/XPath/Predicate.swift b/Sources/XPath/Predicate.swift new file mode 100644 index 0000000..ac97cc1 --- /dev/null +++ b/Sources/XPath/Predicate.swift @@ -0,0 +1,17 @@ +extension PureXML.XPath { + /// A step predicate (`[...]`). A supported, practical subset: a position, an + /// attribute existence or equality test, or a child-element existence or + /// equality test. + enum Predicate: Equatable { + /// A one-based position, as in `book[1]`. + case position(Int) + /// `[@id]`: the context element has the named attribute. + case hasAttribute(String) + /// `[@id='x']`: the named attribute equals the value. + case attributeEquals(name: String, value: String) + /// `[title]`: the context element has a child element with the name. + case hasChild(String) + /// `[title='x']`: a child element with the name has the string value. + case childEquals(name: String, value: String) + } +} diff --git a/Sources/XPath/Query.swift b/Sources/XPath/Query.swift new file mode 100644 index 0000000..e46e262 --- /dev/null +++ b/Sources/XPath/Query.swift @@ -0,0 +1,35 @@ +public extension PureXML.XPath { + /// A compiled XPath location-path query over the supported subset: the + /// forward axes (child, descendant `//`, self `.`, attribute `@`), the node + /// tests (name, `*`, `text()`, `node()`, `comment()`), and the predicates + /// `[n]`, `[@a]`, `[@a='v']`, `[child]`, `[child='v']`. + /// + /// Upward and sibling axes, functions, and the full expression language are + /// intentionally out of scope. Reimplemented from the XPath 1.0 specification. + struct Query: Sendable { + let absolute: Bool + let steps: [Step] + + /// Compiles an XPath location path, throwing ``QueryError`` on a path + /// outside the supported subset. + public init(_ path: String) throws { + (absolute, steps) = try Compiler.compile(path) + } + + /// Evaluates the query over a node, returning the selected node-set in + /// document order. The node is the starting context. + public func evaluate(over node: PureXML.Model.Node) throws -> [Selection] { + try Evaluator.evaluate(steps: steps, over: node) + } + + /// Evaluates the query and returns the selected elements only. + public func elements(over node: PureXML.Model.Node) throws -> [PureXML.Model.Element] { + try evaluate(over: node).compactMap(\.element) + } + + /// Evaluates the query and returns the string-value of each selection. + public func strings(over node: PureXML.Model.Node) throws -> [String] { + try evaluate(over: node).map(\.stringValue) + } + } +} diff --git a/Sources/XPath/QueryError.swift b/Sources/XPath/QueryError.swift new file mode 100644 index 0000000..7d0bed0 --- /dev/null +++ b/Sources/XPath/QueryError.swift @@ -0,0 +1,26 @@ +public extension PureXML.XPath { + /// An error compiling or evaluating an XPath query in the supported subset. + enum QueryError: Swift.Error, Equatable, Sendable, CustomStringConvertible { + case empty + case unexpectedToken(String) + case expectedNodeTest + case unterminatedPredicate + case unsupportedPredicate(String) + /// An upward or sibling axis was used; only forward axes are supported. + case unsupportedAxis(String) + /// An attribute step appeared before the end of the path. + case attributeStepNotLast + + public var description: String { + switch self { + case .empty: "the XPath expression is empty" + case let .unexpectedToken(token): "unexpected token '\(token)'" + case .expectedNodeTest: "expected a node test" + case .unterminatedPredicate: "unterminated predicate" + case let .unsupportedPredicate(detail): "unsupported predicate: \(detail)" + case let .unsupportedAxis(axis): "unsupported axis: \(axis)" + case .attributeStepNotLast: "an attribute step must be the last step" + } + } + } +} diff --git a/Sources/XPath/Selection.swift b/Sources/XPath/Selection.swift new file mode 100644 index 0000000..b67ada4 --- /dev/null +++ b/Sources/XPath/Selection.swift @@ -0,0 +1,41 @@ +public extension PureXML.XPath { + /// A node selected by an XPath query: either a tree node or an attribute + /// (attributes are not ``PureXML/Model/Node`` values, so they are surfaced + /// here alongside nodes). + enum Selection: Equatable, Sendable { + case node(PureXML.Model.Node) + case attribute(PureXML.Model.Attribute) + + /// The selected element, when this selection is an element node. + public var element: PureXML.Model.Element? { + guard case let .node(node) = self, case let .element(element) = node else { + return nil + } + return element + } + + /// The XPath string-value: an attribute's value, or the concatenation of + /// all descendant character data for a node. + public var stringValue: String { + switch self { + case let .attribute(attribute): + attribute.value + case let .node(node): + Self.stringValue(of: node) + } + } + + private static func stringValue(of node: PureXML.Model.Node) -> String { + switch node { + case let .text(value), let .cdata(value), let .comment(value): + value + case let .processingInstruction(_, data): + data + case let .element(element): + element.children.reduce(into: "") { $0 += stringValue(of: $1) } + case let .document(children): + children.reduce(into: "") { $0 += stringValue(of: $1) } + } + } + } +} diff --git a/Sources/XPath/Step.swift b/Sources/XPath/Step.swift new file mode 100644 index 0000000..abfe10d --- /dev/null +++ b/Sources/XPath/Step.swift @@ -0,0 +1,9 @@ +extension PureXML.XPath { + /// One compiled location step: an axis, a node test, and zero or more + /// predicates applied in order. + struct Step: Equatable { + let axis: Axis + let test: NodeTest + let predicates: [Predicate] + } +} diff --git a/Tests/XPathTests.swift b/Tests/XPathTests.swift new file mode 100644 index 0000000..8afb4cb --- /dev/null +++ b/Tests/XPathTests.swift @@ -0,0 +1,95 @@ +@testable import PureXML +import Testing + +@Suite("XPath") +struct XPathTests { + private func catalog() throws -> PureXML.Model.Node { + try PureXML.parse( + "" + + "XML GuideDrobnik" + + "YAML Guide" + + "Wired" + + "", + ) + } + + @Test("Absolute child path selects matching elements") + func test_childPath() throws { + let books = try PureXML.XPath.Query("/catalog/book").elements(over: catalog()) + #expect(books.count == 2) + #expect(books.first?.attributes.first?.value == "bk101") + } + + @Test("Descendant path finds nodes at any depth") + func test_descendantPath() throws { + let titles = try PureXML.XPath.Query("//title").strings(over: catalog()) + #expect(titles == ["XML Guide", "YAML Guide", "Wired"]) + } + + @Test("Positional predicate selects within the parent") + func test_positionPredicate() throws { + let titles = try PureXML.XPath.Query("/catalog/book[1]/title").strings(over: catalog()) + #expect(titles == ["XML Guide"]) + } + + @Test("Attribute-equality predicate filters elements") + func test_attributeEqualityPredicate() throws { + let titles = try PureXML.XPath.Query("//book[@id='bk102']/title").strings(over: catalog()) + #expect(titles == ["YAML Guide"]) + } + + @Test("Attribute step selects attributes") + func test_attributeStep() throws { + let selections = try PureXML.XPath.Query("/catalog/book/@id").evaluate(over: catalog()) + #expect(selections.map(\.stringValue) == ["bk101", "bk102"]) + if case .attribute = selections.first {} else { + Issue.record("expected an attribute selection") + } + } + + @Test("Attribute-existence predicate filters elements") + func test_attributeExistencePredicate() throws { + let books = try PureXML.XPath.Query("//book[@lang]").elements(over: catalog()) + #expect(books.count == 1) + #expect(books.first?.attributes.first?.value == "bk101") + } + + @Test("Wildcard selects all child elements") + func test_wildcard() throws { + let children = try PureXML.XPath.Query("/catalog/*").elements(over: catalog()) + #expect(children.count == 3) + } + + @Test("Child-equality predicate filters by child string value") + func test_childEqualityPredicate() throws { + let books = try PureXML.XPath.Query("//book[title='YAML Guide']").elements(over: catalog()) + #expect(books.count == 1) + #expect(books.first?.attributes.first?.value == "bk102") + } + + @Test("text() selects character data") + func test_textNodeTest() throws { + let strings = try PureXML.XPath.Query("//author/text()").strings(over: catalog()) + #expect(strings == ["Drobnik"]) + } + + @Test("The top-level convenience evaluates a path") + func test_topLevelConvenience() throws { + let selections = try PureXML.xpath("//magazine/title", over: catalog()) + #expect(selections.map(\.stringValue) == ["Wired"]) + } + + @Test("Unsupported upward axis is rejected at compile time") + func test_unsupportedAxis() { + #expect(throws: PureXML.XPath.QueryError.self) { + _ = try PureXML.XPath.Query("/catalog/..") + } + } + + @Test("An empty path is rejected") + func test_emptyPath() { + #expect(throws: PureXML.XPath.QueryError.empty) { + _ = try PureXML.XPath.Query(" ") + } + } +}