diff --git a/CHANGELOG.md b/CHANGELOG.md index d59e589..aae4d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Document-order sorts no longer recompute root paths per comparison (#113): node-set ordering (the evaluator's order-and-dedup, EXSLT's set functions, string-value coercion's first-node lookup) goes through decorate-sort-undecorate helpers (`sortedByDocumentOrder()`, `firstInDocumentOrder()`) that compute each node's order key once, turning O(n log n x depth) sorts into O(n log n). `set:leading`/`set:trailing` also reuse one precomputed pivot key. Behavior is unchanged. - XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path. - The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees. - DTD attribute validation is now decomposed into five named, independently composable `Validation` rules (#101): `DTD.requiredAttributes`, `fixedAttributeValues`, `enumeratedAttributeValues`, `tokenizedAttributeTypes`, and `notationAttributes`, replacing the single `attributeDeclarations` rule. Each is removable by identity and isolation-tested one constraint at a time, honoring the validation-framework idiom. Behavior is unchanged. (XSD content validation stays one recursive rule by a documented scope decision, since its constraints are interdependent through type resolution; Schematron already exposes its rules as composable `Validation` values.) diff --git a/Sources/XPath/EXSLTFunctions.swift b/Sources/XPath/EXSLTFunctions.swift index 7aa6337..c95e060 100644 --- a/Sources/XPath/EXSLTFunctions.swift +++ b/Sources/XPath/EXSLTFunctions.swift @@ -63,7 +63,7 @@ extension PureXML.XPath { guard !target.isNaN else { return [] } return nodes .filter { PureXML.XPath.Value.parseNumber($0.stringValue) == target } - .sorted(by: Node.precedes) + .sortedByDocumentOrder() } // MARK: sets @@ -84,7 +84,7 @@ extension PureXML.XPath { guard let nodes = value?.nodes else { return [] } var seen: Set = [] var result: [Node] = [] - for node in nodes.sorted(by: Node.precedes) where seen.insert(node.stringValue).inserted { + for node in nodes.sortedByDocumentOrder() where seen.insert(node.stringValue).inserted { result.append(node) } return result @@ -93,13 +93,13 @@ extension PureXML.XPath { private static func difference(_ arguments: [Value]) -> [Node] { let first = arguments.first?.nodes ?? [] let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : []) - return first.filter { !second.contains($0) }.sorted(by: Node.precedes) + return first.filter { !second.contains($0) }.sortedByDocumentOrder() } private static func intersection(_ arguments: [Value]) -> [Node] { let first = arguments.first?.nodes ?? [] let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : []) - return first.filter { second.contains($0) }.sorted(by: Node.precedes) + return first.filter { second.contains($0) }.sortedByDocumentOrder() } private static func hasSameNode(_ arguments: [Value]) -> Bool { @@ -111,12 +111,17 @@ extension PureXML.XPath { /// (or follow) the first node, in document order, of the second set. private static func relative(_ arguments: [Value], leading: Bool) -> [Node] { let first = arguments.first?.nodes ?? [] - guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).min(by: Node.precedes) else { + guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).firstInDocumentOrder() else { return [] } + // Decorate once: each node's order key is computed a single time and + // reused for both the pivot comparison and the final sort. + let pivotKey = pivot.documentOrder return first - .filter { leading ? Node.precedes($0, pivot) : Node.precedes(pivot, $0) } - .sorted(by: Node.precedes) + .map { (node: $0, key: $0.documentOrder) } + .filter { leading ? Node.ordered($0.key, before: pivotKey) : Node.ordered(pivotKey, before: $0.key) } + .sorted { Node.ordered($0.key, before: $1.key) } + .map(\.node) } } } diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index e065535..ca64659 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -162,7 +162,7 @@ extension PureXML.XPath { for node in nodes where seen.insert(node).inserted { unique.append(node) } - return unique.sorted(by: Node.precedes) + return unique.sortedByDocumentOrder() } private static func root(of node: Node) -> Node { diff --git a/Sources/XPath/NodeFunctions.swift b/Sources/XPath/NodeFunctions.swift index c7d4e1a..c17afe0 100644 --- a/Sources/XPath/NodeFunctions.swift +++ b/Sources/XPath/NodeFunctions.swift @@ -30,7 +30,7 @@ extension PureXML.XPath { private static func target(_ arguments: [Value], _ context: EvaluationContext) -> Node? { if arguments.isEmpty { return context.node } - return arguments.first?.nodes?.min(by: Node.precedes) + return arguments.first?.nodes?.firstInDocumentOrder() } private static func identified(_ argument: Value?, _ context: EvaluationContext) -> [Node] { diff --git a/Sources/XPath/Value.swift b/Sources/XPath/Value.swift index 30e061b..e25ffc0 100644 --- a/Sources/XPath/Value.swift +++ b/Sources/XPath/Value.swift @@ -37,7 +37,7 @@ public extension PureXML.XPath { public var string: String { switch self { case let .nodeSet(nodes): - guard let first = nodes.min(by: Node.precedes) else { return "" } + guard let first = nodes.firstInDocumentOrder() else { return "" } return first.stringValue case let .boolean(value): return value ? "true" : "false" case let .number(value): return Self.format(value) diff --git a/Sources/XPath/XPathNode.swift b/Sources/XPath/XPathNode.swift index ff0ff41..dc5605f 100644 --- a/Sources/XPath/XPathNode.swift +++ b/Sources/XPath/XPathNode.swift @@ -118,10 +118,16 @@ public extension PureXML.XPath { return path.reversed() } - /// Whether `lhs` precedes `rhs` in document order. + /// Whether `lhs` precedes `rhs` in document order. Each call recomputes + /// both order keys; for sorts and minimums over node-sets, prefer + /// ``Swift/Sequence/sortedByDocumentOrder()`` and + /// ``Swift/Sequence/firstInDocumentOrder()``, which compute each key once. static func precedes(_ lhs: Node, _ rhs: Node) -> Bool { - let left = lhs.documentOrder - let right = rhs.documentOrder + ordered(lhs.documentOrder, before: rhs.documentOrder) + } + + /// Lexicographic compare of two document-order keys. + static func ordered(_ left: [Int], before right: [Int]) -> Bool { for (one, two) in zip(left, right) where one != two { return one < two } @@ -129,3 +135,21 @@ public extension PureXML.XPath { } } } + +extension Sequence { + /// The nodes sorted into document order, computing each node's order key once + /// (decorate-sort-undecorate) rather than recomputing the root path on every + /// comparison, which turns an O(n log n x depth) sort into O(n log n). + func sortedByDocumentOrder() -> [PureXML.XPath.Node] { + map { (node: $0, key: $0.documentOrder) } + .sorted { PureXML.XPath.Node.ordered($0.key, before: $1.key) } + .map(\.node) + } + + /// The first node in document order, computing each node's order key once. + func firstInDocumentOrder() -> PureXML.XPath.Node? { + map { (node: $0, key: $0.documentOrder) } + .min { PureXML.XPath.Node.ordered($0.key, before: $1.key) }? + .node + } +}