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:
parent
d27e108b4d
commit
3405c9709d
@ -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.)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
Tests/NamespaceScaleTests.swift
Normal file
43
Tests/NamespaceScaleTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user