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:
parent
2dfa647111
commit
3a1ecb503a
@ -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],
|
||||
|
||||
150
Sources/Catalog/SGMLCatalogParser.swift
Normal file
150
Sources/Catalog/SGMLCatalogParser.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
Tests/CatalogSGMLTests.swift
Normal file
52
Tests/CatalogSGMLTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user