public extension PureXML.Parsing { /// A pull-based, streaming XML event reader. /// /// It consumes its input incrementally through a character-source closure and /// emits one ``Event`` at a time from ``next()``, holding only bounded state: /// a stack of open element names and a small lookahead buffer. It never /// requires the whole document in memory, so it can drive arbitrarily large /// or chunked input (a file, a socket, a generator). The tree-building /// ``Parser`` is a thin adapter over this core. /// /// Safe by default: a `` declaration is rejected rather than /// processed, removing the DTD-based threat classes (XXE, entity-expansion /// DoS) at the door. Bounded ``Limits`` cap depth, name, and content size. struct EventReader { private var reader: Reader private let limits: Limits private let resolver: EntityResolver private var open: [PureXML.Model.QualifiedName] = [] private var namespaces = NamespaceContext() /// The DTD information extracted so far (entities, element models, /// attribute lists, parameter entities, and external identifiers). private(set) var documentType = DocumentType() private var entityBudget: Int private var pending: [Event] = [] private var sawRoot = false private var primed = false /// When true, ``next()`` repairs malformed input in place instead of /// throwing: it records a ``Diagnostic`` and emits best-effort events. private let recovering: Bool private var reachedEnd = false /// The problems found so far, when reading in recovering mode. private(set) var diagnostics: [Diagnostic] = [] /// Creates a reader over a character-pulling source. The closure returns /// the next character or nil at end of input. init( pulling pull: @escaping () -> Character?, limits: Limits = .default, resolver: EntityResolver = .refusing, recovering: Bool = false, ) { reader = Reader(pulling: pull) self.limits = limits self.resolver = resolver self.recovering = recovering entityBudget = limits.maxEntityExpansion } /// Creates a reader over a string (a convenience over the streaming init). init(_ string: String, limits: Limits = .default, resolver: EntityResolver = .refusing, recovering: Bool = false) { reader = Reader(string) self.limits = limits self.resolver = resolver self.recovering = recovering entityBudget = limits.maxEntityExpansion } /// Returns the next event, or nil at the end of the document. In recovering /// mode it never throws: each malformed construct is recorded as a /// diagnostic and reading continues, popping to a matching open element on /// a mismatched end tag and stopping (so the caller closes what is open) on /// truncation. mutating func next() throws -> Event? { if !primed { primed = true if reader.peek() == "\u{FEFF}" { reader.advance() } } while true { if !pending.isEmpty { return pending.removeFirst() } guard recovering else { return open.isEmpty ? try nextAtTopLevel() : try nextInContent() } if reachedEnd { return nil } let start = offset do { return open.isEmpty ? try nextAtTopLevel() : try nextInContent() } catch let error as ParseError { if let event = recoveredEvent(from: error, startingAt: start) { return event } } } } private mutating func nextAtTopLevel() throws -> Event? { while true { reader.skipSpace() guard let lead = reader.peek() else { return nil } if reader.matches("") { guard let character = reader.advance() else { throw PureXML.Parsing.ParseError.unterminatedComment(mark) } length += 1 try checkContent(length, mark) content.append(character) } reader.consume("-->") return .comment(content) } mutating func scanCDATA() throws -> PureXML.Parsing.Event { let mark = reader.mark reader.consume("") { guard let character = reader.advance() else { throw PureXML.Parsing.ParseError.unterminatedCDATA(mark) } length += 1 try checkContent(length, mark) content.append(character) } reader.consume("]]>") return .cdata(content) } /// Handles a thrown error in recovering mode: records the diagnostic and /// returns an event to emit (an end tag synthesized by popping to a match) or /// nil to continue after resynchronizing. Marks the end of input on truncation /// or an exhausted source. mutating func recoveredEvent(from error: PureXML.Parsing.ParseError, startingAt start: Int) -> PureXML.Parsing.Event? { diagnostics.append(PureXML.Parsing.Diagnostic(error)) switch error { case .unexpectedEndOfInput: reachedEnd = true return nil case let .mismatchedEndTag(_, found, _), let .unexpectedEndTag(found, _): return closeTo(found) default: if !recover(from: start) { reachedEnd = true } return nil } } mutating func closeTo(_ found: String) -> PureXML.Parsing.Event? { guard let index = open.lastIndex(where: { $0.description == found }) else { return nil } var events: [PureXML.Parsing.Event] = [] while open.count > index { let closed = open.removeLast() namespaces.leaveElement() events.append(.endElement(name: closed)) } guard let first = events.first else { return nil } pending.append(contentsOf: events.dropFirst()) return first } }