diff --git a/Sources/Catalog/Resolver.swift b/Sources/Catalog/Resolver.swift index d5ce915..6f6fecb 100644 --- a/Sources/Catalog/Resolver.swift +++ b/Sources/Catalog/Resolver.swift @@ -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], diff --git a/Sources/Catalog/SGMLCatalogParser.swift b/Sources/Catalog/SGMLCatalogParser.swift new file mode 100644 index 0000000..3078d2d --- /dev/null +++ b/Sources/Catalog/SGMLCatalogParser.swift @@ -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) + } + } +} diff --git a/Tests/CatalogSGMLTests.swift b/Tests/CatalogSGMLTests.swift new file mode 100644 index 0000000..42fac65 --- /dev/null +++ b/Tests/CatalogSGMLTests.swift @@ -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) + } +}