From 3405c9709d9d1427dc6f937791177ff05153eaf8 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 26 Jun 2026 17:44:27 +0200 Subject: [PATCH] perf(parsing): resolve namespaces in O(1), making parse linear in depth (#346) Each open element resolved its name and attributes by looking up namespace bindings through NamespaceContext.lookup, which walked a stack of scopes as deep as the document. With one lookup per element over an O(depth) stack, parsing a deeply-nested document was O(depth^2): a 40000-deep document took ~39s. The context now maintains the current in-scope bindings incrementally: a single prefix-to-URI map, plus a per-element undo list recording the bindings each element overwrote so closing it restores the enclosing state. A lookup is O(1) and entering/leaving an element is O(its xmlns declarations), so parsing is O(n). The same document now parses in ~0.2s. Namespace semantics are unchanged: the nearest in-scope binding still wins, an empty default binding still undeclares, and 81 namespace tests plus the full suite (1784) pass. Closes #345. --- CHANGELOG.md | 2 ++ Sources/Parsing/NamespaceContext.swift | 47 ++++++++++++++++---------- Tests/NamespaceScaleTests.swift | 43 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 Tests/NamespaceScaleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 304f913..9f1c59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Parsing is now linear in nesting depth, not quadratic. Resolving each open element's name and attributes looked up namespace bindings by walking a scope stack as deep as the document, so a deeply-nested document was O(depth^2): a 40000-deep document took tens of seconds. The parser now maintains the current in-scope bindings incrementally (a single map plus a per-element undo list), so a lookup is O(1) and parsing is O(n). The same document parses in a fraction of a second, with no change to resolved namespaces. (#345) + - The immutable value `Model.Node` tree type is now depth-safe. An `Element` previously stored its children inline (the enum was `indirect`), so a `Node`'s recursive layout made building, comparing, hashing, and releasing a deeply-nested tree recurse one native frame per level and overflow the stack (on a constrained stack such as a Swift Task, as shallow as a few thousand deep). `Element` now holds its children behind a copy-on-write reference (`ElementStorage`): the `Node` enum is no longer `indirect`, so it is a fixed-size value, and `==`, `hashValue`, and release all walk the tree iteratively through explicit work stacks. Release flattens only storage it solely owns, so a copy-on-write sibling keeps its subtree. Value semantics are unchanged (copying a node still copies its children, lazily) and `Element`/`Node` remain `Equatable`, `Hashable`, and `Sendable`. A 50000-deep tree now builds, compares, hashes, and releases in a fraction of a second with no crash. (Completes the value-`Node` half of #341.) - The mutable `Model.TreeNode` tree type is now depth-safe: building one from a value `Node`, projecting it back with `node`, and releasing it all run in bounded native stack instead of recursing one frame per level. Construction (`TreeNode(_:)`) and projection (`node`/`projectedChildren`) drive explicit post-order work stacks, and `deinit` flattens its subtree into a heap worklist before releasing, so a deeply-nested tree no longer overflows the stack through a recursive chain of child releases. The teardown only flattens nodes it solely owns (`isKnownUniquelyReferenced`), so a child still referenced elsewhere keeps its subtree. A 60000-deep tree releases in well under a tenth of a second; behaviour for ordinary trees is unchanged. The validator walk and the XPath descendant axis (`AxisNavigation.appendDescendants`) were made iterative for the same reason. (Part of #341. The immutable value `Node`'s own recursive release, and XPath result ordering/dedup, are still depth-bounded and tracked there.) diff --git a/Sources/Parsing/NamespaceContext.swift b/Sources/Parsing/NamespaceContext.swift index b3c69b8..4c9212b 100644 --- a/Sources/Parsing/NamespaceContext.swift +++ b/Sources/Parsing/NamespaceContext.swift @@ -13,30 +13,42 @@ extension PureXML.Parsing { /// bound by a declaration. static let xmlnsNamespaceURI = "http://www.w3.org/2000/xmlns/" - /// A stack of scopes; each maps a prefix ("" = default namespace) to a URI. - private var scopes: [[String: String]] = [] + /// The current in-scope bindings, prefix ("" = default namespace) to URI, + /// maintained incrementally so a lookup is O(1) rather than a walk over a + /// stack as deep as the document: a per-element walk would make parsing a + /// deeply-nested document quadratic in depth. + private var current: [String: String] = [:] - /// Opens an element: pushes a scope built from its `xmlns` declarations, - /// then resolves the element name and its attribute names against the - /// now-in-scope bindings. + /// One restore list per open element: the bindings it overwrote, so + /// closing the element can return `current` to its enclosing state. Each + /// entry is a prefix and the value it had before this element (nil when it + /// was unbound), applied in reverse on exit. + private var undo: [[(key: String, previous: String?)]] = [] + + /// Opens an element: applies its `xmlns` declarations to the current + /// bindings, then resolves the element name and its attribute names + /// against the now-in-scope bindings. mutating func enterElement( name: PureXML.Model.QualifiedName, attributes: [PureXML.Model.Attribute], at mark: Mark, ) throws -> (name: PureXML.Model.QualifiedName, attributes: [PureXML.Model.Attribute]) { try checkNameShape(name, at: mark) - var scope: [String: String] = [:] + var changes: [(key: String, previous: String?)] = [] for attribute in attributes { try checkNameShape(attribute.name, at: mark) if attribute.name.prefix == nil, attribute.name.localName == "xmlns" { try checkDefaultBinding(attribute.value, at: mark) - scope[""] = attribute.value + changes.append((key: "", previous: current[""])) + current[""] = attribute.value } else if attribute.name.prefix == "xmlns" { try checkPrefixBinding(attribute.name.localName, uri: attribute.value, at: mark) - scope[attribute.name.localName] = attribute.value + let key = attribute.name.localName + changes.append((key: key, previous: current[key])) + current[key] = attribute.value } } - scopes.append(scope) + undo.append(changes) let resolvedName = try resolveElement(name, at: mark) let resolvedAttributes = try attributes.map { try resolveAttribute($0, at: mark) } @@ -44,10 +56,13 @@ extension PureXML.Parsing { return (resolvedName, resolvedAttributes) } - /// Closes an element, popping its scope. + /// Closes an element, restoring the bindings it overwrote. mutating func leaveElement() { - if !scopes.isEmpty { - scopes.removeLast() + guard let changes = undo.popLast() else { return } + // Restore in reverse so repeated bindings of one prefix on a single + // element unwind to the value that preceded the element. + for change in changes.reversed() { + current[change.key] = change.previous } } @@ -133,12 +148,8 @@ extension PureXML.Parsing { } private func lookup(_ key: String) -> String? { - for scope in scopes.reversed() { - if let uri = scope[key] { - return uri.isEmpty ? nil : uri - } - } - return nil + guard let uri = current[key], !uri.isEmpty else { return nil } + return uri } } } diff --git a/Tests/NamespaceScaleTests.swift b/Tests/NamespaceScaleTests.swift new file mode 100644 index 0000000..e46f06e --- /dev/null +++ b/Tests/NamespaceScaleTests.swift @@ -0,0 +1,43 @@ +import Testing +@testable import PureXML + +/// Namespace resolution must not make parsing quadratic in nesting depth (#345). +/// Each open element resolves its name against the in-scope bindings; doing that +/// by walking a scope stack as deep as the document made parsing O(depth^2). The +/// bindings are now maintained incrementally, so a lookup is O(1) and parsing is +/// linear. The time limit turns a regression back to quadratic into a failure +/// rather than a silent interactive hang: at this depth the old behaviour took +/// minutes, the linear one takes well under a second. +@Suite("Namespace resolution scale (#345)") +struct NamespaceScaleTests { + #if os(WASI) + @Test("a deeply-nested document parses in linear time") + #else + @Test("a deeply-nested document parses in linear time", .timeLimit(.minutes(1))) + #endif + func test_deepNestingParsesLinearly() throws { + let depth = 100_000 + let xml = String(repeating: "", count: depth) + "leaf" + String(repeating: "", count: depth) + let node = try PureXML.parse(xml, limits: .init(maxDepth: depth + 1)) + // The parse produced the leaf at the bottom of the nesting. + #expect(PureXML.serialize(node, options: .compact).contains("leaf")) + } + + @Test("nested default-namespace bindings still resolve to the nearest in scope") + func test_nearestBindingWins() throws { + let xml = """ + + """ + let node = try PureXML.parse(xml) + guard case let .document(roots) = node, let root = roots.first(where: { $0.element != nil })?.element else { + Issue.record("expected a document with a root element") + return + } + #expect(root.name.namespaceURI == "urn:outer") // a + let inner = root.children.first(where: { $0.element?.name.localName == "b" })?.element + let outerSibling = root.children.first(where: { $0.element?.name.localName == "d" })?.element + #expect(inner?.name.namespaceURI == "urn:inner") // b: nearer default binding + #expect(inner?.children.first(where: { $0.element?.name.localName == "c" })?.element?.name.namespaceURI == "urn:inner") // c inherits inner + #expect(outerSibling?.name.namespaceURI == "urn:outer") // d: inner binding popped, back to outer + } +}