From f15786c0a5555861c8aa86803f507fe6ebac837e Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 09:37:16 +0200 Subject: [PATCH] feat(parity): C14N 2.0 text trimming, catalog delegation, XPath number formatting Close the remaining documented parity edges: - Canonical XML 2.0 TrimTextNodes: an option that strips leading and trailing whitespace from each text node (dropping all-whitespace nodes), exposed as Options.canonical2. - XML Catalog delegation and chaining: delegatePublic, delegateSystem, delegateURI, and nextCatalog entries are parsed, and resolveSystem/ resolvePublic/resolveURI gain a loadingCatalog overload that follows the longest matching delegate prefix and then each nextCatalog, with a visited-set guard against cycles. - XPath number formatting: the canonical string form now expands any exponent in the shortest double rendering to a plain decimal, since XPath 1.0 forbids scientific notation. Closes #70 --- Sources/Canonical/CanonicalOptions.swift | 8 +++ Sources/Canonical/Canonicalizer.swift | 3 +- Sources/Catalog/CatalogParser.swift | 33 +++++++++++ Sources/Catalog/Resolver.swift | 71 ++++++++++++++++++++++++ Sources/XPath/Value.swift | 41 +++++++++++++- Tests/ParityEdgeTests.swift | 70 +++++++++++++++++++++++ 6 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 Tests/ParityEdgeTests.swift diff --git a/Sources/Canonical/CanonicalOptions.swift b/Sources/Canonical/CanonicalOptions.swift index f0a83b8..38b50f8 100644 --- a/Sources/Canonical/CanonicalOptions.swift +++ b/Sources/Canonical/CanonicalOptions.swift @@ -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) } } diff --git a/Sources/Canonical/Canonicalizer.swift b/Sources/Canonical/Canonicalizer.swift index 1fae1f1..d0640b7 100644 --- a/Sources/Canonical/Canonicalizer.swift +++ b/Sources/Canonical/Canonicalizer.swift @@ -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 += "" } case let .processingInstruction(target, data): diff --git a/Sources/Catalog/CatalogParser.swift b/Sources/Catalog/CatalogParser.swift index c2279f8..2e5892c 100644 --- a/Sources/Catalog/CatalogParser.swift +++ b/Sources/Catalog/CatalogParser.swift @@ -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, ) } } diff --git a/Sources/Catalog/Resolver.swift b/Sources/Catalog/Resolver.swift index edd9c5c..d12a3b3 100644 --- a/Sources/Catalog/Resolver.swift +++ b/Sources/Catalog/Resolver.swift @@ -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 = [] + 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 = [] + 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 = [] + 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? { + 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) -> 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 diff --git a/Sources/XPath/Value.swift b/Sources/XPath/Value.swift index e0dda11..30e061b 100644 --- a/Sources/XPath/Value.swift +++ b/Sources/XPath/Value.swift @@ -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 diff --git a/Tests/ParityEdgeTests.swift b/Tests/ParityEdgeTests.swift new file mode 100644 index 0000000..17555de --- /dev/null +++ b/Tests/ParityEdgeTests.swift @@ -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 = """ + + + + """ + let main = """ + + + + """ + 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 = """ + + + + """ + let main = """ + + + + """ + 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(" spaced \n x\n ") + let trimmed = PureXML.Canonical.canonicalize(node, options: .canonical2) + #expect(trimmed == "spacedx") + let untrimmed = PureXML.Canonical.canonicalize(node, options: .inclusive) + #expect(untrimmed == " spaced \n x\n ") + } +}