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") + } +}