@miaskiewicz/turbo-dom 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +36 -2
- package/Cargo.toml +30 -6
- package/README.md +11 -12
- package/index.d.ts +2 -2
- package/package.json +6 -5
- package/pkg/turbo_dom_parser_bg.wasm +0 -0
- package/pkg-web/turbo_dom_parser_bg.wasm +0 -0
- package/src/core.rs +17 -11
- package/src/lib.rs +15 -16
- package/src/runtime/cascade.mjs +7 -1
- package/src/runtime/dom.mjs +15 -4
- package/src/runtime/selectors.mjs +345 -109
- package/src/spike.rs +24 -6
- package/turbo-dom-parser.darwin-arm64.node +0 -0
- package/turbo-dom-parser.linux-arm64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-musl.node +0 -0
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/Cargo.lock
CHANGED
|
@@ -17,18 +17,40 @@ version = "2.12.1"
|
|
|
17
17
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
18
18
|
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
|
19
19
|
|
|
20
|
+
[[package]]
|
|
21
|
+
name = "borsh"
|
|
22
|
+
version = "1.7.0"
|
|
23
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
24
|
+
checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145"
|
|
25
|
+
dependencies = [
|
|
26
|
+
"bytes",
|
|
27
|
+
"cfg_aliases",
|
|
28
|
+
]
|
|
29
|
+
|
|
20
30
|
[[package]]
|
|
21
31
|
name = "bumpalo"
|
|
22
32
|
version = "3.20.3"
|
|
23
33
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
24
34
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|
25
35
|
|
|
36
|
+
[[package]]
|
|
37
|
+
name = "bytes"
|
|
38
|
+
version = "1.12.0"
|
|
39
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
40
|
+
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
|
41
|
+
|
|
26
42
|
[[package]]
|
|
27
43
|
name = "cfg-if"
|
|
28
44
|
version = "1.0.4"
|
|
29
45
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
30
46
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
31
47
|
|
|
48
|
+
[[package]]
|
|
49
|
+
name = "cfg_aliases"
|
|
50
|
+
version = "0.2.1"
|
|
51
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
52
|
+
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|
53
|
+
|
|
32
54
|
[[package]]
|
|
33
55
|
name = "convert_case"
|
|
34
56
|
version = "0.6.0"
|
|
@@ -442,6 +464,16 @@ version = "1.15.1"
|
|
|
442
464
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
443
465
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
|
444
466
|
|
|
467
|
+
[[package]]
|
|
468
|
+
name = "smol_str"
|
|
469
|
+
version = "0.3.6"
|
|
470
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
471
|
+
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
|
472
|
+
dependencies = [
|
|
473
|
+
"borsh",
|
|
474
|
+
"serde_core",
|
|
475
|
+
]
|
|
476
|
+
|
|
445
477
|
[[package]]
|
|
446
478
|
name = "string_cache"
|
|
447
479
|
version = "0.9.0"
|
|
@@ -490,16 +522,18 @@ dependencies = [
|
|
|
490
522
|
|
|
491
523
|
[[package]]
|
|
492
524
|
name = "turbo-dom"
|
|
493
|
-
version = "0.
|
|
525
|
+
version = "0.4.0"
|
|
494
526
|
dependencies = [
|
|
495
527
|
"html5ever",
|
|
496
528
|
"markup5ever_rcdom",
|
|
497
529
|
"rustc-hash",
|
|
530
|
+
"smallvec",
|
|
531
|
+
"smol_str",
|
|
498
532
|
]
|
|
499
533
|
|
|
500
534
|
[[package]]
|
|
501
535
|
name = "turbo-dom-parser"
|
|
502
|
-
version = "0.
|
|
536
|
+
version = "0.4.0"
|
|
503
537
|
dependencies = [
|
|
504
538
|
"html5ever",
|
|
505
539
|
"markup5ever_rcdom",
|
package/Cargo.toml
CHANGED
|
@@ -5,7 +5,7 @@ members = ["crates/turbo-dom"]
|
|
|
5
5
|
|
|
6
6
|
[package]
|
|
7
7
|
name = "turbo-dom-parser"
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.4.0"
|
|
9
9
|
edition = "2021"
|
|
10
10
|
description = "html5ever-backed native parser for turbo-dom (Layer 1)"
|
|
11
11
|
|
|
@@ -36,12 +36,36 @@ napi-bind = ["dep:napi", "dep:napi-derive", "dep:napi-build"]
|
|
|
36
36
|
wasm-bind = ["dep:wasm-bindgen", "dep:serde", "dep:serde-wasm-bindgen"]
|
|
37
37
|
# Phase-1 boundary spike: Rust DOM runtime over the SoA, exported to JS via wasm-bindgen.
|
|
38
38
|
wasm-runtime = ["dep:wasm-bindgen"]
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
39
|
+
|
|
40
|
+
# Same clippy pedantic gate as the turbo-dom crate (crates/turbo-dom/Cargo.toml). The
|
|
41
|
+
# allowed lints are intentional for a parser/marshaling layer that bridges f64 and integer
|
|
42
|
+
# indices and hand-rolls hot loops — documented opt-outs, not silent suppression.
|
|
43
|
+
[lints.clippy]
|
|
44
|
+
pedantic = { level = "warn", priority = -1 }
|
|
45
|
+
cast_possible_truncation = "allow"
|
|
46
|
+
cast_precision_loss = "allow"
|
|
47
|
+
cast_sign_loss = "allow"
|
|
48
|
+
cast_possible_wrap = "allow"
|
|
49
|
+
float_cmp = "allow"
|
|
50
|
+
many_single_char_names = "allow"
|
|
51
|
+
missing_panics_doc = "allow"
|
|
52
|
+
missing_errors_doc = "allow"
|
|
53
|
+
struct_excessive_bools = "allow"
|
|
54
|
+
too_many_lines = "allow"
|
|
55
|
+
implicit_hasher = "allow"
|
|
56
|
+
# The #[napi]/#[wasm_bindgen] parse fns take owned `String` per FFI convention — the
|
|
57
|
+
# marshaled JS string is owned at the boundary and the copy is negligible vs parse cost.
|
|
58
|
+
needless_pass_by_value = "allow"
|
|
44
59
|
|
|
45
60
|
[profile.release]
|
|
46
61
|
lto = true
|
|
47
62
|
opt-level = 3
|
|
63
|
+
|
|
64
|
+
# Profiling profile: identical codegen to `release` (lto + opt-level 3) but with
|
|
65
|
+
# debug symbols, so `sample`/Instruments can attribute self-time to source lines.
|
|
66
|
+
# Kept SEPARATE from `[profile.release]` on purpose — the shipped crate stays lean
|
|
67
|
+
# (no debuginfo bloat); this profile is only used by `cargo build --profile profiling`
|
|
68
|
+
# for the CPU profiling harness (examples/churn_profile.rs).
|
|
69
|
+
[profile.profiling]
|
|
70
|
+
inherits = "release"
|
|
71
|
+
debug = true
|
package/README.md
CHANGED
|
@@ -219,14 +219,13 @@ let id = dom.tree.get_attribute(cards[0], "id"); // Some("hero") —
|
|
|
219
219
|
let style = cascade::computed_style(&dom.tree, cards[0]); // partial honest cascade
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
- **crates.io:** [`turbo-dom`](https://crates.io/crates/turbo-dom) — the
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
(= `cargo
|
|
229
|
-
a direct html5lib-tests gate at **99.75%** (`npm run conformance:rtdom`).
|
|
222
|
+
- **crates.io:** [`turbo-dom`](https://crates.io/crates/turbo-dom) — the Rust DOM engine lives
|
|
223
|
+
here and only here (the workspace-member crate `crates/turbo-dom/`; there is no in-repo
|
|
224
|
+
`src/rtdom` copy and no `rust-runtime` cargo feature — an earlier feature gated a duplicate copy
|
|
225
|
+
that was consolidated away). Minimal deps (`html5ever` + `rustc-hash`, no napi/wasm), a runnable
|
|
226
|
+
`examples/crawl.rs`, 227 tests, 100% line coverage, and a direct html5lib-tests gate at
|
|
227
|
+
**99.75%**. From the repo: `npm run build:rtdom` (= `cargo build -p turbo-dom --release`),
|
|
228
|
+
`npm run test:rust` (= `cargo test -p turbo-dom`), `npm run conformance:rtdom`.
|
|
230
229
|
|
|
231
230
|
## Limitations (by design)
|
|
232
231
|
|
|
@@ -262,13 +261,13 @@ let style = cascade::computed_style(&dom.tree, cards[0]); // partial honest ca
|
|
|
262
261
|
|
|
263
262
|
## Development
|
|
264
263
|
|
|
265
|
-
Requires Node ≥
|
|
264
|
+
Requires Node ≥ 24 and a Rust toolchain (`rustup`, stable). `mise.toml` pins both.
|
|
266
265
|
|
|
267
266
|
```bash
|
|
268
267
|
npm install
|
|
269
268
|
npm run build # native addon (.node) + wasm — the JS-consumer artifacts
|
|
270
269
|
npm test # JS suite (unit, conformance, differential, gauntlets)
|
|
271
|
-
npm run test:rust # Rust tests: parser core +
|
|
270
|
+
npm run test:rust # Rust tests: parser core + the turbo-dom crate (cargo test -p turbo-dom)
|
|
272
271
|
npm run conformance # html5lib-tests report (parser)
|
|
273
272
|
npm run conformance:rtdom # html5lib-tests gate run through the rtdom tree
|
|
274
273
|
npm run bench:all # JS-runtime benchmarks
|
|
@@ -276,8 +275,8 @@ npm run build:wasm # wasm32 parser fallback
|
|
|
276
275
|
npm run build:rtdom # pure-Rust DOM runtime (no napi/wasm) — the Rust-consumer build
|
|
277
276
|
```
|
|
278
277
|
|
|
279
|
-
The JS runtime (`src/runtime/*.mjs`) and the Rust runtime (`
|
|
280
|
-
|
|
278
|
+
The JS runtime (`src/runtime/*.mjs`) and the Rust runtime (the `crates/turbo-dom` crate) are
|
|
279
|
+
independent — touching one never affects the other. See
|
|
281
280
|
[RUST_PORT_PLAN.md](./RUST_PORT_PLAN.md) for the dual-runtime architecture and
|
|
282
281
|
[RUST_PORT_PERF_HISTORY.md](./RUST_PORT_PERF_HISTORY.md) for how each JS perf win maps to Rust.
|
|
283
282
|
|
package/index.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export declare function parse(html: string): JsNode
|
|
|
30
30
|
*/
|
|
31
31
|
export declare function parseRaw(html: string): number
|
|
32
32
|
/**
|
|
33
|
-
* SoA flat buffer: structure as typed arrays, crossed once. JS inflates node
|
|
33
|
+
* `SoA` flat buffer: structure as typed arrays, crossed once. JS inflates node
|
|
34
34
|
* objects lazily from this — no eager full-tree allocation. The fast path.
|
|
35
35
|
*/
|
|
36
36
|
export interface JsSoa {
|
|
@@ -40,7 +40,7 @@ export interface JsSoa {
|
|
|
40
40
|
strBlob: Uint8Array
|
|
41
41
|
strMeta: Uint32Array
|
|
42
42
|
}
|
|
43
|
-
/** Parse a document into the SoA flat buffer (the fast runtime path). */
|
|
43
|
+
/** Parse a document into the `SoA` flat buffer (the fast runtime path). */
|
|
44
44
|
export declare function parseBuffer(html: string): JsSoa
|
|
45
45
|
/**
|
|
46
46
|
* Parse an HTML fragment (innerHTML-style). `context` is the context element:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "index.js",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"build:wasm:web": "wasm-pack build --target web --out-dir pkg-web --no-default-features --features wasm-bind",
|
|
77
77
|
"test": "node --test 'test/*.mjs'",
|
|
78
78
|
"test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=99 --test-coverage-functions=95 --test-coverage-branches=95 'test/*.mjs'",
|
|
79
|
-
"test:rust": "cargo test
|
|
79
|
+
"test:rust": "cargo test -p turbo-dom",
|
|
80
80
|
"test:all": "npm run build && npm run test:rust && npm test",
|
|
81
81
|
"prepare": "git config core.hooksPath .githooks || true",
|
|
82
82
|
"conformance": "node harness/conformance.mjs",
|
|
@@ -91,10 +91,11 @@
|
|
|
91
91
|
"test:jest": "jest -c test-jest/jest.config.cjs",
|
|
92
92
|
"bench:query": "node bench/query.mjs",
|
|
93
93
|
"bench:scorecard": "node bench/scorecard.mjs",
|
|
94
|
-
"conformance:rtdom": "cargo test
|
|
95
|
-
"build:rtdom": "cargo build
|
|
94
|
+
"conformance:rtdom": "cargo test -p turbo-dom rtdom::conformance::rtdom_html5lib_conformance_full_document -- --nocapture",
|
|
95
|
+
"build:rtdom": "cargo build -p turbo-dom --release",
|
|
96
|
+
"profile:churn": "cargo build --profile profiling --example churn_profile -p turbo-dom && echo 'built — CPU-profile with: ./target/profiling/examples/churn_profile & sample $! 20 -file target/churn.sample.txt'",
|
|
96
97
|
"bench:compare": "node bench/dom-compare.mjs",
|
|
97
|
-
"bench:compare:all": "RTDOM_JSON=\"$(cargo test
|
|
98
|
+
"bench:compare:all": "RTDOM_JSON=\"$(cargo test -p turbo-dom --release rtdom::bench::compare_all -- --ignored --nocapture 2>/dev/null | sed -n 's/.*RTDOM_JSON //p')\" node --expose-gc --max-old-space-size=8192 bench/compare-all.mjs"
|
|
98
99
|
},
|
|
99
100
|
"keywords": [
|
|
100
101
|
"dom",
|
|
Binary file
|
|
Binary file
|
package/src/core.rs
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//! Both front-ends (napi-rs native, wasm-bindgen fallback) wrap this unchanged.
|
|
3
3
|
//!
|
|
4
4
|
//! Plan note: this is the "full marshaling" version — a *complete* tree.
|
|
5
|
-
//! The SoA flat-buffer (architecture Layer 1) is a later optimization, gated on
|
|
5
|
+
//! The `SoA` flat-buffer (architecture Layer 1) is a later optimization, gated on
|
|
6
6
|
//! marshaling proving to be the actual cost. Do not SoA-ify here yet.
|
|
7
7
|
|
|
8
8
|
use html5ever::driver::ParseOpts;
|
|
9
9
|
use html5ever::tendril::TendrilSink;
|
|
10
10
|
use html5ever::tree_builder::TreeBuilderOpts;
|
|
11
|
-
use html5ever::{parse_document, parse_fragment,
|
|
11
|
+
use html5ever::{parse_document, parse_fragment, ns, LocalName, QualName};
|
|
12
12
|
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
|
13
13
|
|
|
14
14
|
/// DOM nodeType constants (subset html5ever can emit).
|
|
@@ -59,6 +59,7 @@ fn opts() -> ParseOpts {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/// Parse a full HTML document. Always yields a Document root (nodeType 9).
|
|
62
|
+
#[must_use]
|
|
62
63
|
pub fn parse_html_document(html: &str) -> Node {
|
|
63
64
|
let dom = parse_document(RcDom::default(), opts())
|
|
64
65
|
.from_utf8()
|
|
@@ -186,7 +187,7 @@ impl SoaBuilder {
|
|
|
186
187
|
}
|
|
187
188
|
NodeData::ProcessingInstruction { target, contents } => {
|
|
188
189
|
self.soa.node_type[idx] = PROCESSING_INSTRUCTION_NODE;
|
|
189
|
-
let combined = format!("{} {}"
|
|
190
|
+
let combined = format!("{target} {contents}");
|
|
190
191
|
self.soa.text_id[idx] = self.push_string(&combined);
|
|
191
192
|
}
|
|
192
193
|
NodeData::Element { name, attrs, template_contents, .. } => {
|
|
@@ -202,7 +203,7 @@ impl SoaBuilder {
|
|
|
202
203
|
self.soa.attr_name_id.push(nid);
|
|
203
204
|
let vid = self.intern_attr_value(&attr.value);
|
|
204
205
|
self.soa.attr_value_id.push(vid);
|
|
205
|
-
let pfx = attr.name.prefix.as_ref().map(
|
|
206
|
+
let pfx = attr.name.prefix.as_ref().map(std::string::ToString::to_string).unwrap_or_default();
|
|
206
207
|
let pid = self.intern_attr_prefix(&pfx);
|
|
207
208
|
self.soa.attr_prefix_id.push(pid);
|
|
208
209
|
}
|
|
@@ -253,7 +254,8 @@ impl SoaBuilder {
|
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
/// Parse a full document into the SoA flat buffer.
|
|
257
|
+
/// Parse a full document into the `SoA` flat buffer.
|
|
258
|
+
#[must_use]
|
|
257
259
|
pub fn parse_html_soa(html: &str) -> Soa {
|
|
258
260
|
let dom = parse_document(RcDom::default(), opts())
|
|
259
261
|
.from_utf8()
|
|
@@ -263,10 +265,10 @@ pub fn parse_html_soa(html: &str) -> Soa {
|
|
|
263
265
|
soa: Soa::default(),
|
|
264
266
|
// pre-reserve typical intern-table sizes so the build loop doesn't rehash:
|
|
265
267
|
// tag/attr-name sets are small + bounded; values vary more.
|
|
266
|
-
tag_map: rustc_hash::FxHashMap::with_capacity_and_hasher(32,
|
|
267
|
-
attr_name_map: rustc_hash::FxHashMap::with_capacity_and_hasher(64,
|
|
268
|
-
attr_prefix_map: rustc_hash::FxHashMap::with_capacity_and_hasher(8,
|
|
269
|
-
attr_value_map: rustc_hash::FxHashMap::with_capacity_and_hasher(128,
|
|
268
|
+
tag_map: rustc_hash::FxHashMap::with_capacity_and_hasher(32, rustc_hash::FxBuildHasher),
|
|
269
|
+
attr_name_map: rustc_hash::FxHashMap::with_capacity_and_hasher(64, rustc_hash::FxBuildHasher),
|
|
270
|
+
attr_prefix_map: rustc_hash::FxHashMap::with_capacity_and_hasher(8, rustc_hash::FxBuildHasher),
|
|
271
|
+
attr_value_map: rustc_hash::FxHashMap::with_capacity_and_hasher(128, rustc_hash::FxBuildHasher),
|
|
270
272
|
};
|
|
271
273
|
b.alloc(&dom.document, -1);
|
|
272
274
|
b.soa
|
|
@@ -274,6 +276,7 @@ pub fn parse_html_soa(html: &str) -> Soa {
|
|
|
274
276
|
|
|
275
277
|
/// Parse a document and return only the node count — no `core::Node` tree, no
|
|
276
278
|
/// marshaling. Isolates raw html5ever parse cost from tree-build + boundary cost.
|
|
279
|
+
#[must_use]
|
|
277
280
|
pub fn parse_html_document_count(html: &str) -> u32 {
|
|
278
281
|
let dom = parse_document(RcDom::default(), opts())
|
|
279
282
|
.from_utf8()
|
|
@@ -288,12 +291,14 @@ fn count(handle: &Handle) -> u32 {
|
|
|
288
291
|
|
|
289
292
|
/// Parse an HTML fragment (e.g. an `innerHTML=` set) in `<body>` context.
|
|
290
293
|
/// Returns a synthetic Document-fragment-ish root whose children are the parsed nodes.
|
|
294
|
+
#[must_use]
|
|
291
295
|
pub fn parse_html_fragment(html: &str) -> Node {
|
|
292
296
|
parse_html_fragment_in(html, "", "body")
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
/// Parse a fragment given an html5lib-style context string:
|
|
296
300
|
/// "" / "body" / "td" (html ns) or "svg path" / "math ms" (foreign ns).
|
|
301
|
+
#[must_use]
|
|
297
302
|
pub fn parse_html_fragment_context(html: &str, context: &str) -> Node {
|
|
298
303
|
let (ns, local) = match context {
|
|
299
304
|
"" => ("", "body"),
|
|
@@ -309,6 +314,7 @@ pub fn parse_html_fragment_context(html: &str, context: &str) -> Node {
|
|
|
309
314
|
|
|
310
315
|
/// Parse a fragment in an explicit context element.
|
|
311
316
|
/// `context_ns`: "" (html), "svg", or "math"; `context_local`: the element local name.
|
|
317
|
+
#[must_use]
|
|
312
318
|
pub fn parse_html_fragment_in(html: &str, context_ns: &str, context_local: &str) -> Node {
|
|
313
319
|
let ns = match context_ns {
|
|
314
320
|
"svg" => ns!(svg),
|
|
@@ -383,7 +389,7 @@ fn walk(handle: &Handle) -> Node {
|
|
|
383
389
|
.map(|attr| Attr {
|
|
384
390
|
name: attr.name.local.to_string(),
|
|
385
391
|
value: attr.value.to_string(),
|
|
386
|
-
prefix: attr.name.prefix.as_ref().map(
|
|
392
|
+
prefix: attr.name.prefix.as_ref().map(std::string::ToString::to_string).unwrap_or_default(),
|
|
387
393
|
})
|
|
388
394
|
.collect();
|
|
389
395
|
// html5lib short namespace form: "" (html), "svg", "math".
|
|
@@ -502,6 +508,6 @@ mod tests {
|
|
|
502
508
|
let frag = parse_html_fragment_context("<rect/>", "svg path");
|
|
503
509
|
// context is svg path, so a bare <rect> parses in the SVG namespace
|
|
504
510
|
let rect = first(&frag, &|n| n.name == "rect");
|
|
505
|
-
assert!(rect.
|
|
511
|
+
assert!(rect.is_some_and(|n| n.namespace == "svg"));
|
|
506
512
|
}
|
|
507
513
|
}
|
package/src/lib.rs
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
//! turbo-dom parser — Layer 1.
|
|
2
2
|
//! One Rust core (`core`), two interchangeable front-ends selected by feature:
|
|
3
3
|
//! * `napi-bind` → native Node addon (default, fast path)
|
|
4
|
-
//! * `wasm-bind` → wasm32 fallback (StackBlitz / WebContainers / locked-down CI)
|
|
4
|
+
//! * `wasm-bind` → wasm32 fallback (`StackBlitz` / `WebContainers` / locked-down CI)
|
|
5
|
+
//!
|
|
5
6
|
//! Both expose the same logical API: parse(html) -> tree, parseFragment(html) -> tree.
|
|
6
7
|
|
|
7
8
|
pub mod core;
|
|
8
9
|
|
|
9
|
-
// Pure-Rust DOM runtime (native Rust API; no wasm-bindgen / napi). Off by default
|
|
10
|
-
// so shipped parser artifacts stay lean — a Rust consumer enables `rust-runtime`.
|
|
11
|
-
#[cfg(feature = "rust-runtime")]
|
|
12
|
-
pub mod rtdom;
|
|
13
|
-
|
|
14
10
|
// Phase-1 boundary spike (Rust DOM runtime exported to JS via wasm-bindgen).
|
|
15
11
|
#[cfg(feature = "wasm-runtime")]
|
|
16
12
|
pub mod spike;
|
|
@@ -18,7 +14,10 @@ pub mod spike;
|
|
|
18
14
|
// ---------------------------------------------------------------------------
|
|
19
15
|
// napi-rs front-end (native addon)
|
|
20
16
|
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
17
|
+
// `not(test)`: this is cdylib boundary glue, exercised by the JS integration
|
|
18
|
+
// tests, never Rust-unit-tested. napi's export registration doesn't emit under
|
|
19
|
+
// `cfg(test)`, so building it for `cargo test` only yields dead-code warnings.
|
|
20
|
+
#[cfg(all(feature = "napi-bind", not(test)))]
|
|
22
21
|
mod napi_front {
|
|
23
22
|
use crate::core;
|
|
24
23
|
use napi_derive::napi;
|
|
@@ -76,9 +75,9 @@ mod napi_front {
|
|
|
76
75
|
core::parse_html_document_count(&html)
|
|
77
76
|
}
|
|
78
77
|
|
|
79
|
-
use napi::bindgen_prelude::{
|
|
78
|
+
use napi::bindgen_prelude::{Uint32Array, Uint8Array};
|
|
80
79
|
|
|
81
|
-
/// SoA flat buffer: structure as typed arrays, crossed once. JS inflates node
|
|
80
|
+
/// `SoA` flat buffer: structure as typed arrays, crossed once. JS inflates node
|
|
82
81
|
/// objects lazily from this — no eager full-tree allocation. The fast path.
|
|
83
82
|
// All numeric columns packed into ONE little-endian byte blob (1 ArrayBuffer +
|
|
84
83
|
// zero-copy views in JS, vs 13 separate addon buffers + finalizers per parse).
|
|
@@ -106,14 +105,14 @@ mod napi_front {
|
|
|
106
105
|
let n = s.node_type.len();
|
|
107
106
|
let m = s.attr_name_id.len();
|
|
108
107
|
let mut buf: Vec<u8> = Vec::with_capacity(36 * n + 12 * m);
|
|
109
|
-
for col in [&s.tag_id] { for v in col
|
|
108
|
+
for col in [&s.tag_id] { for v in col { buf.extend_from_slice(&v.to_le_bytes()); } }
|
|
110
109
|
for col in [&s.parent, &s.first_child, &s.next_sib, &s.text_id, &s.pub_id, &s.sys_id, &s.attr_start] {
|
|
111
|
-
for v in col
|
|
110
|
+
for v in col { buf.extend_from_slice(&v.to_le_bytes()); }
|
|
112
111
|
}
|
|
113
112
|
for col in [&s.attr_name_id, &s.attr_value_id, &s.attr_prefix_id] {
|
|
114
|
-
for v in col
|
|
113
|
+
for v in col { buf.extend_from_slice(&v.to_le_bytes()); }
|
|
115
114
|
}
|
|
116
|
-
for v in s.attr_count
|
|
115
|
+
for v in &s.attr_count { buf.extend_from_slice(&v.to_le_bytes()); }
|
|
117
116
|
buf.extend_from_slice(&s.node_type);
|
|
118
117
|
buf.extend_from_slice(&s.ns);
|
|
119
118
|
// pack all five string tables into one byte blob + meta (5 counts, then
|
|
@@ -121,12 +120,12 @@ mod napi_front {
|
|
|
121
120
|
let tables: [&Vec<String>; 5] =
|
|
122
121
|
[&s.tag_names, &s.attr_names, &s.attr_prefixes, &s.attr_values, &s.strings];
|
|
123
122
|
let total_strs: usize = tables.iter().map(|t| t.len()).sum();
|
|
124
|
-
let total_bytes: usize = tables.iter().flat_map(|t| t.iter()).map(
|
|
123
|
+
let total_bytes: usize = tables.iter().flat_map(|t| t.iter()).map(std::string::String::len).sum();
|
|
125
124
|
let mut str_blob: Vec<u8> = Vec::with_capacity(total_bytes);
|
|
126
125
|
let mut str_meta: Vec<u32> = Vec::with_capacity(5 + total_strs);
|
|
127
126
|
for t in &tables { str_meta.push(t.len() as u32); }
|
|
128
127
|
for t in &tables {
|
|
129
|
-
for x in t
|
|
128
|
+
for x in *t {
|
|
130
129
|
str_meta.push(x.len() as u32);
|
|
131
130
|
str_blob.extend_from_slice(x.as_bytes());
|
|
132
131
|
}
|
|
@@ -141,7 +140,7 @@ mod napi_front {
|
|
|
141
140
|
}
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
/// Parse a document into the SoA flat buffer (the fast runtime path).
|
|
143
|
+
/// Parse a document into the `SoA` flat buffer (the fast runtime path).
|
|
145
144
|
#[napi(js_name = "parseBuffer")]
|
|
146
145
|
pub fn parse_buffer(html: String) -> JsSoa {
|
|
147
146
|
core::parse_html_soa(&html).into()
|
package/src/runtime/cascade.mjs
CHANGED
|
@@ -91,8 +91,14 @@ function parseStylesheet(css, startOrder, rules) {
|
|
|
91
91
|
const n = css.length;
|
|
92
92
|
while (i < n) {
|
|
93
93
|
let j = i;
|
|
94
|
-
while (j < n && css[j] !== '{' && css[j] !== '}') j++;
|
|
94
|
+
while (j < n && css[j] !== '{' && css[j] !== '}' && css[j] !== ';') j++;
|
|
95
95
|
if (j >= n) break;
|
|
96
|
+
// A `;` outside any block terminates a statement at-rule (@import/@charset/
|
|
97
|
+
// @namespace) or a stray semicolon — it has no block, so skip just the statement
|
|
98
|
+
// and keep scanning. Without this the statement is glued onto the following
|
|
99
|
+
// selector and (starting with `@`) drags that whole rule into the skip. Mirrors
|
|
100
|
+
// rtdom cascade::parse_stylesheet.
|
|
101
|
+
if (css[j] === ';') { order++; i = j + 1; continue; }
|
|
96
102
|
if (css[j] === '}') { i = j + 1; continue; }
|
|
97
103
|
const sel = css.slice(i, j).trim();
|
|
98
104
|
let depth = 1, k = j + 1;
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -298,6 +298,17 @@ Object.assign(Node.prototype, {
|
|
|
298
298
|
DOCUMENT_POSITION_CONTAINED_BY: 16, DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32,
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
// Viable next sibling (DOM spec): the first following sibling that is NOT itself
|
|
302
|
+
// one of the nodes being inserted. Anchoring after()/replaceWith() on the raw
|
|
303
|
+
// nextSibling breaks when that sibling is also an argument (e.g. a.after(b, c)
|
|
304
|
+
// where b already follows a) — insertBefore(b, b) then throws NotFoundError.
|
|
305
|
+
// Mirrors rtdom Tree::viable_next_sibling.
|
|
306
|
+
function viableNextSibling(node, nodes) {
|
|
307
|
+
let s = node.nextSibling;
|
|
308
|
+
while (s && nodes.includes(s)) s = s.nextSibling;
|
|
309
|
+
return s;
|
|
310
|
+
}
|
|
311
|
+
|
|
301
312
|
// ----------------------------------------------------- CharacterData ----
|
|
302
313
|
class CharacterData extends Node {
|
|
303
314
|
constructor(ownerDocument, data) { super(ownerDocument); this._data = data ?? ''; }
|
|
@@ -314,8 +325,8 @@ class CharacterData extends Node {
|
|
|
314
325
|
deleteData(offset, count) { this.data = this._data.slice(0, offset) + this._data.slice(offset + count); }
|
|
315
326
|
replaceData(offset, count, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset + count); }
|
|
316
327
|
before(...nodes) { const p = this.parentNode; if (p) for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, this); }
|
|
317
|
-
after(...nodes) { const p = this.parentNode; if (!p) return; const ref = this
|
|
318
|
-
replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this
|
|
328
|
+
after(...nodes) { const p = this.parentNode; if (!p) return; const ref = viableNextSibling(this, nodes); for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
|
|
329
|
+
replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = viableNextSibling(this, nodes); for (const n of nodes) if (n !== this) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); this.remove(); }
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
export class Text extends CharacterData {
|
|
@@ -693,8 +704,8 @@ export class Element extends Node {
|
|
|
693
704
|
append(...nodes) { for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
|
|
694
705
|
prepend(...nodes) { const first = this.firstChild; for (const n of nodes) this.insertBefore(toNode(this.ownerDocument, n), first); }
|
|
695
706
|
before(...nodes) { const p = this.parentNode; if (!p) return; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), this); }
|
|
696
|
-
after(...nodes) { const p = this.parentNode; if (!p) return; const ref = this
|
|
697
|
-
replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this
|
|
707
|
+
after(...nodes) { const p = this.parentNode; if (!p) return; const ref = viableNextSibling(this, nodes); for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
|
|
708
|
+
replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = viableNextSibling(this, nodes); for (const n of nodes) if (n !== this) p.insertBefore(toNode(this.ownerDocument, n), ref); this.remove(); }
|
|
698
709
|
replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
|
|
699
710
|
|
|
700
711
|
// ---- queries ----
|
|
@@ -1,86 +1,225 @@
|
|
|
1
1
|
// Compact CSS selector engine. Correctness-first, right-to-left matching.
|
|
2
2
|
// Supports: type, *, #id, .class, [attr], [attr op val] (= ^= $= *= ~= |=),
|
|
3
3
|
// combinators (descendant ' ', child '>', adjacent '+', sibling '~'),
|
|
4
|
-
// comma selector lists,
|
|
5
|
-
// :empty, :root. Enough for React
|
|
4
|
+
// comma selector lists, :not()/:is()/:where() (selector lists), the relational
|
|
5
|
+
// :has(), :first-child, :last-child, :only-child, :empty, :root. Enough for React
|
|
6
|
+
// Testing Library usage.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
// ── Tokenizer + recursive-descent parser ─────────────────────────────────────
|
|
9
|
+
// The selector string is first turned into a flat token stream by `tokenize`,
|
|
10
|
+
// which centrally handles the two things an ad-hoc character scanner keeps
|
|
11
|
+
// getting wrong: quoted strings and balanced `[]`/`()` runs. The grammar layer
|
|
12
|
+
// (`parseCompound`/`parseComplexTokens`) then only ever sees whole tokens, so it
|
|
13
|
+
// never has to re-discover where an attribute or pseudo argument ends. The AST it
|
|
14
|
+
// emits — a Complex `{ first, rest: [{ combinator, compound }] }` (mirroring the
|
|
15
|
+
// Rust port's "parse, don't validate" shape: no parallel head/tail arrays that can
|
|
16
|
+
// desync) with the same compound/attr/pseudo object shapes — is exactly what the
|
|
17
|
+
// matcher below consumes.
|
|
18
|
+
//
|
|
19
|
+
// Token kinds:
|
|
20
|
+
// { k:'comma' } a top-level ','
|
|
21
|
+
// { k:'ws' } a run of whitespace (a candidate descendant combinator)
|
|
22
|
+
// { k:'comb', v:'>'|'+'|'~' } an explicit combinator
|
|
23
|
+
// { k:'star' } '*'
|
|
24
|
+
// { k:'type', v } a type/element name
|
|
25
|
+
// { k:'id', v } '#name'
|
|
26
|
+
// { k:'class', v } '.name'
|
|
27
|
+
// { k:'attr', name, match } '[ … ]' (match = { op:'present' } | { op, value })
|
|
28
|
+
// { k:'pseudo', name, arg } ':name' / ':name( arg )' (arg null ⇒ no parens)
|
|
8
29
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
30
|
+
const NAME_CHAR = /[a-zA-Z0-9_-]/;
|
|
31
|
+
function isTypeStart(c) {
|
|
32
|
+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
|
33
|
+
}
|
|
34
|
+
function isWs(c) {
|
|
35
|
+
return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Split a raw `[ … ]` interior into `{ name, match }`, where `match` FOLDS the
|
|
39
|
+
// operator and value so an illegal state is unrepresentable: a presence test is
|
|
40
|
+
// `{ op: 'present' }` (NO `value` key at all), and a valued test is
|
|
41
|
+
// `{ op, value }` with op ∈ equals|includes|dash|prefix|suffix|substr (mirroring
|
|
42
|
+
// the Rust `AttrMatch` variants). The first '=' (if any) splits name from value;
|
|
43
|
+
// an operator char (~ | ^ $ *) immediately before it selects the operator. A
|
|
44
|
+
// surrounding matching quote pair on the value is stripped. No '=' ⇒ presence.
|
|
45
|
+
const ATTR_OP = { '~': 'includes', '|': 'dash', '^': 'prefix', $: 'suffix', '*': 'substr' };
|
|
46
|
+
function parseAttr(inner) {
|
|
47
|
+
const eq = inner.indexOf('=');
|
|
48
|
+
if (eq === -1) return { name: inner.trim(), match: { op: 'present' } };
|
|
49
|
+
let name = inner.slice(0, eq);
|
|
50
|
+
let op = 'equals';
|
|
51
|
+
const last = name[name.length - 1];
|
|
52
|
+
if (ATTR_OP[last] !== undefined) {
|
|
53
|
+
op = ATTR_OP[last];
|
|
54
|
+
name = name.slice(0, -1);
|
|
55
|
+
}
|
|
56
|
+
let value = inner.slice(eq + 1).trim();
|
|
57
|
+
if (
|
|
58
|
+
value.length >= 2 &&
|
|
59
|
+
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
60
|
+
(value[0] === "'" && value[value.length - 1] === "'"))
|
|
61
|
+
) {
|
|
62
|
+
value = value.slice(1, -1);
|
|
63
|
+
}
|
|
64
|
+
return { name: name.trim(), match: { op, value } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Scan from `start` (just past an opening `open`) to its matching `close`,
|
|
68
|
+
// honoring quotes and nesting. Returns { inner, end } where `end` is the index of
|
|
69
|
+
// the closing delimiter (or src.length if unterminated). One shared scanner for
|
|
70
|
+
// both `[...]` and `(...)`, mirroring the Rust `scan_balanced` — a `]`/`)` inside a
|
|
71
|
+
// quoted value never terminates early, and `:not(:nth-child(2))` nests correctly.
|
|
72
|
+
function scanBalanced(src, start, open, close) {
|
|
73
|
+
const n = src.length;
|
|
74
|
+
let depth = 1, i = start, quote = null;
|
|
75
|
+
for (; i < n; i++) {
|
|
76
|
+
const d = src[i];
|
|
77
|
+
if (quote) { if (d === quote) quote = null; continue; }
|
|
78
|
+
if (d === '"' || d === "'") quote = d;
|
|
79
|
+
else if (d === open) depth++;
|
|
80
|
+
else if (d === close) { depth--; if (depth === 0) break; }
|
|
81
|
+
}
|
|
82
|
+
return { inner: src.slice(start, i), end: i };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tokenize(src) {
|
|
86
|
+
const tokens = [];
|
|
87
|
+
const n = src.length;
|
|
88
|
+
let i = 0;
|
|
89
|
+
while (i < n) {
|
|
13
90
|
const c = src[i];
|
|
14
|
-
if (c
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
i = j; matchedAny = true; continue;
|
|
91
|
+
if (isWs(c)) {
|
|
92
|
+
i++;
|
|
93
|
+
while (i < n && isWs(src[i])) i++;
|
|
94
|
+
tokens.push({ k: 'ws' });
|
|
95
|
+
continue;
|
|
20
96
|
}
|
|
97
|
+
if (c === ',') { tokens.push({ k: 'comma' }); i++; continue; }
|
|
98
|
+
if (c === '>' || c === '+' || c === '~') { tokens.push({ k: 'comb', v: c }); i++; continue; }
|
|
99
|
+
if (c === '*') { tokens.push({ k: 'star' }); i++; continue; }
|
|
21
100
|
if (c === '#') {
|
|
22
101
|
let j = i + 1;
|
|
23
|
-
while (j <
|
|
24
|
-
|
|
102
|
+
while (j < n && NAME_CHAR.test(src[j])) j++;
|
|
103
|
+
tokens.push({ k: 'id', v: src.slice(i + 1, j) });
|
|
104
|
+
i = j; continue;
|
|
25
105
|
}
|
|
26
106
|
if (c === '.') {
|
|
27
107
|
let j = i + 1;
|
|
28
|
-
while (j <
|
|
29
|
-
|
|
108
|
+
while (j < n && NAME_CHAR.test(src[j])) j++;
|
|
109
|
+
tokens.push({ k: 'class', v: src.slice(i + 1, j) });
|
|
110
|
+
i = j; continue;
|
|
30
111
|
}
|
|
31
112
|
if (c === '[') {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
i = ATTR_RE.lastIndex; matchedAny = true; continue;
|
|
113
|
+
const { inner, end } = scanBalanced(src, i + 1, '[', ']');
|
|
114
|
+
if (end >= n) throw new SyntaxError(`unterminated attribute selector: ${src.slice(i)}`);
|
|
115
|
+
tokens.push({ k: 'attr', ...parseAttr(inner) });
|
|
116
|
+
i = end + 1; continue;
|
|
37
117
|
}
|
|
38
118
|
if (c === ':') {
|
|
39
119
|
let j = i + 1;
|
|
40
|
-
while (j <
|
|
120
|
+
while (j < n && (isTypeStart(src[j]) || src[j] === '-')) j++;
|
|
41
121
|
const name = src.slice(i + 1, j);
|
|
42
122
|
let arg = null;
|
|
43
|
-
if (src[j] === '(') {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
123
|
+
if (j < n && src[j] === '(') {
|
|
124
|
+
// balanced parens (quotes respected) so a nested ':not(:nth-child(2))'
|
|
125
|
+
// or a quoted ')' survives intact for the recursive parse.
|
|
126
|
+
const { inner, end } = scanBalanced(src, j + 1, '(', ')');
|
|
127
|
+
arg = inner;
|
|
128
|
+
i = end < n ? end + 1 : n;
|
|
129
|
+
} else {
|
|
130
|
+
i = j;
|
|
48
131
|
}
|
|
49
|
-
|
|
50
|
-
|
|
132
|
+
tokens.push({ k: 'pseudo', name, arg });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (isTypeStart(c)) {
|
|
136
|
+
let j = i + 1;
|
|
137
|
+
while (j < n && NAME_CHAR.test(src[j])) j++;
|
|
138
|
+
tokens.push({ k: 'type', v: src.slice(i, j) });
|
|
139
|
+
i = j; continue;
|
|
51
140
|
}
|
|
52
|
-
|
|
141
|
+
throw new SyntaxError(`unexpected '${c}' in selector`);
|
|
53
142
|
}
|
|
54
|
-
|
|
55
|
-
return { compound, i };
|
|
143
|
+
return tokens;
|
|
56
144
|
}
|
|
57
145
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
while (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
146
|
+
// compound := [type|'*'] ( '#'id | '.'class | '['attr']' | ':'pseudo )* — but
|
|
147
|
+
// lenient: parts may appear in any order/multiplicity, and a redundant type/'*'
|
|
148
|
+
// after the first is dropped (so `div[id=x]y` ≡ `div[id=x]`). Consuming an empty
|
|
149
|
+
// compound (no parts at all, e.g. a leading combinator) is a SyntaxError.
|
|
150
|
+
function parseCompound(tokens, pos, end) {
|
|
151
|
+
const compound = { tag: null, id: null, classes: [], attrs: [], pseudos: [] };
|
|
152
|
+
let matched = false;
|
|
153
|
+
while (pos < end) {
|
|
154
|
+
const t = tokens[pos];
|
|
155
|
+
if (t.k === 'star') { if (compound.tag === null) compound.tag = '*'; }
|
|
156
|
+
else if (t.k === 'type') { if (compound.tag === null) compound.tag = t.v.toLowerCase(); }
|
|
157
|
+
else if (t.k === 'id') { compound.id = t.v; }
|
|
158
|
+
else if (t.k === 'class') { compound.classes.push(t.v); }
|
|
159
|
+
else if (t.k === 'attr') { compound.attrs.push({ name: t.name, match: t.match }); }
|
|
160
|
+
else if (t.k === 'pseudo') { compound.pseudos.push({ name: t.name, arg: t.arg }); }
|
|
161
|
+
else break; // ws / comb / comma ends the compound
|
|
162
|
+
pos++; matched = true;
|
|
163
|
+
}
|
|
164
|
+
if (!matched) throw new SyntaxError(`empty compound at token ${pos}`);
|
|
165
|
+
return { compound, pos };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Parse tokens[lo..hi) (one complex selector — commas already split out) into a
|
|
169
|
+
// Complex `{ first, rest: [{ combinator, compound }] }`: `first` is the leftmost
|
|
170
|
+
// compound and each `rest[i].combinator` connects `rest[i].compound` to the
|
|
171
|
+
// compound on its LEFT (`rest[i-1].compound`, or `first` when i==0). Building the
|
|
172
|
+
// pair only AFTER a trailing compound is read keeps combinator/compound in lockstep
|
|
173
|
+
// — there is no way to represent a stray combinator (the parallel-array desync the
|
|
174
|
+
// old shape allowed). An empty segment (no compound at all) returns `null`.
|
|
175
|
+
function parseComplexTokens(tokens, lo, hi) {
|
|
176
|
+
// trim leading / trailing ws tokens (mirrors the old `src.trim()`)
|
|
177
|
+
let start = lo, end = hi;
|
|
178
|
+
while (start < end && tokens[start].k === 'ws') start++;
|
|
179
|
+
while (end > start && tokens[end - 1].k === 'ws') end--;
|
|
180
|
+
if (start >= end) return null; // empty segment ⇒ no compound (filtered by parseSelectorList)
|
|
181
|
+
let r = parseCompound(tokens, start, end);
|
|
182
|
+
const first = r.compound;
|
|
183
|
+
const rest = [];
|
|
184
|
+
let pos = r.pos;
|
|
185
|
+
while (pos < end) {
|
|
186
|
+
while (pos < end && tokens[pos].k === 'ws') pos++; // a ws run here ⇒ candidate descendant
|
|
187
|
+
const t = tokens[pos];
|
|
188
|
+
let combinator;
|
|
189
|
+
if (t.k === 'comb') {
|
|
190
|
+
combinator = t.v;
|
|
191
|
+
pos++;
|
|
192
|
+
while (pos < end && tokens[pos].k === 'ws') pos++;
|
|
193
|
+
if (pos >= end) break; // dangling combinator (`a >`) — drop it (no trailing compound to pair)
|
|
79
194
|
} else {
|
|
80
|
-
|
|
195
|
+
// the only token that can follow a compound after a ws run is the next
|
|
196
|
+
// compound's first part ⇒ a descendant combinator
|
|
197
|
+
combinator = ' ';
|
|
198
|
+
}
|
|
199
|
+
r = parseCompound(tokens, pos, end);
|
|
200
|
+
rest.push({ combinator, compound: r.compound });
|
|
201
|
+
pos = r.pos;
|
|
202
|
+
}
|
|
203
|
+
return { first, rest };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Parse one complex selector STRING (no top-level comma). Exposed via `_internal`.
|
|
207
|
+
// Returns the Complex `{ first, rest }` or `null` (empty selector).
|
|
208
|
+
function parseComplex(src) {
|
|
209
|
+
const tokens = tokenize(src);
|
|
210
|
+
return parseComplexTokens(tokens, 0, tokens.length);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Yield each top-level (comma-separated) segment of `tokens` as a `[lo, hi)` range.
|
|
214
|
+
// One shared loop for both the selector-list and the relative-selector-list parses.
|
|
215
|
+
function* splitTopLevelSegments(tokens) {
|
|
216
|
+
let start = 0;
|
|
217
|
+
for (let i = 0; i <= tokens.length; i++) {
|
|
218
|
+
if (i === tokens.length || tokens[i].k === 'comma') {
|
|
219
|
+
yield [start, i];
|
|
220
|
+
start = i + 1;
|
|
81
221
|
}
|
|
82
222
|
}
|
|
83
|
-
return { compounds, combinators };
|
|
84
223
|
}
|
|
85
224
|
|
|
86
225
|
// parsed-selector cache: querySelector(All)/matches re-run the same selector
|
|
@@ -90,38 +229,57 @@ const __selectorCache = new Map();
|
|
|
90
229
|
export function parseSelectorList(selector) {
|
|
91
230
|
const hit = __selectorCache.get(selector);
|
|
92
231
|
if (hit !== undefined) return hit;
|
|
93
|
-
const
|
|
232
|
+
const tokens = tokenize(selector);
|
|
233
|
+
const parsed = [];
|
|
234
|
+
for (const [lo, hi] of splitTopLevelSegments(tokens)) {
|
|
235
|
+
const cx = parseComplexTokens(tokens, lo, hi);
|
|
236
|
+
if (cx !== null) parsed.push(cx); // a Complex is always non-empty (empties dropped)
|
|
237
|
+
}
|
|
94
238
|
if (__selectorCache.size > 10000) __selectorCache.clear();
|
|
95
239
|
__selectorCache.set(selector, parsed);
|
|
96
240
|
return parsed;
|
|
97
241
|
}
|
|
98
242
|
|
|
99
|
-
|
|
243
|
+
// :has() relative-selector-list parse + cache. Each top-level comma segment may
|
|
244
|
+
// begin with a combinator ('>'/'+'/'~'); absent ⇒ descendant. The remainder is a
|
|
245
|
+
// normal complex (reused parseComplex). Segments yielding no complex are dropped.
|
|
246
|
+
// Mirrors the Rust parse_relative_selector_list.
|
|
247
|
+
const __relativeCache = new Map();
|
|
248
|
+
function parseRelativeSelectorList(arg) {
|
|
249
|
+
const src = arg || '';
|
|
250
|
+
const hit = __relativeCache.get(src);
|
|
251
|
+
if (hit !== undefined) return hit;
|
|
252
|
+
const tokens = tokenize(src);
|
|
100
253
|
const out = [];
|
|
101
|
-
let
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
254
|
+
for (let [lo, hi] of splitTopLevelSegments(tokens)) {
|
|
255
|
+
while (lo < hi && tokens[lo].k === 'ws') lo++; // skip leading ws of the segment
|
|
256
|
+
let combinator = ' ';
|
|
257
|
+
if (lo < hi && tokens[lo].k === 'comb') {
|
|
258
|
+
combinator = tokens[lo].v;
|
|
259
|
+
lo++;
|
|
260
|
+
}
|
|
261
|
+
const complex = parseComplexTokens(tokens, lo, hi);
|
|
262
|
+
if (complex !== null) out.push({ combinator, complex });
|
|
107
263
|
}
|
|
108
|
-
|
|
264
|
+
if (__relativeCache.size > 10000) __relativeCache.clear();
|
|
265
|
+
__relativeCache.set(src, out);
|
|
109
266
|
return out;
|
|
110
267
|
}
|
|
111
268
|
|
|
112
269
|
function matchAttr(el, a) {
|
|
113
270
|
const raw = el.getAttribute(a.name); // single lookup (null = absent)
|
|
114
271
|
if (raw === null) return false;
|
|
115
|
-
|
|
272
|
+
const m = a.match;
|
|
273
|
+
if (m.op === 'present') return true; // presence carries no value
|
|
116
274
|
const v = raw;
|
|
117
|
-
const t =
|
|
118
|
-
switch (
|
|
119
|
-
case '
|
|
120
|
-
case '
|
|
121
|
-
case '
|
|
122
|
-
case '
|
|
123
|
-
case '
|
|
124
|
-
case '
|
|
275
|
+
const t = m.value;
|
|
276
|
+
switch (m.op) {
|
|
277
|
+
case 'equals': return v === t;
|
|
278
|
+
case 'prefix': return t !== '' && v.startsWith(t);
|
|
279
|
+
case 'suffix': return t !== '' && v.endsWith(t);
|
|
280
|
+
case 'substr': return t !== '' && v.includes(t);
|
|
281
|
+
case 'includes': return v.split(/\s+/).includes(t);
|
|
282
|
+
case 'dash': return v === t || v.startsWith(t + '-');
|
|
125
283
|
default: return false;
|
|
126
284
|
}
|
|
127
285
|
}
|
|
@@ -130,6 +288,16 @@ function matchPseudo(el, p) {
|
|
|
130
288
|
switch (p.name) {
|
|
131
289
|
case 'not':
|
|
132
290
|
return !parseSelectorList(p.arg).some((cx) => matchComplex(el, cx));
|
|
291
|
+
// :is()/:where() — element matches if ANY complex in the list matches it
|
|
292
|
+
// (anchored at the element). :where matches identically to :is; the
|
|
293
|
+
// specificity difference is a cascade concern, out of scope here.
|
|
294
|
+
case 'is':
|
|
295
|
+
case 'where':
|
|
296
|
+
return parseSelectorList(p.arg).some((cx) => matchComplex(el, cx));
|
|
297
|
+
// :has() — relational. SOME relative selector finds a matching element
|
|
298
|
+
// reachable from `el` per its leading combinator, scoped to `el`.
|
|
299
|
+
case 'has':
|
|
300
|
+
return parseRelativeSelectorList(p.arg).some((rel) => hasMatch(el, rel));
|
|
133
301
|
case 'first-child':
|
|
134
302
|
return previousElement(el) === null;
|
|
135
303
|
case 'last-child':
|
|
@@ -247,46 +415,114 @@ function matchCompound(el, compound) {
|
|
|
247
415
|
return true;
|
|
248
416
|
}
|
|
249
417
|
|
|
418
|
+
// The k-th compound of a Complex, indexed left-to-right (0 = `first`, the leftmost).
|
|
419
|
+
function compoundAt(cx, k) {
|
|
420
|
+
return k === 0 ? cx.first : cx.rest[k - 1].compound;
|
|
421
|
+
}
|
|
422
|
+
|
|
250
423
|
// Match a parsed complex selector against `el` (the rightmost compound applies to el).
|
|
424
|
+
// The rightmost compound sits at index `cx.rest.length`.
|
|
251
425
|
function matchComplex(el, cx) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!matchCompound(el, compounds[last])) return false;
|
|
426
|
+
return matchChain(el, cx, cx.rest.length, null);
|
|
427
|
+
}
|
|
255
428
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (matchCompound(prev, target)) { current = prev; matched = true; break; }
|
|
283
|
-
prev = previousElement(prev);
|
|
284
|
-
}
|
|
285
|
-
if (!matched) return false;
|
|
429
|
+
// Match compounds 0..=k of `cx` ending at `el`, recursing leftward. Descendant (' ')
|
|
430
|
+
// and general-sibling ('~') combinators try EVERY candidate ancestor/sibling and
|
|
431
|
+
// backtrack — committing to the nearest one (the old greedy walk) wrongly rejected
|
|
432
|
+
// chains like `.a > .b .c` where a farther `.b` is the one that is a child of `.a`.
|
|
433
|
+
// `cx.rest[k-1].combinator` is the relation between compounds k-1 and k.
|
|
434
|
+
// `scope` supports `:has()`: when set to `{ comb, node }`, the LEFTMOST compound must,
|
|
435
|
+
// in addition to matching, relate to the scope element `node` by `comb` (so a relative
|
|
436
|
+
// selector's head is anchored to the `:has()` subject). `null` (what matchComplex and
|
|
437
|
+
// plain matching pass) means the leftmost compound stands alone — one sentinel.
|
|
438
|
+
function matchChain(el, cx, k, scope = null) {
|
|
439
|
+
if (!matchCompound(el, compoundAt(cx, k))) return false;
|
|
440
|
+
if (k === 0) return scope === null ? true : relatesToScope(el, scope.comb, scope.node);
|
|
441
|
+
const comb = cx.rest[k - 1].combinator; // relation between compounds k-1 and k
|
|
442
|
+
if (comb === '>') {
|
|
443
|
+
const p = el.parentNode;
|
|
444
|
+
return !!p && p.nodeType === 1 && matchChain(p, cx, k - 1, scope);
|
|
445
|
+
}
|
|
446
|
+
if (comb === '+') {
|
|
447
|
+
const prev = previousElement(el);
|
|
448
|
+
return !!prev && matchChain(prev, cx, k - 1, scope);
|
|
449
|
+
}
|
|
450
|
+
if (comb === ' ') {
|
|
451
|
+
let anc = el.parentNode;
|
|
452
|
+
while (anc && anc.nodeType === 1) {
|
|
453
|
+
if (matchChain(anc, cx, k - 1, scope)) return true;
|
|
454
|
+
anc = anc.parentNode;
|
|
286
455
|
}
|
|
287
|
-
|
|
456
|
+
return false;
|
|
288
457
|
}
|
|
289
|
-
|
|
458
|
+
// '~' general sibling
|
|
459
|
+
let prev = previousElement(el);
|
|
460
|
+
while (prev) {
|
|
461
|
+
if (matchChain(prev, cx, k - 1, scope)) return true;
|
|
462
|
+
prev = previousElement(prev);
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Does `node` stand in relation `comb` to the `:has()` scope element `el`? This anchors
|
|
468
|
+
// the leftmost compound of a relative selector: `> .a` means the `.a` head is a direct
|
|
469
|
+
// child of the subject, `.a .b` means the `.a` head is a descendant, etc. The leading
|
|
470
|
+
// combinator constrains the HEAD compound — NOT the whole complex (the bug in anchoring
|
|
471
|
+
// the entire relative complex at a child/sibling candidate).
|
|
472
|
+
function relatesToScope(node, comb, el) {
|
|
473
|
+
if (comb === '>') return node.parentNode === el;
|
|
474
|
+
if (comb === '+') return previousElement(node) === el;
|
|
475
|
+
if (comb === '~') { let p = previousElement(node); while (p) { if (p === el) return true; p = previousElement(p); } return false; }
|
|
476
|
+
// ' ' descendant: `el` is a proper ancestor of `node`
|
|
477
|
+
let a = node.parentNode;
|
|
478
|
+
while (a) { if (a === el) return true; a = a.parentNode; }
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// :has() existence search. The relative complex must match some target element (its
|
|
483
|
+
// rightmost compound) reachable from the subject `el`, with its HEAD compound related
|
|
484
|
+
// to `el` by the leading combinator (enforced at k===0 via `relatesToScope`). Candidate
|
|
485
|
+
// targets: descendants of `el` for `>`/descendant heads (the head, and thus the whole
|
|
486
|
+
// match, lives in `el`'s subtree); a following-sibling subtree for `+`/`~` heads.
|
|
487
|
+
// Mirrors the Rust has_match.
|
|
488
|
+
function hasMatch(el, rel) {
|
|
489
|
+
const { combinator, complex } = rel;
|
|
490
|
+
const scope = { comb: combinator, node: el };
|
|
491
|
+
const test = (x) => matchChain(x, complex, complex.rest.length, scope);
|
|
492
|
+
if (combinator === '+') {
|
|
493
|
+
const sib = nextElement(el);
|
|
494
|
+
return !!sib && someSelfOrDescendant(sib, test);
|
|
495
|
+
}
|
|
496
|
+
if (combinator === '~') {
|
|
497
|
+
let sib = nextElement(el);
|
|
498
|
+
while (sib) {
|
|
499
|
+
if (someSelfOrDescendant(sib, test)) return true;
|
|
500
|
+
sib = nextElement(sib);
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
// '>' and descendant leading combinator: the head (hence every match) is within
|
|
505
|
+
// `el`'s subtree, so any descendant can be the target endpoint.
|
|
506
|
+
return someDescendant(el, test);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Visit every descendant element of `node` (document order); short-circuit on the
|
|
510
|
+
// first that satisfies `pred`. No filtered-copy allocation per level.
|
|
511
|
+
function someDescendant(node, pred) {
|
|
512
|
+
const kids = rawChildren(node);
|
|
513
|
+
for (let i = 0; i < kids.length; i++) {
|
|
514
|
+
const c = kids[i];
|
|
515
|
+
if (c.nodeType !== 1) continue;
|
|
516
|
+
if (pred(c)) return true;
|
|
517
|
+
if (someDescendant(c, pred)) return true;
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// `node` itself (if an element) or any of its descendants satisfies `pred`.
|
|
523
|
+
function someSelfOrDescendant(node, pred) {
|
|
524
|
+
if (node.nodeType === 1 && pred(node)) return true;
|
|
525
|
+
return someDescendant(node, pred);
|
|
290
526
|
}
|
|
291
527
|
|
|
292
528
|
export function matchesSelector(el, selector) {
|
package/src/spike.rs
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
//! over `core::Soa` (handle = node index) — no mutation/COW needed for the gate.
|
|
7
7
|
//!
|
|
8
8
|
//! Two access shapes are exported so the bench can A/B them:
|
|
9
|
-
//! * MITIGATED (Option A) — qsa returns a packed Uint32Array in ONE crossing;
|
|
9
|
+
//! * MITIGATED (Option A) — qsa returns a packed `Uint32Array` in ONE crossing;
|
|
10
10
|
//! structure reads return ints (handles/ids); strings resolved JS-side from
|
|
11
11
|
//! bulk blobs pulled once. Boundary crossings ~ O(results), not O(nodes*fields).
|
|
12
|
-
//! * NAIVE — per-node string returns (tag_name/get_attr as String) to show the
|
|
12
|
+
//! * NAIVE — per-node string returns (`tag_name/get_attr` as String) to show the
|
|
13
13
|
//! cost the mitigations avoid.
|
|
14
14
|
|
|
15
15
|
use crate::core::{self, Soa};
|
|
@@ -23,7 +23,7 @@ struct Doc {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
thread_local! {
|
|
26
|
-
static DOCS: RefCell<Vec<Doc>> = RefCell::new(Vec::new());
|
|
26
|
+
static DOCS: RefCell<Vec<Doc>> = const { RefCell::new(Vec::new()) };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
fn with_doc<R>(doc: u32, f: impl FnOnce(&Doc) -> R) -> R {
|
|
@@ -32,6 +32,7 @@ fn with_doc<R>(doc: u32, f: impl FnOnce(&Doc) -> R) -> R {
|
|
|
32
32
|
|
|
33
33
|
/// Parse + register a document. Returns the doc handle. ONE crossing per parse.
|
|
34
34
|
#[wasm_bindgen]
|
|
35
|
+
#[must_use]
|
|
35
36
|
pub fn create(html: &str) -> u32 {
|
|
36
37
|
let soa = core::parse_html_soa(html);
|
|
37
38
|
DOCS.with(|d| {
|
|
@@ -59,33 +60,40 @@ fn join_blob(v: &[String]) -> String {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
#[wasm_bindgen]
|
|
63
|
+
#[must_use]
|
|
62
64
|
pub fn tag_names_blob(doc: u32) -> String {
|
|
63
65
|
with_doc(doc, |d| join_blob(&d.soa.tag_names))
|
|
64
66
|
}
|
|
65
67
|
#[wasm_bindgen]
|
|
68
|
+
#[must_use]
|
|
66
69
|
pub fn attr_names_blob(doc: u32) -> String {
|
|
67
70
|
with_doc(doc, |d| join_blob(&d.soa.attr_names))
|
|
68
71
|
}
|
|
69
72
|
#[wasm_bindgen]
|
|
73
|
+
#[must_use]
|
|
70
74
|
pub fn attr_values_blob(doc: u32) -> String {
|
|
71
75
|
with_doc(doc, |d| join_blob(&d.soa.attr_values))
|
|
72
76
|
}
|
|
73
77
|
#[wasm_bindgen]
|
|
78
|
+
#[must_use]
|
|
74
79
|
pub fn strings_blob(doc: u32) -> String {
|
|
75
80
|
with_doc(doc, |d| join_blob(&d.soa.strings))
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
#[wasm_bindgen]
|
|
84
|
+
#[must_use]
|
|
79
85
|
pub fn node_count(doc: u32) -> u32 {
|
|
80
86
|
with_doc(doc, |d| d.soa.node_type.len() as u32)
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
// --- MITIGATED per-node structure reads (ints only, no string marshaling) ---
|
|
84
90
|
#[wasm_bindgen]
|
|
91
|
+
#[must_use]
|
|
85
92
|
pub fn node_type(doc: u32, h: u32) -> u8 {
|
|
86
93
|
with_doc(doc, |d| d.soa.node_type[h as usize])
|
|
87
94
|
}
|
|
88
95
|
#[wasm_bindgen]
|
|
96
|
+
#[must_use]
|
|
89
97
|
pub fn tag_id(doc: u32, h: u32) -> i32 {
|
|
90
98
|
with_doc(doc, |d| {
|
|
91
99
|
let i = h as usize;
|
|
@@ -93,32 +101,38 @@ pub fn tag_id(doc: u32, h: u32) -> i32 {
|
|
|
93
101
|
})
|
|
94
102
|
}
|
|
95
103
|
#[wasm_bindgen]
|
|
104
|
+
#[must_use]
|
|
96
105
|
pub fn parent(doc: u32, h: u32) -> i32 {
|
|
97
106
|
with_doc(doc, |d| d.soa.parent[h as usize])
|
|
98
107
|
}
|
|
99
108
|
#[wasm_bindgen]
|
|
109
|
+
#[must_use]
|
|
100
110
|
pub fn first_child(doc: u32, h: u32) -> i32 {
|
|
101
111
|
with_doc(doc, |d| d.soa.first_child[h as usize])
|
|
102
112
|
}
|
|
103
113
|
#[wasm_bindgen]
|
|
114
|
+
#[must_use]
|
|
104
115
|
pub fn next_sib(doc: u32, h: u32) -> i32 {
|
|
105
116
|
with_doc(doc, |d| d.soa.next_sib[h as usize])
|
|
106
117
|
}
|
|
107
118
|
#[wasm_bindgen]
|
|
119
|
+
#[must_use]
|
|
108
120
|
pub fn text_id(doc: u32, h: u32) -> i32 {
|
|
109
121
|
with_doc(doc, |d| d.soa.text_id[h as usize])
|
|
110
122
|
}
|
|
111
123
|
|
|
112
|
-
/// MITIGATED getAttribute: returns the interned attr_value id (-1 if absent).
|
|
113
|
-
/// JS resolves the id against the attr_values string table (no per-call string).
|
|
124
|
+
/// MITIGATED getAttribute: returns the interned `attr_value` id (-1 if absent).
|
|
125
|
+
/// JS resolves the id against the `attr_values` string table (no per-call string).
|
|
114
126
|
/// `name` still crosses as &str (RTL passes a literal) — small inbound cost.
|
|
115
127
|
#[wasm_bindgen]
|
|
128
|
+
#[must_use]
|
|
116
129
|
pub fn get_attr_id(doc: u32, h: u32, name: &str) -> i32 {
|
|
117
130
|
with_doc(doc, |d| attr_value_id(&d.soa, h as usize, name).map_or(-1, |v| v as i32))
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
/// NAIVE getAttribute: returns the value String (per-call UTF-8 marshal).
|
|
121
134
|
#[wasm_bindgen]
|
|
135
|
+
#[must_use]
|
|
122
136
|
pub fn get_attr_str(doc: u32, h: u32, name: &str) -> Option<String> {
|
|
123
137
|
with_doc(doc, |d| {
|
|
124
138
|
attr_value_id(&d.soa, h as usize, name).map(|v| d.soa.attr_values[v as usize].clone())
|
|
@@ -127,6 +141,7 @@ pub fn get_attr_str(doc: u32, h: u32, name: &str) -> Option<String> {
|
|
|
127
141
|
|
|
128
142
|
/// NAIVE tagName: per-call String marshal.
|
|
129
143
|
#[wasm_bindgen]
|
|
144
|
+
#[must_use]
|
|
130
145
|
pub fn tag_name_str(doc: u32, h: u32) -> Option<String> {
|
|
131
146
|
with_doc(doc, |d| {
|
|
132
147
|
let i = h as usize;
|
|
@@ -155,10 +170,11 @@ fn attr_value_id(soa: &Soa, i: usize, name: &str) -> Option<u32> {
|
|
|
155
170
|
}
|
|
156
171
|
|
|
157
172
|
/// MITIGATED+: prefetch a node's whole record in ONE crossing. Layout:
|
|
158
|
-
/// [tag_id
|
|
173
|
+
/// [`tag_id`, parent, `attr_count`, (`name_id`, `value_id`)*]. JS caches it on the wrapper
|
|
159
174
|
/// and serves tagName/getAttribute/parentNode from the cache (0 further crossings
|
|
160
175
|
/// until the version bumps). This is the Option-A "cache scalars on the wrapper" path.
|
|
161
176
|
#[wasm_bindgen]
|
|
177
|
+
#[must_use]
|
|
162
178
|
pub fn node_record(doc: u32, h: u32) -> Vec<i32> {
|
|
163
179
|
with_doc(doc, |d| {
|
|
164
180
|
let i = h as usize;
|
|
@@ -183,6 +199,7 @@ pub fn node_record(doc: u32, h: u32) -> Vec<i32> {
|
|
|
183
199
|
/// Listener-less dispatch path: walk ancestors to the root, return path length.
|
|
184
200
|
/// Models the single ancestor-walk dispatch does (no JS callbacks crossed).
|
|
185
201
|
#[wasm_bindgen]
|
|
202
|
+
#[must_use]
|
|
186
203
|
pub fn dispatch_walk(doc: u32, h: u32) -> u32 {
|
|
187
204
|
with_doc(doc, |d| {
|
|
188
205
|
let mut len = 1u32; // target
|
|
@@ -286,6 +303,7 @@ fn matches(soa: &Soa, i: usize, c: &Compound) -> bool {
|
|
|
286
303
|
}
|
|
287
304
|
|
|
288
305
|
#[wasm_bindgen]
|
|
306
|
+
#[must_use]
|
|
289
307
|
pub fn qsa(doc: u32, selector: &str) -> Vec<u32> {
|
|
290
308
|
with_doc(doc, |d| {
|
|
291
309
|
let c = parse_compound(selector);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|