feat(catalog): honor group-level prefer override
A prefer attribute on a <group> (or nested <catalog>) overrides the catalog-wide preference for the <public> entries within it, inherited through nested groups. The resolver records each public entry's effective preference and consults it when falling back from an unmatched system identifier, so two entries in one catalog can prefer differently. A system match still always wins.
This commit is contained in:
parent
cc44800a4a
commit
2414dbc3bc
@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- XML catalog group-level `prefer` (#86). A `prefer` attribute on a `group` (or a
|
||||
nested `catalog`) now overrides the catalog-wide preference for the `public`
|
||||
entries inside it, inherited through nested groups. Resolution of an external
|
||||
identifier consults each public entry's own effective preference, so two entries
|
||||
in one catalog can prefer differently. A system match still always wins.
|
||||
- Schematron abstract patterns (#80). A `<pattern abstract="true">` is a template
|
||||
whose rule queries carry `$name` references; a `<pattern is-a="…">` with
|
||||
`<param name= value=>` children instantiates it, substituting each parameter
|
||||
|
||||
@ -17,13 +17,17 @@ private struct CatalogBuilder {
|
||||
var uriSuffix: [SuffixRule] = []
|
||||
var nextCatalogs: [String] = []
|
||||
var preferPublic = true
|
||||
/// Each `public` entry's effective `prefer`, taken from its nearest enclosing
|
||||
/// `group`/`catalog`. Absent means the catalog-wide `preferPublic` applies.
|
||||
var publicPrefer: [String: Bool] = [:]
|
||||
|
||||
mutating func add(_ node: PureXML.Model.TreeNode, base: String) {
|
||||
mutating func add(_ node: PureXML.Model.TreeNode, base: String, prefer: Bool) {
|
||||
switch node.name?.localName {
|
||||
case "system":
|
||||
insert(node, key: "systemId", base: base, into: &systemMap)
|
||||
case "public":
|
||||
insert(node, key: "publicId", base: base, into: &publicMap)
|
||||
if let id = attribute(node, "publicId") { publicPrefer[id] = prefer }
|
||||
case "uri":
|
||||
insert(node, key: "name", base: base, into: &uriMap)
|
||||
case "rewriteSystem":
|
||||
@ -119,6 +123,7 @@ private struct CatalogBuilder {
|
||||
uriSuffix: uriSuffix,
|
||||
nextCatalogs: nextCatalogs,
|
||||
preferPublic: preferPublic,
|
||||
publicPrefer: publicPrefer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -135,7 +140,7 @@ extension PureXML.Catalog {
|
||||
if let catalog = catalogElement(root), prefer(of: catalog) == "system" {
|
||||
builder.preferPublic = false
|
||||
}
|
||||
collect(root, base: baseURI, into: &builder)
|
||||
collect(root, base: baseURI, prefer: builder.preferPublic, into: &builder)
|
||||
return builder.resolver()
|
||||
}
|
||||
|
||||
@ -147,16 +152,28 @@ extension PureXML.Catalog {
|
||||
node.attributes.first { $0.name.localName == "prefer" }?.value
|
||||
}
|
||||
|
||||
private static func collect(_ node: PureXML.Model.TreeNode, base: String, into builder: inout CatalogBuilder) {
|
||||
private static func collect(_ node: PureXML.Model.TreeNode, base: String, prefer: Bool, into builder: inout CatalogBuilder) {
|
||||
for child in node.children where child.kind == .element {
|
||||
let childBase = elementBase(child, base)
|
||||
builder.add(child, base: childBase)
|
||||
// A group/catalog `prefer` attribute overrides the inherited
|
||||
// preference for its subtree (OASIS XML Catalogs §4).
|
||||
let childPrefer = preferOverride(child) ?? prefer
|
||||
builder.add(child, base: childBase, prefer: childPrefer)
|
||||
if child.name?.localName == "group" || child.name?.localName == "catalog" {
|
||||
collect(child, base: childBase, into: &builder)
|
||||
collect(child, base: childBase, prefer: childPrefer, into: &builder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit `prefer` on a `group`/`catalog`, or nil when it inherits.
|
||||
private static func preferOverride(_ node: PureXML.Model.TreeNode) -> Bool? {
|
||||
switch prefer(of: node) {
|
||||
case "public": true
|
||||
case "system": false
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The base URI in scope for `node`: its own `xml:base` (resolved against
|
||||
/// the inherited base), or the inherited base when it declares none.
|
||||
private static func elementBase(_ node: PureXML.Model.TreeNode, _ inherited: String) -> String {
|
||||
|
||||
@ -49,6 +49,10 @@ public extension PureXML.Catalog {
|
||||
/// also carries a system identifier (`prefer="public"`). A system match
|
||||
/// always wins; this only governs the fallback.
|
||||
private let preferPublic: Bool
|
||||
/// Per-`public`-entry preference, set when a `group`/`catalog` `prefer`
|
||||
/// overrides the catalog default for that entry. A public id absent here
|
||||
/// falls back to ``preferPublic``.
|
||||
private let publicPrefer: [String: Bool]
|
||||
|
||||
/// Parses an OASIS XML catalog document. Replacement URIs are resolved
|
||||
/// against `baseURI` (the catalog's own location) and any in-scope
|
||||
@ -78,6 +82,7 @@ public extension PureXML.Catalog {
|
||||
uriSuffix: [SuffixRule] = [],
|
||||
nextCatalogs: [String] = [],
|
||||
preferPublic: Bool = true,
|
||||
publicPrefer: [String: Bool] = [:],
|
||||
) {
|
||||
self.systemMap = systemMap
|
||||
self.publicMap = publicMap
|
||||
@ -91,6 +96,7 @@ public extension PureXML.Catalog {
|
||||
self.uriSuffix = uriSuffix
|
||||
self.nextCatalogs = nextCatalogs
|
||||
self.preferPublic = preferPublic
|
||||
self.publicPrefer = publicPrefer
|
||||
}
|
||||
|
||||
/// Resolves a system identifier: an exact `system` entry, else the longest
|
||||
@ -121,10 +127,9 @@ public extension PureXML.Catalog {
|
||||
if let systemID, let resolved = resolveSystem(systemID) {
|
||||
return resolved
|
||||
}
|
||||
if let publicID, preferPublic || systemID == nil {
|
||||
return resolvePublic(publicID)
|
||||
}
|
||||
return nil
|
||||
guard let publicID, let resolved = resolvePublic(publicID) else { return nil }
|
||||
let entryPrefer = publicPrefer[publicID] ?? preferPublic
|
||||
return entryPrefer || systemID == nil ? resolved : nil
|
||||
}
|
||||
|
||||
/// Resolves a system identifier, following `delegateSystem` and
|
||||
|
||||
76
Tests/CatalogGroupPreferTests.swift
Normal file
76
Tests/CatalogGroupPreferTests.swift
Normal file
@ -0,0 +1,76 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XML catalog group-level prefer override")
|
||||
struct CatalogGroupPreferTests {
|
||||
private func resolver(_ xml: String) throws -> PureXML.Catalog.Resolver {
|
||||
try PureXML.Catalog.Resolver(xml)
|
||||
}
|
||||
|
||||
@Test("A group prefer=system overrides a catalog prefer=public for its entries")
|
||||
func test_groupOverridesCatalog() throws {
|
||||
let xml = """
|
||||
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="public">
|
||||
<public publicId="-//ROOT//EN" uri="root.dtd"/>
|
||||
<group prefer="system">
|
||||
<public publicId="-//GROUP//EN" uri="group.dtd"/>
|
||||
</group>
|
||||
</catalog>
|
||||
"""
|
||||
let catalog = try resolver(xml)
|
||||
// Root entry inherits prefer=public: consulted even with an unmatched system id.
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//ROOT//EN", systemID: "urn:unmatched") == "root.dtd")
|
||||
// Group entry is prefer=system: not consulted when a system id is present.
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//GROUP//EN", systemID: "urn:unmatched") == nil)
|
||||
// But still consulted when there is no system id at all.
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//GROUP//EN", systemID: nil) == "group.dtd")
|
||||
}
|
||||
|
||||
@Test("A group prefer=public overrides a catalog prefer=system for its entries")
|
||||
func test_groupOverridesSystemCatalog() throws {
|
||||
let xml = """
|
||||
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="system">
|
||||
<public publicId="-//ROOT//EN" uri="root.dtd"/>
|
||||
<group prefer="public">
|
||||
<public publicId="-//GROUP//EN" uri="group.dtd"/>
|
||||
</group>
|
||||
</catalog>
|
||||
"""
|
||||
let catalog = try resolver(xml)
|
||||
// Root inherits prefer=system: public not consulted alongside a system id.
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//ROOT//EN", systemID: "urn:unmatched") == nil)
|
||||
// Group entry is prefer=public: consulted even with an unmatched system id.
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//GROUP//EN", systemID: "urn:unmatched") == "group.dtd")
|
||||
}
|
||||
|
||||
@Test("A nested group inherits the enclosing group's prefer")
|
||||
func test_nestedGroupInherits() throws {
|
||||
let xml = """
|
||||
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="public">
|
||||
<group prefer="system">
|
||||
<group>
|
||||
<public publicId="-//DEEP//EN" uri="deep.dtd"/>
|
||||
</group>
|
||||
</group>
|
||||
</catalog>
|
||||
"""
|
||||
let catalog = try resolver(xml)
|
||||
// Deep entry inherits prefer=system through the inner group.
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//DEEP//EN", systemID: "urn:unmatched") == nil)
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//DEEP//EN", systemID: nil) == "deep.dtd")
|
||||
}
|
||||
|
||||
@Test("A system match always wins regardless of group prefer")
|
||||
func test_systemAlwaysWins() throws {
|
||||
let xml = """
|
||||
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="public">
|
||||
<group prefer="system">
|
||||
<public publicId="-//P//EN" uri="by-public.dtd"/>
|
||||
<system systemId="urn:s" uri="by-system.dtd"/>
|
||||
</group>
|
||||
</catalog>
|
||||
"""
|
||||
let catalog = try resolver(xml)
|
||||
#expect(catalog.resolveExternalIdentifier(publicID: "-//P//EN", systemID: "urn:s") == "by-system.dtd")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user