PureXML/Sources/Parsing/EventReader.swift

245 lines
11 KiB
Swift

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 `<!DOCTYPE ...>` 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 {
var reader: Reader
private let limits: Limits
private let resolver: EntityResolver
var open: [PureXML.Model.QualifiedName] = []
var namespaces = NamespaceContext()
/// The DTD information extracted so far (entities, element models,
/// attribute lists, parameter entities, and external identifiers).
var documentType = DocumentType()
/// The parsed XML declaration `<?xml ... ?>`, when the document opens with
/// one; nil otherwise.
var xmlDeclaration: XMLDeclaration?
var entityBudget: Int
var pending: [Event] = []
/// Scratch storage for `scanAttributes`' duplicate-name check, hoisted
/// to a stored property so one element's check reuses the previous
/// element's `Set` storage (`removeAll(keepingCapacity: true)`) instead
/// of allocating fresh native storage per element with attributes.
/// Safe to share because `scanAttributes` is not reentrant: nothing
/// inside an attribute scan can start another start-tag scan.
var attributeNamesSeen: Set<String> = []
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.
let recovering: Bool
var reachedEnd = false
/// The problems found so far, when reading in recovering mode.
var diagnostics: [Diagnostic] = []
/// The source position where the most recently produced event's node began,
/// so a pull cursor can report each node's line and column.
private(set) var eventStart: Mark = .start
/// Creates a reader over a character-pulling source. The closure returns
/// the next character or nil at end of input.
public 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).
public 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.
public 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
}
eventStart = reader.mark
if reader.matches("<!--") {
return try scanComment()
}
if reader.matches("<?") {
let mark = reader.mark
let instruction = try scanProcessingInstruction()
if instruction.target.lowercased() == "xml" {
// The reserved target is only legal as the XML declaration,
// which must be the very first bytes of the document.
guard mark == .start, instruction.target == "xml" else {
throw ParseError.reservedProcessingInstructionTarget(mark)
}
try recordDeclaration(instruction.data, at: mark)
continue
}
return .processingInstruction(target: instruction.target, data: instruction.data)
}
if reader.matches("<!DOCTYPE") {
documentType = try DoctypeScanner.scan(
&reader,
limits: limits,
resolver: resolver,
standalone: xmlDeclaration?.standalone == true,
documentVersion: xmlDeclaration?.version,
)
continue
}
if reader.matches("</") || reader.matches("<![CDATA[") {
throw ParseError.junkAfterDocumentElement(reader.mark)
}
if lead == "<" {
guard !sawRoot else { throw ParseError.junkAfterDocumentElement(reader.mark) }
return try scanStartTag()
}
// Junk after the root is "content after the root element"; the same
// shape before any root is simply an unexpected character.
throw sawRoot
? ParseError.junkAfterDocumentElement(reader.mark)
: ParseError.unexpectedCharacter(lead, reader.mark)
}
}
private mutating func nextInContent() throws -> Event? {
// Loops because a text scan can end by splicing an entity's
// replacement into the reader with no text before it: the next
// construct is then read from the spliced replacement.
while true {
// Fast byte dispatch: when the next input is a raw byte (an
// empty lookahead buffer over owned storage), route on it
// without buffering a Character, so every matches()/consume()/
// scan call below stays on its byte fast path. The Character
// path is the identical fallback for a streaming source,
// spliced entity text, or a non-ASCII lead.
if let byte = reader.peekByte() {
eventStart = reader.mark
if byte == 0x3C {
return try scanContentMarkup()
} // '<'
if let text = try scanText() {
return text
}
continue
}
guard let lead = reader.peek() else {
throw ParseError.unexpectedEndOfInput(reader.mark)
}
eventStart = reader.mark
if lead == "<" {
return try scanContentMarkup()
}
if let text = try scanText() {
return text
}
}
}
mutating func scanStartTag() throws -> Event {
guard open.count < limits.maxDepth else {
throw ParseError.nestingTooDeep(limit: limits.maxDepth, reader.mark)
}
let mark = reader.mark
reader.consume("<")
let rawName = try scanName()
let rawAttributes = try normalizedBindings(element: rawName, attributes: scanAttributes())
let resolved = try namespaces.enterElement(name: rawName, attributes: rawAttributes, at: mark)
if reader.consume("/>") {
namespaces.leaveElement()
pending.append(.endElement(name: resolved.name))
sawRoot = true
return .startElement(name: resolved.name, attributes: resolved.attributes)
}
guard reader.consume(">") else {
// Balance the namespace scope entered above so a recovering reader
// can drop this malformed start tag without leaking a scope.
namespaces.leaveElement()
throw ParseError.unterminatedTag(reader.mark)
}
open.append(resolved.name)
sawRoot = true
return .startElement(name: resolved.name, attributes: resolved.attributes)
}
mutating func scanName() throws -> PureXML.Model.QualifiedName {
let mark = reader.mark
// Fast path: an all-ASCII name scanned directly from the bytes, with
// no per-character buffering. The Character loop below handles a
// non-ASCII name, the non-fast-path, and the too-long error.
if let fast = reader.takeASCIIName(maxLength: limits.maxNameLength) {
return PureXML.Model.QualifiedName(ascii: fast.name, colonOffset: fast.colon)
}
guard let first = reader.peek(), first.isXMLNameStart else {
throw ParseError.expectedName(reader.mark)
}
var raw = ""
var length = 0
while let character = reader.peek(), character.isXMLNameContinuation {
length += 1
guard length <= limits.maxNameLength else {
throw ParseError.nameTooLong(limit: limits.maxNameLength, mark)
}
raw.append(character)
reader.advance()
}
return PureXML.Model.QualifiedName(raw)
}
func checkContent(_ length: Int, _ mark: Mark) throws {
guard length <= limits.maxContentLength else {
throw ParseError.contentTooLong(limit: limits.maxContentLength, mark)
}
}
}
}