diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b87e67..7773f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Streaming (reader-driven) validation (#107, in progress): `PureXML.validate(streaming:dtd:)` and `PureXML.Validation.StreamingDTDValidator` validate a document against a DTD while it is pulled event by event (the libxml2 `xmlTextReader` validation model), holding only the open-element stack rather than the whole tree. Each element's content model and attributes are checked the moment it closes, by applying the exact per-element DTD rules to a shallow element synthesized from the streamed child names, so streaming and tree validation report the same problems. Errors carry a libxml2 `xmlGetNodePath`-style coding path. RELAX NG / XSD streaming and document-scoped ID/IDREF streaming remain on the issue. - Output encoding (#106): `Serializer.serialize(_:encoding:)` and `PureXML.serialize(_:encoding:)` now emit bytes in a chosen encoding (the libxml2 `xmlSaveToFilename(..., encoding)` model), not only UTF-8. A new `PureXML.Parsing.ByteEncoder` covers the Unicode transformation formats, the single-byte legacy families (ISO-8859, Windows code pages, KOI8), and the multi-byte CJK encodings (Shift-JIS, EUC-JP, EUC-KR, GBK, GB18030, Big5). The CJK and single-byte inverses are built by running the existing, tested decoders over every valid byte sequence and recording the shortest sequence per scalar, so each encoder is by construction the exact inverse of its decoder (no new data, no separately maintained encode tables); GB18030's astral range uses the four-byte pointer formula. The declaration carries the encoding's canonical name, a UTF-16/32 stream is preceded by a byte-order mark, and any scalar the target encoding cannot represent is written as a decimal numeric character reference, so output always round-trips. - Big5 (#103): the byte decoder gains the WHATWG `big5` algorithm, for both the whole-buffer and streaming decoders, selected by the `big5` declaration name (and the `big5-hkscs` / `cn-big5` aliases). A lead in `0x81`-`0xFE` followed by a trail in `0x40`-`0x7E` or `0xA1`-`0xFE` resolves through the index, covering Traditional Chinese and the HKSCS extensions including the astral CJK ideographs (Extension B and the Compatibility Supplement). Four pointers decode to a base letter plus a combining mark (a two-scalar sequence). The 18,590-entry index is vendored verbatim from the WHATWG `index-big5.txt`. Big5 completes the major CJK families libxml2 reaches through iconv (Japanese, Korean, Simplified and Traditional Chinese). - Full GB18030 (#102): the byte decoder gains the GB18030 four-byte sequences on top of the GBK two-byte subset, for both the whole-buffer and streaming decoders, selected by the `gb18030` declaration name. A lead in `0x81`-`0xFE` followed by a digit opens a four-byte form whose pointer is mapped through the WHATWG `index-gb18030-ranges` linear ranges, reaching the supplementary planes (the `189000` range maps to `U+10000` and beyond). The 207 ranges are vendored verbatim from the WHATWG `index-gb18030-ranges.txt`. GB18030 is China's mandatory standard, supported in libxml2 through iconv. diff --git a/README.md b/README.md index baa2d5c..11c45a7 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **777 tests in 118 suites** (`swift test`). +currently runs **781 tests in 119 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Validation/StreamingDTDValidator.swift b/Sources/Validation/StreamingDTDValidator.swift new file mode 100644 index 0000000..1b1271d --- /dev/null +++ b/Sources/Validation/StreamingDTDValidator.swift @@ -0,0 +1,110 @@ +public extension PureXML.Validation { + /// Validates a document against a DTD while it is pulled event by event, the + /// libxml2 `xmlTextReader` validation model. Only the open-element stack is + /// retained (each open element accumulates its direct-child names and a text + /// flag), never the whole tree: an element's content model and attributes are + /// checked the moment it closes, then its frame is dropped. Errors carry a + /// libxml2 `xmlGetNodePath`-style coding path (every child step indexed). + /// + /// This reuses the exact per-element DTD rules (``DTD/contentModel`` and the + /// attribute rules), applied to a shallow element synthesized from the + /// streamed names, so streaming and tree validation agree. Document-scoped + /// ID/IDREF integrity, which needs whole-document state, stays on the tree + /// validator and is tracked separately. + struct StreamingDTDValidator { + private let schema: DTDSchema + private let rules: [Validation] + private var stack: [StreamingDTDFrame] = [] + private var collected: [ValidationError] = [] + + public init(schema: DTDSchema, strict: Bool = false) { + self.schema = schema + var rules: [Validation] = [ + DTD.contentModel, + DTD.requiredAttributes, + DTD.fixedAttributeValues, + DTD.enumeratedAttributeValues, + DTD.tokenizedAttributeTypes, + DTD.notationAttributes, + ] + if strict { rules.append(DTD.undeclaredElement) } + self.rules = rules + } + + /// Feeds one parse event into the validator. + public mutating func consume(_ event: PureXML.Parsing.Event) { + switch event { + case let .startElement(name, attributes): + stack.append(StreamingDTDFrame(name: name, attributes: attributes, path: openPath(name))) + case let .characters(text), let .cdata(text): + if !stack.isEmpty, text.contains(where: { !$0.isWhitespace }) { + stack[stack.count - 1].hasText = true + } + case .endElement: + closeTop() + case .comment, .processingInstruction: + break + } + } + + /// The errors found so far. Call after the last event for the full set. + public var errors: [ValidationError] { + collected + } + + // MARK: Internals + + /// The coding path for an opening element, recording it as a child of the + /// current open element with a one-based occurrence index (the + /// `xmlGetNodePath` convention). + private mutating func openPath(_ name: PureXML.Model.QualifiedName) -> [PathKey] { + let key = name.description + guard !stack.isEmpty else { return [.element(key)] } + let parent = stack.count - 1 + stack[parent].childCounts[key, default: 0] += 1 + stack[parent].childNames.append(key) + let index = stack[parent].childCounts[key] ?? 1 + return stack[parent].path + [.element(key, index: index)] + } + + /// Validates the closing element against the per-element DTD rules, using a + /// shallow element synthesized from the streamed child names and text flag. + private mutating func closeTop() { + guard let frame = stack.popLast() else { return } + let children: [PureXML.Model.Node] = frame.childNames.map { .element(PureXML.Model.Element($0)) } + + (frame.hasText ? [.text("x")] : []) + let element = PureXML.Model.Element(name: frame.name, attributes: frame.attributes, children: children) + collected += rules.flatMap { $0.apply(to: element, at: frame.path, in: schema) } + } + } +} + +public extension PureXML { + /// Validates `xml` against `dtd` while pulling it event by event, the libxml2 + /// `xmlTextReader` validation model. Returns located errors. Memory is bounded + /// to the open-element stack, never the whole tree. + static func validate( + streaming xml: String, + dtd: Validation.DTDSchema, + strict: Bool = false, + limits: Parsing.Limits = .default, + ) throws -> [Validation.ValidationError] { + var validator = Validation.StreamingDTDValidator(schema: dtd, strict: strict) + var reader = events(xml, limits: limits) + while let event = try reader.next() { + validator.consume(event) + } + return validator.errors + } +} + +/// One open element while streaming: its name and attributes, the coding path to +/// it, and the direct-child names and text flag accumulated until it closes. +private struct StreamingDTDFrame { + let name: PureXML.Model.QualifiedName + let attributes: [PureXML.Model.Attribute] + let path: [PureXML.Validation.PathKey] + var childNames: [String] = [] + var childCounts: [String: Int] = [:] + var hasText = false +} diff --git a/Tests/StreamingDTDValidationTests.swift b/Tests/StreamingDTDValidationTests.swift new file mode 100644 index 0000000..d6d0ceb --- /dev/null +++ b/Tests/StreamingDTDValidationTests.swift @@ -0,0 +1,50 @@ +@testable import PureXML +import Testing + +@Suite("Streaming DTD validation") +struct StreamingDTDValidationTests { + /// Builds a DTD schema from an internal subset by parsing a carrier document. + private func schema(_ dtd: String) throws -> PureXML.Validation.DTDSchema { + let carrier = "" + let parsed = try PureXML.Parsing.Parser().parseWithDocumentType(carrier, limits: .init(allowDoctype: true)) + return PureXML.Validation.DTDSchema(parsed.documentType) + } + + @Test("A valid document streams with no errors") + func test_valid() throws { + let dtd = try schema("") + let errors = try PureXML.validate(streaming: "ab", dtd: dtd) + #expect(errors.isEmpty) + } + + @Test("A child outside the content model is reported, located at the parent") + func test_strayChild() throws { + let dtd = try schema("") + let errors = try PureXML.validate(streaming: "", dtd: dtd) + let failure = try #require(errors.first) + #expect(failure.reason.contains("")) + #expect(failure.codingPath.map(\.stringValue) == ["list"]) + } + + @Test("A missing required attribute is reported as each element closes") + func test_requiredAttribute() throws { + let dtd = try schema("") + let errors = try PureXML.validate(streaming: "a", dtd: dtd) + #expect(errors.contains { $0.reason.contains("id") }) + } + + @Test("Streaming validation agrees with tree validation on the same document") + func test_matchesTreeValidation() throws { + let doc = """ + ]> + ab + """ + let tree = try PureXML.validateAgainstInternalDTD(doc) + let dtd = try schema("") + let body = "ab" + let streamed = try PureXML.validate(streaming: body, dtd: dtd) + // Same problems surface (the reasons match; the streamed path always indexes). + #expect(streamed.map(\.reason).sorted() == tree.map(\.reason).sorted()) + #expect(!streamed.isEmpty) + } +}