diff --git a/Sources/Catalog/CatalogParser.swift b/Sources/Catalog/CatalogParser.swift
index 2e5892c..434fddc 100644
--- a/Sources/Catalog/CatalogParser.swift
+++ b/Sources/Catalog/CatalogParser.swift
@@ -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)
diff --git a/Sources/Catalog/Resolver.swift b/Sources/Catalog/Resolver.swift
index d12a3b3..5f196c2 100644
--- a/Sources/Catalog/Resolver.swift
+++ b/Sources/Catalog/Resolver.swift
@@ -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
+ }
}
}
diff --git a/Tests/CatalogSuffixPreferTests.swift b/Tests/CatalogSuffixPreferTests.swift
new file mode 100644
index 0000000..80701ae
--- /dev/null
+++ b/Tests/CatalogSuffixPreferTests.swift
@@ -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 = """
+
+
+
+
+ """
+ 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 = """
+
+
+
+ """
+ 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 = """
+
+
+
+
+ """
+ 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 = """
+
+
+
+ """
+ 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 = """
+
+
+
+ """
+ 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")
+ }
+}