PureXML/Sources/Catalog/SGMLCatalogParser.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

163 lines
5.6 KiB
Swift

/// 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)
}
}
}