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
+ }
+}