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.
158 lines
6.7 KiB
Swift
158 lines
6.7 KiB
Swift
import Testing
|
|
@testable import PureXML
|
|
|
|
/// Locks the parser's byte-level fast paths against their Character-path
|
|
/// fallbacks. Each case is chosen to land exactly on a boundary where the byte
|
|
/// scanner must hand off to the scalar scanner (a non-ASCII byte, a carriage
|
|
/// return, an entity reference) so that a future change to either path cannot
|
|
/// silently diverge. The expected values are the spec-correct results,
|
|
/// independent of which path produced them.
|
|
@Suite("Byte fast-path boundaries")
|
|
struct ByteFastPathTests {
|
|
@Test("An all-ASCII name uses the byte path; a non-ASCII name falls back")
|
|
func test_nameFallback() throws {
|
|
let node = try PureXML.parse("<caf\u{e9} na\u{ef}ve=\"x\">y</caf\u{e9}>")
|
|
let element = node.firstElement
|
|
#expect(element?.name.localName == "caf\u{e9}")
|
|
#expect(element?.attributes == [.init("na\u{ef}ve", "x")])
|
|
#expect(element?.text == "y")
|
|
}
|
|
|
|
@Test("A name whose ASCII prefix precedes a non-ASCII byte stays whole")
|
|
func test_mixedNameStaysWhole() throws {
|
|
// The byte scanner consumes "ab", meets the non-ASCII byte, returns nil,
|
|
// and the Character path re-scans the entire "ab\u{e9}" as one name.
|
|
let node = try PureXML.parse("<ab\u{e9}/>")
|
|
#expect(node.firstElement?.name.localName == "ab\u{e9}")
|
|
}
|
|
|
|
@Test("A prefixed name splits at the colon on the byte path")
|
|
func test_prefixedName() throws {
|
|
let node = try PureXML.parse("<m:row xmlns:m=\"urn:x\" m:k=\"v\"/>")
|
|
let element = node.firstElement
|
|
#expect(element?.name.prefix == "m")
|
|
#expect(element?.name.localName == "row")
|
|
#expect(element?.attributes.contains { $0.name.localName == "k" && $0.value == "v" } == true)
|
|
}
|
|
|
|
@Test("Literal whitespace in an attribute value normalizes to single spaces")
|
|
func test_attributeWhitespaceNormalizes() throws {
|
|
// Tab and line feed each become one space per 3.3.3; these run through
|
|
// the byte scanner's whitespace transform.
|
|
let node = try PureXML.parse("<a v=\"x\ty\nz\"/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "x y z")])
|
|
}
|
|
|
|
@Test("A CRLF in an attribute value collapses to a single space")
|
|
func test_attributeCRLF() throws {
|
|
// 2.11 folds CRLF to one LF before 3.3.3 turns it into one space. The
|
|
// byte scanner defers any carriage return to the Character path so the
|
|
// pair is not double-counted as two spaces.
|
|
let node = try PureXML.parse("<a v=\"x\r\ny\"/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "x y")])
|
|
}
|
|
|
|
@Test("A character reference in an attribute value survives normalization")
|
|
func test_attributeCharReferenceSurvives() throws {
|
|
// A referenced line feed is NOT subject to whitespace normalization: it
|
|
// stays a line feed while a literal line feed would have become a space.
|
|
let node = try PureXML.parse("<a v=\"x y\"/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "x\ny")])
|
|
}
|
|
|
|
@Test("Entities and bare ampersands in text decode through the slow path")
|
|
func test_textEntities() throws {
|
|
let node = try PureXML.parse("<p>plain text then 1 < 2 & ok</p>")
|
|
#expect(node.firstElement?.text == "plain text then 1 < 2 & ok")
|
|
}
|
|
|
|
@Test("Ampersand-free text is returned verbatim")
|
|
func test_textWithoutEntities() throws {
|
|
let node = try PureXML.parse("<p>just plain ascii content, no references</p>")
|
|
#expect(node.firstElement?.text == "just plain ascii content, no references")
|
|
}
|
|
|
|
@Test("A CRLF inside text content folds to a single line feed")
|
|
func test_textCRLF() throws {
|
|
let node = try PureXML.parse("<p>line one\r\nline two</p>")
|
|
#expect(node.firstElement?.text == "line one\nline two")
|
|
}
|
|
|
|
@Test("Non-ASCII text content round-trips unchanged")
|
|
func test_nonASCIIText() throws {
|
|
let node = try PureXML.parse("<p>caf\u{e9} na\u{ef}ve r\u{e9}sum\u{e9}</p>")
|
|
#expect(node.firstElement?.text == "caf\u{e9} na\u{ef}ve r\u{e9}sum\u{e9}")
|
|
}
|
|
|
|
@Test("An over-long ASCII name is rejected, not silently truncated")
|
|
func test_longNameRejected() {
|
|
let limits = PureXML.Parsing.Limits(maxNameLength: 4)
|
|
#expect(throws: PureXML.Parsing.ParseError.self) {
|
|
try PureXML.parse("<abcdefgh/>", limits: limits)
|
|
}
|
|
}
|
|
|
|
@Test("A raw less-than inside an attribute value is rejected")
|
|
func test_rawLessThanInAttribute() {
|
|
#expect(throws: PureXML.Parsing.ParseError.self) {
|
|
try PureXML.parse("<a v=\"x<y\"/>")
|
|
}
|
|
}
|
|
|
|
@Test("An empty attribute value parses on the byte fast path")
|
|
func test_emptyAttributeValue() throws {
|
|
// attributeRunBytes returns nil immediately (empty run), so the
|
|
// closing-quote check must fire on the very first byte peek.
|
|
let node = try PureXML.parse("<a v=\"\"/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "")])
|
|
}
|
|
|
|
@Test("A single-quoted attribute value uses the byte fast path")
|
|
func test_singleQuotedAttributeValue() throws {
|
|
let node = try PureXML.parse("<a v='x'/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "x")])
|
|
}
|
|
|
|
@Test("A double quote is legal content inside a single-quoted value")
|
|
func test_doubleQuoteInsideSingleQuotedValue() throws {
|
|
let node = try PureXML.parse("<a v='it\"s'/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "it\"s")])
|
|
}
|
|
|
|
@Test("A single quote is legal content inside a double-quoted value")
|
|
func test_singleQuoteInsideDoubleQuotedValue() throws {
|
|
let node = try PureXML.parse("<a v=\"it's\"/>")
|
|
#expect(node.firstElement?.attributes == [.init("v", "it's")])
|
|
}
|
|
|
|
@Test("An attribute value unterminated at end of input is rejected")
|
|
func test_unterminatedAttributeValueAtEndOfInput() {
|
|
#expect(throws: PureXML.Parsing.ParseError.self) {
|
|
try PureXML.parse("<a v=\"x")
|
|
}
|
|
}
|
|
|
|
@Test("An attribute value that ends exactly at end of input with no closing quote is rejected")
|
|
func test_attributeValueMissingClosingQuoteAtVeryEnd() {
|
|
#expect(throws: PureXML.Parsing.ParseError.self) {
|
|
try PureXML.parse("<a v=\"")
|
|
}
|
|
}
|
|
|
|
@Test("A leading greater-than in text content is preserved")
|
|
func test_textLeadingGreaterThan() throws {
|
|
let node = try PureXML.parse("<p>a > b</p>")
|
|
#expect(node.firstElement?.text == "a > b")
|
|
}
|
|
}
|
|
|
|
private extension PureXML.Model.Node {
|
|
var firstElement: PureXML.Model.Element? {
|
|
guard case let .document(children) = self else { return element }
|
|
for child in children {
|
|
if case let .element(element) = child { return element }
|
|
}
|
|
return nil
|
|
}
|
|
}
|