diff --git a/CHANGELOG.md b/CHANGELOG.md index 257861b..3e2f0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Element and attribute names split their prefix without a second pass over the name. The byte scanner (`takeASCIIName`) already visits every byte of an all-ASCII name, so it now also records the offset of the name's first colon at the cost of one comparison per byte. `QualifiedName(ascii:colonOffset:)` then splits at the known offset, and the common unprefixed name (no colon) is built with no scan at all, where `QualifiedName(_:)` previously ran `firstIndex(of:)` grapheme by grapheme over the whole name on every element and attribute. The resulting names are identical for every colon placement (none, leading, trailing, middle, doubled). Measured `parse` ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a colon-placement differential (`QualifiedNameColonHintTests`), the W3C XML conformance corpora, and an adversarial equivalence review. + - The entity decoder copies the literal text between references in bulk instead of one character at a time. `EntityExpander.expand` rebuilt every reference-bearing run grapheme by grapheme (`result.append(character)` with a per-character amplification-budget charge), so a text node holding a single `&` was reconstructed character by character; it now scans to the next `&` or `<` and appends the run in one operation, charging the budget by the run length. The decoded text, the CDATA-verbatim handling, and the billion-laughs budget enforcement are unchanged (the same overrun throws at the same input). The element-content reference-findings pass, which scanned each text run for `&` and rendered the enclosing element's qualified name, now returns immediately when the document has no DTD content models, which is the common case. Measured `parse` ~5% faster on the 20k-item generated corpus (whose text uses `&`) via a same-machine-state A/B. Verified by the W3C XML conformance corpora, a six-case decode differential (`EntityDecodeBulkTests`: references in runs, decimal and hex character references, declared-entity replacement with budget overrun, CDATA in replacement text, an unterminated reference), and an adversarial equivalence review. ## [0.4.1] - 2026-06-25 diff --git a/Sources/Model/QualifiedName.swift b/Sources/Model/QualifiedName.swift index bd8b57d..c7fde30 100644 --- a/Sources/Model/QualifiedName.swift +++ b/Sources/Model/QualifiedName.swift @@ -45,6 +45,31 @@ public extension PureXML.Model { } } + /// Builds a name whose first colon is already known by byte offset (the + /// byte scanner found it while reading the name), so the prefix split + /// needs no second scan. `colonOffset` is nil for the common unprefixed + /// name. The split follows ``init(_:)`` exactly: a colon at the start or + /// end (`:local`, `prefix:`) does not split. The name is all-ASCII, so a + /// byte offset is a character offset. + init(ascii raw: String, colonOffset: Int?) { + namespaceURI = nil + guard let colonOffset else { + prefix = nil + localName = raw + return + } + let colon = raw.index(raw.startIndex, offsetBy: colonOffset) + let head = String(raw[raw.startIndex ..< colon]) + let tail = String(raw[raw.index(after: colon)...]) + if head.isEmpty || tail.isEmpty { + prefix = nil + localName = raw + } else { + prefix = head + localName = tail + } + } + /// The full `prefix:local` rendering used in serialized output. public var description: String { guard let prefix else { return localName } diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift index 564b088..fa4fe6b 100644 --- a/Sources/Parsing/EventReader.swift +++ b/Sources/Parsing/EventReader.swift @@ -289,7 +289,7 @@ public extension PureXML.Parsing { // no per-character buffering. The Character loop below handles a // non-ASCII name, the non-fast-path, and the too-long error. if let fast = reader.takeASCIIName(maxLength: limits.maxNameLength) { - return PureXML.Model.QualifiedName(fast) + return PureXML.Model.QualifiedName(ascii: fast.name, colonOffset: fast.colon) } guard let first = reader.peek(), first.isXMLNameStart else { throw ParseError.expectedName(reader.mark) diff --git a/Sources/Parsing/Reader.swift b/Sources/Parsing/Reader.swift index 0c99eef..a6f3212 100644 --- a/Sources/Parsing/Reader.swift +++ b/Sources/Parsing/Reader.swift @@ -222,10 +222,18 @@ extension PureXML.Parsing { /// exceeds `maxLength`, so the caller falls back to the Character scanner /// (which handles the full Unicode name grammar and the length error). /// A name has no newlines, so position tracking is plain arithmetic. - mutating func takeASCIIName(maxLength: Int) -> String? { + /// Scans an all-ASCII name from the byte storage, also reporting the + /// offset of the name's first colon (or nil when it has none). The + /// scanner already visits every byte, so noting the colon costs one + /// compare per byte and lets ``Model/QualifiedName`` split the prefix + /// without a second pass over the name (the common unprefixed name then + /// splits without scanning at all). + mutating func takeASCIIName(maxLength: Int) -> (name: String, colon: Int?)? { guard buffer.isEmpty, pendingRaw == nil, let storage, byteIndex < storage.count else { return nil } let pointer = storage.pointer - guard PureXML.Parsing.XMLCharacter.isASCIINameStart(pointer[byteIndex]) else { return nil } + let startByte = pointer[byteIndex] + guard PureXML.Parsing.XMLCharacter.isASCIINameStart(startByte) else { return nil } + var colon: Int? = startByte == 0x3A ? 0 : nil var index = byteIndex + 1 let limit = byteIndex + maxLength while index < storage.count { @@ -233,6 +241,7 @@ extension PureXML.Parsing { if byte >= 0x80 { return nil } if !PureXML.Parsing.XMLCharacter.isASCIINameChar(byte) { break } if index >= limit { return nil } + if colon == nil, byte == 0x3A { colon = index - byteIndex } index += 1 } let length = index - byteIndex @@ -240,7 +249,7 @@ extension PureXML.Parsing { byteIndex = index offset += length column += length - return run + return (run, colon) } /// If the upcoming characters equal `literal`, consume them and return true. diff --git a/Tests/QualifiedNameColonHintTests.swift b/Tests/QualifiedNameColonHintTests.swift new file mode 100644 index 0000000..ea64200 --- /dev/null +++ b/Tests/QualifiedNameColonHintTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import PureXML + +/// `QualifiedName(ascii:colonOffset:)` splits a scanned ASCII name using the +/// first-colon offset the byte scanner already found, instead of re-scanning +/// the name with `firstIndex(of:)`. It must produce exactly what +/// `QualifiedName(_:)` produces for the same name. This pins that equivalence +/// across every colon placement: none, leading, trailing, middle, doubled, and +/// a bare colon, so the hint path can never disagree with the scanning path. +@Suite("QualifiedName colon-hint equivalence") +struct QualifiedNameColonHintTests { + private static let names = [ + "item", "name", "price", "id", "kind", "currency", + "m:rank", "xml:space", "a:b", "x:y:z", + ":local", "prefix:", "::", ":", "a:", ":b", + "_underscore", "with-dash", "with.dot", "n123", + ] + + /// The offset of the first ':' (a byte offset, which for an ASCII name is a + /// character offset), or nil. This mirrors what `takeASCIIName` records. + private func firstColonOffset(_ name: String) -> Int? { + name.firstIndex(of: ":").map { name.distance(from: name.startIndex, to: $0) } + } + + @Test("the hint init equals the scanning init for every colon placement") + func test_equivalence() { + for name in Self.names { + let scanned = PureXML.Model.QualifiedName(name) + let hinted = PureXML.Model.QualifiedName(ascii: name, colonOffset: firstColonOffset(name)) + #expect(hinted.prefix == scanned.prefix, "prefix mismatch for \(name)") + #expect(hinted.localName == scanned.localName, "localName mismatch for \(name)") + #expect(hinted == scanned, "qualified name mismatch for \(name)") + } + } + + @Test("prefixed and unprefixed ASCII names round-trip through the parser") + func test_throughParser() throws { + let node = try PureXML.parse(#""#) + guard case let .document(children) = node, let root = children.compactMap(\.element).first, + case let .element(child)? = root.children.first + else { + Issue.record("no child element") + return + } + #expect(child.name.prefix == "m") + #expect(child.name.localName == "child") + let attributeNames = child.attributes.map(\.name) + #expect(attributeNames.contains { $0.prefix == nil && $0.localName == "plain" }) + #expect(attributeNames.contains { $0.prefix == "m" && $0.localName == "flag" }) + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 6af35a8..79bb130 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -70,6 +70,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 16 | share the focus-independent evaluation state via an `Environment` reference: `focused(on:position:size:)` (called once per node of a predicate's input) copied the variables, functions, and namespace dictionaries every time; it now copies only node/position/size and shares one reference. Same semantics (same-state A/B, ~16%) | unchanged | 0.020 s (~2.0x) | unchanged | | 17 | compile the step's node test once per query instead of re-evaluating it per node: the descendant/attribute walks called `{ matchesTree($0, step.test, on: step.axis, context.namespaces) }`, passing the `namespaces` dictionary (and the `NodeTest`'s associated string) by value on every one of tens of thousands of nodes, so the dominant traversal carried a dictionary retain/release per node. `compiledTreeTest`/`compiledAttributeTest`/`compiledNodeTest` resolve the axis principal kind, the binding state, and the name shape once and return a tight closure capturing only the local name. Equivalent to `matchesTree`/`matchesAttribute`/`matches` (exhaustive differential test + adversarial review; same-state A/B, ~35%) | unchanged | 0.013 s (~1.3x) | unchanged | | 18 | bulk-copy literal runs in the entity decoder: `EntityExpander.expand` rebuilt every reference-bearing text run one character at a time (`result.append(character)` with a per-character budget charge), so a text node holding a single `&` was copied grapheme by grapheme; it now scans to the next `&`/`<` and appends the literal run in one go (charging the budget by the run length). Also skips the element-content reference-findings pass (an `&` scan plus a qualified-name render per text node) when there is no DTD. Identical decoded text (W3C XML conformance corpora + 6-case differential + adversarial review; same-state A/B, ~5%) | 0.138 s (~8.5x) | unchanged | unchanged | +| 19 | carry the name's first-colon offset out of the byte scanner so `QualifiedName` splits the prefix without a second pass: `takeASCIIName` already visits every name byte, so it records the first `:` (one compare per byte); `QualifiedName(ascii:colonOffset:)` then splits at the known offset, and the common unprefixed name (no colon) takes no scan at all instead of a grapheme-by-grapheme `firstIndex(of:)` over the whole name. Identical names (colon-placement differential + W3C conformance + adversarial review; same-state A/B, ~12%) | 0.124 s (~7.7x) | unchanged | unchanged | ## Profile findings (first sample, parse path)