PureXML/Sources/Schema/RelaxNGCompactParser.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

390 lines
13 KiB
Swift

typealias RNCToken = PureXML.Schema.RNCToken
typealias RNCLexer = PureXML.Schema.RNCLexer
/// A recursive-descent parser from the token stream to the pattern algebra.
/// Module-internal; the only entry point is ``PureXML/Schema/RelaxNGCompactParser``.
final class RNCParser {
typealias Pattern = PureXML.Schema.Pattern
typealias NameClass = PureXML.Schema.NameClass
let tokens: [RNCToken]
var position = 0
let loader: (String) -> String?
private(set) var defines: [String: Pattern] = [:]
var startPattern: Pattern?
var defaultNamespace = ""
var namespaces: [String: String] = [:]
var visited: Set<String>
init(_ tokens: [RNCToken], loader: @escaping (String) -> String?, visited: Set<String> = []) {
self.tokens = tokens
self.loader = loader
self.visited = visited
}
func parse() -> (start: Pattern, defines: [String: Pattern]) {
skipDeclarations()
if looksLikeGrammar() {
parseGrammarBody()
return (startPattern ?? .notAllowed, defines)
}
return (parsePattern(), defines)
}
}
// MARK: Declarations, grammar, and patterns
extension RNCParser {
private func skipDeclarations() {
while true {
skipAnnotation()
guard case let .word(keyword) = peek() else { return }
switch keyword {
case "namespace": parseNamespace(isDefault: false)
case "default": parseDefault()
case "datatypes": parseDatatypes()
default: return
}
}
}
private func parseDefault() {
advance() // default
if case .word("namespace") = peek() {
parseNamespace(isDefault: true)
}
}
private func parseNamespace(isDefault: Bool) {
advance() // namespace
var prefix = ""
if case let .word(name) = peek(), name != "=" {
prefix = name
advance()
}
expectSymbol("=")
let uri = expectString()
if isDefault {
defaultNamespace = uri
}
if !prefix.isEmpty {
namespaces[prefix] = uri
}
}
private func parseDatatypes() {
advance() // datatypes
if case .word = peek() {
advance()
}
expectSymbol("=")
_ = expectString()
}
// MARK: Grammar body
private func looksLikeGrammar() -> Bool {
guard case let .word(word) = peek() else { return false }
if word == "include" || word == "start" {
return true
}
// A define is a word followed by one of =, |=, &=.
if case let .symbol(symbol) = peek(1), symbol == "=" || symbol == "|=" || symbol == "&=" {
return true
}
return false
}
private func parseGrammarBody() {
while position < tokens.count, peek() != .symbol("}") {
skipAnnotation()
guard position < tokens.count, peek() != .symbol("}") else { break }
parseGrammarItem()
}
}
private func parseGrammarItem() {
if case .word("include") = peek() {
parseInclude()
} else if case .word("div") = peek(), peek(1) == .symbol("{") {
parseDiv()
} else {
parseDefine()
}
}
/// A `div { ... }` grouping is transparent: it scopes annotations in the
/// full syntax but contributes its defines and `start` directly here.
private func parseDiv() {
advance() // div
expectSymbol("{")
parseGrammarBody()
expectSymbol("}")
}
/// Skips a leading `[ ... ]` documentation/grammar annotation (balanced over
/// nested brackets). Annotations carry no schema semantics, so they are
/// dropped wherever they may appear: before a define, or before a pattern.
private func skipAnnotation() {
while peek() == .symbol("[") {
advance()
var depth = 1
while position < tokens.count, depth > 0 {
let token = advance()
if token == .symbol("[") {
depth += 1
} else if token == .symbol("]") {
depth -= 1
}
}
}
}
private func parseDefine() {
guard case let .word(name) = peek() else { advance()
return
}
advance()
let combinator = expectAssignment()
let pattern = parsePattern()
if name == "start" {
startPattern = combine(startPattern, pattern, combinator)
} else {
defines[name] = combine(defines[name], pattern, combinator)
}
}
private func parseInclude() {
advance() // include
let href = expectString()
if let text = loader(href), !visited.contains(href) {
visited.insert(href)
let sub = RNCParser(RNCLexer.tokens(text), loader: loader, visited: visited)
sub.skipDeclarations()
sub.parseGrammarBody()
for (name, pattern) in sub.defines {
defines[name] = pattern
}
if startPattern == nil {
startPattern = sub.startPattern
}
visited = sub.visited
}
// An override block re-defines names after the include, overriding them.
if peek() == .symbol("{") {
advance()
parseGrammarBody()
expectSymbol("}")
}
}
private func combine(_ existing: Pattern?, _ new: Pattern, _ combinator: String) -> Pattern {
guard let existing else { return new }
switch combinator {
case "|=": return .choice(existing, new)
case "&=": return .interleave(existing, new)
default: return new
}
}
// MARK: Patterns
private func parsePattern() -> Pattern {
var patterns = [parseParticle()]
guard case let .symbol(operatorSymbol) = peek(), operatorSymbol == "," || operatorSymbol == "|" || operatorSymbol == "&" else {
return patterns[0]
}
while peek() == .symbol(operatorSymbol) {
advance()
patterns.append(parseParticle())
}
return patterns.dropFirst().reduce(patterns[0]) { combinePatterns($0, $1, operatorSymbol) }
}
private func combinePatterns(_ lhs: Pattern, _ rhs: Pattern, _ operatorSymbol: String) -> Pattern {
switch operatorSymbol {
case "|": .choice(lhs, rhs)
case "&": .interleave(lhs, rhs)
default: .group(lhs, rhs)
}
}
private func parseParticle() -> Pattern {
let primary = parsePrimary()
let particle: Pattern
switch peek() {
case .symbol("?"): advance()
particle = .choice(primary, .empty)
case .symbol("*"): advance()
particle = .choice(.oneOrMore(primary), .empty)
case .symbol("+"): advance()
particle = .oneOrMore(primary)
default: particle = primary
}
skipFollowAnnotations()
return particle
}
/// Skips `>>` follow-annotations after a pattern: each is a foreign element
/// name with optional `[ … ]` content, carrying no schema semantics.
private func skipFollowAnnotations() {
while peek() == .symbol(">"), peek(1) == .symbol(">") {
advance()
advance()
if case .word = peek() {
advance()
}
skipAnnotation()
}
}
private func parsePrimary() -> Pattern {
skipAnnotation()
guard case let .word(word) = peek() else { return parseNonWordPrimary() }
if let leaf = leafKeyword(word) {
advance()
return leaf
}
return blockKeyword(word)
}
/// The keyword patterns that take no block and map to a constant.
private func leafKeyword(_ word: String) -> Pattern? {
switch word {
case "text": .text
case "empty": .empty
case "notAllowed": .notAllowed
case "string", "token": .data(PureXML.Schema.SimpleType(base: .string))
default: nil
}
}
/// The keyword patterns that introduce a block, reference, or external
/// schema; a bare word falls through to a datatype or define reference.
private func blockKeyword(_ word: String) -> Pattern {
switch word {
case "element": advance()
return parseElement()
case "attribute": advance()
return parseAttribute()
case "list": advance()
return .list(parseBraced())
case "mixed": advance()
return .interleave(parseBraced(), .text)
case "external": advance()
return externalPattern(expectString())
case "grammar": advance()
return nestedGrammar()
case "parent": advance()
return .ref(wordValue())
default: return wordPrimary(word)
}
}
private func parseNonWordPrimary() -> Pattern {
switch peek() {
case let .string(value): advance()
// A bare string literal is shorthand for a `token`-typed value.
return .value(PureXML.Schema.SimpleType(base: .token), concatenated(value))
case .symbol("("): advance()
let pattern = parsePattern()
expectSymbol(")")
return pattern
default: advance()
return .notAllowed
}
}
/// A `:`-qualified word is a datatype (optionally with a parameter block or a
/// value literal); a bare word is a reference to a define.
private func wordPrimary(_ word: String) -> Pattern {
advance()
guard word.contains(":") else { return .ref(word) }
let type = PureXML.Schema.BuiltinType(rawValue: strip(word)) ?? .string
var facets = PureXML.Schema.Facets()
if peek() == .symbol("{") {
parseParams(into: &facets)
}
if case let .string(value) = peek() {
advance()
return .value(PureXML.Schema.SimpleType(base: type), concatenated(value))
}
return .data(PureXML.Schema.SimpleType(base: type, facets: facets))
}
/// Parses a datatype parameter block `{ name = "value" ... }` into facets (the
/// compact-syntax form of `<param>`).
private func parseParams(into facets: inout PureXML.Schema.Facets) {
advance() // {
while position < tokens.count, peek() != .symbol("}") {
guard case let .word(name) = peek() else { advance()
continue
}
advance()
if peek() == .symbol("=") {
advance()
}
guard case let .string(value) = peek() else { continue }
advance()
PureXML.Schema.RelaxNGFacets.apply(name, value, into: &facets)
}
if peek() == .symbol("}") {
advance()
}
}
private func parseElement() -> Pattern {
let nameClass = parseNameClass(attribute: false)
return .element(nameClass, parseBraced())
}
private func parseAttribute() -> Pattern {
let nameClass = parseNameClass(attribute: true)
return .attribute(nameClass, parseBraced())
}
private func parseBraced() -> Pattern {
expectSymbol("{")
let pattern = parsePattern()
expectSymbol("}")
return pattern
}
private func nestedGrammar() -> Pattern {
expectSymbol("{")
parseGrammarBody()
expectSymbol("}")
return startPattern ?? .notAllowed
}
private func externalPattern(_ href: String) -> Pattern {
guard let text = loader(href), !visited.contains(href) else { return .notAllowed }
visited.insert(href)
let sub = RNCParser(RNCLexer.tokens(text), loader: loader, visited: visited)
let (start, subDefines) = sub.parse()
for (name, pattern) in subDefines {
defines[name] = pattern
}
visited = sub.visited
return start
}
}
extension PureXML.Schema {
/// Parses a RELAX NG schema in the compact syntax (RNC) into a start pattern
/// and named `define` patterns, the same algebra the XML syntax compiles to.
/// Supports element/attribute declarations with name classes and namespaces,
/// the `,` `|` `&` combinators, `?` `*` `+` cardinality, `text`, `empty`,
/// `notAllowed`, `list`, `mixed`, datatypes and value literals, grammars with
/// `start` and named definitions (`=`, `|=`, `&=`), nested grammars, and
/// `include` and `external` resolved through `loader`.
enum RelaxNGCompactParser {
static func parse(
_ rnc: String,
loader: @escaping (String) -> String? = { _ in nil },
) throws -> (start: Pattern, defines: [String: Pattern]) {
RNCParser(RNCLexer.tokens(rnc), loader: loader).parse()
}
}
}