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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 17:44:27 +02:00 committed by GitHub
parent d27e108b4d
commit 3405c9709d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 74 additions and 18 deletions

View File

@ -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.)

View File

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

View File

@ -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: "<a x=\"1\">", count: depth) + "leaf" + String(repeating: "</a>", 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 = """
<a xmlns="urn:outer"><b xmlns="urn:inner"><c/></b><d/></a>
"""
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
}
}