Merge #90: DOM node kinds, ownerDocument, adoption

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 03:43:32 +02:00
commit a8cf4d2d76
7 changed files with 277 additions and 7 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- DOM tree: first-class doctype, entity-reference, and namespace nodes, plus `ownerDocument`, `adopt`, and `importNode` (#90), completing the tree-model audit. `TreeNodeKind` gains `.doctype`, `.entityReference`, and `.namespace`, each with a factory (`TreeNode.doctype(name:publicID:systemID:internalSubset:)`, `.entityReference(_:children:)`, `.namespace(prefix:uri:)`) and typed accessors; doctype and namespace nodes drop out of the content value-`Node` projection while an entity reference splices its replacement into place. `ownerDocument` derives the owning document from the parent chain; `adopt` and `importNode` move or copy a subtree into another document, re-declaring every namespace it relied on from an outer scope so the result is self-contained.
- Schematron `key()`, `document()`, and `current()` in tests (#80), completing the Schematron audit. Test, let, and message expressions can now resolve nodes through `xsl:key` declarations, load an external document for cross-document checks (via an injected `documentLoader`), and refer to the rule context node with `current()`.
- RELAX NG compact `>>` follow-annotations (#79), completing the RNC syntax. A `>>` annotation after a pattern (a foreign element name with optional `[ … ]` content) is skipped, chaining and combining with leading `[ … ]` annotations; both carry no schema semantics. (Namespace declarations inside annotations are already dropped by the annotation skipper.)
- XSLT `document()` fragments and base-URI resolution (#82), completing the XSLT function/output audit. A `#fragment` selects a subset of the loaded document via XPointer, a node-set first argument unions the documents named by each node, and a relative URI is resolved against a caller-supplied `baseURI` (also applied to `xsl:include`/`xsl:import`).

View File

@ -77,9 +77,9 @@ flowchart TB
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Epic71["#71 Beyond-parity libxml2 audit"]:::epic
AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80"]:::done
AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90"]:::done
Epic74["#74 Parser-breadth sub-epic: standalone/decl #95, XML 1.1 #96, reader #98, recovery #100 done; encodings #97 #99 open"]:::epic
Partial["Partial in main: XInclude #87, XPath/XPointer #88, DOM #90, serialization #91"]:::todo
Partial["Partial in main: XInclude #87, XPath/XPointer #88, serialization #91"]:::todo
Open["Open: HTML5 #83 #84, C14N #85, encoding tables #97 #99, validator decomposition #101"]:::todo
Epic71 --> AuditDone
AuditDone --> Epic74
@ -91,7 +91,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **666 tests in 106 suites** (`swift test`).
currently runs **673 tests in 107 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -8,6 +8,19 @@ public extension PureXML.Model {
case cdata
case comment
case processingInstruction
/// A `<!DOCTYPE>` node, carried as a child of the document rather than out
/// of band. Its `name` is the document type (root element) name, `value`
/// is the internal subset text, and the public and system identifiers are
/// held as `public`/`system` attributes.
case doctype
/// An `&name;` reference kept unexpanded as a node, with its replacement
/// content as `children` (the DOM `EntityReference` model). `name` is the
/// entity name; `value` is the replacement text for quick access.
case entityReference
/// A namespace binding as a node (the DOM `Namespace` model and the XPath
/// namespace axis): `name`'s local part is the prefix (empty for the
/// default namespace) and `value` is the namespace URI.
case namespace
}
/// A mutable, parent-aware XML tree node (the libxml2 `tree.h` model).
@ -109,5 +122,33 @@ public extension PureXML.Model {
public static func processingInstruction(target: String, data: String) -> TreeNode {
TreeNode(kind: .processingInstruction, name: PureXML.Model.QualifiedName(target), value: data)
}
/// Creates a `<!DOCTYPE>` node for the given root element name, with an
/// optional external identifier and internal subset text.
public static func doctype(
name: String,
publicID: String? = nil,
systemID: String? = nil,
internalSubset: String = "",
) -> TreeNode {
var attributes: [PureXML.Model.Attribute] = []
if let publicID { attributes.append(PureXML.Model.Attribute("public", publicID)) }
if let systemID { attributes.append(PureXML.Model.Attribute("system", systemID)) }
return TreeNode(kind: .doctype, name: PureXML.Model.QualifiedName(name), attributes: attributes, value: internalSubset)
}
/// Creates an `&name;` entity-reference node whose `children` are its
/// replacement content. `value` mirrors the children's text for quick use.
public static func entityReference(_ name: String, children: [TreeNode] = []) -> TreeNode {
let node = TreeNode(kind: .entityReference, name: PureXML.Model.QualifiedName(name), children: children)
node.value = children.reduce(into: "") { $0 += $1.stringValue }
return node
}
/// Creates a namespace node binding `prefix` (empty for the default
/// namespace) to `uri`.
public static func namespace(prefix: String, uri: String) -> TreeNode {
TreeNode(kind: .namespace, name: PureXML.Model.QualifiedName(prefix), value: uri)
}
}
}

View File

@ -27,21 +27,44 @@ public extension PureXML.Model.TreeNode {
var node: PureXML.Model.Node {
switch kind {
case .document:
.document(children.map(\.node))
.document(projectedChildren)
case .element:
.element(PureXML.Model.Element(
name: name ?? PureXML.Model.QualifiedName(""),
attributes: attributes,
children: children.map(\.node),
children: projectedChildren,
))
case .text:
.text(value)
case .entityReference:
.text(stringValue)
case .cdata:
.cdata(value)
case .comment:
.comment(value)
case .processingInstruction:
.processingInstruction(target: name?.description ?? "", data: value)
case .doctype, .namespace:
// DOM-structural kinds with no place in the content value model; they
// are dropped from a parent's projection (see projectedChildren), so a
// direct call here yields empty text only as a defensive fallback.
.text("")
}
}
/// The value-`Node` children of this subtree: doctype and namespace nodes are
/// dropped (they carry no content), and an entity-reference node is spliced
/// open into its replacement nodes, so the projection is a pure content tree.
private var projectedChildren: [PureXML.Model.Node] {
children.flatMap { child -> [PureXML.Model.Node] in
switch child.kind {
case .doctype, .namespace:
[]
case .entityReference:
child.projectedChildren
default:
[child.node]
}
}
}
}

View File

@ -0,0 +1,108 @@
public extension PureXML.Model.TreeNode {
/// The document node this node ultimately lives under, or nil when the node
/// is detached or rooted at something other than a document (the DOM
/// `ownerDocument`). Derived from the parent chain, so it stays correct after
/// any move or adoption without a separate identity field.
var ownerDocument: PureXML.Model.TreeNode? {
let top = root
return top.kind == .document ? top : nil
}
/// Detaches `node` from its current tree and makes it self-contained for this
/// document (the DOM `adoptNode`): every namespace the subtree relied on from
/// an outer scope is re-declared on the subtree, so moving it never silently
/// rebinds a prefix. Returns the adopted node, ready to insert with
/// ``append(_:)`` or one of the `insert` methods.
@discardableResult
func adopt(_ node: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode {
Self.rebindNamespaces(on: node)
node.removeFromParent()
return node
}
/// A self-contained copy of `node` for use in this document (the DOM
/// `importNode`), leaving the original where it is. Deep by default; pass
/// `deep: false` for a shallow copy. Namespaces the subtree relied on from an
/// outer scope are re-declared on the copy so it carries its own bindings.
func importNode(_ node: PureXML.Model.TreeNode, deep: Bool = true) -> PureXML.Model.TreeNode {
let copy = deep ? node.copy() : node.shallowCopy()
Self.rebindNamespaces(on: copy)
return copy
}
// MARK: Typed accessors for the structural node kinds
/// The document-type (root element) name of a `.doctype` node.
var doctypeName: String? {
kind == .doctype ? name?.description : nil
}
/// The public identifier of a `.doctype` node, when it has an external subset.
var publicID: String? {
kind == .doctype ? attributeValue("public") : nil
}
/// The system identifier of a `.doctype` node, when it has an external subset.
var systemID: String? {
kind == .doctype ? attributeValue("system") : nil
}
/// The internal subset text of a `.doctype` node (empty when it has none).
var internalSubset: String? {
kind == .doctype ? value : nil
}
/// The entity name of a `.entityReference` node (without the `&` and `;`).
var entityReferenceName: String? {
kind == .entityReference ? name?.description : nil
}
/// The prefix bound by a `.namespace` node, or nil for the default namespace.
var namespacePrefix: String? {
guard kind == .namespace, let local = name?.localName, !local.isEmpty else { return nil }
return local
}
/// The URI bound by a `.namespace` node.
var namespaceBinding: String? {
kind == .namespace ? value : nil
}
/// Re-declares on `root` every namespace its subtree uses, so a moved or
/// copied subtree no longer depends on declarations that lived on outer
/// ancestors. Only adds a declaration the node does not already carry, so an
/// inner rebinding (which travels as its own `xmlns` attribute) is preserved.
private static func rebindNamespaces(on root: PureXML.Model.TreeNode) {
guard root.kind == .element else { return }
var declared = Set(root.attributes.map(\.name.description))
for (attributeName, uri) in usedNamespaces(in: root) where !declared.contains(attributeName) {
root.attributes.append(PureXML.Model.Attribute(attributeName, uri))
declared.insert(attributeName)
}
}
/// The `xmlns`/`xmlns:prefix` declarations the subtree's resolved names imply,
/// keyed by the declaration's attribute name. An element contributes its own
/// namespace; a prefixed attribute contributes its prefix's namespace.
private static func usedNamespaces(in node: PureXML.Model.TreeNode) -> [String: String] {
var result: [String: String] = [:]
var stack = [node]
while let current = stack.popLast() {
if current.kind == .element, let uri = current.name?.namespaceURI {
result[declarationName(forPrefix: current.name?.prefix)] = uri
}
for attribute in current.attributes {
if let prefix = attribute.name.prefix, prefix != "xmlns", let uri = attribute.name.namespaceURI {
result["xmlns:\(prefix)"] = uri
}
}
stack.append(contentsOf: current.children)
}
return result
}
private static func declarationName(forPrefix prefix: String?) -> String {
guard let prefix, !prefix.isEmpty else { return "xmlns" }
return "xmlns:\(prefix)"
}
}

View File

@ -55,9 +55,11 @@ public extension PureXML.Model.TreeNode {
switch kind {
case .text, .cdata:
value
case .element, .document:
case .element, .document, .entityReference:
children.reduce(into: "") { $0 += $1.stringValue }
case .comment, .processingInstruction:
case .namespace:
value
case .comment, .processingInstruction, .doctype:
""
}
}

View File

@ -0,0 +1,95 @@
@testable import PureXML
import Testing
@Suite("TreeNode DOM identity: structural kinds, ownerDocument, adoption")
struct TreeNodeIdentityTests {
private typealias Tree = PureXML.Model.TreeNode
@Test("Doctype node carries name, identifiers, and internal subset")
func test_doctype() {
let doctype = Tree.doctype(
name: "html",
publicID: "-//W3C//DTD XHTML 1.0//EN",
systemID: "xhtml1.dtd",
internalSubset: "<!ENTITY x \"y\">",
)
#expect(doctype.kind == .doctype)
#expect(doctype.doctypeName == "html")
#expect(doctype.publicID == "-//W3C//DTD XHTML 1.0//EN")
#expect(doctype.systemID == "xhtml1.dtd")
#expect(doctype.internalSubset == "<!ENTITY x \"y\">")
#expect(doctype.stringValue.isEmpty)
}
@Test("A doctype child drops out of the content projection")
func test_doctypeNotProjected() {
let document = Tree.document(children: [
Tree.doctype(name: "html"),
Tree.element("html"),
])
guard case let .document(children) = document.node else { Issue.record("not a document")
return
}
#expect(children.count == 1)
#expect(children.first == .element(.init(name: .init("html"))))
}
@Test("An entity-reference node splices its replacement into the content")
func test_entityReferenceProjection() {
let reference = Tree.entityReference("ref", children: [Tree.element("b"), Tree.text("tail")])
let element = Tree.element("a", children: [Tree.text("head"), reference])
#expect(reference.entityReferenceName == "ref")
#expect(element.stringValue == "headtail")
guard case let .element(projected) = element.node else { Issue.record("not an element")
return
}
#expect(projected.children == [.text("head"), .element(.init(name: .init("b"))), .text("tail")])
}
@Test("A namespace node binds a prefix to a URI")
func test_namespaceNode() {
let bound = Tree.namespace(prefix: "p", uri: "urn:x")
#expect(bound.kind == .namespace)
#expect(bound.namespacePrefix == "p")
#expect(bound.namespaceBinding == "urn:x")
#expect(bound.stringValue == "urn:x")
let defaultNS = Tree.namespace(prefix: "", uri: "urn:d")
#expect(defaultNS.namespacePrefix == nil)
#expect(defaultNS.namespaceBinding == "urn:d")
}
@Test("ownerDocument is the document root, or nil when detached")
func test_ownerDocument() {
let child = Tree.element("b")
let document = Tree.document(children: [Tree.element("a", children: [child])])
#expect(child.ownerDocument === document)
child.removeFromParent()
#expect(child.ownerDocument == nil)
// A subtree not rooted at a document has no owner document.
let loose = Tree.element("a", children: [Tree.element("b")])
#expect(loose.children[0].ownerDocument == nil)
}
@Test("adopt detaches a node and re-declares its inherited namespaces")
func test_adoptRebindsNamespaces() throws {
let source = try PureXML.parseTree("<root xmlns:p=\"urn:x\"><p:child p:k=\"v\"/></root>")
let child = source.children[0].children[0]
#expect(child.attributeValue("xmlns:p") == nil)
let target = Tree.document()
let adopted = target.adopt(child)
#expect(adopted.parent == nil)
// The prefix that was declared on the outer <root> now travels with the node.
#expect(adopted.attributeValue("xmlns:p") == "urn:x")
}
@Test("importNode copies a self-contained subtree without moving the original")
func test_importNode() throws {
let source = try PureXML.parseTree("<root xmlns:p=\"urn:x\"><p:child/></root>")
let child = source.children[0].children[0]
let target = Tree.document()
let imported = target.importNode(child)
#expect(imported !== child)
#expect(child.parent === source.children[0]) // original stays put
#expect(imported.attributeValue("xmlns:p") == "urn:x")
}
}