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") + } +}