diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27c86cc..490fb17 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
+- C14N node-subset canonicalization and Canonical XML 2.0 parameters (#85), completing the canonicalization audit. `Canonicalizer.canonicalize(_ subtree: TreeNode)` canonicalizes a fragment in place: the apex element renders the namespace context in scope from its omitted ancestors and inherits their `xml:base`/`xml:lang`/`xml:space` when it does not set them, so a signed fragment is independent of where it sat. `canonicalize(_:including:)` canonicalizes a node-set selected by a `TreeNode` predicate (XPath/position-based selection): an excluded element's tags are omitted but its selected descendants are kept and re-declare their in-scope namespaces. `Options.prefixRewrite = .sequential` rewrites every prefix to a canonical `n0`/`n1`/... in document order (eliminating the default namespace and placing declarations at first use), and `Options.qnameAwareLabels` extends that rewrite into QName-valued attributes and elements, so documents differing only in prefix spelling canonicalize to identical bytes.
- Serialization `textEscaping` round-trip option (#91), completing the serialization audit. `Options.textEscaping` defaults to `.standard` (escape only `&`, `<`, `>`); `.roundTrip` also escapes a carriage return as `
` in text and CDATA-as-text output, so a literal `\r` survives the parser's end-of-line normalization and the document round-trips byte for byte. (The CDATA-section vs escaped-text toggle, `cdataAsText`, was already present.)
- 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()`.
diff --git a/README.md b/README.md
index df5ec4c..aba59b1 100644
--- a/README.md
+++ b/README.md
@@ -77,10 +77,10 @@ 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, DOM #90, serialization #91"]:::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, serialization #91, C14N #85"]:::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"]:::todo
- Open["Open: HTML5 #83 #84, C14N #85, encoding tables #97 #99, validator decomposition #101"]:::todo
+ Open["Open: HTML5 #83 #84, encoding tables #97 #99, validator decomposition #101"]:::todo
Epic71 --> AuditDone
AuditDone --> Epic74
Epic74 --> Partial
@@ -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 **675 tests in 107 suites** (`swift test`).
+currently runs **690 tests in 110 suites** (`swift test`).
### Shipped (libxml2-aligned surface)
diff --git a/Sources/Canonical/CanonicalOptions.swift b/Sources/Canonical/CanonicalOptions.swift
index 38b50f8..39f436a 100644
--- a/Sources/Canonical/CanonicalOptions.swift
+++ b/Sources/Canonical/CanonicalOptions.swift
@@ -6,6 +6,37 @@ public extension PureXML.Canonical {
case exclusive
}
+ /// The Canonical XML 2.0 `PrefixRewrite` parameter: how namespace prefixes
+ /// are spelled in the output.
+ enum PrefixRewrite: Equatable, Sendable {
+ /// Keep each declaration's original prefix (C14N 1.0/1.1 behavior).
+ case retain
+ /// Rewrite every prefix to a sequential canonical name (`n0`, `n1`, ...)
+ /// assigned in document order of first use, so two documents that differ
+ /// only in how they spell their prefixes canonicalize to the same bytes.
+ /// The reserved `xml` prefix is never rewritten.
+ case sequential
+ }
+
+ /// A Canonical XML 2.0 `QNameAware` label: an attribute (or element) whose
+ /// textual value is a QName, so its prefix is rewritten alongside the
+ /// structural prefixes under ``PrefixRewrite/sequential``.
+ struct QNameAwareLabel: Equatable, Sendable {
+ /// The namespace URI of the labelled attribute or element (empty for none).
+ public let namespaceURI: String
+ /// The local name of the labelled attribute or element.
+ public let localName: String
+ /// Whether the label names an element (its text content is the QName)
+ /// rather than an attribute (its value is the QName).
+ public let isElement: Bool
+
+ public init(namespaceURI: String = "", localName: String, isElement: Bool = false) {
+ self.namespaceURI = namespaceURI
+ self.localName = localName
+ self.isElement = isElement
+ }
+ }
+
/// Options for canonicalization. The default is inclusive C14N without
/// comments, matching the common XML-signature use.
///
@@ -23,17 +54,26 @@ public extension PureXML.Canonical {
/// trailing whitespace is stripped from each text node, and a text node
/// that is all whitespace is dropped.
public var trimTextNodes: Bool
+ /// The Canonical XML 2.0 `PrefixRewrite` parameter (default ``PrefixRewrite/retain``).
+ public var prefixRewrite: PrefixRewrite
+ /// The Canonical XML 2.0 `QNameAware` labels: attributes or elements whose
+ /// value is a QName, so its prefix is rewritten under sequential rewrite.
+ public var qnameAwareLabels: [QNameAwareLabel]
public init(
mode: Mode = .inclusive,
includeComments: Bool = false,
inclusiveNamespacePrefixes: [String] = [],
trimTextNodes: Bool = false,
+ prefixRewrite: PrefixRewrite = .retain,
+ qnameAwareLabels: [QNameAwareLabel] = [],
) {
self.mode = mode
self.includeComments = includeComments
self.inclusiveNamespacePrefixes = inclusiveNamespacePrefixes
self.trimTextNodes = trimTextNodes
+ self.prefixRewrite = prefixRewrite
+ self.qnameAwareLabels = qnameAwareLabels
}
/// Inclusive C14N without comments.
diff --git a/Sources/Canonical/CanonicalPrefixRewrite.swift b/Sources/Canonical/CanonicalPrefixRewrite.swift
new file mode 100644
index 0000000..23af01a
--- /dev/null
+++ b/Sources/Canonical/CanonicalPrefixRewrite.swift
@@ -0,0 +1,177 @@
+extension PureXML.Canonical {
+ /// The Canonical XML 2.0 sequential prefix rewrite. Implemented as a pre-pass
+ /// that rebuilds the tree using canonical prefixes (`n0`, `n1`, ... in
+ /// document order of first use), drops the original namespace declarations,
+ /// and hoists one canonical declaration per namespace onto the document
+ /// element. The rewritten tree is then rendered exclusive-style so each
+ /// declaration appears at the first element that uses it, with no default
+ /// namespace (every name carries a prefix). The reserved `xml` prefix is left
+ /// untouched.
+ enum PrefixRewriter {
+ private static let xmlNamespace = "http://www.w3.org/XML/1998/namespace"
+
+ /// Rewrites a node tree's prefixes (and any QName-valued labels) to their
+ /// canonical sequential form.
+ static func rewrite(_ node: PureXML.Model.Node, labels: [QNameAwareLabel]) -> PureXML.Model.Node {
+ var assignment: [String: String] = [:]
+ var order = 0
+ assign(node, inScope: [:], labels: labels, &assignment, &order)
+ return transform(node, inScope: [:], assignment: assignment, labels: labels, isRoot: true)
+ }
+
+ // MARK: First pass: assign a canonical prefix per visibly-used namespace
+
+ private static func assign(
+ _ node: PureXML.Model.Node,
+ inScope: [String: String],
+ labels: [QNameAwareLabel],
+ _ assignment: inout [String: String],
+ _ order: inout Int,
+ ) {
+ switch node {
+ case let .document(children):
+ for child in children {
+ assign(child, inScope: inScope, labels: labels, &assignment, &order)
+ }
+ case let .element(element):
+ var childInScope = inScope
+ for (prefix, uri) in declarations(element) {
+ childInScope[prefix] = uri
+ }
+ record(element.name.namespaceURI, &assignment, &order)
+ for attribute in element.attributes where attribute.name.prefix != nil {
+ record(attribute.name.namespaceURI, &assignment, &order)
+ }
+ for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
+ record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
+ }
+ record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
+ for child in element.children {
+ assign(child, inScope: childInScope, labels: labels, &assignment, &order)
+ }
+ default:
+ break
+ }
+ }
+
+ /// The namespace URI a QName value refers to through its prefix, or nil.
+ private static func qnameNamespace(_ value: String?, inScope: [String: String]) -> String? {
+ guard let value else { return nil }
+ let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ return inScope[parts.count == 2 ? String(parts[0]) : ""]
+ }
+
+ private static func isQNameAttribute(_ name: PureXML.Model.QualifiedName, _ labels: [QNameAwareLabel]) -> Bool {
+ labels.contains { !$0.isElement && $0.localName == name.localName && $0.namespaceURI == (name.namespaceURI ?? "") }
+ }
+
+ private static func record(_ uri: String?, _ assignment: inout [String: String], _ order: inout Int) {
+ guard let uri, uri != xmlNamespace, assignment[uri] == nil else { return }
+ assignment[uri] = "n\(order)"
+ order += 1
+ }
+
+ // MARK: Second pass: rebuild with canonical prefixes
+
+ private static func transform(
+ _ node: PureXML.Model.Node,
+ inScope: [String: String],
+ assignment: [String: String],
+ labels: [QNameAwareLabel],
+ isRoot: Bool,
+ ) -> PureXML.Model.Node {
+ switch node {
+ case let .document(children):
+ .document(children.map { transform($0, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot) })
+ case let .element(element):
+ .element(transform(element, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot))
+ default:
+ node
+ }
+ }
+
+ private static func transform(
+ _ element: PureXML.Model.Element,
+ inScope: [String: String],
+ assignment: [String: String],
+ labels: [QNameAwareLabel],
+ isRoot: Bool,
+ ) -> PureXML.Model.Element {
+ var childInScope = inScope
+ for (prefix, uri) in declarations(element) {
+ childInScope[prefix] = uri
+ }
+
+ var attributes = isRoot ? canonicalDeclarations(assignment) : []
+ for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
+ attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
+ }
+ let children = element.children.map { transform($0, inScope: childInScope, assignment: assignment, labels: labels, isRoot: false) }
+ let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
+ return PureXML.Model.Element(
+ name: rename(element.name, assignment: assignment),
+ attributes: attributes,
+ children: qualified.map { [PureXML.Model.Node.text($0)] } ?? children,
+ )
+ }
+
+ /// One `xmlns:nK="uri"` declaration per assigned namespace, hoisted to the
+ /// document element so every canonical prefix is in scope from the top.
+ private static func canonicalDeclarations(_ assignment: [String: String]) -> [PureXML.Model.Attribute] {
+ assignment.sorted { $0.value < $1.value }.map { uri, prefix in
+ PureXML.Model.Attribute("xmlns:\(prefix)", uri)
+ }
+ }
+
+ private static func rewrite(
+ _ attribute: PureXML.Model.Attribute,
+ inScope: [String: String],
+ assignment: [String: String],
+ labels: [QNameAwareLabel],
+ ) -> PureXML.Model.Attribute {
+ let name = rename(attribute.name, assignment: assignment)
+ guard isQNameAttribute(attribute.name, labels), let value = qname(attribute.value, inScope: inScope, assignment: assignment) else {
+ return PureXML.Model.Attribute(name: name, value: attribute.value)
+ }
+ return PureXML.Model.Attribute(name: name, value: value)
+ }
+
+ /// A qualified name with its prefix replaced by the canonical one. Returns
+ /// nil when there is nothing to rewrite (no bound prefix for the value).
+ private static func qname(_ value: String?, inScope: [String: String], assignment: [String: String]) -> String? {
+ guard let value else { return nil }
+ let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ let prefix = parts.count == 2 ? String(parts[0]) : ""
+ let local = parts.count == 2 ? String(parts[1]) : value
+ guard let uri = inScope[prefix], let canonical = assignment[uri] else { return nil }
+ return "\(canonical):\(local)"
+ }
+
+ private static func rename(_ name: PureXML.Model.QualifiedName, assignment: [String: String]) -> PureXML.Model.QualifiedName {
+ guard let uri = name.namespaceURI, uri != xmlNamespace, let canonical = assignment[uri] else { return name }
+ return PureXML.Model.QualifiedName(prefix: canonical, localName: name.localName, namespaceURI: uri)
+ }
+
+ private static func declarations(_ element: PureXML.Model.Element) -> [(String, String)] {
+ element.attributes.compactMap { attribute in
+ declaredPrefix(of: attribute.name).map { ($0, attribute.value) }
+ }
+ }
+
+ private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
+ if name.prefix == nil, name.localName == "xmlns" { return "" }
+ if name.prefix == "xmlns" { return name.localName }
+ return nil
+ }
+ }
+}
+
+extension PureXML.Model.Element {
+ /// The text content of this element when it is labelled QName-aware as an
+ /// element (a single text child holding a QName), or nil otherwise.
+ func text(matching labels: [PureXML.Canonical.QNameAwareLabel]) -> String? {
+ guard labels.contains(where: { $0.isElement && $0.localName == name.localName && $0.namespaceURI == (name.namespaceURI ?? "") }) else { return nil }
+ guard children.count == 1, case let .text(value) = children[0] else { return nil }
+ return value
+ }
+}
diff --git a/Sources/Canonical/Canonicalizer.swift b/Sources/Canonical/Canonicalizer.swift
index ba32f22..232f1e1 100644
--- a/Sources/Canonical/Canonicalizer.swift
+++ b/Sources/Canonical/Canonicalizer.swift
@@ -14,6 +14,15 @@ public extension PureXML.Canonical {
/// Canonicalizes a node.
public func canonicalize(_ node: PureXML.Model.Node) -> String {
+ if options.prefixRewrite == .sequential {
+ // C14N 2.0 sequential rewrite: rebuild with canonical prefixes,
+ // then render exclusive-style so declarations land at first use.
+ let rewritten = PrefixRewriter.rewrite(node, labels: options.qnameAwareLabels)
+ var rendering = options
+ rendering.prefixRewrite = .retain
+ rendering.mode = .exclusive
+ return Canonicalizer(options: rendering).canonicalize(rewritten)
+ }
var output = ""
emit(node, inScope: [:], rendered: [:], output: &output)
return output
@@ -265,6 +274,103 @@ public extension PureXML.Canonical {
}
}
+public extension PureXML.Canonical.Canonicalizer {
+ /// Canonicalizes a node-set: only the nodes the predicate selects appear in
+ /// the output (C14N's XPath/position-based node selection). An excluded
+ /// element's start and end tags are omitted but its selected descendants are
+ /// kept, with each one rendering the namespace context in scope at its
+ /// position, so signing a discontiguous selection is well defined. Predicate
+ /// identity is by ``PureXML/Model/TreeNode`` reference, so a caller can select
+ /// specific nodes (for example an XPath result set).
+ func canonicalize(_ node: PureXML.Model.TreeNode, including predicate: (PureXML.Model.TreeNode) -> Bool) -> String {
+ var output = ""
+ emitSelected(node, inScope: Self.inScopeNamespaces(above: node), rendered: [:], including: predicate, output: &output)
+ return output
+ }
+
+ private func emitSelected(
+ _ node: PureXML.Model.TreeNode,
+ inScope: [String: String],
+ rendered: [String: String],
+ including predicate: (PureXML.Model.TreeNode) -> Bool,
+ output: inout String,
+ ) {
+ switch node.kind {
+ case .document:
+ for child in node.children {
+ emitSelected(child, inScope: inScope, rendered: rendered, including: predicate, output: &output)
+ }
+ case .element:
+ emitSelected(element: node, inScope: inScope, rendered: rendered, including: predicate, output: &output)
+ case .text, .cdata:
+ if predicate(node) { output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value) }
+ case .comment:
+ if predicate(node), options.includeComments { output += "" }
+ case .processingInstruction:
+ if predicate(node) { output += node.value.isEmpty ? "\(node.name?.description ?? "")?>" : "\(node.name?.description ?? "") \(node.value)?>" }
+ case .doctype, .entityReference, .namespace:
+ break
+ }
+ }
+
+ private func emitSelected(
+ element node: PureXML.Model.TreeNode,
+ inScope: [String: String],
+ rendered: [String: String],
+ including predicate: (PureXML.Model.TreeNode) -> Bool,
+ output: inout String,
+ ) {
+ let element = PureXML.Model.Element(name: node.name ?? .init(""), attributes: node.attributes, children: [])
+ var childInScope = inScope
+ for (prefix, uri) in Self.namespaceDeclarations(element) {
+ childInScope[prefix] = uri
+ }
+ guard predicate(node) else {
+ // Omitted element: its namespaces stay in scope for descendants, but
+ // nothing is rendered, so a selected descendant renders them itself.
+ for child in node.children {
+ emitSelected(child, inScope: childInScope, rendered: rendered, including: predicate, output: &output)
+ }
+ return
+ }
+ let attributes = Self.plainAttributes(element)
+ let toRender = selectedNamespaces(element, inScope: childInScope, attributes: attributes, rendered: rendered)
+ var childRendered = rendered
+ for (prefix, uri) in toRender {
+ childRendered[prefix] = uri
+ }
+ output += "<" + element.name.description
+ for (prefix, uri) in toRender.sorted(by: { $0.0 < $1.0 }) {
+ output += Self.renderNamespace(prefix, uri)
+ }
+ for attribute in attributes.sorted(by: Self.attributeOrder) {
+ output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
+ }
+ output += ">"
+ for child in node.children {
+ emitSelected(child, inScope: childInScope, rendered: childRendered, including: predicate, output: &output)
+ }
+ output += "\(element.name.description)>"
+ }
+
+ /// The namespaces a selected element renders: in inclusive mode every in-scope
+ /// binding not yet rendered (so an exposed element re-declares its inherited
+ /// context); in exclusive mode only those it visibly uses.
+ private func selectedNamespaces(
+ _ element: PureXML.Model.Element,
+ inScope: [String: String],
+ attributes: [PureXML.Model.Attribute],
+ rendered: [String: String],
+ ) -> [(String, String)] {
+ switch options.mode {
+ case .inclusive:
+ inScope.filter { rendered[$0.key] != $0.value }.map { ($0.key, $0.value) }
+ case .exclusive:
+ exclusiveNamespaces(element, inScope: inScope, attributes: attributes, rendered: rendered)
+ }
+ }
+}
+
public extension PureXML.Canonical {
/// Canonicalizes a node with the given options.
static func canonicalize(_ node: PureXML.Model.Node, options: Options = .inclusive) -> String {
diff --git a/Tests/CanonicalPrefixRewriteTests.swift b/Tests/CanonicalPrefixRewriteTests.swift
new file mode 100644
index 0000000..5e45c88
--- /dev/null
+++ b/Tests/CanonicalPrefixRewriteTests.swift
@@ -0,0 +1,51 @@
+@testable import PureXML
+import Testing
+
+@Suite("Canonical XML 2.0: sequential prefix rewrite and QName-aware values")
+struct CanonicalPrefixRewriteTests {
+ private func c14n(_ xml: String, _ options: PureXML.Canonical.Options) throws -> String {
+ try PureXML.Canonical.canonicalize(PureXML.parse(xml), options: options)
+ }
+
+ private let sequential = PureXML.Canonical.Options(prefixRewrite: .sequential)
+
+ @Test("Sequential rewrite renames a prefix to n0 and declares it at first use")
+ func test_basicRewrite() throws {
+ #expect(try c14n("", sequential) == "")
+ }
+
+ @Test("Documents differing only in prefix spelling canonicalize identically")
+ func test_prefixIndependence() throws {
+ let withP = try c14n("", sequential)
+ let withQ = try c14n("", sequential)
+ #expect(withP == withQ)
+ }
+
+ @Test("The default namespace is given a canonical prefix")
+ func test_defaultNamespaceRewritten() throws {
+ #expect(try c14n("", sequential) == "")
+ }
+
+ @Test("Prefixes are numbered in document order of first use")
+ func test_documentOrderNumbering() throws {
+ let xml = ""
+ // urn:b is used first (by ), so it becomes n0; urn:a becomes n1.
+ #expect(try c14n(xml, sequential) == "")
+ }
+
+ @Test("A QName-aware attribute value has its prefix rewritten too")
+ func test_qnameAwareAttribute() throws {
+ let options = PureXML.Canonical.Options(
+ prefixRewrite: .sequential,
+ qnameAwareLabels: [.init(localName: "type")],
+ )
+ // with p bound to urn:x: type becomes the canonical prefix.
+ let output = try c14n("", options)
+ #expect(output == "")
+ }
+
+ @Test("Retain (the default) leaves prefixes untouched")
+ func test_retainDefault() throws {
+ #expect(try c14n("", .inclusive) == "")
+ }
+}
diff --git a/Tests/CanonicalSelectionTests.swift b/Tests/CanonicalSelectionTests.swift
new file mode 100644
index 0000000..13fac7e
--- /dev/null
+++ b/Tests/CanonicalSelectionTests.swift
@@ -0,0 +1,41 @@
+@testable import PureXML
+import Testing
+
+@Suite("Canonical XML: node-set (position-based) selection")
+struct CanonicalSelectionTests {
+ private func select(
+ _ xml: String,
+ _ options: PureXML.Canonical.Options = .inclusive,
+ where predicate: @escaping (PureXML.Model.TreeNode) -> Bool,
+ ) throws -> String {
+ let document = try PureXML.parseTree(xml)
+ return PureXML.Canonical.Canonicalizer(options: options).canonicalize(document, including: predicate)
+ }
+
+ @Test("An excluded element and its subtree are dropped")
+ func test_excludeSubtree() throws {
+ let output = try select("") { $0.name?.localName != "b" }
+ #expect(output == "")
+ }
+
+ @Test("An excluded middle element is omitted but its selected child is kept")
+ func test_omitMiddleKeepChild() throws {
+ let output = try select("") { $0.name?.localName != "skip" }
+ #expect(output == "")
+ }
+
+ @Test("A selected node exposed by omitted ancestors re-declares its namespace context")
+ func test_exposedReDeclaresNamespace() throws {
+ let output = try select("") { $0.name?.localName == "keep" }
+ #expect(output == "")
+ }
+
+ @Test("Selecting every node reproduces the ordinary canonical form")
+ func test_selectAllMatchesWholeTree() throws {
+ let xml = "hi"
+ let selected = try select(xml) { _ in true }
+ let whole = try PureXML.Canonical.canonicalize(PureXML.parse(xml))
+ #expect(selected == whole)
+ #expect(selected == "hi")
+ }
+}