Merge #90: DOM node kinds, ownerDocument, adoption
This commit is contained in:
commit
a8cf4d2d76
@ -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`).
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
Sources/Model/TreeNodeIdentity.swift
Normal file
108
Sources/Model/TreeNodeIdentity.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
95
Tests/TreeNodeIdentityTests.swift
Normal file
95
Tests/TreeNodeIdentityTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user