PureXML/Sources/Parsing/Reader.swift
Mihaela Mihaljevic 5c306482e5 feat(parser): add a streaming, iterative XML parser
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.
2026-06-08 00:10:50 +02:00

106 lines
3.5 KiB
Swift

extension PureXML.Parsing {
/// A streaming character cursor. It pulls characters from a source closure
/// one at a time, keeping only a small lookahead buffer, so it never holds
/// the whole input in memory. Tracks line, column, and offset for
/// diagnostics. Internal to the parser; not part of the API.
struct Reader {
private let pull: () -> Character?
private var buffer: [Character] = []
private var exhausted = false
private(set) var offset = 0
private(set) var line = 1
private(set) var column = 1
init(pulling pull: @escaping () -> Character?) {
self.pull = pull
}
init(_ string: String) {
var iterator = string.makeIterator()
self.init(pulling: { iterator.next() })
}
/// Ensures the lookahead buffer holds at least `count` characters (or the
/// source is exhausted). The buffer stays bounded by the largest lookahead
/// any single scan needs, never by the document size.
private mutating func ensure(_ count: Int) {
while buffer.count < count, !exhausted {
if let character = pull() {
buffer.append(character)
} else {
exhausted = true
}
}
}
mutating func peek(_ ahead: Int = 0) -> Character? {
ensure(ahead + 1)
return ahead < buffer.count ? buffer[ahead] : nil
}
var mark: Mark {
Mark(line: line, column: column, offset: offset)
}
@discardableResult
mutating func advance() -> Character? {
ensure(1)
guard !buffer.isEmpty else { return nil }
let character = buffer.removeFirst()
offset += 1
if character == "\n" {
line += 1
column = 1
} else {
column += 1
}
return character
}
/// True if the upcoming characters equal `literal`, without consuming.
mutating func matches(_ literal: String) -> Bool {
let target = Array(literal)
ensure(target.count)
guard buffer.count >= target.count else { return false }
for index in target.indices where buffer[index] != target[index] {
return false
}
return true
}
/// If the upcoming characters equal `literal`, consume them and return true.
@discardableResult
mutating func consume(_ literal: String) -> Bool {
guard matches(literal) else { return false }
for _ in literal {
advance()
}
return true
}
mutating func skipSpace() {
while let character = peek(), character.isXMLWhitespace {
advance()
}
}
}
}
extension Character {
/// XML S production: space, tab, carriage return, line feed.
var isXMLWhitespace: Bool {
self == " " || self == "\t" || self == "\r" || self == "\n"
}
/// A character allowed to start an XML name (a practical subset of the spec
/// NameStartChar production: letters, underscore, and colon).
var isXMLNameStart: Bool {
isLetter || self == "_" || self == ":"
}
/// A character allowed within an XML name after the first.
var isXMLNameContinuation: Bool {
isXMLNameStart || isNumber || self == "-" || self == "."
}
}