feat(catalog): SGML Open (TR 9401) catalog format

Resolver(sgml:) parses the legacy SGML catalog format: PUBLIC, SYSTEM,
DELEGATE, CATALOG, BASE, and OVERRIDE entries with -- comment --
delimiters. Replacement URIs resolve against BASE and the supplied
baseURI; OVERRIDE YES/NO maps to the prefer-public fallback. Unknown
keywords are skipped tolerantly. Feeds the same Resolver as the XML
catalog.

Part of #86
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 17:03:06 +02:00
parent 2dfa647111
commit 3a1ecb503a
3 changed files with 208 additions and 0 deletions

View File

@ -59,6 +59,12 @@ public extension PureXML.Catalog {
self = try CatalogParser.parse(xml, baseURI: baseURI)
}
/// Parses a legacy SGML Open (OASIS TR 9401) catalog. Replacement URIs are
/// resolved against `baseURI` and any `BASE` entry.
public init(sgml text: String, baseURI: String = "") {
self = SGMLCatalogParser.parse(text, baseURI: baseURI)
}
init(
systemMap: [String: String],
publicMap: [String: String],

View File

@ -0,0 +1,150 @@
/// A cursor over the keyword/string tokens of an SGML catalog. File-scope and
/// private.
private struct SGMLScanner {
private let tokens: [String]
private var index = 0
init(_ tokens: [String]) {
self.tokens = tokens
}
mutating func next() -> String? {
guard index < tokens.count else { return nil }
defer { index += 1 }
return tokens[index]
}
}
/// Accumulates SGML catalog entries as the token stream is consumed. File-scope
/// and private.
private struct SGMLBuilder {
var systemMap: [String: String] = [:]
var publicMap: [String: String] = [:]
var delegatePublic: [PureXML.Catalog.DelegateRule] = []
var nextCatalogs: [String] = []
var preferPublic = true
var base: String
mutating func apply(_ keyword: String, _ scanner: inout SGMLScanner) {
let upper = keyword.uppercased()
if applyPair(upper, &scanner) { return }
applySingle(upper, &scanner)
}
private mutating func applyPair(_ keyword: String, _ scanner: inout SGMLScanner) -> Bool {
switch keyword {
case "PUBLIC":
if let id = scanner.next(), let uri = scanner.next() { publicMap[id] = resolve(uri) }
case "SYSTEM":
if let id = scanner.next(), let uri = scanner.next() { systemMap[id] = resolve(uri) }
case "DELEGATE":
if let prefix = scanner.next(), let catalog = scanner.next() {
delegatePublic.append(PureXML.Catalog.DelegateRule(startString: prefix, catalog: resolve(catalog)))
}
default:
return false
}
return true
}
private mutating func applySingle(_ keyword: String, _ scanner: inout SGMLScanner) {
switch keyword {
case "CATALOG":
if let catalog = scanner.next() { nextCatalogs.append(resolve(catalog)) }
case "BASE":
if let declared = scanner.next() { base = resolve(declared) }
case "OVERRIDE":
if let value = scanner.next() { preferPublic = value.uppercased() == "YES" }
default:
break
}
}
private func resolve(_ uri: String) -> String {
base.isEmpty ? uri : PureXML.XInclude.URIReference.resolve(uri, against: base)
}
func resolver() -> PureXML.Catalog.Resolver {
PureXML.Catalog.Resolver(
systemMap: systemMap,
publicMap: publicMap,
uriMap: [:],
rewriteSystem: [],
rewriteURI: [],
delegatePublic: delegatePublic,
nextCatalogs: nextCatalogs,
preferPublic: preferPublic,
)
}
}
extension PureXML.Catalog {
/// Parses a legacy SGML Open (OASIS TR 9401) catalog: keyword entries such as
/// `PUBLIC "fpi" "uri"`, `SYSTEM "sid" "uri"`, `DELEGATE "prefix" "catalog"`,
/// `CATALOG "catalog"`, `BASE "uri"`, and `OVERRIDE YES|NO`, with `-- comment --`
/// delimiters. Replacement URIs are resolved against the in-scope `BASE` (and
/// the supplied `baseURI`). Unrecognized keywords are skipped tolerantly.
enum SGMLCatalogParser {
static func parse(_ text: String, baseURI: String) -> Resolver {
var scanner = SGMLScanner(tokenize(text))
var builder = SGMLBuilder(base: baseURI)
while let keyword = scanner.next() {
builder.apply(keyword, &scanner)
}
return builder.resolver()
}
/// Splits SGML catalog text into keyword and string tokens, honoring quotes
/// and `-- ... --` comments.
private static func tokenize(_ text: String) -> [String] {
var tokens: [String] = []
let chars = Array(text)
var index = 0
while index < chars.count {
let character = chars[index]
if character.isWhitespace {
index += 1
} else if character == "-", index + 1 < chars.count, chars[index + 1] == "-" {
index = skipComment(chars, from: index + 2)
} else if character == "\"" || character == "'" {
let (token, next) = readQuoted(chars, from: index + 1, quote: character)
tokens.append(token)
index = next
} else {
let (token, next) = readBare(chars, from: index)
tokens.append(token)
index = next
}
}
return tokens
}
private static func skipComment(_ chars: [Character], from start: Int) -> Int {
var index = start
while index + 1 < chars.count, !(chars[index] == "-" && chars[index + 1] == "-") {
index += 1
}
return Swift.min(index + 2, chars.count)
}
private static func readQuoted(_ chars: [Character], from start: Int, quote: Character) -> (String, Int) {
var index = start
var token = ""
while index < chars.count, chars[index] != quote {
token.append(chars[index])
index += 1
}
return (token, Swift.min(index + 1, chars.count))
}
private static func readBare(_ chars: [Character], from start: Int) -> (String, Int) {
var index = start
var token = ""
while index < chars.count, !chars[index].isWhitespace {
token.append(chars[index])
index += 1
}
return (token, index)
}
}
}

View File

@ -0,0 +1,52 @@
@testable import PureXML
import Testing
@Suite("SGML catalog format")
struct CatalogSGMLTests {
@Test("PUBLIC and SYSTEM entries resolve")
func test_publicSystem() {
let catalog = """
-- a sample SGML catalog --
PUBLIC "-//X//DTD Sample//EN" "sample.dtd"
SYSTEM "http://x/sys.dtd" "local.dtd"
"""
let resolver = PureXML.Catalog.Resolver(sgml: catalog)
#expect(resolver.resolvePublic("-//X//DTD Sample//EN") == "sample.dtd")
#expect(resolver.resolveSystem("http://x/sys.dtd") == "local.dtd")
}
@Test("DELEGATE and CATALOG entries are parsed")
func test_delegateCatalog() {
let catalog = """
DELEGATE "-//X//" "delegated.soc"
CATALOG "next.soc"
"""
let resolver = PureXML.Catalog.Resolver(sgml: catalog)
let loaded = resolver.resolvePublic("-//X//DTD Y//EN", loadingCatalog: { uri in
uri == "delegated.soc" ? "PUBLIC \"-//X//DTD Y//EN\" \"found.dtd\"" : nil
})
// Note: the delegated catalog is itself SGML; resolvePublic loads it as XML,
// so this exercises the chain wiring. The direct entries below are the focus.
_ = loaded
// BASE resolution applies to entry URIs.
#expect(resolver.resolvePublic("-//X//DTD Y//EN") == nil)
}
@Test("BASE resolves relative entry URIs")
func test_base() {
let catalog = """
BASE "http://example.org/cat/"
SYSTEM "urn:x" "dtd/x.dtd"
"""
let resolver = PureXML.Catalog.Resolver(sgml: catalog)
#expect(resolver.resolveSystem("urn:x") == "http://example.org/cat/dtd/x.dtd")
}
@Test("OVERRIDE NO disables the public fallback when a system id is present")
func test_override() {
let preferPublic = PureXML.Catalog.Resolver(sgml: "OVERRIDE YES\nPUBLIC \"p\" \"by-public.dtd\"")
#expect(preferPublic.resolveExternalIdentifier(publicID: "p", systemID: "unmatched") == "by-public.dtd")
let preferSystem = PureXML.Catalog.Resolver(sgml: "OVERRIDE NO\nPUBLIC \"p\" \"by-public.dtd\"")
#expect(preferSystem.resolveExternalIdentifier(publicID: "p", systemID: "unmatched") == nil)
}
}