feat(parser): streaming byte decode (incremental UTF-8/UTF-16)
Add PureXML.parse(pullingBytes:) and events(pullingBytes:) over an incremental byte source (() -> UInt8?). StreamingDecoder detects the encoding from the leading bytes, strips any BOM, and yields one scalar at a time as a Character, so a byte stream is never fully buffered. UTF-8 (1 to 4 bytes) and UTF-16 (with surrogate pairs) are decoded incrementally; invalid sequences become the Unicode replacement character. Standard library only, no Foundation. Reimplemented from the documented UTF decoding rules; no upstream code copied.
This commit is contained in:
parent
43f82312e6
commit
204dd3895e
@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Streaming byte decode. `PureXML.parse(pullingBytes:)` and
|
||||
`events(pullingBytes:)` accept an incremental byte source (`() -> UInt8?`) and
|
||||
decode UTF-8 or UTF-16 on the fly, so the bytes are never fully buffered.
|
||||
Encoding is detected from the leading bytes; invalid sequences become the
|
||||
Unicode replacement character rather than failing the stream.
|
||||
- Namespace resolution. Qualified names now carry an optional resolved
|
||||
`namespaceURI`, populated by the parser from in-scope `xmlns` declarations at
|
||||
the start-element boundary (the libxml2 SAX2 model). A default namespace applies
|
||||
|
||||
@ -54,7 +54,7 @@ flowchart TB
|
||||
Limits["Bounded-by-default limits"]:::done
|
||||
Bytes["Byte input + encoding detection"]:::done
|
||||
Namespaces["Namespace resolution"]:::done
|
||||
ByteStream["Streaming byte decode"]:::todo
|
||||
ByteStream["Streaming byte decode"]:::done
|
||||
DTD["Optional DTD + entities"]:::todo
|
||||
Validation["Schema validation"]:::todo
|
||||
XPath["XPath 1.0"]:::todo
|
||||
|
||||
@ -45,6 +45,25 @@ public extension PureXML {
|
||||
Parsing.EventReader(pulling: pull, limits: limits)
|
||||
}
|
||||
|
||||
/// Parses an XML document from an incremental byte source, decoding the
|
||||
/// encoding on the fly so the bytes are never fully buffered.
|
||||
static func parse(
|
||||
pullingBytes pull: @escaping () -> UInt8?,
|
||||
limits: Parsing.Limits = .default,
|
||||
) throws -> Model.Node {
|
||||
try Parsing.Parser().parse(pullingBytes: pull, limits: limits)
|
||||
}
|
||||
|
||||
/// Returns a streaming ``Parsing/EventReader`` over an incremental byte
|
||||
/// source, decoding the encoding on the fly.
|
||||
static func events(
|
||||
pullingBytes pull: @escaping () -> UInt8?,
|
||||
limits: Parsing.Limits = .default,
|
||||
) -> Parsing.EventReader {
|
||||
var decoder = Parsing.StreamingDecoder(pullingBytes: pull)
|
||||
return Parsing.EventReader(pulling: { decoder.next() }, limits: limits)
|
||||
}
|
||||
|
||||
/// Serializes a ``Model/Node`` tree into XML with the selected options.
|
||||
static func serialize(
|
||||
_ node: Model.Node,
|
||||
|
||||
@ -36,6 +36,17 @@ public extension PureXML.Parsing {
|
||||
try build(EventReader(pulling: pull, limits: limits))
|
||||
}
|
||||
|
||||
/// Parses a single XML document from an incremental BYTE source, decoding
|
||||
/// the encoding (UTF-8 or UTF-16) on the fly so the bytes are never fully
|
||||
/// buffered. The closure returns the next byte or nil at end of input.
|
||||
public func parse(
|
||||
pullingBytes pull: @escaping () -> UInt8?,
|
||||
limits: Limits = .default,
|
||||
) throws -> PureXML.Model.Node {
|
||||
var decoder = StreamingDecoder(pullingBytes: pull)
|
||||
return try build(EventReader(pulling: { decoder.next() }, limits: limits))
|
||||
}
|
||||
|
||||
private func build(_ source: EventReader) throws -> PureXML.Model.Node {
|
||||
var reader = source
|
||||
var roots: [PureXML.Model.Node] = []
|
||||
|
||||
121
Sources/Parsing/StreamingDecoder.swift
Normal file
121
Sources/Parsing/StreamingDecoder.swift
Normal file
@ -0,0 +1,121 @@
|
||||
extension PureXML.Parsing {
|
||||
/// Decodes a byte stream into characters incrementally. It detects the
|
||||
/// encoding from the leading bytes (buffering only those few), strips any
|
||||
/// byte-order mark, then yields one Unicode scalar at a time as a Character,
|
||||
/// so a byte source is never fully materialized. Invalid sequences yield the
|
||||
/// Unicode replacement character rather than failing the stream.
|
||||
///
|
||||
/// This works at scalar granularity; a multi-scalar grapheme cluster is
|
||||
/// surfaced as separate characters, which is harmless for XML tokenization.
|
||||
struct StreamingDecoder {
|
||||
private let pull: () -> UInt8?
|
||||
private var encoding: InputEncoding?
|
||||
private var prefix: [UInt8] = []
|
||||
private var prefixIndex = 0
|
||||
private var finished = false
|
||||
|
||||
private static let replacement: Character = "\u{FFFD}"
|
||||
|
||||
init(pullingBytes pull: @escaping () -> UInt8?) {
|
||||
self.pull = pull
|
||||
}
|
||||
|
||||
/// Returns the next decoded character, or nil at the end of the stream.
|
||||
mutating func next() -> Character? {
|
||||
if encoding == nil {
|
||||
detect()
|
||||
}
|
||||
guard !finished, let encoding else {
|
||||
return nil
|
||||
}
|
||||
switch encoding {
|
||||
case .utf8:
|
||||
return nextUTF8()
|
||||
case .utf16BigEndian:
|
||||
return nextUTF16(bigEndian: true)
|
||||
case .utf16LittleEndian:
|
||||
return nextUTF16(bigEndian: false)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func detect() {
|
||||
while prefix.count < 4, let byte = pull() {
|
||||
prefix.append(byte)
|
||||
}
|
||||
let detected = InputEncoding.detectWithBOM(prefix)
|
||||
encoding = detected.encoding
|
||||
prefixIndex = detected.bomLength
|
||||
}
|
||||
|
||||
private mutating func nextByte() -> UInt8? {
|
||||
if prefixIndex < prefix.count {
|
||||
let byte = prefix[prefixIndex]
|
||||
prefixIndex += 1
|
||||
return byte
|
||||
}
|
||||
return pull()
|
||||
}
|
||||
|
||||
private mutating func nextUTF8() -> Character? {
|
||||
guard let first = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if first < 0x80 {
|
||||
return Character(Unicode.Scalar(first))
|
||||
}
|
||||
let length: Int
|
||||
var value: UInt32
|
||||
if first & 0xE0 == 0xC0 {
|
||||
length = 2
|
||||
value = UInt32(first & 0x1F)
|
||||
} else if first & 0xF0 == 0xE0 {
|
||||
length = 3
|
||||
value = UInt32(first & 0x0F)
|
||||
} else if first & 0xF8 == 0xF0 {
|
||||
length = 4
|
||||
value = UInt32(first & 0x07)
|
||||
} else {
|
||||
return Self.replacement
|
||||
}
|
||||
for _ in 1 ..< length {
|
||||
guard let continuation = nextByte(), continuation & 0xC0 == 0x80 else {
|
||||
return Self.replacement
|
||||
}
|
||||
value = (value << 6) | UInt32(continuation & 0x3F)
|
||||
}
|
||||
guard let scalar = Unicode.Scalar(value) else {
|
||||
return Self.replacement
|
||||
}
|
||||
return Character(scalar)
|
||||
}
|
||||
|
||||
private mutating func nextUTF16(bigEndian: Bool) -> Character? {
|
||||
guard let unit = nextUnit(bigEndian: bigEndian) else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if (0xD800 ... 0xDBFF).contains(unit) {
|
||||
guard let low = nextUnit(bigEndian: bigEndian), (0xDC00 ... 0xDFFF).contains(low) else {
|
||||
return Self.replacement
|
||||
}
|
||||
let value = 0x10000 + (UInt32(unit - 0xD800) << 10) + UInt32(low - 0xDC00)
|
||||
guard let scalar = Unicode.Scalar(value) else {
|
||||
return Self.replacement
|
||||
}
|
||||
return Character(scalar)
|
||||
}
|
||||
guard let scalar = Unicode.Scalar(unit) else {
|
||||
return Self.replacement
|
||||
}
|
||||
return Character(scalar)
|
||||
}
|
||||
|
||||
private mutating func nextUnit(bigEndian: Bool) -> UInt16? {
|
||||
guard let high = nextByte(), let low = nextByte() else {
|
||||
return nil
|
||||
}
|
||||
return bigEndian ? (UInt16(high) << 8 | UInt16(low)) : (UInt16(low) << 8 | UInt16(high))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,20 @@ struct EncodingTests {
|
||||
#expect(rootText(littleEndianNode) == "hi")
|
||||
}
|
||||
|
||||
@Test("Decodes a UTF-8 byte stream incrementally (multi-byte scalars)")
|
||||
func test_streamingUTF8() throws {
|
||||
let node = try PureXML.parse(pullingBytes: byteSource(Array("<r>café \u{1F600}</r>".utf8)))
|
||||
#expect(rootText(node) == "café \u{1F600}")
|
||||
}
|
||||
|
||||
@Test("Decodes a UTF-16 byte stream incrementally, both byte orders")
|
||||
func test_streamingUTF16() throws {
|
||||
let bigEndianNode = try PureXML.parse(pullingBytes: byteSource(utf16("<r>héllo</r>", bigEndian: true)))
|
||||
#expect(rootText(bigEndianNode) == "héllo")
|
||||
let littleEndianNode = try PureXML.parse(pullingBytes: byteSource(utf16("<r>héllo</r>", bigEndian: false)))
|
||||
#expect(rootText(littleEndianNode) == "héllo")
|
||||
}
|
||||
|
||||
@Test("Odd-length UTF-16 input is rejected as malformed")
|
||||
func test_malformedUTF16() {
|
||||
#expect(throws: PureXML.Parsing.ParseError.malformedEncoding) {
|
||||
@ -37,6 +51,15 @@ struct EncodingTests {
|
||||
}
|
||||
}
|
||||
|
||||
private func byteSource(_ bytes: [UInt8]) -> () -> UInt8? {
|
||||
var index = 0
|
||||
return {
|
||||
guard index < bytes.count else { return nil }
|
||||
defer { index += 1 }
|
||||
return bytes[index]
|
||||
}
|
||||
}
|
||||
|
||||
private func utf16(_ string: String, bigEndian: Bool) -> [UInt8] {
|
||||
var bytes: [UInt8] = bigEndian ? [0xFE, 0xFF] : [0xFF, 0xFE]
|
||||
for unit in string.utf16 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user