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, " 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 `<`, ` 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 `