PureXML/Sources/Catalog/Resolver.swift
Mihaela Mihaljevic f15786c0a5 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
2026-06-08 09:37:16 +02:00

183 lines
8.1 KiB
Swift

extension PureXML.Catalog {
/// A longest-prefix rewrite rule (`rewriteSystem`, `rewriteURI`).
struct RewriteRule: Sendable {
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 {
/// An OASIS XML Catalog (the libxml2 `catalog.h` model): it maps public and
/// system identifiers, and URI names, to replacement URIs.
///
/// The catalog itself performs no I/O: it resolves an identifier to a URI
/// string. To actually load the target, build a ``PureXML/Parsing/EntityResolver``
/// with ``entityResolver(loadingURI:)`` and supply a closure that turns a
/// resolved URI into text. With no such closure nothing is loaded, so XXE
/// stays closed.
///
/// Supported entries (matched by local name, namespace-agnostic): `public`,
/// `system`, `uri`, `rewriteSystem`, `rewriteURI`, and `group` (recursed).
struct Resolver: Sendable {
private let systemMap: [String: String]
private let publicMap: [String: String]
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 {
self = try CatalogParser.parse(xml)
}
init(
systemMap: [String: String],
publicMap: [String: String],
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
/// matching `rewriteSystem` prefix.
public func resolveSystem(_ systemID: String) -> String? {
systemMap[systemID] ?? Self.rewrite(systemID, with: rewriteSystem)
}
/// Resolves a public identifier against the `public` entries.
public func resolvePublic(_ publicID: String) -> String? {
publicMap[publicID]
}
/// Resolves a URI name: an exact `uri` entry, else the longest matching
/// `rewriteURI` prefix.
public func resolveURI(_ name: String) -> String? {
uriMap[name] ?? Self.rewrite(name, with: rewriteURI)
}
/// Resolves an external identifier the way a parser would: the system
/// identifier takes precedence (OASIS rule), then the public identifier.
public func resolveExternalIdentifier(publicID: String?, systemID: String?) -> String? {
if let systemID, let resolved = resolveSystem(systemID) {
return resolved
}
if let publicID { return resolvePublic(publicID) }
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
/// loader returns nil for, is refused, so the default posture stays closed.
public func entityResolver(
loadingURI: @escaping @Sendable (_ uri: String) -> String?,
) -> PureXML.Parsing.EntityResolver {
PureXML.Parsing.EntityResolver(
resolveEntity: { _, identifier in
load(identifier, loadingURI)
},
resolveExternalSubset: { identifier in
load(identifier, loadingURI)
},
)
}
private func load(
_ identifier: PureXML.Parsing.ExternalID,
_ loadingURI: @Sendable (String) -> String?,
) -> String? {
guard let uri = resolveExternalIdentifier(publicID: identifier.publicID, systemID: identifier.systemID) else {
return nil
}
return loadingURI(uri)
}
/// The rewrite with the longest matching `startString`, applied as a prefix
/// replacement.
private static func rewrite(_ input: String, with rules: [RewriteRule]) -> String? {
let best = rules
.filter { input.hasPrefix($0.startString) }
.max { $0.startString.count < $1.startString.count }
guard let best else { return nil }
return best.rewritePrefix + input.dropFirst(best.startString.count)
}
}
}