Merge epic/70-parity-edges: C14N 2.0 trim, catalog delegation, XPath number formatting

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 09:37:17 +02:00
commit 60f9e9dfb2
6 changed files with 224 additions and 2 deletions

View File

@ -19,20 +19,28 @@ public extension PureXML.Canonical {
/// Prefixes always rendered in exclusive mode even when not visibly used
/// (the `InclusiveNamespaces` PrefixList).
public var inclusiveNamespacePrefixes: [String]
/// The Canonical XML 2.0 `TrimTextNodes` parameter: when true, leading and
/// trailing whitespace is stripped from each text node, and a text node
/// that is all whitespace is dropped.
public var trimTextNodes: Bool
public init(
mode: Mode = .inclusive,
includeComments: Bool = false,
inclusiveNamespacePrefixes: [String] = [],
trimTextNodes: Bool = false,
) {
self.mode = mode
self.includeComments = includeComments
self.inclusiveNamespacePrefixes = inclusiveNamespacePrefixes
self.trimTextNodes = trimTextNodes
}
/// Inclusive C14N without comments.
public static let inclusive = Options(mode: .inclusive)
/// Exclusive C14N without comments.
public static let exclusive = Options(mode: .exclusive)
/// Canonical XML 2.0 with text-node trimming enabled.
public static let canonical2 = Options(mode: .inclusive, trimTextNodes: true)
}
}

View File

@ -33,7 +33,8 @@ public extension PureXML.Canonical {
case let .element(element):
emit(element, inScope: inScope, rendered: rendered, output: &output)
case let .text(value), let .cdata(value):
output += Self.escapeText(value)
let text = options.trimTextNodes ? value.trimmingXMLWhitespace() : value
output += Self.escapeText(text)
case let .comment(value):
if options.includeComments { output += "<!--\(value)-->" }
case let .processingInstruction(target, data):

View File

@ -2,12 +2,17 @@
/// internal detail of ``PureXML/Catalog/CatalogParser``.
private struct CatalogBuilder {
typealias RewriteRule = PureXML.Catalog.RewriteRule
typealias DelegateRule = PureXML.Catalog.DelegateRule
var systemMap: [String: String] = [:]
var publicMap: [String: String] = [:]
var uriMap: [String: String] = [:]
var rewriteSystem: [RewriteRule] = []
var rewriteURI: [RewriteRule] = []
var delegateSystem: [DelegateRule] = []
var delegatePublic: [DelegateRule] = []
var delegateURI: [DelegateRule] = []
var nextCatalogs: [String] = []
mutating func add(_ node: PureXML.Model.TreeNode) {
switch node.name?.localName {
@ -21,11 +26,35 @@ private struct CatalogBuilder {
appendRewrite(node, start: "systemIdStartString", into: &rewriteSystem)
case "rewriteURI":
appendRewrite(node, start: "uriStartString", into: &rewriteURI)
default:
addDelegation(node)
}
}
private mutating func addDelegation(_ node: PureXML.Model.TreeNode) {
switch node.name?.localName {
case "delegateSystem":
appendDelegate(node, start: "systemIdStartString", into: &delegateSystem)
case "delegatePublic":
appendDelegate(node, start: "publicIdStartString", into: &delegatePublic)
case "delegateURI":
appendDelegate(node, start: "uriStartString", into: &delegateURI)
case "nextCatalog":
if let catalog = attribute(node, "catalog") { nextCatalogs.append(catalog) }
default:
break
}
}
private func appendDelegate(
_ node: PureXML.Model.TreeNode,
start: String,
into rules: inout [DelegateRule],
) {
guard let startString = attribute(node, start), let catalog = attribute(node, "catalog") else { return }
rules.append(DelegateRule(startString: startString, catalog: catalog))
}
private func insert(
_ node: PureXML.Model.TreeNode,
key: String,
@ -56,6 +85,10 @@ private struct CatalogBuilder {
uriMap: uriMap,
rewriteSystem: rewriteSystem,
rewriteURI: rewriteURI,
delegateSystem: delegateSystem,
delegatePublic: delegatePublic,
delegateURI: delegateURI,
nextCatalogs: nextCatalogs,
)
}
}

View File

@ -4,6 +4,14 @@ extension PureXML.Catalog {
let startString: String
let rewritePrefix: String
}
/// A longest-prefix delegation rule (`delegateSystem`, `delegatePublic`,
/// `delegateURI`): a matching identifier is resolved by the catalog at
/// `catalog` instead.
struct DelegateRule: Sendable {
let startString: String
let catalog: String
}
}
public extension PureXML.Catalog {
@ -24,6 +32,10 @@ public extension PureXML.Catalog {
private let uriMap: [String: String]
private let rewriteSystem: [RewriteRule]
private let rewriteURI: [RewriteRule]
private let delegateSystem: [DelegateRule]
private let delegatePublic: [DelegateRule]
private let delegateURI: [DelegateRule]
private let nextCatalogs: [String]
/// Parses an OASIS XML catalog document.
public init(_ xml: String) throws {
@ -36,12 +48,20 @@ public extension PureXML.Catalog {
uriMap: [String: String],
rewriteSystem: [RewriteRule],
rewriteURI: [RewriteRule],
delegateSystem: [DelegateRule] = [],
delegatePublic: [DelegateRule] = [],
delegateURI: [DelegateRule] = [],
nextCatalogs: [String] = [],
) {
self.systemMap = systemMap
self.publicMap = publicMap
self.uriMap = uriMap
self.rewriteSystem = rewriteSystem
self.rewriteURI = rewriteURI
self.delegateSystem = delegateSystem
self.delegatePublic = delegatePublic
self.delegateURI = delegateURI
self.nextCatalogs = nextCatalogs
}
/// Resolves a system identifier: an exact `system` entry, else the longest
@ -71,6 +91,57 @@ public extension PureXML.Catalog {
return nil
}
/// Resolves a system identifier, following `delegateSystem` and
/// `nextCatalog` chains: the local entries are tried first, then the
/// catalog named by the longest matching delegate prefix, then each
/// `nextCatalog` in turn. `loadingCatalog` fetches a catalog document by
/// URI; nil refuses it, and already-visited catalogs are skipped.
public func resolveSystem(_ systemID: String, loadingCatalog: (String) -> String?) -> String? {
var visited: Set<String> = []
return chained(systemID, delegates: delegateSystem, pick: { $0.resolveSystem(systemID) }, loadingCatalog, &visited)
}
/// Resolves a public identifier, following `delegatePublic` and
/// `nextCatalog` chains. See ``resolveSystem(_:loadingCatalog:)``.
public func resolvePublic(_ publicID: String, loadingCatalog: (String) -> String?) -> String? {
var visited: Set<String> = []
return chained(publicID, delegates: delegatePublic, pick: { $0.resolvePublic(publicID) }, loadingCatalog, &visited)
}
/// Resolves a URI name, following `delegateURI` and `nextCatalog` chains.
/// See ``resolveSystem(_:loadingCatalog:)``.
public func resolveURI(_ name: String, loadingCatalog: (String) -> String?) -> String? {
var visited: Set<String> = []
return chained(name, delegates: delegateURI, pick: { $0.resolveURI(name) }, loadingCatalog, &visited)
}
private func chained(
_ identifier: String,
delegates: [DelegateRule],
pick: (Resolver) -> String?,
_ loadingCatalog: (String) -> String?,
_ visited: inout Set<String>,
) -> String? {
if let local = pick(self) { return local }
let matching = delegates
.filter { identifier.hasPrefix($0.startString) }
.sorted { $0.startString.count > $1.startString.count }
for rule in matching {
if let sub = load(rule.catalog, loadingCatalog, &visited), let resolved = pick(sub) { return resolved }
}
for next in nextCatalogs {
guard let sub = load(next, loadingCatalog, &visited) else { continue }
if let resolved = sub.chained(identifier, delegates: [], pick: pick, loadingCatalog, &visited) { return resolved }
}
return nil
}
private func load(_ uri: String, _ loadingCatalog: (String) -> String?, _ visited: inout Set<String>) -> Resolver? {
guard !visited.contains(uri), let text = loadingCatalog(uri) else { return nil }
visited.insert(uri)
return try? Resolver(text)
}
/// Builds a ``PureXML/Parsing/EntityResolver`` that resolves an external
/// entity or subset through this catalog and then loads the resolved URI
/// with `loadingURI`. An identifier the catalog cannot map, or a URI the

View File

@ -53,13 +53,52 @@ public extension PureXML.XPath {
/// Formats a number in the XPath canonical form: `NaN`, `Infinity`, an
/// integer without a decimal point, or the shortest round-tripping decimal.
/// XPath 1.0 forbids exponential notation, so any exponent in the shortest
/// representation is expanded to a plain decimal.
static func format(_ value: Double) -> String {
if value.isNaN { return "NaN" }
if value.isInfinite { return value < 0 ? "-Infinity" : "Infinity" }
if value == value.rounded(), abs(value) < 1e15 {
return String(Int64(value))
}
return String(value)
return expandExponent(String(value))
}
/// Rewrites a Swift double rendering into a plain decimal without an
/// exponent: `1.5e-05` becomes `0.000015`, `1.23e+21` becomes
/// `1230000000000000000000`.
private static func expandExponent(_ text: String) -> String {
let lowered = text.lowercased()
guard let exponentIndex = lowered.firstIndex(of: "e") else { return text }
let mantissa = String(lowered[lowered.startIndex ..< exponentIndex])
guard let exponent = Int(lowered[lowered.index(after: exponentIndex)...]) else { return text }
let negative = mantissa.hasPrefix("-")
let unsigned = negative ? String(mantissa.dropFirst()) : mantissa
let parts = unsigned.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false)
let integerDigits = String(parts.first ?? "")
let fractionDigits = parts.count > 1 ? String(parts[1]) : ""
var digits = Array(integerDigits + fractionDigits)
// The decimal point currently sits after `integerDigits`; the exponent
// shifts it right (positive) or left (negative).
var point = integerDigits.count + exponent
if point <= 0 {
digits = Array(repeating: "0", count: 1 - point) + digits
point = 1
} else if point > digits.count {
digits += Array(repeating: "0", count: point - digits.count)
}
let integerPart = String(digits[0 ..< point])
let fractionPart = String(digits[point...])
let body = fractionPart.isEmpty ? integerPart : "\(integerPart).\(trimTrailingZeros(fractionPart))"
return (negative ? "-" : "") + body
}
private static func trimTrailingZeros(_ fraction: String) -> String {
var trimmed = fraction
while trimmed.count > 1, trimmed.hasSuffix("0") {
trimmed.removeLast()
}
return trimmed
}
/// Parses a string to a number per the XPath `Number` grammar: optional

View File

@ -0,0 +1,70 @@
@testable import PureXML
import Testing
@Suite("Parity edges: C14N 2.0, catalog delegation, XPath number formatting")
struct ParityEdgeTests {
// MARK: XPath number formatting
@Test("XPath formats numbers as plain decimals without an exponent")
func test_numberFormatting() {
#expect(PureXML.XPath.Value.format(0.000015) == "0.000015")
#expect(PureXML.XPath.Value.format(1.23e21) == "1230000000000000000000")
#expect(PureXML.XPath.Value.format(-0.5) == "-0.5")
#expect(PureXML.XPath.Value.format(3) == "3")
#expect(PureXML.XPath.Value.format(Double.nan) == "NaN")
}
@Test("A small fractional XPath result avoids scientific notation")
func test_numberFromQuery() throws {
let result = try PureXML.XPath.Query("1 div 100000").number(over: .text(""))
#expect(PureXML.XPath.Value.format(result) == "0.00001")
}
// MARK: Catalog delegation and nextCatalog
@Test("delegateSystem resolves through the delegated catalog")
func test_delegateSystem() throws {
let delegated = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<system systemId="http://example.com/a.dtd" uri="local/a.dtd"/>
</catalog>
"""
let main = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<delegateSystem systemIdStartString="http://example.com/" catalog="sub.xml"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(main)
let loader: (String) -> String? = { $0 == "sub.xml" ? delegated : nil }
#expect(resolver.resolveSystem("http://example.com/a.dtd", loadingCatalog: loader) == "local/a.dtd")
#expect(resolver.resolveSystem("http://other.com/a.dtd", loadingCatalog: loader) == nil)
}
@Test("nextCatalog chains to a following catalog")
func test_nextCatalog() throws {
let next = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<public publicId="-//Example//DTD//EN" uri="example.dtd"/>
</catalog>
"""
let main = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<nextCatalog catalog="next.xml"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(main)
let loader: (String) -> String? = { $0 == "next.xml" ? next : nil }
#expect(resolver.resolvePublic("-//Example//DTD//EN", loadingCatalog: loader) == "example.dtd")
}
// MARK: Canonical XML 2.0 text trimming
@Test("C14N 2.0 TrimTextNodes strips text-node whitespace")
func test_canonical2Trim() throws {
let node = try PureXML.parse("<r><a> spaced </a><b>\n x\n </b></r>")
let trimmed = PureXML.Canonical.canonicalize(node, options: .canonical2)
#expect(trimmed == "<r><a>spaced</a><b>x</b></r>")
let untrimmed = PureXML.Canonical.canonicalize(node, options: .inclusive)
#expect(untrimmed == "<r><a> spaced </a><b>\n x\n </b></r>")
}
}