diff --git a/CHANGELOG.md b/CHANGELOG.md index c6efec7..e37c089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` is a template whose rule queries carry `$name` references; a `` with `` children instantiates it, substituting each parameter diff --git a/Sources/Catalog/CatalogParser.swift b/Sources/Catalog/CatalogParser.swift index 1fb3d55..1e53240 100644 --- a/Sources/Catalog/CatalogParser.swift +++ b/Sources/Catalog/CatalogParser.swift @@ -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 { diff --git a/Sources/Catalog/Resolver.swift b/Sources/Catalog/Resolver.swift index 6f6fecb..2f4c0a6 100644 --- a/Sources/Catalog/Resolver.swift +++ b/Sources/Catalog/Resolver.swift @@ -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 diff --git a/Tests/CatalogGroupPreferTests.swift b/Tests/CatalogGroupPreferTests.swift new file mode 100644 index 0000000..4e6f76b --- /dev/null +++ b/Tests/CatalogGroupPreferTests.swift @@ -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 = """ + + + + + + + """ + 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 = """ + + + + + + + """ + 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 = """ + + + + + + + + """ + 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 = """ + + + + + + + """ + let catalog = try resolver(xml) + #expect(catalog.resolveExternalIdentifier(publicID: "-//P//EN", systemID: "urn:s") == "by-system.dtd") + } +}