Replace the notImplemented stub with a real parser built on a pull-based, streaming event core that never requires the whole document in memory. - Parsing.EventReader: pulls characters through a source closure with a bounded lookahead buffer and emits one Parsing.Event at a time, holding only an open-element stack. Drives chunked/streamed input. - Parsing.Parser: builds a Model.Node tree iteratively over the event core (explicit stack, no recursion). - Reader: streaming character cursor (closure-backed, bounded buffer) replacing the previous whole-string Array. - EntityDecoder: the five predefined entities and decimal/hex char references. - Handles elements, attributes, text, comments, CDATA, processing instructions, and an XML declaration in the prolog. Rejects <!DOCTYPE> by default (no DTD/XXE/entity-expansion exposure). - Top-level API: parse(_:), parse(pulling:), events(_:), events(pulling:). - 21 tests including event-stream and chunked-source coverage.
249 lines
9.5 KiB
Swift
249 lines
9.5 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.
|
|
struct EventReader {
|
|
private var reader: Reader
|
|
private var open: [PureXML.Model.QualifiedName] = []
|
|
private var pending: [Event] = []
|
|
private var sawRoot = false
|
|
private var primed = false
|
|
|
|
/// 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?) {
|
|
reader = Reader(pulling: pull)
|
|
}
|
|
|
|
/// Creates a reader over a string (a convenience over the streaming init).
|
|
init(_ string: String) {
|
|
reader = Reader(string)
|
|
}
|
|
|
|
/// Returns the next event, or nil at the end of the document.
|
|
mutating func next() throws -> Event? {
|
|
if !primed {
|
|
primed = true
|
|
if reader.peek() == "\u{FEFF}" {
|
|
reader.advance()
|
|
}
|
|
}
|
|
if !pending.isEmpty {
|
|
return pending.removeFirst()
|
|
}
|
|
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
|
|
}
|
|
|
|
private mutating func nextAtTopLevel() throws -> Event? {
|
|
while true {
|
|
reader.skipSpace()
|
|
guard let lead = reader.peek() else {
|
|
return nil
|
|
}
|
|
if reader.matches("<!--") {
|
|
return try scanComment()
|
|
}
|
|
if reader.matches("<![CDATA[") {
|
|
throw ParseError.unexpectedCharacter("[", reader.mark)
|
|
}
|
|
if reader.matches("<?") {
|
|
let instruction = try scanProcessingInstruction()
|
|
if instruction.target.lowercased() == "xml" {
|
|
continue
|
|
}
|
|
return .processingInstruction(target: instruction.target, data: instruction.data)
|
|
}
|
|
if reader.matches("<!DOCTYPE") {
|
|
throw ParseError.unsupportedDoctype(reader.mark)
|
|
}
|
|
if reader.matches("</") {
|
|
throw ParseError.unexpectedEndTag(name: "", reader.mark)
|
|
}
|
|
if lead == "<" {
|
|
if sawRoot {
|
|
throw ParseError.junkAfterDocumentElement(reader.mark)
|
|
}
|
|
return try scanStartTag()
|
|
}
|
|
throw ParseError.junkAfterDocumentElement(reader.mark)
|
|
}
|
|
}
|
|
|
|
private mutating func nextInContent() throws -> Event? {
|
|
guard let lead = reader.peek() else {
|
|
throw ParseError.unexpectedEndOfInput(reader.mark)
|
|
}
|
|
if reader.matches("</") {
|
|
return try scanEndTag()
|
|
}
|
|
if reader.matches("<!--") {
|
|
return try scanComment()
|
|
}
|
|
if reader.matches("<![CDATA[") {
|
|
return try scanCDATA()
|
|
}
|
|
if reader.matches("<?") {
|
|
let instruction = try scanProcessingInstruction()
|
|
return .processingInstruction(target: instruction.target, data: instruction.data)
|
|
}
|
|
if reader.matches("<!DOCTYPE") {
|
|
throw ParseError.unsupportedDoctype(reader.mark)
|
|
}
|
|
if lead == "<" {
|
|
return try scanStartTag()
|
|
}
|
|
return try scanText()
|
|
}
|
|
|
|
private mutating func scanStartTag() throws -> Event {
|
|
reader.consume("<")
|
|
let name = try scanName()
|
|
let attributes = try scanAttributes()
|
|
if reader.consume("/>") {
|
|
pending.append(.endElement(name: name))
|
|
sawRoot = true
|
|
return .startElement(name: name, attributes: attributes)
|
|
}
|
|
guard reader.consume(">") else {
|
|
throw ParseError.unterminatedTag(reader.mark)
|
|
}
|
|
open.append(name)
|
|
sawRoot = true
|
|
return .startElement(name: name, attributes: attributes)
|
|
}
|
|
|
|
private mutating func scanEndTag() throws -> Event {
|
|
let mark = reader.mark
|
|
reader.consume("</")
|
|
let name = try scanName()
|
|
reader.skipSpace()
|
|
guard reader.consume(">") else {
|
|
throw ParseError.unterminatedTag(reader.mark)
|
|
}
|
|
guard let top = open.last else {
|
|
throw ParseError.unexpectedEndTag(name: name.description, mark)
|
|
}
|
|
guard top == name else {
|
|
throw ParseError.mismatchedEndTag(expected: top.description, found: name.description, mark)
|
|
}
|
|
open.removeLast()
|
|
return .endElement(name: name)
|
|
}
|
|
|
|
private mutating func scanText() throws -> Event {
|
|
let mark = reader.mark
|
|
var raw = ""
|
|
while let character = reader.peek(), character != "<" {
|
|
raw.append(character)
|
|
reader.advance()
|
|
}
|
|
return try .characters(EntityDecoder.decode(raw, at: mark))
|
|
}
|
|
|
|
private mutating func scanComment() throws -> Event {
|
|
let mark = reader.mark
|
|
reader.consume("<!--")
|
|
var content = ""
|
|
while !reader.matches("-->") {
|
|
guard let character = reader.advance() else {
|
|
throw ParseError.unterminatedComment(mark)
|
|
}
|
|
content.append(character)
|
|
}
|
|
reader.consume("-->")
|
|
return .comment(content)
|
|
}
|
|
|
|
private mutating func scanCDATA() throws -> Event {
|
|
let mark = reader.mark
|
|
reader.consume("<![CDATA[")
|
|
var content = ""
|
|
while !reader.matches("]]>") {
|
|
guard let character = reader.advance() else {
|
|
throw ParseError.unterminatedCDATA(mark)
|
|
}
|
|
content.append(character)
|
|
}
|
|
reader.consume("]]>")
|
|
return .cdata(content)
|
|
}
|
|
|
|
private mutating func scanProcessingInstruction() throws -> (target: String, data: String) {
|
|
let mark = reader.mark
|
|
reader.consume("<?")
|
|
let target = try scanName()
|
|
reader.skipSpace()
|
|
var data = ""
|
|
while !reader.matches("?>") {
|
|
guard let character = reader.advance() else {
|
|
throw ParseError.unterminatedTag(mark)
|
|
}
|
|
data.append(character)
|
|
}
|
|
reader.consume("?>")
|
|
return (target.description, data)
|
|
}
|
|
|
|
private mutating func scanAttributes() throws -> [PureXML.Model.Attribute] {
|
|
var attributes: [PureXML.Model.Attribute] = []
|
|
var seen: Set<String> = []
|
|
while true {
|
|
reader.skipSpace()
|
|
guard let next = reader.peek(), next != ">", next != "/" else {
|
|
return attributes
|
|
}
|
|
let mark = reader.mark
|
|
let name = try scanName()
|
|
reader.skipSpace()
|
|
guard reader.consume("=") else {
|
|
throw ParseError.expectedEquals(reader.mark)
|
|
}
|
|
reader.skipSpace()
|
|
let value = try scanAttributeValue()
|
|
guard seen.insert(name.description).inserted else {
|
|
throw ParseError.duplicateAttribute(name: name.description, mark)
|
|
}
|
|
attributes.append(PureXML.Model.Attribute(name: name, value: value))
|
|
}
|
|
}
|
|
|
|
private mutating func scanAttributeValue() throws -> String {
|
|
let mark = reader.mark
|
|
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
|
|
throw ParseError.unquotedAttributeValue(reader.mark)
|
|
}
|
|
reader.advance()
|
|
var raw = ""
|
|
while let character = reader.peek(), character != quote {
|
|
raw.append(character)
|
|
reader.advance()
|
|
}
|
|
guard reader.consume(String(quote)) else {
|
|
throw ParseError.unexpectedEndOfInput(reader.mark)
|
|
}
|
|
return try EntityDecoder.decode(raw, at: mark)
|
|
}
|
|
|
|
private mutating func scanName() throws -> PureXML.Model.QualifiedName {
|
|
guard let first = reader.peek(), first.isXMLNameStart else {
|
|
throw ParseError.expectedName(reader.mark)
|
|
}
|
|
var raw = ""
|
|
while let character = reader.peek(), character.isXMLNameContinuation {
|
|
raw.append(character)
|
|
reader.advance()
|
|
}
|
|
return PureXML.Model.QualifiedName(raw)
|
|
}
|
|
}
|
|
}
|