diff --git a/Cargo.lock b/Cargo.lock
index 3f386b432a26..5c8ed1083cc9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1356,10 +1356,13 @@ dependencies = [
"enum-map",
"enumset",
"libc",
+ "quick-xml",
+ "roxmltree",
"scopeguard",
"strum",
"thiserror",
"typed-arena",
+ "xml-rs",
]
[[package]]
@@ -3040,6 +3043,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "quick-xml"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -3104,6 +3116,12 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "roxmltree"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+
[[package]]
name = "rust-argon2"
version = "3.0.0"
@@ -3683,6 +3701,12 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "xml-rs"
+version = "0.8.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
+
[[package]]
name = "zerocopy"
version = "0.8.48"
diff --git a/bench/xml/bun.lock b/bench/xml/bun.lock
index 73d263753256..6134b1bde62a 100644
--- a/bench/xml/bun.lock
+++ b/bench/xml/bun.lock
@@ -5,7 +5,9 @@
"": {
"name": "xml-benchmark",
"dependencies": {
+ "@xmldom/xmldom": "^0.9.10",
"fast-xml-parser": "^5.2.5",
+ "txml": "^6.0.0",
"xml2js": "^0.6.2",
},
},
@@ -13,6 +15,8 @@
"packages": {
"@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
+
"anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
@@ -27,6 +31,8 @@
"strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="],
+ "txml": ["txml@6.0.0", "", {}, "sha512-SJ1tLEiSsraRDvSxCHhwhvS5e3YALvRWxNDKR2vCX3gQG4MVepj6dWEclnbnMEOUZ2s690lWC4pgpRjKj6lhkw=="],
+
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
diff --git a/bench/xml/package.json b/bench/xml/package.json
index a7534edd3106..03f7ccf96217 100644
--- a/bench/xml/package.json
+++ b/bench/xml/package.json
@@ -2,7 +2,9 @@
"name": "xml-benchmark",
"version": "1.0.0",
"dependencies": {
+ "@xmldom/xmldom": "^0.9.10",
"fast-xml-parser": "^5.2.5",
+ "txml": "^6.0.0",
"xml2js": "^0.6.2"
}
}
diff --git a/bench/xml/xml.mjs b/bench/xml/xml.mjs
index ec08badd780b..5909a4b825eb 100644
--- a/bench/xml/xml.mjs
+++ b/bench/xml/xml.mjs
@@ -1,4 +1,8 @@
+import { DOMParser } from "@xmldom/xmldom";
import { XMLBuilder, XMLParser } from "fast-xml-parser";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import * as txml from "txml";
import xml2js from "xml2js";
import { bench, group, run } from "../runner.mjs";
@@ -69,18 +73,30 @@ const parseXml2js = doc => {
return result;
};
-for (const [label, doc] of [
+const docs = [
["small", small],
["S3 listing", large],
["Atom feed", mixed],
-]) {
+];
+// Real-world files: every *.xml / *.svg in $BUN_XML_BENCH_FIXTURES.
+if (process.env.BUN_XML_BENCH_FIXTURES) {
+ const dir = process.env.BUN_XML_BENCH_FIXTURES;
+ for (const f of readdirSync(dir).sort()) {
+ if (/\.(xml|svg)$/.test(f)) docs.push([f, readFileSync(join(dir, f), "utf8")]);
+ }
+}
+const xmldom = new DOMParser();
+
+for (const [label, doc] of docs) {
group(`parse ${label} (${sizeLabel(doc.length)})`, () => {
if (isBun) {
bench("Bun.XML.parse", () => Bun.XML.parse(doc));
bench("Bun.XML.parse { compact: false }", () => Bun.XML.parse(doc, { compact: false }));
}
+ bench("txml", () => txml.parse(doc));
bench("fast-xml-parser", () => fxp.parse(doc));
- bench("xml2js", () => parseXml2js(doc));
+ bench("@xmldom/xmldom DOMParser", () => xmldom.parseFromString(doc, "text/xml"));
+ if (doc.length < 512 * 1024) bench("xml2js", () => parseXml2js(doc));
});
}
diff --git a/docs/runtime/xml.mdx b/docs/runtime/xml.mdx
index 3f457582d920..263d342fa8b1 100644
--- a/docs/runtime/xml.mdx
+++ b/docs/runtime/xml.mdx
@@ -24,6 +24,34 @@ It is run against the [W3C XML Conformance Test Suite](https://www.w3.org/XML/Te
---
+## Performance
+
+The parser works in two stages, like Bun's JSON parser: a SIMD pass (runtime-dispatched AVX2/AVX-512/NEON/SVE kernels) records the position of every byte that can change the parse — `<`, `>`, `&`, line ends, quotes and `=` inside tags — and the parser then hops from one of those positions to the next, so character data, attribute values, comments and CDATA sections are never scanned a byte at a time. The result is written as flat rows and turned into JavaScript objects in a single pass that reuses JavaScriptCore's atom-string cache for element and attribute names, the same way `JSON.parse` does. A JS string is parsed in place in whatever representation the engine holds it in (Latin-1 or UTF-16) and the strings in the result share that representation, so nothing is transcoded on the way in or out; `Buffer` and `Blob` input is parsed as UTF-8.
+
+`bench/xml/xml.mjs` in the Bun repository compares `Bun.XML.parse` with popular npm parsers on the same documents (lower is better; Linux x64, one core):
+
+| Document | `Bun.XML.parse` | txml | fast-xml-parser | @xmldom/xmldom | xml2js |
+| ----------------------------------- | --------------: | -----: | --------------: | -------------: | -----: |
+| S3 `ListObjectsV2` response, 231 KB | **1.1 ms** | 4.0 ms | 23 ms | 31 ms | 19 ms |
+| Atom feed, 193 KB | **1.1 ms** | 3.7 ms | 19 ms | 23 ms | 16 ms |
+| libphonenumber metadata, 960 KB | **5.3 ms** | 9.6 ms | 56 ms | 53 ms | — |
+| Chromium `enums.xml`, 1.4 MB | **16 ms** | 41 ms | 150 ms | 103 ms | — |
+| freedesktop MIME database, 2.2 MB | **27 ms** | 56 ms | 299 ms | 280 ms | — |
+
+Roughly half of `Bun.XML.parse`'s time on these documents is creating the JavaScript objects rather than parsing. Measured at the native level (`scripts/bench-json-rust.sh --xml`, parse to the in-memory tree, MiB/s, higher is better), against widely used C, C++ and Rust parsers:
+
+| Document | Bun | pugixml | quick-xml | expat | roxmltree | libxml2 |
+| --------------------------------- | ----: | ------: | --------: | ----: | --------: | ------: |
+| SVG drawing (path data), 1.2 MB | 3,600 | 2,200 | 960 | 170 | 290 | 630 |
+| Vulkan `vk.xml`, 3.2 MB | 340 | 630 | 240 | 130 | 110 | 41 |
+| Chromium `enums.xml`, 1.4 MB | 320 | 700 | 275 | 114 | 106 | 35 |
+| libphonenumber metadata, 960 KB | 440 | 840 | 580 | 170 | 175 | 88 |
+| freedesktop MIME database, 2.4 MB | 210 | 585 | 240 | 120 | 91 | 30 |
+
+Unlike the fastest C++ parsers, Bun's parser checks everything XML requires of a well-formed document (valid UTF-8, legal characters, unique attributes, entity expansion limits) and expands entities declared in the DTD; attribute- and text-heavy documents are where the SIMD stage pays off most.
+
+---
+
## Runtime API
### `Bun.XML.parse()`
diff --git a/scripts/bench-json-rust.sh b/scripts/bench-json-rust.sh
index ce30188e8974..1d92041b54f1 100755
--- a/scripts/bench-json-rust.sh
+++ b/scripts/bench-json-rust.sh
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
-# Build + run the JSON parser criterion bench (src/parsers/benches/json_parse.rs): compiles the
-# native pieces the parser reaches into one archive and points RUSTFLAGS at it. Needs `bun bd` once.
+# Build + run the JSON parser criterion bench (src/parsers/benches/json_parse.rs), or with `--xml`
+# the XML one (benches/xml_parse.rs): compiles the native pieces the parsers reach into one archive
+# and points RUSTFLAGS at it. Needs `bun bd` once. `--test` runs the crate's unit tests instead.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -41,9 +42,30 @@ build "$SUP/simdutf_shim.o" $CXX -O3 -fPIC -std=c++20 -I"$SUP" -c src/parsers/be
for f in abort targets per_target print timer nanobenchmark aligned_allocator; do
build "$SUP/hwy_$f.o" $CXX -O3 -fPIC -std=c++17 -Ivendor/highway -c "vendor/highway/hwy/$f.cc"
done
-if [ -f src/jsc/bindings/highway_json.cpp ]; then
- $CXX -O3 -fPIC -std=c++17 -Ivendor/highway -Isrc/jsc/bindings -I"$BUN_CODEGEN_DIR" -c src/jsc/bindings/highway_json.cpp -o "$SUP/highway_json.o"
+for k in json xml; do
+ if [ -f src/jsc/bindings/highway_$k.cpp ]; then
+ $CXX -O3 -fPIC -std=c++17 -Ivendor/highway -Isrc/jsc/bindings -I"$BUN_CODEGEN_DIR" -c src/jsc/bindings/highway_$k.cpp -o "$SUP/highway_$k.o"
+ fi
+done
+# C/C++ XML parsers for benches/xml_parse.rs to compare against (optional).
+XML_C_DEFS=()
+XML_C_LIBS=()
+PUGI_VERSION=1.14
+if [ ! -f "$SUP/pugixml-$PUGI_VERSION/src/pugixml.cpp" ]; then
+ curl -fsSL "https://github.com/zeux/pugixml/releases/download/v$PUGI_VERSION/pugixml-$PUGI_VERSION.tar.gz" | tar -xz -C "$SUP" || true
+fi
+if [ -f "$SUP/pugixml-$PUGI_VERSION/src/pugixml.cpp" ]; then
+ build "$SUP/pugixml.o" $CXX -O3 -fPIC -std=c++17 -DNDEBUG -c "$SUP/pugixml-$PUGI_VERSION/src/pugixml.cpp"
+ XML_C_DEFS+=(-DHAVE_PUGIXML "-I$SUP/pugixml-$PUGI_VERSION/src")
fi
+if [ -f /usr/include/expat.h ]; then XML_C_DEFS+=(-DHAVE_EXPAT); XML_C_LIBS+=(-Clink-arg=-lexpat); fi
+if [ -d /usr/include/libxml2 ]; then XML_C_DEFS+=(-DHAVE_LIBXML2 -I/usr/include/libxml2); XML_C_LIBS+=(-Clink-arg=-lxml2); fi
+$CXX -O3 -fPIC -std=c++17 ${XML_C_DEFS[@]+"${XML_C_DEFS[@]}"} -c src/parsers/benches/support/xml_c_shim.cpp -o "$SUP/xml_c_shim.o"
+XML_CFG=()
+for d in ${XML_C_DEFS[@]+"${XML_C_DEFS[@]}"}; do
+ case "$d" in -DHAVE_*) XML_CFG+=("--cfg" "$(echo "${d#-DHAVE_}" | tr A-Z a-z)") ;; esac
+done
+
rm -f "$SUP/libbun_bench_cdeps.a"
ar rcs "$SUP/libbun_bench_cdeps.a" "$SUP"/*.o
ranlib "$SUP/libbun_bench_cdeps.a"
@@ -52,10 +74,16 @@ export MIMALLOC_PURGE_DELAY=${MIMALLOC_PURGE_DELAY:-2000}
export BUN_JSON_BENCH_FIXTURES=${BUN_JSON_BENCH_FIXTURES:-$PWD/bench/json-corpus}
CXXLIB=stdc++
[ "$(uname -s)" = Darwin ] && CXXLIB=c++
-export RUSTFLAGS="${RUSTFLAGS:-} -Clink-arg=$PWD/$SUP/libbun_bench_cdeps.a -Clink-arg=-l$CXXLIB -Clink-arg=-lm -Clink-arg=-ldl -Clink-arg=-lpthread -Clink-arg=-lc"
+export BUN_XML_BENCH_FIXTURES=${BUN_XML_BENCH_FIXTURES:-$PWD/bench/xml-corpus}
+export RUSTFLAGS="${RUSTFLAGS:-} ${XML_CFG[*]-} -Clink-arg=$PWD/$SUP/libbun_bench_cdeps.a ${XML_C_LIBS[*]-} -Clink-arg=-l$CXXLIB -Clink-arg=-lm -Clink-arg=-ldl -Clink-arg=-lpthread -Clink-arg=-lc"
if [ "${1:-}" = "--test" ]; then
shift
exec cargo test -p bun_parsers --lib --release "$@"
fi
-exec cargo bench -p bun_parsers --bench json_parse "$@"
+BENCH=json_parse
+if [ "${1:-}" = "--xml" ]; then
+ shift
+ BENCH=xml_parse
+fi
+exec cargo bench -p bun_parsers --bench "$BENCH" "$@"
diff --git a/scripts/build/CLAUDE.md b/scripts/build/CLAUDE.md
index aa9015b38a9e..3c44f5cbe3d9 100644
--- a/scripts/build/CLAUDE.md
+++ b/scripts/build/CLAUDE.md
@@ -197,6 +197,7 @@ Split CI modes: `rust-only` (lolhtml+codegen+cargo → libbun_rust.a), `cpp-only
| `depVersionsHeader.ts` | Generates `bun_dependency_versions.h` for `process.versions` |
| `buildOptionsRs.ts` | Generates `build_options.rs` (`bun_core::build_options`) from `Config` |
| `jsonByteClass.ts` | Generates `json_byte_class.{h,rs}` — the JSON byte classification shared by the SIMD kernel and the Rust scalar indexer |
+| `xmlByteClass.ts` | Generates `xml_byte_class.{h,rs}` — the XML byte classification shared by the SIMD kernels and the Rust scalar indexer |
| `stream.ts` | Subprocess output wrapper — FD-3 sideband, prefixed line streaming |
| `shell.ts` | `quote()`/`slash()` — shell escaping for ninja commands |
| `fs.ts` | `writeIfChanged()`, `mkdirAll()` |
diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts
index be8aa5a23074..340666caced4 100644
--- a/scripts/build/bun.ts
+++ b/scripts/build/bun.ts
@@ -312,6 +312,7 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput {
// file"). It only includes highway + libc headers anyway.
if (cfg.debug) {
noPchSources.add(resolve(cfg.cwd, "src/jsc/bindings/highway_json.cpp"));
+ noPchSources.add(resolve(cfg.cwd, "src/jsc/bindings/highway_xml.cpp"));
}
// Windows-only cpp sources (rescle — PE resource editor for --compile).
diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts
index 966b4c1098d7..e848841fc71c 100644
--- a/scripts/build/codegen.ts
+++ b/scripts/build/codegen.ts
@@ -44,6 +44,7 @@ import { writeIfChanged } from "./fs.ts";
import { generateJsonByteClass } from "./jsonByteClass.ts";
import type { Ninja } from "./ninja.ts";
import { quote, quoteArgs } from "./shell.ts";
+import { generateXmlByteClass } from "./xmlByteClass.ts";
// The individual emit functions take these four params. Bundled to keep
// signatures short.
@@ -290,6 +291,10 @@ export function emitCodegen(n: Ninja, cfg: Config, sources: Sources): CodegenOut
o.all.push(jsonByteClass.h, jsonByteClass.rs);
o.rustInputs.push(jsonByteClass.rs);
o.cppHeaders.push(jsonByteClass.h);
+ const xmlByteClass = generateXmlByteClass(cfg);
+ o.all.push(xmlByteClass.h, xmlByteClass.rs);
+ o.rustInputs.push(xmlByteClass.rs);
+ o.cppHeaders.push(xmlByteClass.h);
emitBunError(ctx);
emitStringMaps(ctx);
diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts
index f0ba7dfe1f7b..59e199b6e579 100644
--- a/scripts/build/flags.ts
+++ b/scripts/build/flags.ts
@@ -1581,6 +1581,12 @@ export const fileOverrides: FileOverride[] = [
when: c => c.windows,
desc: "Vendored electron/rcedit; VersionInfo ctor throws std::system_error caught in OnEnumResourceLanguage. Self-contained throw/catch — already excluded from PCH",
},
+ {
+ file: "src/jsc/bindings/highway_xml.cpp",
+ extraFlags: ["-O2"],
+ when: c => c.debug,
+ desc: "Same as highway_json.cpp below: the XML structural-index kernel must be optimized even in debug builds.",
+ },
{
file: "src/jsc/bindings/highway_json.cpp",
extraFlags: ["-O2"],
diff --git a/scripts/build/unified.ts b/scripts/build/unified.ts
index 38036a112387..6ea8a8e00ab7 100644
--- a/scripts/build/unified.ts
+++ b/scripts/build/unified.ts
@@ -140,6 +140,8 @@ const noUnify: readonly string[] = [
// Fifth highway TU (JSON structural indexer) — same foreach_target.h
// include-guard reason.
"src/jsc/bindings/highway_json.cpp",
+ // Sixth highway TU (XML structural indexer) — same reason.
+ "src/jsc/bindings/highway_xml.cpp",
// Declares its own minimal CGRect/kCFStringEncodingUTF8/kCFNumberDoubleType
// so it doesn't pull a CoreGraphics load command; bundled with files that
// include the real CF headers those names become ambiguous.
diff --git a/scripts/build/xmlByteClass.ts b/scripts/build/xmlByteClass.ts
new file mode 100644
index 000000000000..4dd3c7b7f168
--- /dev/null
+++ b/scripts/build/xmlByteClass.ts
@@ -0,0 +1,106 @@
+// Generates `xml_byte_class.h` (nibble LUTs, Highway SIMD kernel) and `xml_byte_class.rs` (the
+// derived 256-entry table, Rust scalar indexer): both come from this one table so they agree.
+
+import { mkdirSync } from "node:fs";
+import { resolve } from "node:path";
+import type { Config } from "./config.ts";
+import { writeIfChanged } from "./fs.ts";
+
+// Indexed everywhere: `&`, `\r`, and the control characters XML forbids.
+const ALWAYS = 0x07;
+// Indexed only between `<` and the next `>`: `\t`, `\n`, `"`, `'`, `=`.
+const TAG = 0x38;
+const LT = 0x40;
+const GT = 0x80;
+
+const LUT_LO = [0x03, 0x03, 0x13, 0x03, 0x03, 0x03, 0x07, 0x13, 0x03, 0x0a, 0x0a, 0x03, 0x43, 0x23, 0x83, 0x03];
+const LUT_HI = [0x09, 0x02, 0x14, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
+
+const classOf = (b: number): number => LUT_LO[b & 0xf] & LUT_HI[b >> 4];
+
+function check() {
+ const expectedClass = (b: number): number => {
+ const ch = String.fromCharCode(b);
+ if (ch === "<") return LT;
+ if (ch === ">") return GT;
+ if (ch === "&" || ch === "\r") return ALWAYS;
+ if (b < 0x20 && ch !== "\t" && ch !== "\n") return ALWAYS;
+ if ("\t\n\"'=".includes(ch)) return TAG;
+ return 0;
+ };
+ for (let b = 0; b < 0x100; b++) {
+ const got = classOf(b);
+ const expected = b < 0x80 ? expectedClass(b) : 0;
+ const ok = expected === 0 ? got === 0 : (got & expected) !== 0 && (got & ~expected) === 0;
+ if (!ok) {
+ throw new Error(
+ `xml_byte_class: byte 0x${b.toString(16)} classifies as 0x${got.toString(16)}, expected class 0x${expected.toString(16)}`,
+ );
+ }
+ }
+}
+
+export function generateXmlByteClass(cfg: Config): { h: string; rs: string } {
+ check();
+
+ const banner = (comment: string) => [
+ `${comment} Generated by scripts/build/xmlByteClass.ts at configure time. Do not`,
+ `${comment} edit. The same definition feeds the Highway XML kernel (these nibble`,
+ `${comment} LUTs) and the Rust scalar indexer (the derived 256-entry table).`,
+ `${comment}`,
+ `${comment} cls = LUT_LO[b & 0xF] & LUT_HI[b >> 4]`,
+ `${comment} 0x07 always & \\r control 0x38 tag \\t \\n " ' =`,
+ `${comment} 0x40 < 0x80 >`,
+ "",
+ ];
+ const hex = (v: number) => `0x${v.toString(16).padStart(2, "0")}`;
+
+ const h = [
+ ...banner("//"),
+ "#pragma once",
+ "#include ",
+ "",
+ `#define BUN_XML_CLASS_ALWAYS ${hex(ALWAYS)}`,
+ `#define BUN_XML_CLASS_TAG ${hex(TAG)}`,
+ `#define BUN_XML_CLASS_LT ${hex(LT)}`,
+ `#define BUN_XML_CLASS_GT ${hex(GT)}`,
+ "",
+ `alignas(16) static const uint8_t kBunXmlLutLo[16] = { ${LUT_LO.map(hex).join(", ")} };`,
+ `alignas(16) static const uint8_t kBunXmlLutHi[16] = { ${LUT_HI.map(hex).join(", ")} };`,
+ "",
+ ].join("\n");
+
+ const table: string[] = [];
+ for (let row = 0; row < 256; row += 16) {
+ const cells = [];
+ for (let b = row; b < row + 16; b++) cells.push(hex(classOf(b)));
+ table.push(` ${cells.join(", ")},`);
+ }
+ const allow = "#[allow(dead_code, unreachable_pub, unused)]";
+ const rs = [
+ ...banner("//"),
+ allow,
+ `pub const CLASS_ALWAYS: u8 = ${hex(ALWAYS)};`,
+ allow,
+ `pub const CLASS_TAG: u8 = ${hex(TAG)};`,
+ allow,
+ `pub const CLASS_LT: u8 = ${hex(LT)};`,
+ allow,
+ `pub const CLASS_GT: u8 = ${hex(GT)};`,
+ "",
+ "/// `LUT_LO[b & 0xF] & LUT_HI[b >> 4]` for every byte `b`.",
+ allow,
+ "#[rustfmt::skip]",
+ "pub const XML_BYTE_CLASS: [u8; 256] = [",
+ ...table,
+ "];",
+ "",
+ ].join("\n");
+
+ mkdirSync(cfg.codegenDir, { recursive: true });
+ const hPath = resolve(cfg.codegenDir, "xml_byte_class.h");
+ const rsPath = resolve(cfg.codegenDir, "xml_byte_class.rs");
+ writeIfChanged(hPath, h);
+ writeIfChanged(rsPath, rs);
+ return { h: hPath, rs: rsPath };
+}
diff --git a/scripts/verify-baseline-static/allowlist-aarch64.txt b/scripts/verify-baseline-static/allowlist-aarch64.txt
index 70a2d22281d5..64c495316915 100644
--- a/scripts/verify-baseline-static/allowlist-aarch64.txt
+++ b/scripts/verify-baseline-static/allowlist-aarch64.txt
@@ -12,6 +12,8 @@ _ZN3bun10N_SVE2_12811MemRMemImplEPKhmS2_m [S
_ZN3bun10N_SVE2_12812MemMem16ImplEPKtmS2_m [SVE]
_ZN3bun10N_SVE2_12813MemRMem16ImplEPKtmS2_m [SVE]
_ZN3bun10N_SVE2_12813JsonIndexImplEPKhmmPjPmS4_S3_ [SVE]
+_ZN3bun10N_SVE2_12812XmlIndexImplEPKhmmPjPm [SVE]
+_ZN3bun10N_SVE2_12814XmlIndex16ImplEPKtmmPjPm [SVE]
_ZN3bun10N_SVE2_12814DecodeHex8ImplEPKhPhm [SVE]
_ZN3bun10N_SVE2_12814LowerAsciiImplEPKhmPh [SVE]
_ZN3bun10N_SVE2_12815CopyU16ToU8ImplEPKtmPh [SVE]
@@ -235,6 +237,7 @@ __aarch64_ldset2_acq_rel [LSE]
__aarch64_ldset4_acq_rel [LSE]
__aarch64_ldset4_rel [LSE]
__aarch64_ldset4_relax [LSE]
+__aarch64_ldset8_rel [LSE]
__aarch64_ldset8_acq_rel [LSE]
__aarch64_ldset8_relax [LSE]
__aarch64_swp1_acq [LSE]
diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt
index 103da35b999d..854cd62c444e 100644
--- a/scripts/verify-baseline-static/allowlist-x64-windows.txt
+++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt
@@ -514,6 +514,8 @@ bun::N_AVX2::MemRMemImpl [AVX, AVX2]
bun::N_AVX2::MemMem16Impl [AVX, AVX2, BMI2]
bun::N_AVX2::MemRMem16Impl [AVX, AVX2, BMI2]
bun::N_AVX2::JsonIndexImpl [AVX, AVX2, BMI1, BMI2]
+bun::N_AVX2::XmlIndexImpl [AVX, AVX2, BMI1, BMI2]
+bun::N_AVX2::XmlIndex16Impl [AVX, AVX2, BMI1, BMI2]
bun::N_AVX2::VisibleLatin1WidthExcludeANSIImpl [AVX, AVX2, BMI1, BMI2]
bun::N_AVX2::VisibleLatin1WidthImpl [AVX, AVX2]
bun::N_AVX2::VisibleUTF16WidthImpl [AVX, AVX2, BMI1, BMI2]
@@ -556,6 +558,8 @@ bun::N_AVX3::MemMem16Impl [AVX, AVX51
bun::N_AVX3::MemRMem16Impl [AVX, AVX512F]
bun::N_AVX3::MemMemReverse [AVX, AVX512BW, AVX512F, BMI2]
bun::N_AVX3::JsonIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2]
+bun::N_AVX3::XmlIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2]
+bun::N_AVX3::XmlIndex16Impl [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2]
bun::N_AVX3::VisibleLatin1WidthExcludeANSIImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
bun::N_AVX3::VisibleLatin1WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL]
bun::N_AVX3::VisibleUTF16WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
@@ -597,6 +601,8 @@ bun::N_AVX3_DL::MemRMemImpl [AVX, AVX51
bun::N_AVX3_DL::MemMem16Impl [AVX, AVX512BW, AVX512F, BMI1]
bun::N_AVX3_DL::MemRMem16Impl [AVX, AVX512F]
bun::N_AVX3_DL::JsonIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2, GFNI]
+bun::N_AVX3_DL::XmlIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
+bun::N_AVX3_DL::XmlIndex16Impl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
bun::N_AVX3_DL::VisibleLatin1WidthExcludeANSIImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
bun::N_AVX3_DL::VisibleLatin1WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL]
bun::N_AVX3_DL::VisibleUTF16WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
@@ -638,6 +644,8 @@ bun::N_AVX3_SPR::MemRMemImpl [AVX, AVX51
bun::N_AVX3_SPR::MemMem16Impl [AVX, AVX512BW, AVX512F, BMI1]
bun::N_AVX3_SPR::MemRMem16Impl [AVX, AVX512F]
bun::N_AVX3_SPR::JsonIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2, GFNI]
+bun::N_AVX3_SPR::XmlIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
+bun::N_AVX3_SPR::XmlIndex16Impl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
bun::N_AVX3_SPR::VisibleLatin1WidthExcludeANSIImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
bun::N_AVX3_SPR::VisibleLatin1WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL]
bun::N_AVX3_SPR::VisibleUTF16WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
@@ -679,6 +687,8 @@ bun::N_AVX3_ZEN4::MemRMemImpl [AVX, AVX51
bun::N_AVX3_ZEN4::MemMem16Impl [AVX, AVX512BW, AVX512F, BMI1]
bun::N_AVX3_ZEN4::MemRMem16Impl [AVX, AVX512F]
bun::N_AVX3_ZEN4::JsonIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2, GFNI]
+bun::N_AVX3_ZEN4::XmlIndexImpl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
+bun::N_AVX3_ZEN4::XmlIndex16Impl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
bun::N_AVX3_ZEN4::VisibleLatin1WidthExcludeANSIImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
bun::N_AVX3_ZEN4::VisibleLatin1WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL]
bun::N_AVX3_ZEN4::VisibleUTF16WidthImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
diff --git a/scripts/verify-baseline-static/allowlist-x64.txt b/scripts/verify-baseline-static/allowlist-x64.txt
index 4606dd92bfdb..1c95b2ace975 100644
--- a/scripts/verify-baseline-static/allowlist-x64.txt
+++ b/scripts/verify-baseline-static/allowlist-x64.txt
@@ -466,6 +466,8 @@ _ZN3bun10N_AVX3_SPR11MemRMemImplEPKhmS2_m [
_ZN3bun10N_AVX3_SPR12MemMem16ImplEPKtmS2_m [AVX, AVX512BW, AVX512F, BMI1]
_ZN3bun10N_AVX3_SPR13MemRMem16ImplEPKtmS2_m [AVX, AVX512F]
_ZN3bun10N_AVX3_SPR13JsonIndexImplEPKhmmPjPmS4_S3_ [AVX, AVX512BW, AVX512F, BMI1, BMI2, GFNI]
+_ZN3bun10N_AVX3_SPR12XmlIndexImplEPKhmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
+_ZN3bun10N_AVX3_SPR14XmlIndex16ImplEPKtmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
_ZN3bun10N_AVX3_SPR14DecodeHex8ImplEPKhPhm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, GFNI]
_ZN3bun10N_AVX3_SPR14LowerAsciiImplEPKhmPh [AVX, AVX512BW, AVX512F, AVX512VL]
_ZN3bun10N_AVX3_SPR15CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI]
@@ -507,6 +509,8 @@ _ZN3bun11N_AVX3_ZEN411MemRMemImplEPKhmS2_m [
_ZN3bun11N_AVX3_ZEN412MemMem16ImplEPKtmS2_m [AVX, AVX512BW, AVX512F, BMI1]
_ZN3bun11N_AVX3_ZEN413MemRMem16ImplEPKtmS2_m [AVX, AVX512F]
_ZN3bun11N_AVX3_ZEN413JsonIndexImplEPKhmmPjPmS4_S3_ [AVX, AVX512BW, AVX512F, BMI1, BMI2, GFNI]
+_ZN3bun11N_AVX3_ZEN412XmlIndexImplEPKhmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
+_ZN3bun11N_AVX3_ZEN414XmlIndex16ImplEPKtmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
_ZN3bun11N_AVX3_ZEN414DecodeHex8ImplEPKhPhm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, GFNI]
_ZN3bun11N_AVX3_ZEN414LowerAsciiImplEPKhmPh [AVX, AVX512BW, AVX512F, AVX512VL]
_ZN3bun11N_AVX3_ZEN415CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI]
@@ -548,6 +552,8 @@ _ZN3bun6N_AVX211MemRMemImplEPKhmS2_m [
_ZN3bun6N_AVX212MemMem16ImplEPKtmS2_m [AVX, AVX2, BMI2]
_ZN3bun6N_AVX213MemRMem16ImplEPKtmS2_m [AVX, AVX2, BMI2]
_ZN3bun6N_AVX213JsonIndexImplEPKhmmPjPmS4_S3_ [AVX, AVX2, BMI1, BMI2]
+_ZN3bun6N_AVX212XmlIndexImplEPKhmmPjPm [AVX, AVX2, BMI1, BMI2]
+_ZN3bun6N_AVX214XmlIndex16ImplEPKtmmPjPm [AVX, AVX2, BMI1, BMI2]
_ZN3bun6N_AVX214DecodeHex8ImplEPKhPhm [AVX, AVX2]
_ZN3bun6N_AVX214LowerAsciiImplEPKhmPh [AVX, AVX2]
_ZN3bun6N_AVX215CopyU16ToU8ImplEPKtmPh [AVX, AVX2]
@@ -590,6 +596,8 @@ _ZN3bun6N_AVX312MemMem16ImplEPKtmS2_m [
_ZN3bun6N_AVX313MemMemReverseItEEmPKT_mS4_mmmPm [AVX, AVX512BW, AVX512F, BMI2]
_ZN3bun6N_AVX313MemRMem16ImplEPKtmS2_m [AVX, AVX512F]
_ZN3bun6N_AVX313JsonIndexImplEPKhmmPjPmS4_S3_ [AVX, AVX512BW, AVX512F, BMI1, BMI2]
+_ZN3bun6N_AVX312XmlIndexImplEPKhmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2]
+_ZN3bun6N_AVX314XmlIndex16ImplEPKtmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, BMI1, BMI2]
_ZN3bun6N_AVX314DecodeHex8ImplEPKhPhm [AVX, AVX2, AVX512BW, AVX512F, AVX512VL]
_ZN3bun6N_AVX314LowerAsciiImplEPKhmPh [AVX, AVX512BW, AVX512F, AVX512VL]
_ZN3bun6N_AVX315CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL]
@@ -668,6 +676,8 @@ _ZN3bun9N_AVX3_DL11MemRMemImplEPKhmS2_m [
_ZN3bun9N_AVX3_DL12MemMem16ImplEPKtmS2_m [AVX, AVX512BW, AVX512F, BMI1]
_ZN3bun9N_AVX3_DL13MemRMem16ImplEPKtmS2_m [AVX, AVX512F]
_ZN3bun9N_AVX3_DL13JsonIndexImplEPKhmmPjPmS4_S3_ [AVX, AVX512BW, AVX512F, BMI1, BMI2, GFNI]
+_ZN3bun9N_AVX3_DL12XmlIndexImplEPKhmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
+_ZN3bun9N_AVX3_DL14XmlIndex16ImplEPKtmmPjPm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, GFNI]
_ZN3bun9N_AVX3_DL14DecodeHex8ImplEPKhPhm [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, GFNI]
_ZN3bun9N_AVX3_DL14LowerAsciiImplEPKhmPh [AVX, AVX512BW, AVX512F, AVX512VL]
_ZN3bun9N_AVX3_DL15CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI]
diff --git a/src/ast/e.rs b/src/ast/e.rs
index 4067d111f55e..d5b977566cd8 100644
--- a/src/ast/e.rs
+++ b/src/ast/e.rs
@@ -840,15 +840,17 @@ impl BigInt {
// Compact, read-only object/array nodes: the JSON parser's native output.
// Children are `PropertyJSON` rows / inline `JsonValue`s in the document's `JsonTape`.
-/// A JSON value inside an `ObjectJSON` / `ArrayJSON`.
+/// A JSON value inside an `ObjectJSON` / `ArrayJSON`. The layout is read
+/// from C++ (`JSONRowsToJS.cpp`).
#[derive(Clone, Copy)]
+#[repr(C, u32)]
pub enum JsonValue {
- Null,
- Boolean(bool),
- Number(Number),
- String(Str),
- Object(StoreRef),
- Array(StoreRef),
+ Null = 0,
+ Boolean(bool) = 1,
+ Number(Number) = 2,
+ String(Str) = 3,
+ Object(StoreRef) = 4,
+ Array(StoreRef) = 5,
}
const _: () = assert!(core::mem::size_of::() == 16);
@@ -912,8 +914,9 @@ impl JsonValue {
}
}
-/// One `"key": value` row of an [`ObjectJSON`].
+/// One `"key": value` row of an [`ObjectJSON`]. Layout read from C++.
#[derive(Clone, Copy)]
+#[repr(C)]
pub struct PropertyJSON {
pub key: Str,
pub key_loc: crate::Loc,
@@ -921,6 +924,10 @@ pub struct PropertyJSON {
}
const _: () = assert!(core::mem::size_of::() == 32);
+// Read from C++ (JSONRowsToJS.cpp), which asserts the same offsets.
+const _: () = assert!(core::mem::offset_of!(PropertyJSON, key) == 0);
+const _: () = assert!(core::mem::offset_of!(PropertyJSON, key_loc) == 12);
+const _: () = assert!(core::mem::offset_of!(PropertyJSON, value) == 16);
/// Where a [`JsonTape`]'s buffers (and, in arena mode, the tape itself) live.
#[derive(Clone, Copy)]
@@ -966,6 +973,17 @@ unsafe impl core::alloc::Allocator for TapeAlloc {
}
}
+/// How the bytes behind every [`Str`] on one tape are encoded. JSON tapes are
+/// UTF-8 (WTF-8 where an escape named a lone surrogate); the XML parser also
+/// produces Latin-1 tapes when its input was a Latin-1 string.
+#[derive(Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum StrEncoding {
+ Utf8 = 0,
+ Latin1 = 1,
+ Utf16 = 2,
+}
+
/// Everything one parsed JSON document allocates that does not borrow the source.
pub struct JsonTape {
props: Vec,
@@ -974,6 +992,7 @@ pub struct JsonTape {
item_locs: Vec,
str_chunks: Vec, TapeAlloc>,
str_used: usize,
+ pub encoding: StrEncoding,
}
// SAFETY: only the parsing thread writes it; shared use afterwards is read-only.
@@ -996,9 +1015,18 @@ impl JsonTape {
item_locs: Vec::new_in(alloc),
str_chunks: Vec::new_in(alloc),
str_used: 0,
+ encoding: StrEncoding::Utf8,
}
}
+ /// Pre-size the row buffers (a growth step copies the whole tape).
+ pub fn reserve(&mut self, props: usize, items: usize) {
+ self.props.reserve(props);
+ self.prop_value_locs.reserve(props);
+ self.items.reserve(items);
+ self.item_locs.reserve(items);
+ }
+
/// The tape allocation's own pointer, for [`ObjectJSON::new`] /
/// [`ArrayJSON::new`].
///
@@ -1035,24 +1063,43 @@ impl JsonTape {
/// Copy decoded string bytes into the tape; chunks never move once handed out.
pub fn alloc_str(&mut self, bytes: &[u8]) -> Str {
+ self.alloc_str_join(bytes, b"")
+ }
+
+ /// [`alloc_str`](Self::alloc_str) of the concatenation `a ++ b`.
+ pub fn alloc_str_join(&mut self, a: &[u8], b: &[u8]) -> Str {
+ let len = a.len() + b.len();
let fits = self
.str_chunks
.last()
- .is_some_and(|c| c.len() - self.str_used >= bytes.len());
+ .is_some_and(|c| c.len() - self.str_used >= len);
if !fits {
- let cap = bytes.len().max(Self::STR_CHUNK);
+ let cap = len.max(Self::STR_CHUNK);
let mut chunk: Vec = Vec::with_capacity_in(cap, self.alloc());
chunk.resize(cap, 0);
self.str_chunks.push(chunk);
self.str_used = 0;
}
let chunk = self.str_chunks.last_mut().expect("chunk pushed above");
- let out = &mut chunk[self.str_used..self.str_used + bytes.len()];
- out.copy_from_slice(bytes);
- self.str_used += bytes.len();
+ let out = &mut chunk[self.str_used..self.str_used + len];
+ if len <= 32 {
+ for (o, &c) in out.iter_mut().zip(a.iter().chain(b)) {
+ *o = c;
+ }
+ } else {
+ out[..a.len()].copy_from_slice(a);
+ out[a.len()..].copy_from_slice(b);
+ }
+ self.str_used += len;
Str::new(out)
}
+ /// The row buffers, for a reader that resolves spans itself.
+ #[inline]
+ pub fn raw_rows(&self) -> (*const PropertyJSON, *const JsonValue) {
+ (self.props.as_ptr(), self.items.as_ptr())
+ }
+
#[inline]
fn prop_value_locs_span(&self, first: u32, count: u32) -> Option<&[crate::Loc]> {
self.prop_value_locs
@@ -1077,6 +1124,7 @@ impl JsonTape {
}
/// `Data::EObjectJSON`: a `(first, count)` span of the document's property-row tape.
+#[repr(C)]
pub struct ObjectJSON {
tape: core::ptr::NonNull,
first: u32,
@@ -1085,6 +1133,11 @@ pub struct ObjectJSON {
pub is_single_line: bool,
}
+const _: () = assert!(core::mem::offset_of!(ObjectJSON, first) == 8);
+const _: () = assert!(core::mem::offset_of!(ObjectJSON, count) == 12);
+const _: () = assert!(core::mem::offset_of!(ArrayJSON, first) == 8);
+const _: () = assert!(core::mem::offset_of!(ArrayJSON, count) == 12);
+
// SAFETY: the tape outlives the AST (`StoreRef`'s contract) and is read-only once parsing returns.
unsafe impl Send for ObjectJSON {}
// SAFETY: see the `Send` impl.
@@ -1117,6 +1170,13 @@ impl ObjectJSON {
}
}
+ /// The tape this node's rows live on.
+ #[inline]
+ pub fn tape(&self) -> &JsonTape {
+ // SAFETY: per the constructor's contract the tape outlives this node.
+ unsafe { self.tape.as_ref() }
+ }
+
#[inline]
pub fn properties(&self) -> &[PropertyJSON] {
if self.count == 0 {
@@ -1146,6 +1206,7 @@ impl ObjectJSON {
}
/// `Data::EArrayJSON`: a `(first, count)` span of the document's item tape.
+#[repr(C)]
pub struct ArrayJSON {
tape: core::ptr::NonNull,
first: u32,
@@ -1181,6 +1242,13 @@ impl ArrayJSON {
}
}
+ /// The tape this node's rows live on.
+ #[inline]
+ pub fn tape(&self) -> &JsonTape {
+ // SAFETY: see `ObjectJSON::properties`.
+ unsafe { self.tape.as_ref() }
+ }
+
#[inline]
pub fn items(&self) -> &[JsonValue] {
if self.count == 0 {
diff --git a/src/ast/lib.rs b/src/ast/lib.rs
index 9cdb38c89acd..f0c8bd8ef1ab 100644
--- a/src/ast/lib.rs
+++ b/src/ast/lib.rs
@@ -566,6 +566,7 @@ impl Kind {
// ───────────────────────────────────────────────────────────────────────────
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
+#[repr(transparent)]
pub struct Loc {
pub start: i32,
}
diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs
index 2910838fa900..cea5989fb7fb 100644
--- a/src/bundler/ParseTask.rs
+++ b/src/bundler/ParseTask.rs
@@ -847,7 +847,7 @@ pub mod parse_worker {
let _trace = perf::trace("Bundler.ParseXML");
let mut temp_log = Log::init();
let result = (|| -> core::result::Result, AnyError> {
- let root: Expr = bun_parsers::xml::XML::parse(
+ let rows: Expr = bun_parsers::xml::XML::parse(
source,
&mut temp_log,
bump,
@@ -856,6 +856,7 @@ pub mod parse_worker {
encoding: bun_parsers::xml::InputEncoding::File,
},
)?;
+ let root = bun_parsers::json::materialize(&rows, source, &mut temp_log, bump)?;
Ok(JSAst::init(
js_parser::new_lazy_export_ast(
bump,
diff --git a/src/highway/lib.rs b/src/highway/lib.rs
index fb2ca825210c..95eb419fd7f6 100644
--- a/src/highway/lib.rs
+++ b/src/highway/lib.rs
@@ -142,6 +142,22 @@ unsafe extern "C" {
inout_state: *mut u64,
out_flags: *mut u32,
) -> usize;
+
+ fn highway_xml_index_chunk(
+ input: *const u8,
+ len: usize,
+ base_offset: usize,
+ out_indices: *mut u32,
+ inout_state: *mut u64,
+ ) -> usize;
+
+ fn highway_xml_index16_chunk(
+ input: *const u16,
+ len: usize,
+ base_offset: usize,
+ out_indices: *mut u32,
+ inout_state: *mut u64,
+ ) -> usize;
}
// NOTE: every public wrapper below is `#[inline(always)]`. They are thin
@@ -894,6 +910,48 @@ pub fn json_structural_index_chunk(
(n, flags)
}
+/// XML structural index (stage 1) for one chunk of a document.
+#[inline(always)]
+pub fn xml_structural_index_chunk(
+ chunk: &[u8],
+ base_offset: usize,
+ out: &mut [core::mem::MaybeUninit],
+ state: &mut [u64; 3],
+) -> usize {
+ assert!(out.len() >= chunk.len() + 64);
+ // SAFETY: `out` has room for one index per byte plus a full trailing block (asserted above).
+ unsafe {
+ highway_xml_index_chunk(
+ chunk.as_ptr(),
+ chunk.len(),
+ base_offset,
+ out.as_mut_ptr().cast::(),
+ state.as_mut_ptr(),
+ )
+ }
+}
+
+/// [`xml_structural_index_chunk`] over UTF-16 code units (positions in units).
+#[inline(always)]
+pub fn xml_structural_index16_chunk(
+ chunk: &[u16],
+ base_offset: usize,
+ out: &mut [core::mem::MaybeUninit],
+ state: &mut [u64; 3],
+) -> usize {
+ assert!(out.len() >= chunk.len() + 64);
+ // SAFETY: `out` has room for one index per unit plus a full trailing block (asserted above).
+ unsafe {
+ highway_xml_index16_chunk(
+ chunk.as_ptr(),
+ chunk.len(),
+ base_offset,
+ out.as_mut_ptr().cast::(),
+ state.as_mut_ptr(),
+ )
+ }
+}
+
/// Raw output column pointers for [`parse_mappings`]. Each points to `cap`
/// writable rows: `generated`/`original` as `[line, column]` i32 pairs
/// (byte-compatible with `bun_sourcemap::LineColumnOffset`, which is
diff --git a/src/js_parser_jsc/expr_jsc.rs b/src/js_parser_jsc/expr_jsc.rs
index 745c9da0b586..75673dd08cd3 100644
--- a/src/js_parser_jsc/expr_jsc.rs
+++ b/src/js_parser_jsc/expr_jsc.rs
@@ -23,6 +23,17 @@ pub fn expr_to_js(this: &Expr, global: &JSGlobalObject) -> Result JsError {
+ match e {
+ ToJSError::OutOfMemory => JsError::OutOfMemory,
+ ToJSError::JSError => JsError::Thrown,
+ ToJSError::JSTerminated => JsError::Terminated,
+ _ => global.throw(format_args!("Cannot convert value to JS")),
+ }
+}
+
/// Extension trait providing `Expr.toJS` / `Expr::Data.toJS` as method syntax.
/// `Expr` lives in `bun_js_parser` (lower tier, no JSC dep), so an inherent
/// `impl Expr { fn to_js }` is forbidden by orphan rules. Mirrors the
@@ -59,8 +70,8 @@ fn data_to_js_with_check(
match this {
ExprData::EArray(e) => array_to_js(e, global, stack_check),
ExprData::EObject(e) => object_to_js(e, global, stack_check),
- ExprData::EObjectJSON(e) => object_json_to_js(e, global, stack_check),
- ExprData::EArrayJSON(e) => array_json_to_js(e, global, stack_check),
+ ExprData::EObjectJSON(e) => object_json_to_js(e, global),
+ ExprData::EArrayJSON(e) => array_json_to_js(e, global),
ExprData::EString(e) => string_to_js(e, global),
ExprData::ENull(_) => Ok(JSValue::NULL),
ExprData::EUndefined(_) => Ok(JSValue::UNDEFINED),
@@ -145,55 +156,60 @@ fn object_to_js(
Ok(obj)
}
-fn object_json_to_js(
- this: &E::ObjectJSON,
- global: &JSGlobalObject,
- stack_check: StackCheck,
-) -> Result {
- if !stack_check.is_safe_to_recurse() {
- return Err(js_err(global.throw_stack_overflow()));
- }
- let obj = JSValue::create_empty_object(global, this.properties().len());
- let _guard = obj.protected();
- for prop in this.properties().iter() {
- let key = utf8_bytes_to_js(prop.key.slice(), global)?;
- let value = json_value_to_js(&prop.value, global, stack_check)?;
- JSValue::put_to_property_key(obj, global, key, value).map_err(js_err)?;
- }
- Ok(obj)
+#[allow(improper_ctypes)] // reached through JsonValue → ObjectJSON.tape; C++ never touches it
+unsafe extern "C" {
+ fn Bun__JSONRows__toJS(
+ global: *const JSGlobalObject,
+ root: *const E::JsonValue,
+ props: *const E::PropertyJSON,
+ items: *const E::JsonValue,
+ encoding: u8,
+ ) -> JSValue;
}
-fn array_json_to_js(
- this: &E::ArrayJSON,
+/// For `JSONRowsToJS.cpp`: a UTF-8 tape string that strict UTF-8 decoding
+/// rejected, i.e. WTF-8 carrying a lone surrogate from a JSON `\uD800`-style
+/// escape. Decoded the way every other WTF-8 string in the runtime is.
+#[unsafe(no_mangle)]
+extern "C" fn Bun__JSONRows__wtf8ToJS(
global: &JSGlobalObject,
- stack_check: StackCheck,
-) -> Result {
- if !stack_check.is_safe_to_recurse() {
- return Err(js_err(global.throw_stack_overflow()));
- }
- let array = JSValue::create_empty_array(global, this.items().len()).map_err(js_err)?;
- let _guard = array.protected();
- for (j, item) in this.items().iter().enumerate() {
- let value = json_value_to_js(item, global, stack_check)?;
- array.put_index(global, j as u32, value).map_err(js_err)?;
- }
- Ok(array)
+ ptr: *const u8,
+ len: usize,
+) -> JSValue {
+ // SAFETY: the C++ caller passes a live tape string.
+ let bytes = unsafe { core::slice::from_raw_parts(ptr, len) };
+ utf8_bytes_to_js(bytes, global).unwrap_or(JSValue::ZERO)
}
-fn json_value_to_js(
- value: &E::JsonValue,
+/// The whole document under `root` in one call into C++ (keys and short
+/// values go through the VM's JSON atom-string cache, as for `JSON.parse`).
+fn json_rows_to_js(
+ root: E::JsonValue,
+ tape: &E::JsonTape,
global: &JSGlobalObject,
- stack_check: StackCheck,
) -> Result {
- Ok(match value {
- E::JsonValue::Null => JSValue::NULL,
- E::JsonValue::Boolean(true) => JSValue::TRUE,
- E::JsonValue::Boolean(false) => JSValue::FALSE,
- E::JsonValue::Number(n) => number_to_js(*n),
- E::JsonValue::String(s) => utf8_bytes_to_js(s.slice(), global)?,
- E::JsonValue::Object(o) => object_json_to_js(o.get(), global, stack_check)?,
- E::JsonValue::Array(a) => array_json_to_js(a.get(), global, stack_check)?,
+ let (props, items) = tape.raw_rows();
+ let encoding = tape.encoding as u8;
+ // SAFETY: `root`, `props` and `items` all belong to `tape`, which is complete
+ // and outlives the call; the C++ side only reads them.
+ bun_jsc::from_js_host_call(global, || unsafe {
+ Bun__JSONRows__toJS(global, &raw const root, props, items, encoding)
})
+ .map_err(js_err)
+}
+
+fn object_json_to_js(this: &E::ObjectJSON, global: &JSGlobalObject) -> Result {
+ let root = E::JsonValue::Object(bun_ast::StoreRef::from_raw(
+ core::ptr::from_ref(this).cast_mut(),
+ ));
+ json_rows_to_js(root, this.tape(), global)
+}
+
+fn array_json_to_js(this: &E::ArrayJSON, global: &JSGlobalObject) -> Result {
+ let root = E::JsonValue::Array(bun_ast::StoreRef::from_raw(
+ core::ptr::from_ref(this).cast_mut(),
+ ));
+ json_rows_to_js(root, this.tape(), global)
}
fn utf8_bytes_to_js(bytes: &[u8], global: &JSGlobalObject) -> Result {
diff --git a/src/js_parser_jsc/lib.rs b/src/js_parser_jsc/lib.rs
index ba924b24c9c9..74a4a72b11a1 100644
--- a/src/js_parser_jsc/lib.rs
+++ b/src/js_parser_jsc/lib.rs
@@ -11,4 +11,6 @@ pub mod expr_jsc;
// Re-export the foreign `Expr` alongside its JSC extension trait so downstream
// callers can write `bun_js_parser_jsc::Expr` / `expr.to_js(global)` without
// also depending on `bun_js_parser` directly.
-pub use expr_jsc::{ExprJsc, data_to_js, expr_to_js, string_to_js, value_string_to_js};
+pub use expr_jsc::{
+ ExprJsc, data_to_js, expr_to_js, string_to_js, to_js_error, value_string_to_js,
+};
diff --git a/src/jsc/bindings/JSONRowsToJS.cpp b/src/jsc/bindings/JSONRowsToJS.cpp
new file mode 100644
index 000000000000..cf0c96f3dcd8
--- /dev/null
+++ b/src/jsc/bindings/JSONRowsToJS.cpp
@@ -0,0 +1,229 @@
+// Builds JS values from the immutable JSON AST rows (`E::JsonTape`, see src/ast/e.rs) that the
+// JSON and XML parsers produce: one call for the whole document, keys and short values through
+// the VM's JSONAtomStringCache the way JSON.parse does it.
+
+#include "root.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Bun {
+using namespace JSC;
+
+// Mirrors of the `#[repr(C)]` Rust types.
+struct __attribute__((packed, aligned(4))) RowStr {
+ const Latin1Character* ptr;
+ uint32_t len;
+ std::span span() const { return { ptr, len }; }
+};
+static_assert(sizeof(RowStr) == 12);
+
+struct __attribute__((packed, aligned(4))) RowRef {
+ const void* ptr;
+};
+
+struct __attribute__((packed, aligned(4))) RowNumber {
+ double value;
+};
+
+struct RowValue {
+ enum Tag : uint32_t {
+ Null = 0,
+ Boolean = 1,
+ Number = 2,
+ String = 3,
+ Object = 4,
+ Array = 5,
+ };
+ Tag tag;
+ union {
+ bool boolean;
+ RowNumber number;
+ RowStr string;
+ RowRef object;
+ RowRef array;
+ };
+};
+static_assert(sizeof(RowValue) == 16);
+
+struct RowProperty {
+ RowStr key;
+ int32_t keyLoc;
+ RowValue value;
+};
+static_assert(sizeof(RowProperty) == 32);
+
+// `ObjectJSON` / `ArrayJSON`: a span of the tape (only the leading fields are read).
+struct RowSpan {
+ const void* tape;
+ uint32_t first;
+ uint32_t count;
+};
+
+// Kept in step with the `offset_of!` assertions next to the Rust definitions.
+static_assert(offsetof(RowValue, tag) == 0 && offsetof(RowValue, string) == 4);
+static_assert(offsetof(RowProperty, key) == 0 && offsetof(RowProperty, keyLoc) == 12 && offsetof(RowProperty, value) == 16);
+static_assert(offsetof(RowSpan, tape) == 0 && offsetof(RowSpan, first) == 8 && offsetof(RowSpan, count) == 12);
+
+// `E::StrEncoding`: how the bytes behind every string on one tape are encoded.
+enum class RowEncoding : uint8_t {
+ Utf8 = 0,
+ Latin1 = 1,
+ Utf16 = 2,
+};
+
+extern "C" EncodedJSValue Bun__JSONRows__wtf8ToJS(JSGlobalObject*, const Latin1Character*, size_t);
+
+template
+class RowsToJS {
+public:
+ RowsToJS(JSGlobalObject* globalObject, const RowProperty* props, const RowValue* items)
+ : m_globalObject(globalObject)
+ , m_vm(globalObject->vm())
+ , m_props(props)
+ , m_items(items)
+ {
+ }
+
+ JSValue value(const RowValue& v)
+ {
+ switch (v.tag) {
+ case RowValue::Null:
+ return jsNull();
+ case RowValue::Boolean:
+ return jsBoolean(v.boolean);
+ case RowValue::Number:
+ return jsNumber(v.number.value);
+ case RowValue::String:
+ return string(v.string);
+ case RowValue::Object:
+ return object(*static_cast(v.object.ptr));
+ case RowValue::Array:
+ return array(*static_cast(v.array.ptr));
+ }
+ RELEASE_ASSERT_NOT_REACHED();
+ }
+
+ JSValue object(const RowSpan& o)
+ {
+ auto scope = DECLARE_THROW_SCOPE(m_vm);
+ if (!m_vm.isSafeToRecurse()) [[unlikely]] {
+ throwStackOverflowError(m_globalObject, scope);
+ return {};
+ }
+ const RowProperty* rows = m_props + o.first;
+ JSObject* object = constructEmptyObject(m_globalObject, m_globalObject->objectPrototype(),
+ std::min(o.count, JSFinalObject::maxInlineCapacity));
+ RETURN_IF_EXCEPTION(scope, {});
+ for (uint32_t i = 0; i < o.count; ++i) {
+ Identifier ident = identifier(rows[i].key);
+ RETURN_IF_EXCEPTION(scope, {});
+ JSValue v = value(rows[i].value);
+ RETURN_IF_EXCEPTION(scope, {});
+ if (std::optional index = parseIndex(ident)) [[unlikely]] {
+ object->putDirectIndex(m_globalObject, index.value(), v);
+ RETURN_IF_EXCEPTION(scope, {});
+ } else
+ object->putDirect(m_vm, ident, v);
+ }
+ return object;
+ }
+
+ JSValue array(const RowSpan& a)
+ {
+ auto scope = DECLARE_THROW_SCOPE(m_vm);
+ if (!m_vm.isSafeToRecurse()) [[unlikely]] {
+ throwStackOverflowError(m_globalObject, scope);
+ return {};
+ }
+ const RowValue* rows = m_items + a.first;
+ MarkedArgumentBuffer elements;
+ elements.ensureCapacity(a.count);
+ for (uint32_t i = 0; i < a.count; ++i) {
+ JSValue v = value(rows[i]);
+ RETURN_IF_EXCEPTION(scope, {});
+ elements.append(v);
+ }
+ if (elements.hasOverflowed()) [[unlikely]] {
+ throwOutOfMemoryError(m_globalObject, scope);
+ return {};
+ }
+ RELEASE_AND_RETURN(scope, constructArray(m_globalObject, static_cast(nullptr), elements));
+ }
+
+private:
+ // The three encodings: Latin-1 and UTF-16 strings are the characters as they stand; UTF-8 is
+ // Latin-1 when it is ASCII (nearly always, for keys) and decoded otherwise.
+
+ ALWAYS_INLINE Identifier identifier(const RowStr& key)
+ {
+ if constexpr (encoding == RowEncoding::Utf16)
+ return Identifier::fromString(m_vm, m_vm.jsonAtomStringCache.makeIdentifier(utf16(key)));
+ else {
+ if (encoding == RowEncoding::Latin1 || charactersAreAllASCII(key.span())) [[likely]]
+ return Identifier::fromString(m_vm, m_vm.jsonAtomStringCache.makeIdentifier(key.span()));
+ JSValue decoded = utf8(key);
+ if (!decoded) [[unlikely]]
+ return {};
+ return asString(decoded)->toIdentifier(m_globalObject);
+ }
+ }
+
+ ALWAYS_INLINE JSValue string(const RowStr& s)
+ {
+ JSString* result;
+ if constexpr (encoding == RowEncoding::Utf16)
+ result = m_vm.jsonAtomStringCache.tryMakeJSString(utf16(s));
+ else {
+ if (encoding == RowEncoding::Utf8 && !charactersAreAllASCII(s.span())) [[unlikely]]
+ return utf8(s);
+ result = m_vm.jsonAtomStringCache.tryMakeJSString(s.span());
+ }
+ if (!result) [[unlikely]] {
+ auto scope = DECLARE_THROW_SCOPE(m_vm);
+ throwOutOfMemoryError(m_globalObject, scope);
+ return {};
+ }
+ return result;
+ }
+
+ static std::span utf16(const RowStr& s)
+ {
+ return { reinterpret_cast(s.ptr), s.len / 2 };
+ }
+
+ // Non-ASCII UTF-8. Strict first (simdutf); what that rejects is WTF-8 from a JSON escape
+ // naming a lone surrogate, which the runtime's WTF-8 path decodes.
+ JSValue utf8(const RowStr& s)
+ {
+ String strict = String::fromUTF8(s.span());
+ if (!strict.isNull()) [[likely]]
+ return jsString(m_vm, WTF::move(strict));
+ return JSValue::decode(Bun__JSONRows__wtf8ToJS(m_globalObject, s.ptr, s.len));
+ }
+
+ JSGlobalObject* m_globalObject;
+ VM& m_vm;
+ const RowProperty* m_props;
+ const RowValue* m_items;
+};
+
+extern "C" EncodedJSValue Bun__JSONRows__toJS(JSGlobalObject* globalObject, const RowValue* root, const RowProperty* props, const RowValue* items, uint8_t encoding)
+{
+ switch (static_cast(encoding)) {
+ case RowEncoding::Latin1:
+ return JSValue::encode(RowsToJS(globalObject, props, items).value(*root));
+ case RowEncoding::Utf16:
+ return JSValue::encode(RowsToJS(globalObject, props, items).value(*root));
+ case RowEncoding::Utf8:
+ break;
+ }
+ return JSValue::encode(RowsToJS(globalObject, props, items).value(*root));
+}
+
+} // namespace Bun
diff --git a/src/jsc/bindings/highway_xml.cpp b/src/jsc/bindings/highway_xml.cpp
new file mode 100644
index 000000000000..ede855a9b004
--- /dev/null
+++ b/src/jsc/bindings/highway_xml.cpp
@@ -0,0 +1,222 @@
+// SIMD structural indexer for XML ("stage 1"), runtime-dispatched via Google Highway, over
+// bytes (UTF-8 / Latin-1) or over UTF-16 code units. Emits the position of every `<`, `>`, `&`,
+// `\r` and forbidden control character, of a non-character (bytes: the last byte of an encoded
+// U+FFFE / U+FFFF, EF BF BE|BF; units: 0xFFFE / 0xFFFF), and, between a `<` and the next `>`, of
+// every `\t`, `\n`, `"`, `'` and `=` as well.
+
+#undef HWY_TARGET_INCLUDE
+#define HWY_TARGET_INCLUDE "highway_xml.cpp"
+#include
+#include
+
+#include
+
+#include "xml_byte_class.h"
+
+HWY_BEFORE_NAMESPACE();
+namespace bun {
+namespace HWY_NAMESPACE {
+
+namespace hn = hwy::HWY_NAMESPACE;
+
+using D8 = hn::CappedTag;
+
+// The classes of one 64-position block, as bit masks.
+struct BlockMasks {
+ uint64_t lt = 0, gt = 0, always = 0, tag = 0, nonchar = 0;
+};
+
+// Classifies one vector of (low) bytes and ORs its masks in at `sh`.
+template
+static HWY_INLINE void Classify(D8 d, V lo, unsigned sh, BlockMasks& m)
+{
+ const auto v_0f = hn::Set(d, (uint8_t)0x0f);
+ const auto lut_lo = hn::LoadDup128(d, kBunXmlLutLo);
+ const auto lut_hi = hn::LoadDup128(d, kBunXmlLutHi);
+ const auto v_zero = hn::Zero(d);
+ const auto cls = hn::And(hn::TableLookupBytes(lut_lo, hn::And(lo, v_0f)),
+ hn::TableLookupBytes(lut_hi, hn::ShiftRight<4>(lo)));
+ m.lt |= hn::BitsFromMask(d, hn::Ne(hn::And(cls, hn::Set(d, (uint8_t)BUN_XML_CLASS_LT)), v_zero)) << sh;
+ m.gt |= hn::BitsFromMask(d, hn::Ne(hn::And(cls, hn::Set(d, (uint8_t)BUN_XML_CLASS_GT)), v_zero)) << sh;
+ m.always |= hn::BitsFromMask(d, hn::Ne(hn::And(cls, hn::Set(d, (uint8_t)BUN_XML_CLASS_ALWAYS)), v_zero)) << sh;
+ m.tag |= hn::BitsFromMask(d, hn::Ne(hn::And(cls, hn::Set(d, (uint8_t)BUN_XML_CLASS_TAG)), v_zero)) << sh;
+}
+
+// The part both kernels share: `in_tag` = MatchStar(lt, ~gt) — every position reachable from a
+// `<` through non-`>` positions (the `>` included), the addition's carry linking blocks — then
+// the positions to emit, compressed out as `base`-relative indices. Returns how many.
+static HWY_INLINE size_t EmitBlock(const BlockMasks& m, uint64_t valid, uint64_t& carry, uint32_t base,
+ uint32_t* HWY_RESTRICT out)
+{
+ const hn::ScalableTag d32;
+ const size_t L = hn::Lanes(d32);
+ const uint64_t run = ~m.gt;
+ uint64_t sum;
+ uint64_t c1 = __builtin_add_overflow(m.lt & run, run, &sum) ? 1 : 0;
+ uint64_t c2 = __builtin_add_overflow(sum, carry, &sum) ? 1 : 0;
+ carry = c1 | c2;
+ const uint64_t in_tag = (sum ^ run) | m.lt;
+ const uint64_t emit = (m.lt | m.gt | m.always | m.nonchar | (m.tag & in_tag)) & valid;
+
+ size_t n = 0;
+ const auto iota32 = hn::Iota(d32, 0);
+ for (size_t k = 0; k < 64; k += L) {
+ uint64_t slice = (emit >> k) & (L >= 64 ? ~(uint64_t)0 : (((uint64_t)1 << L) - 1));
+ uint8_t slice_bytes[8];
+ memcpy(slice_bytes, &slice, 8);
+ const auto mask = hn::LoadMaskBits(d32, slice_bytes);
+ const auto v = hn::Add(hn::Set(d32, base + (uint32_t)k), iota32);
+ n += hn::CompressStore(v, mask, d32, out + n);
+ }
+ return n;
+}
+
+size_t XmlIndexImpl(const uint8_t* HWY_RESTRICT input, size_t len, size_t base_offset,
+ uint32_t* HWY_RESTRICT out, uint64_t* HWY_RESTRICT inout_state)
+{
+ const D8 d;
+ const size_t N = hn::Lanes(d);
+ const auto v_01 = hn::Set(d, (uint8_t)0x01);
+ const auto v_ef = hn::Set(d, (uint8_t)0xEF);
+ const auto v_bf = hn::Set(d, (uint8_t)0xBF);
+
+ // Whether the previous block ended inside `<` … `>`.
+ uint64_t carry = inout_state[0];
+ // The previous block's 0xEF / 0xBF masks (for a non-character straddling blocks).
+ uint64_t prev_ef = inout_state[1];
+ uint64_t prev_bf = inout_state[2];
+ size_t n_out = 0;
+
+ for (size_t pos = 0; pos < len; pos += 64) {
+ const uint8_t* p = input + pos;
+ size_t rem = len - pos;
+ uint64_t valid = ~(uint64_t)0;
+ uint8_t tmp[64];
+ if (rem < 64) {
+ memset(tmp, 0x20, sizeof(tmp));
+ memcpy(tmp, p, rem);
+ p = tmp;
+ valid = (((uint64_t)1) << rem) - 1;
+ }
+
+ BlockMasks m;
+ uint64_t m_ef = 0, m_bf = 0, m_bebf = 0;
+ for (size_t v = 0; v < 64 / N; ++v) {
+ const auto chunk = hn::LoadU(d, p + v * N);
+ const unsigned sh = (unsigned)(v * N);
+ Classify(d, chunk, sh, m);
+ m_ef |= hn::BitsFromMask(d, hn::Eq(chunk, v_ef)) << sh;
+ m_bf |= hn::BitsFromMask(d, hn::Eq(chunk, v_bf)) << sh;
+ m_bebf |= hn::BitsFromMask(d, hn::Eq(hn::Or(chunk, v_01), v_bf)) << sh;
+ }
+ m.nonchar = m_bebf & ((m_bf << 1) | (prev_bf >> 63)) & ((m_ef << 2) | (prev_ef >> 62));
+ prev_ef = m_ef;
+ prev_bf = m_bf;
+
+ n_out += EmitBlock(m, valid, carry, (uint32_t)(base_offset + pos), out + n_out);
+ }
+
+ inout_state[0] = carry;
+ inout_state[1] = prev_ef;
+ inout_state[2] = prev_bf;
+ return n_out;
+}
+
+// The same over UTF-16 code units (`len` and positions in units): a unit classifies as its low
+// byte when its high byte is zero; 0xFFFE / 0xFFFF are the non-characters, and a surrogate that
+// is not half of a pair is flagged too (a lone lead surrogate ending a block is decided, and
+// emitted first, when the next block sees what follows; the caller settles one that ends the input).
+size_t XmlIndex16Impl(const uint16_t* HWY_RESTRICT input, size_t len, size_t base_offset,
+ uint32_t* HWY_RESTRICT out, uint64_t* HWY_RESTRICT inout_state)
+{
+ const D8 d;
+ const size_t N = hn::Lanes(d);
+ const auto v_zero = hn::Zero(d);
+ const auto v_01 = hn::Set(d, (uint8_t)0x01);
+ const auto v_ff = hn::Set(d, (uint8_t)0xFF);
+ const auto v_fc = hn::Set(d, (uint8_t)0xFC);
+ const auto v_d8 = hn::Set(d, (uint8_t)0xD8);
+ const auto v_dc = hn::Set(d, (uint8_t)0xDC);
+
+ uint64_t carry = inout_state[0];
+ // Bit 0: the previous block's last unit was a lead surrogate (still owed a trail).
+ uint64_t prev_lead = inout_state[1];
+ size_t n_out = 0;
+
+ for (size_t pos = 0; pos < len; pos += 64) {
+ const uint8_t* p = reinterpret_cast(input + pos);
+ size_t rem = len - pos;
+ uint64_t valid = ~(uint64_t)0;
+ uint16_t tmp[64];
+ if (rem < 64) {
+ for (size_t i = 0; i < 64; ++i)
+ tmp[i] = 0x20;
+ memcpy(tmp, p, rem * 2);
+ p = reinterpret_cast(tmp);
+ valid = (((uint64_t)1) << rem) - 1;
+ }
+
+ BlockMasks m;
+ uint64_t lead = 0, trail = 0;
+ // Each pair of byte vectors covers N units: even bytes are the (little-endian) low
+ // bytes, odd bytes the high bytes.
+ for (size_t v = 0; v < 64 / N; ++v) {
+ const auto a = hn::LoadU(d, p + v * 2 * N);
+ const auto b = hn::LoadU(d, p + v * 2 * N + N);
+ const auto lo = hn::ConcatEven(d, b, a);
+ const auto hi = hn::ConcatOdd(d, b, a);
+ const unsigned sh = (unsigned)(v * N);
+ BlockMasks unit;
+ Classify(d, lo, 0, unit);
+ const uint64_t ascii = hn::BitsFromMask(d, hn::Eq(hi, v_zero));
+ m.lt |= (unit.lt & ascii) << sh;
+ m.gt |= (unit.gt & ascii) << sh;
+ m.always |= (unit.always & ascii) << sh;
+ m.tag |= (unit.tag & ascii) << sh;
+ const uint64_t nonchar = hn::BitsFromMask(d, hn::And(hn::Eq(hi, v_ff), hn::Eq(hn::Or(lo, v_01), v_ff)));
+ m.nonchar |= nonchar << sh;
+ const auto plane = hn::And(hi, v_fc);
+ lead |= hn::BitsFromMask(d, hn::Eq(plane, v_d8)) << sh;
+ trail |= hn::BitsFromMask(d, hn::Eq(plane, v_dc)) << sh;
+ }
+ lead &= valid;
+ trail &= valid;
+ // A trail with no lead before it; a lead with no trail after it (bit 63 waits).
+ const uint64_t lone = (trail & ~((lead << 1) | prev_lead)) | (lead & ~(trail >> 1) & ~(1ull << 63));
+ if (prev_lead && !(trail & 1))
+ out[n_out++] = (uint32_t)(base_offset + pos - 1);
+ prev_lead = lead >> 63;
+ m.nonchar |= lone;
+
+ n_out += EmitBlock(m, valid, carry, (uint32_t)(base_offset + pos), out + n_out);
+ }
+
+ inout_state[0] = carry;
+ inout_state[1] = prev_lead;
+ return n_out;
+}
+
+// NOLINTNEXTLINE(google-readability-namespace-comments)
+} // namespace HWY_NAMESPACE
+} // namespace bun
+HWY_AFTER_NAMESPACE();
+
+#if HWY_ONCE
+namespace bun {
+HWY_EXPORT(XmlIndexImpl);
+HWY_EXPORT(XmlIndex16Impl);
+
+// Resumable forms. Sentinels are the caller's job.
+extern "C" size_t highway_xml_index_chunk(const uint8_t* input, size_t len, size_t base_offset,
+ uint32_t* out_indices, uint64_t* inout_state)
+{
+ return HWY_DYNAMIC_DISPATCH(XmlIndexImpl)(input, len, base_offset, out_indices, inout_state);
+}
+
+extern "C" size_t highway_xml_index16_chunk(const uint16_t* input, size_t len, size_t base_offset,
+ uint32_t* out_indices, uint64_t* inout_state)
+{
+ return HWY_DYNAMIC_DISPATCH(XmlIndex16Impl)(input, len, base_offset, out_indices, inout_state);
+}
+} // namespace bun
+#endif
diff --git a/src/parsers/Cargo.toml b/src/parsers/Cargo.toml
index dd1983fdbfe8..acd68f948333 100644
--- a/src/parsers/Cargo.toml
+++ b/src/parsers/Cargo.toml
@@ -32,7 +32,14 @@ bun_simdutf_sys.workspace = true
[dev-dependencies]
criterion = "0.5"
+quick-xml = "0.38"
+roxmltree = "0.20"
+xml_rs = { package = "xml-rs", version = "0.8" }
[[bench]]
name = "json_parse"
harness = false
+
+[[bench]]
+name = "xml_parse"
+harness = false
diff --git a/src/parsers/benches/support/xml_c_shim.cpp b/src/parsers/benches/support/xml_c_shim.cpp
new file mode 100644
index 000000000000..e7d267baa987
--- /dev/null
+++ b/src/parsers/benches/support/xml_c_shim.cpp
@@ -0,0 +1,57 @@
+// C/C++ XML parsers for the `xml_parse` criterion bench to compare against.
+// Built by scripts/bench-json-rust.sh when the libraries are available.
+
+#include
+#include
+#include
+
+#ifdef HAVE_PUGIXML
+#include "pugixml.hpp"
+extern "C" void* mi_malloc(size_t);
+extern "C" void mi_free(void*);
+extern "C" size_t bench_pugixml_parse(const char* data, size_t len)
+{
+ // Same allocator as the Rust side (glibc's mmap threshold otherwise makes pugixml's
+ // numbers depend on heap history more than on pugixml).
+ static bool init = (pugi::set_memory_management_functions(mi_malloc, mi_free), true);
+ (void)init;
+ pugi::xml_document doc;
+ // load_buffer copies; pugixml parses in situ on its own copy (its normal mode of use).
+ pugi::xml_parse_result r = doc.load_buffer(data, len, pugi::parse_default | pugi::parse_ws_pcdata, pugi::encoding_utf8);
+ return r ? 1 : 0;
+}
+#endif
+
+#ifdef HAVE_EXPAT
+#include
+static void XMLCALL onStart(void* u, const XML_Char*, const XML_Char** atts)
+{
+ size_t* n = (size_t*)u;
+ for (size_t i = 0; atts[i]; i += 2) *n += 1;
+ *n += 1;
+}
+static void XMLCALL onEnd(void* u, const XML_Char*) { *(size_t*)u += 1; }
+static void XMLCALL onText(void* u, const XML_Char*, int len) { *(size_t*)u += (size_t)len; }
+extern "C" size_t bench_expat_parse(const char* data, size_t len)
+{
+ size_t n = 0;
+ XML_Parser p = XML_ParserCreate(NULL);
+ XML_SetUserData(p, &n);
+ XML_SetElementHandler(p, onStart, onEnd);
+ XML_SetCharacterDataHandler(p, onText);
+ int ok = XML_Parse(p, data, (int)len, 1) == XML_STATUS_OK;
+ XML_ParserFree(p);
+ return ok ? n + 1 : 0;
+}
+#endif
+
+#ifdef HAVE_LIBXML2
+#include
+extern "C" size_t bench_libxml2_parse(const char* data, size_t len)
+{
+ xmlDocPtr doc = xmlReadMemory(data, (int)len, "bench.xml", NULL, XML_PARSE_NONET);
+ if (!doc) return 0;
+ xmlFreeDoc(doc);
+ return 1;
+}
+#endif
diff --git a/src/parsers/benches/xml_parse.rs b/src/parsers/benches/xml_parse.rs
new file mode 100644
index 000000000000..266638716990
--- /dev/null
+++ b/src/parsers/benches/xml_parse.rs
@@ -0,0 +1,318 @@
+//! Throughput benchmark for the XML parser against other Rust and C/C++ parsers.
+//! Run via `scripts/bench-json-rust.sh --xml [criterion args]`. Fixtures: every `*.xml` / `*.svg` in
+//! `$BUN_XML_BENCH_FIXTURES` (default `bench/xml-corpus/`), plus built-in synthetic documents.
+use bun_alloc::Arena as Bump;
+use bun_ast as js_ast;
+use bun_parsers::xml;
+use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
+
+#[path = "../native_test_shims.rs"]
+mod native_test_shims;
+
+unsafe extern "C" {
+ #[cfg(pugixml)]
+ fn bench_pugixml_parse(data: *const u8, len: usize) -> usize;
+ #[cfg(expat)]
+ fn bench_expat_parse(data: *const u8, len: usize) -> usize;
+ #[cfg(libxml2)]
+ fn bench_libxml2_parse(data: *const u8, len: usize) -> usize;
+}
+
+fn fixtures() -> Vec<(String, Vec)> {
+ let mut out = Vec::new();
+ let dir = std::env::var("BUN_XML_BENCH_FIXTURES")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|_| {
+ let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.pop();
+ p.pop();
+ p.push("bench/xml-corpus");
+ p
+ });
+ if let Ok(rd) = std::fs::read_dir(&dir) {
+ let mut files: Vec<_> = rd
+ .filter_map(|e| {
+ let p = e.ok()?.path();
+ matches!(p.extension()?.to_str()?, "xml" | "svg").then_some(p)
+ })
+ .collect();
+ files.sort();
+ for p in files {
+ let name = p.file_stem().unwrap().to_string_lossy().into_owned();
+ out.push((name, std::fs::read(&p).unwrap()));
+ }
+ }
+ out.push(("synth-feed".into(), synth_feed()));
+ out.push(("synth-records".into(), synth_records()));
+ out.push(("synth-soup".into(), synth_soup()));
+ out
+}
+
+/// Atom-like feed: long text runs, entities, some CDATA (~1 MB).
+fn synth_feed() -> Vec {
+ let mut s = String::from(
+ "\n\n Example Feed\n",
+ );
+ let mut i = 0;
+ while s.len() < 1_000_000 {
+ s.push_str(&format!(
+ " \n Entry number {i} & friends\n \n urn:uuid:1225c695-cfb8-4ebb-aaaa-{i:012}\n 2003-12-13T18:30:02Z\n Some text that goes on for a while, describing entry {i} in enough detail that the text run is realistically long — with an em dash, «quotes», and <escaped> markup.\n Paragraph {i} with links & raw ampersands.
]]>\n \n"
+ ));
+ i += 1;
+ }
+ s.push_str("\n");
+ s.into_bytes()
+}
+
+/// Data-centric records: many short elements and attributes (~1 MB).
+fn synth_records() -> Vec {
+ let mut s = String::from("");
+ let mut i = 0;
+ while s.len() < 1_000_000 {
+ s.push_str(&format!(
+ "Item {i}{}.99{}abc{}",
+ i % 7,
+ i % 100,
+ i % 13,
+ i % 5
+ ));
+ i += 1;
+ }
+ s.push_str("");
+ s.into_bytes()
+}
+
+/// Markup soup: deep nesting, tiny names, whitespace-only text (~1 MB).
+fn synth_soup() -> Vec {
+ let mut s = String::from("\n");
+ while s.len() < 1_000_000 {
+ for d in 0..12 {
+ s.push_str(&" ".repeat(d + 1));
+ s.push_str("\n");
+ }
+ s.push_str(&" ".repeat(13));
+ s.push_str("\n");
+ for d in (0..12).rev() {
+ s.push_str(&" ".repeat(d + 1));
+ s.push_str("\n");
+ }
+ }
+ s.push_str("\n");
+ s.into_bytes()
+}
+
+/// `BUN_XML_BENCH_LOOP=impl:fixture:iterations` runs one parser in a plain loop and exits, for
+/// `perf stat` (instruction counts are stable where wall time on a shared box is not).
+fn maybe_loop() {
+ let Ok(spec) = std::env::var("BUN_XML_BENCH_LOOP") else {
+ return;
+ };
+ let mut parts = spec.split(':');
+ let (imp, fx, n) = (
+ parts.next().unwrap(),
+ parts.next().unwrap(),
+ parts.next().unwrap().parse::().unwrap(),
+ );
+ let (_, contents) = fixtures()
+ .into_iter()
+ .find(|(name, _)| name == fx)
+ .expect("fixture");
+ bun_ast::initialize_store();
+ let mut bump = Bump::new();
+ let start = std::time::Instant::now();
+ for _ in 0..n {
+ match imp {
+ "bun_compact" | "bun_tree" => {
+ let _store_scope = js_ast::StoreResetGuard::new();
+ let mut log = js_ast::Log::init();
+ bump.reset();
+ let source = js_ast::Source::init_path_string("fixture.xml", &contents[..]);
+ let opts = xml::Options {
+ compact: imp == "bun_compact",
+ encoding: xml::InputEncoding::Bytes,
+ };
+ let e = xml::XML::parse(&source, &mut log, &bump, opts).expect("parse");
+ std::hint::black_box(&e);
+ }
+ #[cfg(pugixml)]
+ "pugixml" => {
+ std::hint::black_box(unsafe {
+ bench_pugixml_parse(contents.as_ptr(), contents.len())
+ });
+ }
+ "stage1" => {
+ let mut x = bun_parsers::xml_index::StructuralIndex::new(&contents);
+ let mut i = 0usize;
+ while x.at(i) != contents.len() {
+ i += 1;
+ }
+ std::hint::black_box(i);
+ }
+ _ => panic!("unknown impl {imp}"),
+ }
+ }
+ let secs = start.elapsed().as_secs_f64();
+ eprintln!(
+ "{imp}/{fx}: {n} iterations, {:.1} MiB/s wall",
+ (contents.len() * n) as f64 / secs / 1048576.0
+ );
+ std::process::exit(0);
+}
+
+fn bench_xml(c: &mut Criterion) {
+ maybe_loop();
+ bun_ast::initialize_store();
+ let mut group = c.benchmark_group("xml_parse");
+ group.sample_size(20);
+ for (name, contents) in fixtures() {
+ group.throughput(Throughput::Bytes(contents.len() as u64));
+
+ for (id, compact) in [("bun_compact", true), ("bun_tree", false)] {
+ group.bench_function(BenchmarkId::new(id, &name), |b| {
+ let mut bump = Bump::new();
+ b.iter(|| {
+ let _store_scope = js_ast::StoreResetGuard::new();
+ let mut log = js_ast::Log::init();
+ bump.reset();
+ let source = js_ast::Source::init_path_string("fixture.xml", &contents[..]);
+ let opts = xml::Options {
+ compact,
+ encoding: xml::InputEncoding::Bytes,
+ };
+ let e = xml::XML::parse(&source, &mut log, &bump, opts).unwrap_or_else(|_| {
+ panic!(
+ "{name}: {}",
+ log.msgs
+ .first()
+ .map(|m| String::from_utf8_lossy(&m.data.text).into_owned())
+ .unwrap_or_default()
+ )
+ });
+ std::hint::black_box(&e);
+ })
+ });
+ }
+
+ group.bench_function(BenchmarkId::new("stage1_index", &name), |b| {
+ b.iter(|| {
+ let mut x = bun_parsers::xml_index::StructuralIndex::new(&contents);
+ let mut i = 0usize;
+ let mut sum = 0usize;
+ loop {
+ let p = x.at(i);
+ if p == contents.len() {
+ break;
+ }
+ sum += p;
+ i += 1;
+ }
+ std::hint::black_box((sum, i))
+ })
+ });
+
+ // quick-xml: pull parser; touch every event's name/text/attributes (unescaped) so the
+ // work is comparable to building values, but build no tree.
+ fn quick_xml_run(contents: &[u8]) -> Option {
+ use quick_xml::events::Event;
+ let mut reader = quick_xml::Reader::from_reader(contents);
+ reader.config_mut().check_end_names = true;
+ let mut n = 0usize;
+ let mut buf = Vec::new();
+ loop {
+ match reader.read_event_into(&mut buf).ok()? {
+ Event::Start(e) | Event::Empty(e) => {
+ n += e.name().as_ref().len();
+ for a in e.attributes() {
+ let a = a.ok()?;
+ n += match a.unescape_value() {
+ Ok(v) => v.len(),
+ Err(_) => a.value.len(),
+ };
+ }
+ }
+ Event::Text(t) => n += t.xml10_content().ok()?.len(),
+ Event::GeneralRef(r) => n += r.len(),
+ Event::CData(t) => n += t.len(),
+ Event::End(e) => n += e.name().as_ref().len(),
+ Event::Eof => break,
+ _ => {}
+ }
+ buf.clear();
+ }
+ Some(n)
+ }
+ if quick_xml_run(&contents).is_some() {
+ group.bench_function(BenchmarkId::new("quick_xml_events", &name), |b| {
+ b.iter(|| std::hint::black_box(quick_xml_run(&contents)))
+ });
+ }
+
+ // roxmltree: read-only DOM (the closest Rust analogue to what Bun builds).
+ if let Ok(text) = std::str::from_utf8(&contents) {
+ let opts = roxmltree::ParsingOptions {
+ allow_dtd: true,
+ ..Default::default()
+ };
+ if roxmltree::Document::parse_with_options(text, opts).is_ok() {
+ group.bench_function(BenchmarkId::new("roxmltree_dom", &name), |b| {
+ b.iter(|| {
+ let doc = roxmltree::Document::parse_with_options(text, opts).unwrap();
+ std::hint::black_box(doc.root_element().children().count())
+ })
+ });
+ }
+ }
+
+ // xml-rs: the classic (slow) pull parser, for scale.
+ fn xml_rs_run(contents: &[u8]) -> Option {
+ let mut n = 0usize;
+ for ev in xml_rs::EventReader::new(contents) {
+ match ev.ok()? {
+ xml_rs::reader::XmlEvent::Characters(t) => n += t.len(),
+ _ => n += 1,
+ }
+ }
+ Some(n)
+ }
+ if contents.len() <= 1_500_000 && xml_rs_run(&contents).is_some() {
+ group.bench_function(BenchmarkId::new("xml_rs_events", &name), |b| {
+ b.iter(|| std::hint::black_box(xml_rs_run(&contents)))
+ });
+ }
+
+ #[cfg(pugixml)]
+ if unsafe { bench_pugixml_parse(contents.as_ptr(), contents.len()) } != 0 {
+ group.bench_function(BenchmarkId::new("pugixml_dom", &name), |b| {
+ b.iter(|| {
+ std::hint::black_box(unsafe {
+ bench_pugixml_parse(contents.as_ptr(), contents.len())
+ })
+ })
+ });
+ }
+ #[cfg(expat)]
+ if unsafe { bench_expat_parse(contents.as_ptr(), contents.len()) } != 0 {
+ group.bench_function(BenchmarkId::new("expat_sax", &name), |b| {
+ b.iter(|| {
+ std::hint::black_box(unsafe {
+ bench_expat_parse(contents.as_ptr(), contents.len())
+ })
+ })
+ });
+ }
+ #[cfg(libxml2)]
+ if unsafe { bench_libxml2_parse(contents.as_ptr(), contents.len()) } != 0 {
+ group.bench_function(BenchmarkId::new("libxml2_dom", &name), |b| {
+ b.iter(|| {
+ std::hint::black_box(unsafe {
+ bench_libxml2_parse(contents.as_ptr(), contents.len())
+ })
+ })
+ });
+ }
+ }
+ group.finish();
+}
+
+criterion_group!(benches, bench_xml);
+criterion_main!(benches);
diff --git a/src/parsers/build.rs b/src/parsers/build.rs
index 1f992ecb1ab3..4225af5ea3db 100644
--- a/src/parsers/build.rs
+++ b/src/parsers/build.rs
@@ -3,8 +3,8 @@
clippy::disallowed_types,
clippy::disallowed_macros
)]
-//! Export `BUN_CODEGEN_DIR` for `include!(concat!(env!("BUN_CODEGEN_DIR"), "/json_byte_class.rs"))`,
-//! written at configure time by `scripts/build/jsonByteClass.ts`.
+//! Export `BUN_CODEGEN_DIR` for the `include!`d byte-class tables written at configure time by
+//! `scripts/build/{json,xml}ByteClass.ts`.
use std::env;
use std::path::{Path, PathBuf};
@@ -21,15 +21,21 @@ fn main() {
.map(PathBuf::from)
.unwrap_or_else(|_| repo.join("build/debug/codegen"));
- let byte_class = codegen_dir.join("json_byte_class.rs");
- if !byte_class.exists() {
- panic!(
- "json_byte_class.rs not found at {} — run `bun bd --configure-only` first",
- byte_class.display()
- );
+ for name in ["json_byte_class.rs", "xml_byte_class.rs"] {
+ let byte_class = codegen_dir.join(name);
+ if !byte_class.exists() {
+ panic!(
+ "{name} not found at {} — run `bun bd --configure-only` first",
+ byte_class.display()
+ );
+ }
+ println!("cargo:rerun-if-changed={}", byte_class.display());
}
+ // cfgs `scripts/bench-json-rust.sh` sets when the comparison C libraries are available.
+ println!("cargo:rustc-check-cfg=cfg(pugixml)");
+ println!("cargo:rustc-check-cfg=cfg(expat)");
+ println!("cargo:rustc-check-cfg=cfg(libxml2)");
println!("cargo:rustc-env=BUN_CODEGEN_DIR={}", codegen_dir.display());
- println!("cargo:rerun-if-changed={}", byte_class.display());
println!("cargo:rerun-if-env-changed=BUN_CODEGEN_DIR");
}
diff --git a/src/parsers/error.rs b/src/parsers/error.rs
index 67d8d8144e99..f0616f4e1cfb 100644
--- a/src/parsers/error.rs
+++ b/src/parsers/error.rs
@@ -8,6 +8,11 @@ pub enum Error {
ParserError,
#[error("UTF8Fail")]
UTF8Fail,
+ /// The input's narrow encoding cannot hold the result (a Latin-1 XML
+ /// document with a character reference above U+00FF); parse it again
+ /// as UTF-8.
+ #[error("NeedsWiderEncoding")]
+ NeedsWiderEncoding,
#[error("UnexpectedSyntax")]
UnexpectedSyntax,
#[error("JSONStringsMustUseDoubleQuotes")]
@@ -24,6 +29,7 @@ impl Error {
Self::SyntaxError => "SyntaxError",
Self::ParserError => "ParserError",
Self::UTF8Fail => "UTF8Fail",
+ Self::NeedsWiderEncoding => "NeedsWiderEncoding",
Self::UnexpectedSyntax => "UnexpectedSyntax",
Self::JSONStringsMustUseDoubleQuotes => "JSONStringsMustUseDoubleQuotes",
Self::Alloc(_) => "OutOfMemory",
diff --git a/src/parsers/json.rs b/src/parsers/json.rs
index 3996a89b9ef7..cb05acbdb7ba 100644
--- a/src/parsers/json.rs
+++ b/src/parsers/json.rs
@@ -915,7 +915,7 @@ pub fn materialize(
) -> crate::Result {
materialize_impl(root, source, bump, false).inspect_err(|_| {
log.add_error_fmt_opts(
- format_args!("JSON document is too deeply nested"),
+ format_args!("Document is too deeply nested"),
bun_ast::AddErrorOptions {
source: Some(source),
loc: root.loc,
diff --git a/src/parsers/lib.rs b/src/parsers/lib.rs
index 83bed221c01f..af38d757b41c 100644
--- a/src/parsers/lib.rs
+++ b/src/parsers/lib.rs
@@ -7,6 +7,7 @@ pub use error::{Error, Result};
pub mod json_index;
mod json_stage2;
+pub mod xml_index;
#[cfg(test)]
mod native_test_shims;
diff --git a/src/parsers/xml.rs b/src/parsers/xml.rs
index cee6b1fcfa8e..999fd9163266 100644
--- a/src/parsers/xml.rs
+++ b/src/parsers/xml.rs
@@ -1,34 +1,25 @@
-//! XML 1.0 (Fifth Edition) scanner/parser — a non-validating processor that
-//! does not read external entities (§5.1).
+//! XML 1.0 (Fifth Edition) parser — a non-validating processor that does not
+//! read external entities (§5.1).
//!
-//! Architecture (mirrors `yaml.rs`): the scanner turns bytes into tokens and
-//! the parser is recursive descent over tokens, never touching source bytes.
-//! Outside element content XML's lexical grammar is uniform — names, quoted
-//! literals, a handful of punctuation marks and the ``), the parser
-//! checks the token's `spaced` flag. The one context-sensitive lexeme is the
-//! quoted literal (`AttValue`, `EntityValue`, `SystemLiteral` and
-//! `PubidLiteral` decode differently), so `next` takes the `Literal` kind the
-//! parser's grammar position calls for. Element content, where whitespace is
-//! character data, has its own loop (`Scanner::next_content`).
+//! Stage 1 ([`crate::xml_index`]) is the SIMD structural index of the
+//! document: every `<`, `>`, `&`, `\r` and forbidden control character, plus
+//! the whitespace, quotes and `=` inside tags. Stage 2 is this file: the
+//! scanner walks the document from index entry to index entry — character
+//! data, attribute values, comments, CDATA sections and processing
+//! instructions are never visited byte by byte — and hands tokens to a
+//! recursive-descent parser that checks the grammar and the well-formedness
+//! constraints and writes the result as immutable rows on an `E::JsonTape`
+//! (the same node representation the JSON parser produces).
//!
-//! Entity replacement (§4.4) is character-level substitution, so it lives in
-//! the scanner: an entity reference in a context where the spec says
-//! "included" pushes the replacement text as a new input frame and scanning
-//! continues there. Tokens carry the id of the frame they came from so the
-//! parser can enforce the structural rules (an element or markup declaration
-//! must start and end in the same entity). The parser feeds declarations from
-//! the internal DTD subset back to the scanner's entity tables; per §5.1 those
-//! declarations are used to expand internal entities, supply attribute
-//! defaults, and normalize attribute values, and declarations after a
-//! reference to a parameter entity that is not read are ignored (unless
-//! `standalone="yes"`).
+//! What stays byte-level: names (they have to be validated character by
+//! character anyway), the document type declaration, and entity replacement
+//! text, which is not part of the indexed buffer — an included entity (§4.4)
+//! is pushed as a new input frame and scanned with the scalar classifier the
+//! index is built from. Tokens carry the id of the frame they came from so
+//! the parser can enforce that elements and declarations start and end in
+//! the same entity.
//!
-//! Two JS value shapes are built from the same token stream (see `Sink`): the
+//! Two value shapes are built from the same token stream (see `Sink`): the
//! compact object (`{"@attr": .., child: .., "#text": ..}`) used by
//! `Bun.XML.parse` by default and by the module loader, and the ordered node
//! tree (`{name, attributes, children}`) for `{ compact: false }`.
@@ -36,11 +27,20 @@
use bun_alloc::Arena as Bump;
use bun_alloc::ArenaVec;
use bun_alloc::ArenaVecExt as _;
-use bun_ast::{self as ast, E, Expr, G, Loc, Log, Source};
-use bun_collections::{HashMap, VecExt};
+use bun_ast::expr::Data;
+use bun_ast::{self as ast, E, Expr, Loc, Log, Source, StoreRef};
+use bun_collections::HashMap;
use bun_core::{StackCheck, strings};
use bun_simdutf_sys::simdutf;
+use crate::xml_index::StructuralIndex;
+use crate::xml_index::byte_class::{CLASS_ALWAYS, CLASS_GT, CLASS_LT, CLASS_TAG, XML_BYTE_CLASS};
+
+/// Scalar stop classes for the contexts that skip ahead (see `Scanner::next_stop`).
+const STOP_CONTENT: u8 = CLASS_LT | CLASS_GT | CLASS_ALWAYS;
+const STOP_ATT_VALUE: u8 = CLASS_LT | CLASS_GT | CLASS_ALWAYS | CLASS_TAG;
+const STOP_SKIPPED: u8 = CLASS_ALWAYS;
+
// ── public entry point ──────────────────────────────────────────────────────
pub struct XML;
@@ -67,26 +67,73 @@ pub enum InputEncoding {
/// Already-decoded text (a JS string, re-encoded as UTF-8): nothing to
/// detect; the declaration is checked for syntax but not acted upon.
Text,
+ /// Already-decoded text, one byte per character (a Latin-1 JS string,
+ /// borrowed as is). Strings in the result are Latin-1 too; a character
+ /// reference above U+00FF cannot be represented and the parse stops
+ /// with [`crate::Error::NeedsWiderEncoding`].
+ Latin1,
}
impl XML {
+ /// Parses `source` (UTF-8 bytes, or Latin-1 with `InputEncoding::Latin1`)
+ /// into `E::ObjectJSON` / `E::ArrayJSON` rows whose tape (and every string
+ /// that does not borrow the source) lives in `bump`.
pub fn parse<'a>(
source: &'a Source,
log: &mut Log,
bump: &'a Bump,
options: Options,
+ ) -> crate::Result {
+ let contents: &'a [u8] = source.contents.as_ref();
+ Self::parse_units(source, contents, log, bump, options)
+ }
+
+ /// [`parse`](Self::parse) for a UTF-16 document (a 16-bit JS string):
+ /// the strings in the result are UTF-16 as well. `source` is only what
+ /// diagnostics are attributed to.
+ pub fn parse_utf16<'a>(
+ source: &'a Source,
+ units: &'a [u16],
+ log: &mut Log,
+ bump: &'a Bump,
+ compact: bool,
+ ) -> crate::Result {
+ let options = Options {
+ compact,
+ encoding: InputEncoding::Text,
+ };
+ Self::parse_units(source, units, log, bump, options)
+ }
+
+ fn parse_units<'a, U: Unit>(
+ source: &'a Source,
+ contents: &'a [U],
+ log: &mut Log,
+ bump: &'a Bump,
+ options: Options,
) -> crate::Result {
bun_core::analytics::Features::xml_parse_inc();
+ let mut tape = Tape::new_in(bump, core::mem::size_of_val(contents));
+ // SAFETY: see `Tape::object_from`.
+ unsafe { tape.tape.as_mut() }.encoding = if U::WIDE {
+ E::StrEncoding::Utf16
+ } else if options.encoding == InputEncoding::Latin1 {
+ E::StrEncoding::Latin1
+ } else {
+ E::StrEncoding::Utf8
+ };
let result = if options.compact {
- Parser::new(source, log, bump, options, CompactSink::new(bump)).parse_document()
+ Parser::new(source, contents, log, bump, options, CompactSink::new(tape))
+ .parse_document()
} else {
- Parser::new(source, log, bump, options, NodeSink::new(bump)).parse_document()
+ Parser::new(source, contents, log, bump, options, NodeSink::new(tape)).parse_document()
};
match result {
Ok(root) => Ok(root),
Err(PErr::Syntax) => Err(crate::Error::SyntaxError),
Err(PErr::Oom) => Err(crate::Error::Alloc(bun_alloc::AllocError)),
Err(PErr::StackOverflow) => Err(crate::Error::StackOverflow),
+ Err(PErr::NeedsWiderEncoding) => Err(crate::Error::NeedsWiderEncoding),
}
}
}
@@ -97,6 +144,8 @@ enum PErr {
Syntax,
Oom,
StackOverflow,
+ /// See `InputEncoding::Latin1`.
+ NeedsWiderEncoding,
}
impl From for PErr {
@@ -118,6 +167,141 @@ const MAX_AMPLIFICATION: u64 = 100;
/// Entity references open at any one time (the depth of the reference chain).
const MAX_ENTITY_DEPTH: usize = 256;
+// ── code units ──────────────────────────────────────────────────────────────
+
+/// The parser runs over UTF-8 / Latin-1 bytes or UTF-16 code units alike:
+/// it only ever dispatches on ASCII units and hands anything else to
+/// `decode`, so a code unit type just has to say how it maps to those.
+pub trait Unit: Copy + Eq + Ord + core::hash::Hash + Default + 'static {
+ /// Two bytes per unit.
+ const WIDE: bool;
+ /// The unit if it is below U+0100, else 0xFF: what byte-oriented
+ /// dispatch (`peek`) sees. Never an ASCII value for a non-ASCII unit.
+ fn low(self) -> u8;
+ fn value(self) -> u32;
+ fn ascii(b: u8) -> Self;
+ /// The units' storage, for tape strings (whose encoding tag says how
+ /// to read them back).
+ fn bytes(units: &[Self]) -> &[u8];
+ /// The fixed keys of the two output shapes, in this unit type.
+ const KEY_TEXT: &'static [Self];
+ const KEY_NAME: &'static [Self];
+ const KEY_ATTRIBUTES: &'static [Self];
+ const KEY_CHILDREN: &'static [Self];
+}
+
+impl Unit for u8 {
+ const WIDE: bool = false;
+ #[inline(always)]
+ fn low(self) -> u8 {
+ self
+ }
+ #[inline(always)]
+ fn value(self) -> u32 {
+ u32::from(self)
+ }
+ #[inline(always)]
+ fn ascii(b: u8) -> Self {
+ b
+ }
+ #[inline(always)]
+ fn bytes(units: &[Self]) -> &[u8] {
+ units
+ }
+ const KEY_TEXT: &'static [Self] = b"#text";
+ const KEY_NAME: &'static [Self] = b"name";
+ const KEY_ATTRIBUTES: &'static [Self] = b"attributes";
+ const KEY_CHILDREN: &'static [Self] = b"children";
+}
+
+macro_rules! utf16 {
+ ($s:literal) => {{
+ const B: &[u8] = $s;
+ const N: usize = B.len();
+ const fn widen() -> [u16; N] {
+ let mut out = [0u16; N];
+ let mut i = 0;
+ while i < N {
+ out[i] = B[i] as u16;
+ i += 1;
+ }
+ out
+ }
+ const W: [u16; N] = widen();
+ &W
+ }};
+}
+
+impl Unit for u16 {
+ const WIDE: bool = true;
+ #[inline(always)]
+ fn low(self) -> u8 {
+ if self < 0x100 { self as u8 } else { 0xFF }
+ }
+ #[inline(always)]
+ fn value(self) -> u32 {
+ u32::from(self)
+ }
+ #[inline(always)]
+ fn ascii(b: u8) -> Self {
+ u16::from(b)
+ }
+ #[inline(always)]
+ fn bytes(units: &[Self]) -> &[u8] {
+ bytemuck::cast_slice(units)
+ }
+ const KEY_TEXT: &'static [Self] = utf16!(b"#text");
+ const KEY_NAME: &'static [Self] = utf16!(b"name");
+ const KEY_ATTRIBUTES: &'static [Self] = utf16!(b"attributes");
+ const KEY_CHILDREN: &'static [Self] = utf16!(b"children");
+}
+
+#[inline(always)]
+fn unit_from_u16(u: u16) -> U {
+ debug_assert!(U::WIDE);
+ // SAFETY: only called when `U` is `u16` (`U::WIDE`).
+ unsafe { core::mem::transmute_copy(&u) }
+}
+
+/// `units` spell the ASCII `lit`.
+#[inline]
+fn eq_ascii(units: &[U], lit: &[u8]) -> bool {
+ units.len() == lit.len() && units.iter().zip(lit).all(|(&u, &b)| u.low() == b)
+}
+
+#[inline]
+fn eq_ascii_ignore_case(units: &[U], lit: &[u8]) -> bool {
+ units.len() == lit.len()
+ && units
+ .iter()
+ .zip(lit)
+ .all(|(&u, &b)| u.low().eq_ignore_ascii_case(&b))
+}
+
+#[inline]
+fn starts_with_ascii(units: &[U], lit: &[u8]) -> bool {
+ units.len() >= lit.len() && eq_ascii(&units[..lit.len()], lit)
+}
+
+/// The first occurrence of the ASCII `lit` in `units`.
+fn find_ascii(units: &[U], lit: &[u8]) -> Option {
+ if !U::WIDE {
+ // SAFETY: `!WIDE` units are bytes.
+ let bytes: &[u8] =
+ unsafe { core::slice::from_raw_parts(units.as_ptr().cast(), units.len()) };
+ return strings::index_of(bytes, lit);
+ }
+ let first = lit[0];
+ let mut i = 0;
+ while i + lit.len() <= units.len() {
+ if units[i].low() == first && eq_ascii(&units[i..i + lit.len()], lit) {
+ return Some(i);
+ }
+ i += 1;
+ }
+ None
+}
+
// ── character classes ───────────────────────────────────────────────────────
/// `S` (§2.3 [3]).
@@ -126,14 +310,29 @@ fn is_ws(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\n' | b'\r')
}
+/// Bit 0: an ASCII `NameStartChar`; bit 1: an ASCII `NameChar`. Zero for
+/// bytes >= 0x80, which callers decode separately.
+static NAME_ASCII: [u8; 256] = {
+ let mut t = [0u8; 256];
+ let mut c = 0usize;
+ while c < 0x80 {
+ let b = c as u8;
+ let start = b.is_ascii_alphabetic() || b == b'_' || b == b':';
+ let cont = start || b.is_ascii_digit() || b == b'-' || b == b'.';
+ t[c] = (start as u8) | ((cont as u8) << 1);
+ c += 1;
+ }
+ t
+};
+
#[inline]
fn is_name_start_ascii(c: u8) -> bool {
- c.is_ascii_alphabetic() || c == b'_' || c == b':'
+ NAME_ASCII[c as usize] & 1 != 0
}
#[inline]
fn is_name_char_ascii(c: u8) -> bool {
- is_name_start_ascii(c) || c.is_ascii_digit() || c == b'-' || c == b'.'
+ NAME_ASCII[c as usize] & 2 != 0
}
/// `NameStartChar` (§2.3 [4]) above ASCII.
@@ -213,16 +412,56 @@ fn is_pubid_char(c: u8) -> bool {
)
}
-/// The `S` characters, for `strings::trim`.
-const XML_WS: &[u8] = b" \t\n\r";
+/// `text` with `S` trimmed from both ends.
+#[inline]
+fn trim_ws(text: &[U]) -> &[U] {
+ trim_ws_end(trim_ws_start(text))
+}
+
+#[inline]
+fn trim_ws_start(text: &[U]) -> &[U] {
+ let mut a = 0;
+ while a < text.len() && is_ws(text[a].low()) {
+ a += 1;
+ }
+ &text[a..]
+}
+
+#[inline]
+fn trim_ws_end(text: &[U]) -> &[U] {
+ let mut b = text.len();
+ while b > 0 && is_ws(text[b - 1].low()) {
+ b -= 1;
+ }
+ &text[..b]
+}
+
+/// `a == b` for the short slices names are, without a `memcmp` call.
+#[inline]
+fn name_eq(a: &[T], b: &[T]) -> bool {
+ if a.len() != b.len() {
+ return false;
+ }
+ if a.len() >= 16 {
+ return a == b;
+ }
+ let mut i = 0;
+ while i < a.len() {
+ if a[i] != b[i] {
+ return false;
+ }
+ i += 1;
+ }
+ true
+}
// ── tokens ──────────────────────────────────────────────────────────────────
/// What the scanner hands the parser: `Scanner::next` produces everything
/// but `Text`; `Scanner::next_content` produces `Text`, the tags and `Eof`.
#[derive(Clone, Copy)]
-struct Token<'a> {
- kind: Kind<'a>,
+struct Token<'a, U: Unit> {
+ kind: Kind<'a, U>,
/// Byte offset in the document, for diagnostics.
pos: usize,
/// The input frame (document or entity replacement text) the token was
@@ -236,30 +475,27 @@ struct Token<'a> {
}
#[derive(Clone, Copy, PartialEq, Eq)]
-enum Kind<'a> {
+enum Kind<'a, U: Unit> {
/// The end of the document or, with its name, of an entity's replacement
/// text where that is not simply the end of an inclusion.
- Eof(Option<&'a [u8]>),
+ Eof(Option<&'a [U]>),
/// `Name` (§2.3 [5]); keywords such as `SYSTEM` or `CDATA` are names too.
- Name(&'a [u8]),
+ Name(&'a [U]),
/// A run of name characters that does not start with a `NameStartChar`,
/// so only an `Nmtoken` (§2.3 [7]).
- Nmtoken(&'a [u8]),
+ Nmtoken(&'a [U]),
/// `#` and a name: `#PCDATA`, `#REQUIRED`, `#IMPLIED`, `#FIXED`.
- Hash(&'a [u8]),
+ Hash(&'a [U]),
/// `%Name;` outside parameter-entity replacement text (inside it, a
/// reference is included in place, §4.4.8, and never surfaces).
- PeReference(&'a [u8]),
+ PeReference(&'a [U]),
/// `%` not followed by a name: the parameter-entity declaration marker.
Percent,
/// `%Name` with no `;`: a malformed reference — or, right after
/// ` {
Comment,
Pi,
/// ` {
- /// How a token is named in "but found …" diagnostics.
+/// Source text (a name, usually) quoted in a diagnostic: UTF-8 as is, or
+/// Latin-1 (`InputEncoding::Latin1`) transcoded so the message stays UTF-8.
+struct Show<'b, U: Unit>(&'b [U], bool);
+
+impl core::fmt::Display for Show<'_, U> {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ if U::WIDE {
+ let units = self.0.iter().map(|u| u.value() as u16);
+ for c in char::decode_utf16(units) {
+ core::fmt::Write::write_char(f, c.unwrap_or(char::REPLACEMENT_CHARACTER))?;
+ }
+ return Ok(());
+ }
+ if !self.1 {
+ return core::fmt::Display::fmt(bstr::BStr::new(U::bytes(self.0)), f);
+ }
+ for &b in self.0 {
+ core::fmt::Write::write_char(f, char::from(b.low()))?;
+ }
+ Ok(())
+ }
+}
+
+/// A token as named in "but found …" diagnostics; `.1` as for `Show`.
+struct KindDisplay<'a, U: Unit>(Kind<'a, U>, bool);
+
+impl core::fmt::Display for KindDisplay<'_, U> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
- let name = |f: &mut core::fmt::Formatter<'_>, prefix: &str, name: &[u8], suffix: &str| {
- write!(f, "'{}{}{}'", prefix, bstr::BStr::new(name), suffix)
+ let latin1 = self.1;
+ let name = |f: &mut core::fmt::Formatter<'_>, prefix: &str, name: &[U], suffix: &str| {
+ write!(f, "'{}{}{}'", prefix, Show(name, latin1), suffix)
};
- match *self {
- Kind::Eof(Some(entity)) => write!(f, "the end of entity '{}'", bstr::BStr::new(entity)),
+ match self.0 {
+ Kind::Eof(Some(entity)) => write!(f, "the end of entity '{}'", Show(entity, latin1)),
Kind::Eof(None) => f.write_str("end of input"),
Kind::Name(n) | Kind::Nmtoken(n) => name(f, "", n, ""),
Kind::Hash(n) => name(f, "#", n, ""),
Kind::PeReference(n) => name(f, "%", n, ";"),
Kind::Percent => f.write_str("'%'"),
Kind::PercentName(n) => name(f, "%", n, ""),
- Kind::Literal { .. } => f.write_str("a quoted string"),
+ Kind::Literal(_) => f.write_str("a quoted string"),
Kind::Eq => f.write_str("'='"),
Kind::Gt => f.write_str("'>'"),
Kind::SlashGt => f.write_str("'/>'"),
@@ -328,7 +587,7 @@ impl core::fmt::Display for Kind<'_> {
Kind::Pi => f.write_str("a processing instruction"),
Kind::StartTag(n) => name(f, "<", n, ""),
Kind::EndTag(n) => name(f, "", n, ""),
- Kind::Text { .. } => f.write_str("text"),
+ Kind::Text(_) => f.write_str("text"),
Kind::Unexpected(cp) => match char::from_u32(cp) {
Some(c) if c.is_ascii_graphic() => write!(f, "'{}'", c),
Some(c) if !c.is_control() => write!(f, "'{}' (U+{:04X})", c, cp),
@@ -338,6 +597,26 @@ impl core::fmt::Display for Kind<'_> {
}
}
+/// See `Parser::content_step`.
+enum Step {
+ Done,
+ Continue,
+ Slow,
+}
+
+/// See `Scanner::tag_step`.
+enum TagStep<'a, U: Unit> {
+ End {
+ empty: bool,
+ },
+ Attr {
+ name: &'a [U],
+ pos: usize,
+ quote: u8,
+ },
+ Slow,
+}
+
#[derive(Copy, Clone, PartialEq, Eq)]
enum DeclKind {
Doctype,
@@ -386,39 +665,39 @@ enum Literal {
// ── entities and input frames ───────────────────────────────────────────────
#[derive(Copy, Clone)]
-enum EntityValue<'a> {
+enum EntityValue<'a, U: Unit> {
/// Replacement text: character references (and, in text that came from
/// a parameter entity, parameter-entity references) already resolved,
/// general entity references bypassed (§4.4.7), line ends normalized.
- Internal(&'a [u8]),
+ Internal(&'a [U]),
/// Declared SYSTEM/PUBLIC; never read.
External,
/// External with NDATA — not a parsed entity at all.
Unparsed,
}
-struct Entities<'a> {
- general: HashMap<&'a [u8], EntityValue<'a>>,
- parameter: HashMap<&'a [u8], EntityValue<'a>>,
+struct Entities<'a, U: Unit> {
+ general: HashMap<&'a [U], EntityValue<'a, U>>,
+ parameter: HashMap<&'a [U], EntityValue<'a, U>>,
}
-fn predefined_entity(name: &[u8]) -> Option {
- match name {
- b"lt" => Some(b'<'),
- b"gt" => Some(b'>'),
- b"amp" => Some(b'&'),
- b"apos" => Some(b'\''),
- b"quot" => Some(b'"'),
+fn predefined_entity(name: &[U]) -> Option {
+ match name.len() {
+ 2 if eq_ascii(name, b"lt") => Some(b'<'),
+ 2 if eq_ascii(name, b"gt") => Some(b'>'),
+ 3 if eq_ascii(name, b"amp") => Some(b'&'),
+ 4 if eq_ascii(name, b"apos") => Some(b'\''),
+ 4 if eq_ascii(name, b"quot") => Some(b'"'),
_ => None,
}
}
/// What a general entity reference contributes where it is included.
-enum Resolved<'a> {
+enum Resolved<'a, U: Unit> {
/// A predefined entity's character.
Byte(u8),
/// Replacement text to scan in a new input frame.
- Text(&'a [u8]),
+ Text(&'a [U]),
/// Nothing known: the reference itself is kept as character data.
Unexpanded,
}
@@ -435,13 +714,13 @@ enum FrameKind {
Declarations,
}
-struct Frame<'a> {
- src: &'a [u8],
+struct Frame<'a, U: Unit> {
+ src: &'a [U],
pos: usize,
id: u32,
kind: FrameKind,
/// The entity this frame is the replacement text of: (name, is-parameter).
- entity: Option<(&'a [u8], bool)>,
+ entity: Option<(&'a [U], bool)>,
/// Where diagnostics for tokens read from this frame point: the position
/// of the outermost reference in the document.
report_pos: usize,
@@ -451,19 +730,19 @@ struct Frame<'a> {
/// Owns the byte cursor, the input-frame stack and the entity tables; the
/// only component that reads bytes.
-struct Scanner<'a, 'log> {
+struct Scanner<'a, 'log, U: Unit> {
/// The frame being read (the fields of `Frame`, unpacked for the hot
/// path); enclosing frames wait in `suspended`.
- src: &'a [u8],
+ src: &'a [U],
pos: usize,
frame_id: u32,
frame_kind: FrameKind,
- frame_entity: Option<(&'a [u8], bool)>,
+ frame_entity: Option<(&'a [U], bool)>,
frame_report_pos: usize,
- suspended: Vec>,
+ suspended: Vec>,
next_frame_id: u32,
- entities: Entities<'a>,
+ entities: Entities<'a, U>,
/// Bytes of replacement text pushed so far, for the amplification limit.
expanded_bytes: u64,
document_len: u64,
@@ -486,12 +765,88 @@ struct Scanner<'a, 'log> {
/// place an XML declaration may stand.
content_start: usize,
encoding: InputEncoding,
+
+ /// The structural index of the document buffer (`src` of frame 0), built
+ /// once the encoding is settled and the input validated; `cursor` is the
+ /// scanner's position in it.
+ idx: Option>,
+ cursor: usize,
+ /// One byte per character (`InputEncoding::Latin1`).
+ latin1: bool,
+ /// The current token: `next` / `next_content` write it in place.
+ tok: Token<'a, U>,
+ /// A `>` was read as literal data since the last `<`: the index producer
+ /// (which does not track quotes — XML has two quote characters that
+ /// only mean anything inside tags, so quote state and in-tag state are
+ /// circular) took it for the end of the tag, so until markup next
+ /// closes or opens a tag its in-tag entries are missing and attribute
+ /// values are scanned bytewise. Set by every literal scanner
+ /// (`saw_gt_in_literal`), cleared wherever a `<`, `>` or `/>` is
+ /// consumed as markup.
+ tag_degraded: bool,
+
bump: &'a Bump,
source: &'a Source,
log: &'log mut Log,
}
-impl<'a, 'log> Scanner<'a, 'log> {
+impl<'a, 'log, U: Unit> Scanner<'a, 'log, U> {
+ // ── structural index ───────────────────────────────────────────────────
+
+ fn build_index(&mut self) {
+ self.idx = Some(StructuralIndex::new(self.src));
+ self.cursor = 0;
+ }
+
+ /// The next position at or after the cursor that the current context
+ /// has to look at, or the end of the frame. In the document that is the
+ /// next index entry (whatever its class — callers treat an entry they do
+ /// not care about as one ordinary byte); in entity replacement text, the
+ /// next byte whose class is in `scalar_mask`.
+ #[inline(always)]
+ fn next_stop(&mut self, scalar_mask: u8) -> usize {
+ if self.frame_kind == FrameKind::Document
+ && let Some(idx) = self.idx.as_mut()
+ {
+ let (cursor, p) = idx.seek(self.cursor, self.pos);
+ self.cursor = cursor;
+ if !self.tag_degraded {
+ return p;
+ }
+ // The in-tag entries may be missing, but the ones the index
+ // always has (control and non-characters among them) still count.
+ return p.min(self.scalar_stop(scalar_mask));
+ }
+ self.scalar_stop(scalar_mask)
+ }
+
+ #[inline]
+ fn scalar_stop(&self, mask: u8) -> usize {
+ let mut p = self.pos;
+ while p < self.src.len() && XML_BYTE_CLASS[self.src[p].low() as usize] & mask == 0 {
+ p += 1;
+ }
+ p
+ }
+
+ /// The error for an index entry that is not markup: a control character,
+ /// or the last byte of an encoded U+FFFE / U+FFFF.
+ #[cold]
+ fn err_at_entry(&mut self, c: u8) -> PErr {
+ if c >= 0x80 && !U::WIDE {
+ self.pos -= 2;
+ }
+ self.err_invalid_char()
+ }
+
+ /// See `tag_degraded`: call for every `>` consumed as literal data.
+ #[inline]
+ fn saw_gt_in_literal(&mut self) {
+ if self.in_document() {
+ self.tag_degraded = true;
+ }
+ }
+
// ── error helpers ──────────────────────────────────────────────────────
fn loc(&self, pos: usize) -> Loc {
@@ -527,12 +882,12 @@ impl<'a, 'log> Scanner<'a, 'log> {
&mut self,
pos: usize,
before: &'static str,
- name: &[u8],
+ name: &[U],
after: &'static str,
) -> PErr {
self.err_fmt(
pos,
- format_args!("{} '{}'{}", before, bstr::BStr::new(name), after),
+ format_args!("{} '{}'{}", before, Show(name, self.latin1), after),
)
}
@@ -544,7 +899,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
return match self.frame_entity {
Some((name, _)) => self.err_fmt(
pos,
- format_args!("{} the end of entity '{}'", what, bstr::BStr::new(name)),
+ format_args!("{} the end of entity '{}'", what, Show(name, self.latin1)),
),
None => self.err_fmt(pos, format_args!("{} end of input", what)),
};
@@ -599,12 +954,18 @@ impl<'a, 'log> Scanner<'a, 'log> {
#[inline]
fn peek_at(&self, pos: usize) -> u8 {
if pos < self.src.len() {
- self.src[pos]
+ self.src[pos].low()
} else {
0
}
}
+ /// The code unit at the cursor (which must not be at the end).
+ #[inline]
+ fn unit(&self) -> U {
+ self.src[self.pos]
+ }
+
/// End of the current frame (not necessarily of the document).
#[inline]
fn at_end(&self) -> bool {
@@ -613,7 +974,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
#[inline]
fn starts_with(&self, s: &[u8]) -> bool {
- self.src[self.pos.min(self.src.len())..].starts_with(s)
+ starts_with_ascii(&self.src[self.pos.min(self.src.len())..], s)
}
#[inline]
@@ -627,13 +988,31 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// the end of the frame, as (lead byte, 1) — never past the end, and
/// never as a character a name or the parser accepts.
fn decode_utf8(&self) -> (u32, usize) {
- let first = self.peek();
+ let first = self.unit();
+ if U::WIDE {
+ // UTF-16: a surrogate pair is one character; a lone surrogate
+ // stands for itself (and is no name character).
+ let lead = first.value();
+ if (0xD800..0xDC00).contains(&lead) && self.pos + 1 < self.src.len() {
+ let trail = self.src[self.pos + 1].value();
+ if (0xDC00..0xE000).contains(&trail) {
+ return (0x10000 + ((lead - 0xD800) << 10) + (trail - 0xDC00), 2);
+ }
+ }
+ return (lead, 1);
+ }
+ if self.latin1 {
+ return (first.value(), 1);
+ }
+ let first = first.low();
let len = strings::wtf8_byte_sequence_length(first);
if len == 1 || self.pos + usize::from(len) > self.src.len() {
return (u32::from(first), 1);
}
let mut bytes = [0u8; 4];
- bytes[..usize::from(len)].copy_from_slice(&self.src[self.pos..self.pos + usize::from(len)]);
+ for (i, b) in bytes[..usize::from(len)].iter_mut().enumerate() {
+ *b = self.src[self.pos + i].low();
+ }
(
strings::decode_wtf8_rune_t(bytes, len, 0u32),
usize::from(len),
@@ -645,7 +1024,16 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// length. A malformed sequence can only be met inside the XML
/// declaration, before the input has been validated.
fn check_non_ascii_char(&mut self) -> PResult {
+ if self.latin1 {
+ return Ok(1);
+ }
let (cp, len) = self.decode_utf8();
+ if U::WIDE {
+ if cp == 0xFFFE || cp == 0xFFFF || (0xD800..0xE000).contains(&cp) {
+ return Err(self.err_invalid_char());
+ }
+ return Ok(len);
+ }
if len == 1 || cp == 0 {
return Err(self.err(self.here(), "Invalid UTF-8"));
}
@@ -659,9 +1047,9 @@ impl<'a, 'log> Scanner<'a, 'log> {
fn push_frame(
&mut self,
- text: &'a [u8],
+ text: &'a [U],
kind: FrameKind,
- entity: (&'a [u8], bool),
+ entity: (&'a [U], bool),
ref_pos: usize,
) -> PResult<()> {
if self.suspended.len() >= MAX_ENTITY_DEPTH {
@@ -719,10 +1107,10 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// Resolves `&name;` for inclusion in content or an attribute value.
fn resolve_general_entity(
&mut self,
- name: &'a [u8],
+ name: &'a [U],
ref_pos: usize,
in_attribute: bool,
- ) -> PResult> {
+ ) -> PResult> {
if let Some(c) = predefined_entity(name) {
return Ok(Resolved::Byte(c));
}
@@ -757,18 +1145,17 @@ impl<'a, 'log> Scanner<'a, 'log> {
}
/// Appends `&name;` for a reference that is kept rather than expanded.
- fn push_reference(buf: &mut ArenaVec<'a, u8>, name: &[u8], is_ascii: &mut bool) {
- *is_ascii &= name.is_ascii();
- buf.push(b'&');
+ fn push_reference(buf: &mut ArenaVec<'a, U>, name: &[U]) {
+ buf.push(U::ascii(b'&'));
buf.extend_from_slice(name);
- buf.push(b';');
+ buf.push(U::ascii(b';'));
}
/// Includes the parameter entity `name` as declarations (§4.4.8): pushes
/// its replacement text (the caller accounts for the space it counts as
/// on either side), or records that an entity that is not read was
/// referenced.
- fn include_parameter_entity(&mut self, name: &'a [u8], ref_pos: usize) -> PResult<()> {
+ fn include_parameter_entity(&mut self, name: &'a [U], ref_pos: usize) -> PResult<()> {
self.saw_pe_reference = true;
match self.entities.parameter.get(name).copied() {
Some(EntityValue::Internal(text)) => {
@@ -797,32 +1184,55 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// declaration may follow, in which case validating the input has to
/// wait for the encoding it declares.
fn init_document(&mut self) -> PResult {
- let bytes = self.src;
- if bytes.starts_with(b"\xEF\xBB\xBF") {
- self.pos = 3;
- self.saw_utf8_bom = true;
- } else if self.encoding == InputEncoding::Text {
- // A JS string is characters, not bytes: nothing to detect.
- } else if bytes.starts_with(b"\xFE\xFF") {
- self.transcode_utf16(&bytes[2..], true)?;
- } else if bytes.starts_with(b"\xFF\xFE") {
- self.transcode_utf16(&bytes[2..], false)?;
- } else if bytes.starts_with(b"\x00<") {
- self.transcode_utf16(bytes, true)?;
- self.needs_utf16_declaration = true;
- } else if bytes.starts_with(b"<\x00") {
- self.transcode_utf16(bytes, false)?;
- self.needs_utf16_declaration = true;
+ if U::WIDE {
+ // A UTF-16 JS string: characters, nothing to detect but a BOM.
+ if !self.src.is_empty() && self.src[0].value() == 0xFEFF {
+ self.pos = 1;
+ }
+ } else {
+ let bytes = U::bytes(self.src);
+ if bytes.starts_with(b"\xEF\xBB\xBF") {
+ self.pos = 3;
+ self.saw_utf8_bom = true;
+ } else if matches!(self.encoding, InputEncoding::Text | InputEncoding::Latin1) {
+ // A JS string is characters, not bytes: nothing to detect.
+ } else if bytes.starts_with(b"\xFE\xFF") {
+ self.transcode_utf16(&bytes[2..], true)?;
+ } else if bytes.starts_with(b"\xFF\xFE") {
+ self.transcode_utf16(&bytes[2..], false)?;
+ } else if bytes.starts_with(b"\x00<") {
+ self.transcode_utf16(bytes, true)?;
+ self.needs_utf16_declaration = true;
+ } else if bytes.starts_with(b"<\x00") {
+ self.transcode_utf16(bytes, false)?;
+ self.needs_utf16_declaration = true;
+ }
}
self.content_start = self.pos;
Ok(self.starts_with(b" &'a [U] {
+ assert!(!U::WIDE);
+ // SAFETY: `U` is `u8` (asserted).
+ unsafe { core::slice::from_raw_parts(bytes.as_ptr().cast(), bytes.len()) }
+ }
+
/// The input must be valid UTF-8 (§4.3.3: malformed byte sequences are
/// fatal). Run after the XML declaration — whose encoding may first
/// cause the input to be transcoded — and before anything is decoded.
fn validate_utf8(&mut self) -> PResult<()> {
- let result = simdutf::validate::with_errors::utf8(self.src);
+ // Text re-encoded from a JS string, or transcoded here from UTF-16 /
+ // Latin-1, is valid UTF-8 by construction; only raw bytes need it.
+ if U::WIDE
+ || matches!(self.encoding, InputEncoding::Text | InputEncoding::Latin1)
+ || self.transcoded
+ {
+ return Ok(());
+ }
+ let result = simdutf::validate::with_errors::utf8(U::bytes(self.src));
if result.is_successful() {
Ok(())
} else {
@@ -851,7 +1261,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
return Err(self.err(result.count * 2, "Invalid UTF-16"));
}
utf8.truncate(result.count);
- self.src = self.bump.alloc_slice_copy(&utf8);
+ self.src = Self::units_of(self.bump.alloc_slice_copy(&utf8));
self.pos = 0;
self.transcoded = true;
Ok(())
@@ -872,11 +1282,11 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// Acts on the encoding named by the XML declaration (the cursor is just
/// past the declaration, which is ASCII in every supported encoding).
- fn apply_declared_encoding(&mut self, name: &[u8], pos: usize) -> PResult<()> {
- if self.encoding == InputEncoding::Text {
+ fn apply_declared_encoding(&mut self, name: &[U], pos: usize) -> PResult<()> {
+ if U::WIDE || matches!(self.encoding, InputEncoding::Text | InputEncoding::Latin1) {
return Ok(());
}
- let is = |canonical: &str| name.eq_ignore_ascii_case(canonical.as_bytes());
+ let is = |canonical: &str| eq_ascii_ignore_case(name, canonical.as_bytes());
if is("UTF-8") || is("UTF8") || is("US-ASCII") || is("ASCII") {
if self.transcoded {
return Err(self.err_named(
@@ -915,8 +1325,8 @@ impl<'a, 'log> Scanner<'a, 'log> {
"",
));
}
- if let Some(utf8) = strings::to_utf8_from_latin1(&self.src[self.pos..]) {
- self.src = self.bump.alloc_slice_copy(&utf8);
+ if let Some(utf8) = strings::to_utf8_from_latin1(U::bytes(&self.src[self.pos..])) {
+ self.src = Self::units_of(self.bump.alloc_slice_copy(&utf8));
self.pos = 0;
self.content_start = usize::MAX;
self.transcoded = true;
@@ -937,13 +1347,17 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// `Name` (§2.3 [5]) at the cursor, where the grammar allows nothing
/// else (after `<`, ``, `&`, `%`, `#`, ``). `what` phrases the "but
/// found" error.
- fn scan_name(&mut self, what: &'static str) -> PResult<&'a [u8]> {
+ #[inline(always)]
+ fn scan_name(&mut self, what: &'static str) -> PResult<&'a [U]> {
let start = self.pos;
- if !self.at_name_start() {
+ let c = self.peek();
+ if is_name_start_ascii(c) {
+ self.pos += 1;
+ } else if c >= 0x80 && is_name_start_code_point(self.decode_utf8().0) {
+ self.pos += self.decode_utf8().1;
+ } else {
return Err(self.err_here(what));
}
- let (_, len) = self.decode_utf8();
- self.pos += len;
self.scan_name_chars();
Ok(&self.src[start..self.pos])
}
@@ -958,6 +1372,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
}
}
+ #[inline(always)]
fn scan_name_chars(&mut self) {
loop {
let c = self.peek();
@@ -978,7 +1393,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// A maximal run of `NameChar`s at the cursor and whether it starts with
/// a `NameStartChar` (a `Name`) or not (only an `Nmtoken`); `None` if the
/// character at the cursor cannot start either.
- fn scan_name_run(&mut self) -> Option<(&'a [u8], bool)> {
+ fn scan_name_run(&mut self) -> Option<(&'a [U], bool)> {
let start = self.pos;
let c = self.peek();
let (is_name, len) = if c < 0x80 {
@@ -1005,7 +1420,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
}
/// `Name ';'` after `&` or `%`.
- fn scan_reference_name(&mut self, what: &'static str) -> PResult<&'a [u8]> {
+ fn scan_reference_name(&mut self, what: &'static str) -> PResult<&'a [U]> {
let name = self.scan_name(what)?;
if self.peek() != b';' {
return Err(self.err_here("Expected ';' after the entity name but found"));
@@ -1056,26 +1471,40 @@ impl<'a, 'log> Scanner<'a, 'log> {
Ok(value)
}
- fn push_code_point(buf: &mut ArenaVec<'a, u8>, cp: u32, is_ascii: &mut bool) {
+ fn push_code_point(&self, buf: &mut ArenaVec<'a, U>, cp: u32) -> PResult<()> {
+ if U::WIDE {
+ let mut tmp = [0u16; 2];
+ for u in char::from_u32(cp).expect("a Char").encode_utf16(&mut tmp) {
+ buf.push(unit_from_u16::(*u));
+ }
+ return Ok(());
+ }
+ if self.latin1 {
+ let Ok(byte) = u8::try_from(cp) else {
+ return Err(PErr::NeedsWiderEncoding);
+ };
+ buf.push(U::ascii(byte));
+ return Ok(());
+ }
let mut tmp = [0u8; 4];
let n = strings::encode_wtf8_rune(&mut tmp, cp);
- if cp >= 0x80 {
- *is_ascii = false;
+ for &b in &tmp[..n] {
+ buf.push(U::ascii(b));
}
- buf.extend_from_slice(&tmp[..n]);
+ Ok(())
}
/// Copies the borrowed run `src[start..end]` into a buffer the first
/// time decoding has to diverge from the source bytes.
fn materialize<'b>(
bump: &'a Bump,
- src: &[u8],
+ src: &[U],
start: usize,
end: usize,
- buf: &'b mut Option>,
- ) -> &'b mut ArenaVec<'a, u8> {
+ buf: &'b mut Option>,
+ ) -> &'b mut ArenaVec<'a, U> {
if buf.is_none() {
- let mut b: ArenaVec<'a, u8> = ArenaVec::with_capacity_in(end - start + 32, bump);
+ let mut b: ArenaVec<'a, U> = ArenaVec::with_capacity_in(end - start + 32, bump);
b.extend_from_slice(&src[start..end]);
*buf = Some(b);
}
@@ -1084,22 +1513,21 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// A `Plain`, `System` or `Pubid` literal after the opening quote: no
/// references are recognized; the kinds differ only in the characters
- /// they admit. Returns (value, is_ascii).
+ /// they admit.
fn scan_simple_literal(
&mut self,
quote: u8,
open: usize,
literal: Literal,
- ) -> PResult<(&'a [u8], bool)> {
+ ) -> PResult<&'a [U]> {
let start = self.pos;
- let mut is_ascii = true;
loop {
match self.peek() {
_ if self.at_end() => return Err(self.err(open, "Unterminated quoted string")),
c if c == quote => {
let value = &self.src[start..self.pos];
self.pos += 1;
- return Ok((value, is_ascii));
+ return Ok(value);
}
c if literal == Literal::Pubid && !is_pubid_char(c) => {
return Err(self.err_here("Invalid character in a public identifier:"));
@@ -1107,10 +1535,11 @@ impl<'a, 'log> Scanner<'a, 'log> {
b'<' | b'>' if literal == Literal::Plain => {
return Err(self.err_here("Invalid character in a quoted string:"));
}
- c if c >= 0x80 => {
- is_ascii = false;
- self.pos += self.check_non_ascii_char()?;
+ b'>' => {
+ self.saw_gt_in_literal();
+ self.pos += 1;
}
+ c if c >= 0x80 => self.pos += self.check_non_ascii_char()?,
c if c < 0x20 && !is_ws(c) => return Err(self.err_invalid_char()),
_ => self.pos += 1,
}
@@ -1121,16 +1550,37 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// a character reference appends the character, an entity reference
/// appends its (recursively normalized) replacement text, a whitespace
/// character appends a space; then, for a tokenized type (`collapse`),
- /// spaces are trimmed and collapsed. Returns (value, is_ascii).
- fn scan_att_value(&mut self, quote: u8, collapse: bool) -> PResult<(&'a [u8], bool)> {
+ /// spaces are trimmed and collapsed.
+ #[inline(always)]
+ fn scan_att_value(&mut self, quote: u8, collapse: bool) -> PResult<&'a [U]> {
+ // Nothing but ordinary characters up to the closing quote: the
+ // overwhelmingly common case, one index hop.
+ if self.in_document() && !self.tag_degraded && self.idx.is_some() && !collapse {
+ let start = self.pos;
+ let stop = self.next_stop(STOP_ATT_VALUE);
+ if stop < self.src.len() && self.src[stop].low() == quote {
+ self.pos = stop + 1;
+ return Ok(&self.src[start..stop]);
+ }
+ }
+ self.scan_att_value_general(quote, collapse)
+ }
+
+ fn scan_att_value_general(&mut self, quote: u8, collapse: bool) -> PResult<&'a [U]> {
let literal_frame = self.frame_id;
let open_pos = self.here();
// The value borrows `src[start..]` until normalization or a
// reference forces a copy; once `buf` exists everything is appended.
let start = self.pos;
- let mut buf: Option> = None;
- let mut is_ascii = true;
+ let mut buf: Option> = None;
loop {
+ let stop = self.next_stop(STOP_ATT_VALUE);
+ if stop != self.pos {
+ if let Some(b) = buf.as_mut() {
+ b.extend_from_slice(&self.src[self.pos..stop]);
+ }
+ self.pos = stop;
+ }
let c = self.peek();
match c {
_ if self.at_end() => {
@@ -1147,12 +1597,11 @@ impl<'a, 'log> Scanner<'a, 'log> {
Some(b) => b.into_bump_slice(),
None => &self.src[start..end],
};
- let value = if collapse {
+ return Ok(if collapse {
collapse_spaces(self.bump, value)
} else {
value
- };
- return Ok((value, is_ascii));
+ });
}
// WFC: No < in Attribute Values (also via replacement text).
b'<' => return Err(self.err(self.here(), "'<' is not allowed in attribute values")),
@@ -1163,44 +1612,45 @@ impl<'a, 'log> Scanner<'a, 'log> {
if self.peek() == b'#' {
self.pos += 1;
let cp = self.scan_char_ref(ref_pos)?;
- Self::push_code_point(b, cp, &mut is_ascii);
+ self.push_code_point(b, cp)?;
} else {
let name = self
.scan_reference_name("Expected an entity name after '&' but found")?;
match self.resolve_general_entity(name, ref_pos, true)? {
- Resolved::Byte(byte) => b.push(byte),
+ Resolved::Byte(byte) => b.push(U::ascii(byte)),
Resolved::Text(text) => {
self.push_frame(text, FrameKind::Literal, (name, false), ref_pos)?
}
- Resolved::Unexpanded => Self::push_reference(b, name, &mut is_ascii),
+ Resolved::Unexpanded => Self::push_reference(b, name),
}
}
}
b'\r' if self.in_document() => {
// A line end in the document (CR or CRLF) is one #xA,
// hence one space.
- Self::materialize(self.bump, self.src, start, self.pos, &mut buf).push(b' ');
+ Self::materialize(self.bump, self.src, start, self.pos, &mut buf)
+ .push(U::ascii(b' '));
self.pos += 1;
if self.peek() == b'\n' {
self.pos += 1;
}
}
b'\t' | b'\n' | b'\r' => {
- Self::materialize(self.bump, self.src, start, self.pos, &mut buf).push(b' ');
+ Self::materialize(self.bump, self.src, start, self.pos, &mut buf)
+ .push(U::ascii(b' '));
self.pos += 1;
}
- _ if c < 0x20 => return Err(self.err_invalid_char()),
+ _ if c < 0x20 || (c >= 0x80 && !self.latin1) => return Err(self.err_at_entry(c)),
+ // `>`, `=` or the other quote (or, in Latin-1, a byte the
+ // index took for part of a non-character): plain data here.
_ => {
- let len = if c >= 0x80 {
- is_ascii = false;
- self.check_non_ascii_char()?
- } else {
- 1
- };
+ if c == b'>' {
+ self.saw_gt_in_literal();
+ }
if let Some(b) = buf.as_mut() {
- b.extend_from_slice(&self.src[self.pos..self.pos + len]);
+ b.push(self.unit());
}
- self.pos += len;
+ self.pos += 1;
}
}
}
@@ -1211,12 +1661,11 @@ impl<'a, 'log> Scanner<'a, 'log> {
/// literal, which is only legal outside the internal subset proper (WFC:
/// PEs in Internal Subset); general entity references are bypassed —
/// checked for form and kept verbatim (§4.4.7).
- fn scan_entity_value(&mut self, quote: u8) -> PResult<(&'a [u8], bool)> {
+ fn scan_entity_value(&mut self, quote: u8) -> PResult<&'a [U]> {
let literal_frame = self.frame_id;
let in_internal_subset = self.in_document();
let open_pos = self.here();
- let mut buf: ArenaVec<'a, u8> = ArenaVec::with_capacity_in(32, self.bump);
- let mut is_ascii = true;
+ let mut buf: ArenaVec<'a, U> = ArenaVec::with_capacity_in(32, self.bump);
loop {
let c = self.peek();
match c {
@@ -1229,7 +1678,7 @@ impl<'a, 'log> Scanner<'a, 'log> {
}
_ if c == quote && self.frame_id == literal_frame => {
self.pos += 1;
- return Ok((buf.into_bump_slice(), is_ascii));
+ return Ok(buf.into_bump_slice());
}
b'%' => {
let ref_pos = self.here();
@@ -1269,15 +1718,15 @@ impl<'a, 'log> Scanner<'a, 'log> {
if self.peek() == b'#' {
self.pos += 1;
let cp = self.scan_char_ref(ref_pos)?;
- Self::push_code_point(&mut buf, cp, &mut is_ascii);
+ self.push_code_point(&mut buf, cp)?;
} else {
let name = self
.scan_reference_name("Expected an entity name after '&' but found")?;
- Self::push_reference(&mut buf, name, &mut is_ascii);
+ Self::push_reference(&mut buf, name);
}
}
b'\r' if self.in_document() => {
- buf.push(b'\n');
+ buf.push(U::ascii(b'\n'));
self.pos += 1;
if self.peek() == b'\n' {
self.pos += 1;
@@ -1285,8 +1734,10 @@ impl<'a, 'log> Scanner<'a, 'log> {
}
_ if c < 0x20 && !is_ws(c) => return Err(self.err_invalid_char()),
_ => {
+ if c == b'>' {
+ self.saw_gt_in_literal();
+ }
let len = if c >= 0x80 {
- is_ascii = false;
self.check_non_ascii_char()?
} else {
1
@@ -1298,24 +1749,36 @@ impl<'a, 'log> Scanner<'a, 'log> {
}
}
- // ── comments and processing instructions ───────────────────────────────
+ // ── comments, processing instructions, CDATA ─────────────────────────────
+
+ /// Moves the cursor to `limit` over bytes that are dropped (a comment or
+ /// processing instruction body), rejecting invalid characters on the way.
+ fn skip_dropped(&mut self, limit: usize) -> PResult<()> {
+ loop {
+ let stop = self.next_stop(STOP_SKIPPED).min(limit);
+ self.pos = stop;
+ if stop >= limit {
+ return Ok(());
+ }
+ let c = self.peek();
+ if (c < 0x20 && !is_ws(c)) || (c >= 0x80 && !self.latin1) {
+ return Err(self.err_at_entry(c));
+ }
+ self.pos += 1;
+ }
+ }
/// The rest of a comment after `