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:
parent
5b24530a72
commit
b7da5652f3
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
70
Tests/CatalogSuffixPreferTests.swift
Normal file
70
Tests/CatalogSuffixPreferTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user