diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff8c774..db08ddb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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`).
diff --git a/README.md b/README.md
index b339f85..7c0a66a 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/Sources/Model/TreeNode.swift b/Sources/Model/TreeNode.swift
index 6ce599d..183c918 100644
--- a/Sources/Model/TreeNode.swift
+++ b/Sources/Model/TreeNode.swift
@@ -8,6 +8,19 @@ public extension PureXML.Model {
case cdata
case comment
case processingInstruction
+ /// A `` 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 `` 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)
+ }
}
}
diff --git a/Sources/Model/TreeNodeConversion.swift b/Sources/Model/TreeNodeConversion.swift
index 3d8e571..22c60c8 100644
--- a/Sources/Model/TreeNodeConversion.swift
+++ b/Sources/Model/TreeNodeConversion.swift
@@ -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]
+ }
}
}
}
diff --git a/Sources/Model/TreeNodeIdentity.swift b/Sources/Model/TreeNodeIdentity.swift
new file mode 100644
index 0000000..2bc5b69
--- /dev/null
+++ b/Sources/Model/TreeNodeIdentity.swift
@@ -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)"
+ }
+}
diff --git a/Sources/Model/TreeNodeNavigation.swift b/Sources/Model/TreeNodeNavigation.swift
index 75fd10a..cef2899 100644
--- a/Sources/Model/TreeNodeNavigation.swift
+++ b/Sources/Model/TreeNodeNavigation.swift
@@ -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:
""
}
}
diff --git a/Tests/TreeNodeIdentityTests.swift b/Tests/TreeNodeIdentityTests.swift
new file mode 100644
index 0000000..ffd5c6c
--- /dev/null
+++ b/Tests/TreeNodeIdentityTests.swift
@@ -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: "",
+ )
+ #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 == "")
+ #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("")
+ 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 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("")
+ 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")
+ }
+}