feat(canonical): C14N 2.0 sequential prefix rewrite, QName-aware values, node-set selection (#85)
This commit is contained in:
parent
e77b39a449
commit
b3bbacb4e8
@ -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()`.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
177
Sources/Canonical/CanonicalPrefixRewrite.swift
Normal file
177
Sources/Canonical/CanonicalPrefixRewrite.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 += "<!--\(node.value)-->" }
|
||||
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 {
|
||||
|
||||
51
Tests/CanonicalPrefixRewriteTests.swift
Normal file
51
Tests/CanonicalPrefixRewriteTests.swift
Normal file
@ -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("<r xmlns:p=\"urn:x\"><p:c/></r>", sequential) == "<r><n0:c xmlns:n0=\"urn:x\"></n0:c></r>")
|
||||
}
|
||||
|
||||
@Test("Documents differing only in prefix spelling canonicalize identically")
|
||||
func test_prefixIndependence() throws {
|
||||
let withP = try c14n("<r xmlns:p=\"urn:x\"><p:c/></r>", sequential)
|
||||
let withQ = try c14n("<r xmlns:q=\"urn:x\"><q:c/></r>", sequential)
|
||||
#expect(withP == withQ)
|
||||
}
|
||||
|
||||
@Test("The default namespace is given a canonical prefix")
|
||||
func test_defaultNamespaceRewritten() throws {
|
||||
#expect(try c14n("<a xmlns=\"urn:d\"><b/></a>", sequential) == "<n0:a xmlns:n0=\"urn:d\"><n0:b></n0:b></n0:a>")
|
||||
}
|
||||
|
||||
@Test("Prefixes are numbered in document order of first use")
|
||||
func test_documentOrderNumbering() throws {
|
||||
let xml = "<r xmlns:b=\"urn:b\" xmlns:a=\"urn:a\"><b:x/><a:y/></r>"
|
||||
// urn:b is used first (by <b:x>), so it becomes n0; urn:a becomes n1.
|
||||
#expect(try c14n(xml, sequential) == "<r><n0:x xmlns:n0=\"urn:b\"></n0:x><n1:y xmlns:n1=\"urn:a\"></n1:y></r>")
|
||||
}
|
||||
|
||||
@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")],
|
||||
)
|
||||
// <e type="p:T"> with p bound to urn:x: type becomes the canonical prefix.
|
||||
let output = try c14n("<r xmlns:p=\"urn:x\"><p:e type=\"p:T\"/></r>", options)
|
||||
#expect(output == "<r><n0:e xmlns:n0=\"urn:x\" type=\"n0:T\"></n0:e></r>")
|
||||
}
|
||||
|
||||
@Test("Retain (the default) leaves prefixes untouched")
|
||||
func test_retainDefault() throws {
|
||||
#expect(try c14n("<r xmlns:p=\"urn:x\"><p:c/></r>", .inclusive) == "<r xmlns:p=\"urn:x\"><p:c></p:c></r>")
|
||||
}
|
||||
}
|
||||
41
Tests/CanonicalSelectionTests.swift
Normal file
41
Tests/CanonicalSelectionTests.swift
Normal file
@ -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("<r><a/><b/></r>") { $0.name?.localName != "b" }
|
||||
#expect(output == "<r><a></a></r>")
|
||||
}
|
||||
|
||||
@Test("An excluded middle element is omitted but its selected child is kept")
|
||||
func test_omitMiddleKeepChild() throws {
|
||||
let output = try select("<r><skip><keep/></skip></r>") { $0.name?.localName != "skip" }
|
||||
#expect(output == "<r><keep></keep></r>")
|
||||
}
|
||||
|
||||
@Test("A selected node exposed by omitted ancestors re-declares its namespace context")
|
||||
func test_exposedReDeclaresNamespace() throws {
|
||||
let output = try select("<r xmlns:p=\"urn:x\"><skip><p:keep/></skip></r>") { $0.name?.localName == "keep" }
|
||||
#expect(output == "<p:keep xmlns:p=\"urn:x\"></p:keep>")
|
||||
}
|
||||
|
||||
@Test("Selecting every node reproduces the ordinary canonical form")
|
||||
func test_selectAllMatchesWholeTree() throws {
|
||||
let xml = "<r xmlns:p=\"urn:x\"><p:c>hi</p:c></r>"
|
||||
let selected = try select(xml) { _ in true }
|
||||
let whole = try PureXML.Canonical.canonicalize(PureXML.parse(xml))
|
||||
#expect(selected == whole)
|
||||
#expect(selected == "<r xmlns:p=\"urn:x\"><p:c>hi</p:c></r>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user