PureXML/Sources/Parsing/NamespaceContext.swift
Mihaela Mihaljevic 3405c9709d
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.
2026-06-26 17:44:27 +02:00

156 lines
7.8 KiB
Swift

extension PureXML.Parsing {
/// Tracks in-scope namespace bindings as elements open and close, resolving
/// qualified names to their namespace URI. The reserved `xml` and `xmlns`
/// prefixes are built in. Internal to the parser.
///
/// Follows the XML Namespaces rules: a default namespace (`xmlns="..."`)
/// applies to unprefixed element names but never to attribute names, an empty
/// declaration undeclares a binding, and an unbound prefix is an error.
struct NamespaceContext {
/// The reserved binding for the `xml` prefix.
static let xmlNamespaceURI = "http://www.w3.org/XML/1998/namespace"
/// The namespace name reserved for `xmlns` itself; it may never be
/// bound by a declaration.
static let xmlnsNamespaceURI = "http://www.w3.org/2000/xmlns/"
/// 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] = [:]
/// 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 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)
changes.append((key: "", previous: current[""]))
current[""] = attribute.value
} else if attribute.name.prefix == "xmlns" {
try checkPrefixBinding(attribute.name.localName, uri: attribute.value, at: mark)
let key = attribute.name.localName
changes.append((key: key, previous: current[key]))
current[key] = attribute.value
}
}
undo.append(changes)
let resolvedName = try resolveElement(name, at: mark)
let resolvedAttributes = try attributes.map { try resolveAttribute($0, at: mark) }
try checkExpandedNames(resolvedAttributes, at: mark)
return (resolvedName, resolvedAttributes)
}
/// Closes an element, restoring the bindings it overwrote.
mutating func leaveElement() {
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
}
}
private func resolveElement(
_ name: PureXML.Model.QualifiedName,
at mark: Mark,
) throws -> PureXML.Model.QualifiedName {
if name.prefix == nil {
return name.resolved(namespaceURI: lookup(""))
}
return try name.resolved(namespaceURI: requireURI(forPrefix: name.prefix, at: mark))
}
private func resolveAttribute(
_ attribute: PureXML.Model.Attribute,
at mark: Mark,
) throws -> PureXML.Model.Attribute {
let name = attribute.name
if name.prefix == nil || name.prefix == "xmlns" {
return attribute
}
let uri = try requireURI(forPrefix: name.prefix, at: mark)
return PureXML.Model.Attribute(name: name.resolved(namespaceURI: uri), value: attribute.value)
}
private func requireURI(forPrefix prefix: String?, at mark: Mark) throws -> String {
if prefix == "xml" {
return Self.xmlNamespaceURI
}
if let uri = lookup(prefix ?? ""), !uri.isEmpty {
return uri
}
throw ParseError.undefinedNamespacePrefix(prefix: prefix ?? "", mark)
}
/// A qualified name has exactly one colon separating two non-empty
/// NCNames; `QualifiedName` keeps a malformed raw form (`foo:`,
/// `:foo`, `a:b:c`, `xmlns:`) in its local name, where the colon
/// betrays it.
private func checkNameShape(_ name: PureXML.Model.QualifiedName, at mark: Mark) throws {
guard name.localName.contains(":") else { return }
throw ParseError.namespaceConstraint(reason: "'\(name.description)' is not a legal qualified name", mark)
}
/// The reserved-binding rules for `xmlns:prefix` declarations: `xmlns`
/// itself may never be declared, `xml` only with its own namespace
/// name, no other prefix may take either reserved namespace name, and
/// a prefix may not be undeclared in Namespaces 1.0.
private func checkPrefixBinding(_ prefix: String, uri: String, at mark: Mark) throws {
if prefix == "xmlns" {
throw ParseError.namespaceConstraint(reason: "the 'xmlns' prefix must not be declared", mark)
}
if prefix == "xml" {
guard uri == Self.xmlNamespaceURI else {
throw ParseError.namespaceConstraint(reason: "the 'xml' prefix is bound to '\(Self.xmlNamespaceURI)' and cannot be rebound", mark)
}
return
}
if uri == Self.xmlNamespaceURI || uri == Self.xmlnsNamespaceURI {
throw ParseError.namespaceConstraint(reason: "the reserved namespace name '\(uri)' must not be bound to prefix '\(prefix)'", mark)
}
if uri.isEmpty {
throw ParseError.namespaceConstraint(reason: "prefix '\(prefix)' may not be undeclared in Namespaces 1.0", mark)
}
}
/// Neither reserved namespace name may become the default namespace.
private func checkDefaultBinding(_ uri: String, at mark: Mark) throws {
guard uri == Self.xmlNamespaceURI || uri == Self.xmlnsNamespaceURI else { return }
throw ParseError.namespaceConstraint(reason: "the reserved namespace name '\(uri)' must not be the default namespace", mark)
}
/// Attributes must be distinct by expanded name: two prefixes bound to
/// the same URI cannot carry the same local name on one element.
private func checkExpandedNames(_ attributes: [PureXML.Model.Attribute], at mark: Mark) throws {
var seen: Set<String> = []
for attribute in attributes where attribute.name.namespaceURI != nil {
let key = (attribute.name.namespaceURI ?? "") + "\u{0}" + attribute.name.localName
guard seen.insert(key).inserted else {
throw ParseError.duplicateAttribute(name: attribute.name.description, mark)
}
}
}
private func lookup(_ key: String) -> String? {
guard let uri = current[key], !uri.isEmpty else { return nil }
return uri
}
}
}