Merge epic/70-parity-edges: C14N 2.0 trim, catalog delegation, XPath number formatting
This commit is contained in:
commit
60f9e9dfb2
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
70
Tests/ParityEdgeTests.swift
Normal file
70
Tests/ParityEdgeTests.swift
Normal 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>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user