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.
141 lines
7.3 KiB
Swift
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 y\"/>")
|
|
expectAgreement("char-ref LF survives", "<a v=\"x y\"/>")
|
|
expectAgreement("literal tab plus ref tab", "<a v=\"a\tb	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 < 2 & ok</p>")
|
|
expectAgreement("undefined entity mark", "<a>&unknown;</a>")
|
|
expectAgreement("entity after run", "<a>text & 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 < y & z"/>"#)
|
|
expectAgreement("char ref in attr", #"<a v="x y"/>"#)
|
|
expectAgreement("ampersand-free with whitespace", "<a v=\"x\ty\nz\"/>")
|
|
expectAgreement("entity after whitespace run", "<a v=\"a b c & d\"/>")
|
|
expectAgreement("undefined entity in attr", #"<a v="&bogus;"/>"#)
|
|
}
|
|
}
|