feat(catalog): systemSuffix/uriSuffix entries and the prefer attribute

Two OASIS catalog gaps from the audit:

- systemSuffix and uriSuffix entries: an identifier ending in the rule's
  suffix maps to its URI, longest suffix winning, tried after exact and
  rewrite matches.
- prefer=public|system on the catalog: a system match always wins, and
  the public identifier is consulted as a fallback only when prefer is
  public or no system identifier was given (the OASIS rule). The default
  stays prefer=public.

xml:base / RFC 3986 relative-URI resolution of entry URIs and the legacy
SGML catalog format remain.

Part of #86
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 15:31:10 +02:00
parent 5b24530a72
commit b7da5652f3
3 changed files with 143 additions and 7 deletions

View File

@ -3,6 +3,7 @@
private struct CatalogBuilder {
typealias RewriteRule = PureXML.Catalog.RewriteRule
typealias DelegateRule = PureXML.Catalog.DelegateRule
typealias SuffixRule = PureXML.Catalog.SuffixRule
var systemMap: [String: String] = [:]
var publicMap: [String: String] = [:]
@ -12,7 +13,10 @@ private struct CatalogBuilder {
var delegateSystem: [DelegateRule] = []
var delegatePublic: [DelegateRule] = []
var delegateURI: [DelegateRule] = []
var systemSuffix: [SuffixRule] = []
var uriSuffix: [SuffixRule] = []
var nextCatalogs: [String] = []
var preferPublic = true
mutating func add(_ node: PureXML.Model.TreeNode) {
switch node.name?.localName {
@ -26,11 +30,24 @@ private struct CatalogBuilder {
appendRewrite(node, start: "systemIdStartString", into: &rewriteSystem)
case "rewriteURI":
appendRewrite(node, start: "uriStartString", into: &rewriteURI)
case "systemSuffix":
appendSuffix(node, suffix: "systemIdSuffix", into: &systemSuffix)
case "uriSuffix":
appendSuffix(node, suffix: "uriSuffix", into: &uriSuffix)
default:
addDelegation(node)
}
}
private func appendSuffix(
_ node: PureXML.Model.TreeNode,
suffix: String,
into rules: inout [SuffixRule],
) {
guard let suffixString = attribute(node, suffix), let uri = attribute(node, "uri") else { return }
rules.append(SuffixRule(suffixString: suffixString, uri: uri))
}
private mutating func addDelegation(_ node: PureXML.Model.TreeNode) {
switch node.name?.localName {
case "delegateSystem":
@ -88,7 +105,10 @@ private struct CatalogBuilder {
delegateSystem: delegateSystem,
delegatePublic: delegatePublic,
delegateURI: delegateURI,
systemSuffix: systemSuffix,
uriSuffix: uriSuffix,
nextCatalogs: nextCatalogs,
preferPublic: preferPublic,
)
}
}
@ -101,10 +121,21 @@ extension PureXML.Catalog {
static func parse(_ xml: String) throws -> Resolver {
let root = try PureXML.parseTree(xml)
var builder = CatalogBuilder()
if let catalog = catalogElement(root), prefer(of: catalog) == "system" {
builder.preferPublic = false
}
collect(root, into: &builder)
return builder.resolver()
}
private static func catalogElement(_ node: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode? {
node.kind == .element ? node : node.children.first { $0.kind == .element }
}
private static func prefer(of node: PureXML.Model.TreeNode) -> String? {
node.attributes.first { $0.name.localName == "prefer" }?.value
}
private static func collect(_ node: PureXML.Model.TreeNode, into builder: inout CatalogBuilder) {
for child in node.children where child.kind == .element {
builder.add(child)

View File

@ -12,6 +12,13 @@ extension PureXML.Catalog {
let startString: String
let catalog: String
}
/// A longest-suffix rewrite rule (`systemSuffix`, `uriSuffix`): an identifier
/// ending in `suffixString` maps to `uri`.
struct SuffixRule: Sendable {
let suffixString: String
let uri: String
}
}
public extension PureXML.Catalog {
@ -35,7 +42,13 @@ public extension PureXML.Catalog {
private let delegateSystem: [DelegateRule]
private let delegatePublic: [DelegateRule]
private let delegateURI: [DelegateRule]
private let systemSuffix: [SuffixRule]
private let uriSuffix: [SuffixRule]
private let nextCatalogs: [String]
/// Whether `public` entries are consulted for an external identifier that
/// also carries a system identifier (`prefer="public"`). A system match
/// always wins; this only governs the fallback.
private let preferPublic: Bool
/// Parses an OASIS XML catalog document.
public init(_ xml: String) throws {
@ -51,7 +64,10 @@ public extension PureXML.Catalog {
delegateSystem: [DelegateRule] = [],
delegatePublic: [DelegateRule] = [],
delegateURI: [DelegateRule] = [],
systemSuffix: [SuffixRule] = [],
uriSuffix: [SuffixRule] = [],
nextCatalogs: [String] = [],
preferPublic: Bool = true,
) {
self.systemMap = systemMap
self.publicMap = publicMap
@ -61,13 +77,18 @@ public extension PureXML.Catalog {
self.delegateSystem = delegateSystem
self.delegatePublic = delegatePublic
self.delegateURI = delegateURI
self.systemSuffix = systemSuffix
self.uriSuffix = uriSuffix
self.nextCatalogs = nextCatalogs
self.preferPublic = preferPublic
}
/// Resolves a system identifier: an exact `system` entry, else the longest
/// matching `rewriteSystem` prefix.
/// matching `rewriteSystem` prefix, else the longest matching `systemSuffix`.
public func resolveSystem(_ systemID: String) -> String? {
systemMap[systemID] ?? Self.rewrite(systemID, with: rewriteSystem)
systemMap[systemID]
?? Self.rewrite(systemID, with: rewriteSystem)
?? Self.suffix(systemID, with: systemSuffix)
}
/// Resolves a public identifier against the `public` entries.
@ -76,18 +97,23 @@ public extension PureXML.Catalog {
}
/// Resolves a URI name: an exact `uri` entry, else the longest matching
/// `rewriteURI` prefix.
/// `rewriteURI` prefix, else the longest matching `uriSuffix`.
public func resolveURI(_ name: String) -> String? {
uriMap[name] ?? Self.rewrite(name, with: rewriteURI)
uriMap[name]
?? Self.rewrite(name, with: rewriteURI)
?? Self.suffix(name, with: uriSuffix)
}
/// Resolves an external identifier the way a parser would: the system
/// identifier takes precedence (OASIS rule), then the public identifier.
/// Resolves an external identifier the way a parser would: a system match
/// always wins; the public identifier is consulted as a fallback only when
/// `prefer="public"` or no system identifier was given (the OASIS rule).
public func resolveExternalIdentifier(publicID: String?, systemID: String?) -> String? {
if let systemID, let resolved = resolveSystem(systemID) {
return resolved
}
if let publicID { return resolvePublic(publicID) }
if let publicID, preferPublic || systemID == nil {
return resolvePublic(publicID)
}
return nil
}
@ -178,5 +204,14 @@ public extension PureXML.Catalog {
guard let best else { return nil }
return best.rewritePrefix + input.dropFirst(best.startString.count)
}
/// The URI of the rule whose `suffixString` is the longest suffix of
/// `input`, or nil when none matches.
private static func suffix(_ input: String, with rules: [SuffixRule]) -> String? {
rules
.filter { input.hasSuffix($0.suffixString) }
.max { $0.suffixString.count < $1.suffixString.count }?
.uri
}
}
}

View File

@ -0,0 +1,70 @@
@testable import PureXML
import Testing
@Suite("Catalog suffix entries and prefer attribute")
struct CatalogSuffixPreferTests {
@Test("systemSuffix resolves by longest matching suffix")
func test_systemSuffix() throws {
let catalog = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<systemSuffix systemIdSuffix=".dtd" uri="generic.dtd"/>
<systemSuffix systemIdSuffix="docbook.dtd" uri="docbook.dtd"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(catalog)
#expect(resolver.resolveSystem("http://x/foo.dtd") == "generic.dtd")
// The longer matching suffix wins.
#expect(resolver.resolveSystem("http://x/docbook.dtd") == "docbook.dtd")
#expect(resolver.resolveSystem("http://x/foo.xsd") == nil)
}
@Test("uriSuffix resolves a URI name by suffix")
func test_uriSuffix() throws {
let catalog = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<uriSuffix uriSuffix=".xsd" uri="schema.xsd"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(catalog)
#expect(resolver.resolveURI("http://x/types.xsd") == "schema.xsd")
#expect(resolver.resolveURI("http://x/types.dtd") == nil)
}
@Test("An exact system entry still wins over a suffix")
func test_exactBeatsSuffix() throws {
let catalog = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<system systemId="http://x/foo.dtd" uri="exact.dtd"/>
<systemSuffix systemIdSuffix=".dtd" uri="generic.dtd"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(catalog)
#expect(resolver.resolveSystem("http://x/foo.dtd") == "exact.dtd")
}
@Test("prefer=public falls back to a public entry when the system id is unmatched")
func test_preferPublic() throws {
let catalog = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="public">
<public publicId="-//X//DTD//EN" uri="bypublic.dtd"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(catalog)
let resolved = resolver.resolveExternalIdentifier(publicID: "-//X//DTD//EN", systemID: "http://x/unmatched.dtd")
#expect(resolved == "bypublic.dtd")
}
@Test("prefer=system does not fall back to public when a system id is present")
func test_preferSystem() throws {
let catalog = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="system">
<public publicId="-//X//DTD//EN" uri="bypublic.dtd"/>
</catalog>
"""
let resolver = try PureXML.Catalog.Resolver(catalog)
// A system id is present but unmatched, and prefer=system, so no fallback.
#expect(resolver.resolveExternalIdentifier(publicID: "-//X//DTD//EN", systemID: "http://x/unmatched.dtd") == nil)
// With no system id, the public entry is still used.
#expect(resolver.resolveExternalIdentifier(publicID: "-//X//DTD//EN", systemID: nil) == "bypublic.dtd")
}
}