PureXML/Tests/ParserPathDifferentialTests.swift
Mihaela Mihaljevic fe0400c3df perf(parsing): byte-path quote handling, lazy expanded-name check, byte-scan entity guard
Third profile-guided pass (#139, docs/benchmarks.md pass 27), targeting
scanContentMarkup(), which inlines the start-tag/attribute/namespace scanners
and carries ~46% of parse time.

scanAttributeValue's opening and closing quote checks ran on the Character
path (peek + advance, buffering the quote as a Character only to compare and
discard it) on every attribute value in the document; both now check the raw
byte first, reaching the byte fast path with no Character buffering, with the
Character path kept as the off-fast-path fallback for streaming sources. The
quote bytes are ASCII and non-CR/LF, so the raw byte and the normalized
stream agree, the same reasoning peekByte's other callers already rely on.

NamespaceContext.checkExpandedNames allocated a Set's native storage and
built a concatenated URI+local key string per element even when at most one
attribute carries a namespace URI, where no duplicate is possible (the
default namespace never applies to attributes, and xmlns declarations carry
no URI). Counting URI-bearing attributes first, with no allocation, and
returning early below two skips all of it on the overwhelming majority of
elements; for xml:* attributes specifically the key exceeded the small-string
limit, so this also removes a real per-element heap allocation.

EntityDecoder.decode/decodeLenient guarded with raw.contains("&"), a
grapheme-by-grapheme walk to find one ASCII byte, once per ampersand-bearing
text node or attribute value; raw.utf8.contains(0x26) finds the same byte
without decoding graphemes, the same shape as checkNameShape's colon check.

The quote-handling extraction into two helpers also resolves the cyclomatic
complexity the inline version tripped. Six new regression tests pin the
quote edge cases (empty value, single-quoted, a quote kind nested inside the
other, unterminated at end of input) plus byte/Character-path agreement for
each in the parser path differential.

Verified: full local suite (1858 tests) and full W3C XSTS suite (14383
groups) pass unchanged. Parse ~6.00x -> ~4.75x versus libxml2 at the
20000-item corpus, best of 5.
2026-07-12 12:27:00 +02:00

141 lines
7.3 KiB
Swift

import Testing
@testable import PureXML
/// A differential oracle for the parser's byte-level fast paths. The byte path
/// (a reader built from a `String`, which owns its UTF-8 and scans straight off
/// the bytes) and the streaming Character path (a reader built from a pulling
/// closure, whose `storage` is nil so every byte method returns nil) are two
/// implementations of the same grammar. They must therefore emit the identical
/// event stream AND, on malformed input, fail at the identical source mark, for
/// every input. Any divergence is a fast-path bug. The corpus is chosen to land
/// on the boundaries where the two paths differ in mechanism: carriage-return
/// folding, attribute-value normalization, multi-line position tracking, the
/// `]]>` guard, name limits, and the ASCII/non-ASCII handoff.
@Suite("Parser byte/Character path differential")
struct ParserPathDifferentialTests {
private func drain(_ reader: inout PureXML.Parsing.EventReader) -> String {
var out = ""
do {
while let event = try reader.next() {
let mark = reader.eventStart
out += "\(event)@\(mark.line):\(mark.column):\(mark.offset)|"
}
out += "EOF"
} catch {
out += "ERR:\(error)"
}
return out
}
private func bytePath(_ input: String, _ limits: PureXML.Parsing.Limits) -> String {
var reader = PureXML.Parsing.EventReader(input, limits: limits)
return drain(&reader)
}
private func characterPath(_ input: String, _ limits: PureXML.Parsing.Limits) -> String {
var iterator = input.makeIterator()
var reader = PureXML.Parsing.EventReader(pulling: { iterator.next() }, limits: limits)
return drain(&reader)
}
private func expectAgreement(_ label: String, _ input: String, limits: PureXML.Parsing.Limits = .default) {
let viaBytes = bytePath(input, limits)
let viaCharacters = characterPath(input, limits)
#expect(
viaBytes == viaCharacters,
"byte and Character paths diverged for \(label) on \(input.debugDescription)\n bytes: \(viaBytes)\n chars: \(viaCharacters)",
)
}
@Test("Carriage-return and line-feed folding in content agree on both paths")
func test_contentLineEndings() {
expectAgreement("lone CR", "<a>x\ry</a>")
expectAgreement("CRLF", "<a>x\r\ny</a>")
expectAgreement("CR then CRLF", "<a>x\r\r\ny</a>")
expectAgreement("only CR", "<a>\r</a>")
expectAgreement("CR then element", "<a>x\r<b/></a>")
expectAgreement("leading CRs then content and element", "<a>\r\r\rx<b/>y</a>")
expectAgreement("multi-line then element mark", "<a>line1\nline2\nline3<b/></a>")
expectAgreement("blank lines then element mark", "<a>\n\n\n<b/></a>")
}
@Test("Attribute-value normalization agrees on both paths")
func test_attributeNormalization() {
expectAgreement("lone CR attr", "<a v=\"x\ry\"/>")
expectAgreement("only CR attr", "<a v=\"\r\"/>")
expectAgreement("CR then CRLF attr", "<a v=\"x\r\r\ny\"/>")
expectAgreement("two lone CR attr", "<a v=\"x\ry\rz\"/>")
expectAgreement("mixed whitespace attr", "<a v=\"\t\r\n \"/>")
expectAgreement("char-ref CR survives", "<a v=\"x&#13;y\"/>")
expectAgreement("char-ref LF survives", "<a v=\"x&#10;y\"/>")
expectAgreement("literal tab plus ref tab", "<a v=\"a\tb&#9;c\"/>")
expectAgreement("multi-line attr then attr", "<a b=\"\nval\nval2\" c=\"y\"/>")
expectAgreement("empty value", "<a v=\"\"/>")
expectAgreement("single-quoted value", "<a v='x'/>")
expectAgreement("double quote inside single-quoted value", "<a v='it\"s'/>")
expectAgreement("single quote inside double-quoted value", "<a v=\"it's\"/>")
expectAgreement("empty then non-empty value", "<a v=\"\" w=\"x\"/>")
expectAgreement("unterminated value at end of input", "<a v=\"x")
expectAgreement("value with only the opening quote then end", "<a v=\"")
}
@Test("Bracket and CDATA-close boundaries agree on both paths")
func test_bracketBoundaries() {
expectAgreement("trailing ]]", "<a>]]</a>")
expectAgreement("]]> error mark", "<a>x]]>y</a>")
expectAgreement("] then ]]>", "<a>]x]]>y</a>")
expectAgreement("triple ] gt", "<a>]]]>x</a>")
expectAgreement("scattered brackets", "<a>a]b]c</a>")
expectAgreement("newline then ]]>", "<a>line1\n]]>x</a>")
expectAgreement("leading greater-than", "<p>a > b</p>")
}
@Test("Name boundaries and the ASCII/non-ASCII handoff agree on both paths")
func test_nameBoundaries() {
let short = PureXML.Parsing.Limits(maxNameLength: 3)
expectAgreement("over-long name", "<abcd/>", limits: short)
expectAgreement("over-long attribute name", "<aaa bcde=\"x\"/>", limits: short)
expectAgreement("double colon name", "<a:b:c/>")
expectAgreement("leading colon name", "<:x/>")
expectAgreement("non-ASCII name start", "<\u{e9}x/>")
expectAgreement("ASCII then non-ASCII name", "<ab\u{e9}/>")
expectAgreement("nested non-ASCII name", "<a><b\u{e9}c/></a>")
expectAgreement("prefix then non-ASCII local", "<x:\u{e9}/>")
}
@Test("Error marks after multi-line byte runs agree on both paths")
func test_errorMarksAfterRuns() {
expectAgreement("raw less-than in attribute", "<a v=\"x<y\"/>")
expectAgreement("invalid char in content", "<a>x\u{0}y</a>")
expectAgreement("invalid char in attribute", "<a v=\"x\u{0}y\"/>")
expectAgreement("invalid char after run crossing newline", "<a>hello\nworld\u{0}x</a>")
expectAgreement("invalid char after two newlines", "<a>ab\ncd\nef\u{0}g</a>")
expectAgreement("missing space before attribute", "<a b=\"1\"c=\"2\"/>")
expectAgreement("missing space after newline", "<a\nb=\"1\"c=\"2\"/>")
expectAgreement("truncated content after newline", "<a>line1\nline2<")
}
@Test("Entity and ampersand handling agrees on both paths")
func test_entitiesAndAmpersands() {
expectAgreement("ampersand-free text", "<a>just plain text</a>")
expectAgreement("decoded entities", "<p>1 &lt; 2 &amp; ok</p>")
expectAgreement("undefined entity mark", "<a>&unknown;</a>")
expectAgreement("entity after run", "<a>text &amp; more &bad;</a>")
expectAgreement("ampersand truncation after newlines", "<a>\n\nbad &x</a>")
expectAgreement("whitespace-only content", "<a> </a>")
expectAgreement("tabs then element", "<a>\t\t<b/></a>")
}
@Test("Attribute-value ampersand handling agrees on both paths")
func test_attributeAmpersands() {
// The byte path skips reference decoding when no '&' is seen; the runs
// and the Character fallback must still agree on the decoded value.
expectAgreement("ampersand-free attr", #"<a v="just plain value"/>"#)
expectAgreement("entity in attr", #"<a v="x &lt; y &amp; z"/>"#)
expectAgreement("char ref in attr", #"<a v="x&#10;y"/>"#)
expectAgreement("ampersand-free with whitespace", "<a v=\"x\ty\nz\"/>")
expectAgreement("entity after whitespace run", "<a v=\"a b c &amp; d\"/>")
expectAgreement("undefined entity in attr", #"<a v="&bogus;"/>"#)
}
}