@miaskiewicz/turbo-dom 0.2.5 → 0.3.1
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 +10 -1
- package/Cargo.toml +13 -1
- package/README.md +53 -6
- package/package.json +7 -3
- package/pkg/turbo_dom_parser_bg.wasm +0 -0
- package/pkg-web/turbo_dom_parser_bg.wasm +0 -0
- package/src/lib.rs +9 -0
- package/src/spike.rs +301 -0
- 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
|
@@ -488,9 +488,18 @@ dependencies = [
|
|
|
488
488
|
"utf-8",
|
|
489
489
|
]
|
|
490
490
|
|
|
491
|
+
[[package]]
|
|
492
|
+
name = "turbo-dom"
|
|
493
|
+
version = "0.3.1"
|
|
494
|
+
dependencies = [
|
|
495
|
+
"html5ever",
|
|
496
|
+
"markup5ever_rcdom",
|
|
497
|
+
"rustc-hash",
|
|
498
|
+
]
|
|
499
|
+
|
|
491
500
|
[[package]]
|
|
492
501
|
name = "turbo-dom-parser"
|
|
493
|
-
version = "0.
|
|
502
|
+
version = "0.3.1"
|
|
494
503
|
dependencies = [
|
|
495
504
|
"html5ever",
|
|
496
505
|
"markup5ever_rcdom",
|
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
# The root package (turbo-dom-parser) plus the standalone Rust-native DOM runtime
|
|
3
|
+
# crate published to crates.io as `turbo-dom`. The parser ships to npm.
|
|
4
|
+
members = ["crates/turbo-dom"]
|
|
5
|
+
|
|
1
6
|
[package]
|
|
2
7
|
name = "turbo-dom-parser"
|
|
3
|
-
version = "0.
|
|
8
|
+
version = "0.3.1"
|
|
4
9
|
edition = "2021"
|
|
5
10
|
description = "html5ever-backed native parser for turbo-dom (Layer 1)"
|
|
6
11
|
|
|
@@ -29,6 +34,13 @@ napi-build = { version = "2", optional = true }
|
|
|
29
34
|
default = ["napi-bind"]
|
|
30
35
|
napi-bind = ["dep:napi", "dep:napi-derive", "dep:napi-build"]
|
|
31
36
|
wasm-bind = ["dep:wasm-bindgen", "dep:serde", "dep:serde-wasm-bindgen"]
|
|
37
|
+
# Phase-1 boundary spike: Rust DOM runtime over the SoA, exported to JS via wasm-bindgen.
|
|
38
|
+
wasm-runtime = ["dep:wasm-bindgen"]
|
|
39
|
+
# Pure-Rust DOM runtime (the `rtdom` module). Off by default so the published
|
|
40
|
+
# napi `.node` + wasm parser artifacts stay lean — no JS consumer uses rtdom (a
|
|
41
|
+
# JS-side WASM/napi boundary measured SLOWER than the JS runtime). A Rust consumer
|
|
42
|
+
# adds this crate as a dep and enables this feature; deps are pure std + core.
|
|
43
|
+
rust-runtime = []
|
|
32
44
|
|
|
33
45
|
[profile.release]
|
|
34
46
|
lto = true
|
package/README.md
CHANGED
|
@@ -188,6 +188,46 @@ The parser runs in Rust (compute-bound, one boundary crossing per parse). The DO
|
|
|
188
188
|
JS (chatty, fine-grained) but pays only for what a test touches. Full design notes:
|
|
189
189
|
[turbo-dom-spec.md](./turbo-dom-spec.md).
|
|
190
190
|
|
|
191
|
+
## Rust-native DOM runtime (`rtdom`) — for Rust consumers
|
|
192
|
+
|
|
193
|
+
Everything above is the **JS-consumer** path: the DOM is JS objects, so React/RTL touch it with
|
|
194
|
+
zero boundary — that's why it's fast for vitest/jest. A **Rust** consumer (crawler, extractor,
|
|
195
|
+
server-side scraper) wants the opposite: the DOM in-process in Rust, no JS at all. For that there's
|
|
196
|
+
**`rtdom`** — a pure-Rust port of the runtime (lazy copy-on-write tree over the same SoA buffer,
|
|
197
|
+
version-cached queries, partial `getComputedStyle`, events, shadow DOM, serialize).
|
|
198
|
+
|
|
199
|
+
Why a separate runtime instead of exposing this one to Rust via WASM? Measured: a Rust-DOM-in-WASM
|
|
200
|
+
called *from JS* is **~0.55×** the JS runtime (the boundary crossing dominates — exactly what the
|
|
201
|
+
[spec §3](./turbo-dom-spec.md) predicted), while `rtdom` run **in-process from Rust** is **~2.7×**
|
|
202
|
+
the JS runtime on the same chatty workload (zero boundary). So: JS consumers keep the JS runtime,
|
|
203
|
+
Rust consumers use `rtdom`.
|
|
204
|
+
|
|
205
|
+
Add it to a Rust project from crates.io (the `turbo-dom` crate is the Rust-native
|
|
206
|
+
runtime; the npm `@miaskiewicz/turbo-dom` is the JS path):
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
cargo add turbo-dom
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```rust
|
|
213
|
+
use turbo_dom::{Dom, DocumentExt};
|
|
214
|
+
use turbo_dom::rtdom::cascade;
|
|
215
|
+
|
|
216
|
+
let mut dom = Dom::parse("<main class=grid><div class=card id=hero>hi</div></main>");
|
|
217
|
+
let cards = dom.tree.query_selector_all("div.card"); // version-cached, in-process
|
|
218
|
+
let id = dom.tree.get_attribute(cards[0], "id"); // Some("hero") — plain Rust call
|
|
219
|
+
let style = cascade::computed_style(&dom.tree, cards[0]); // partial honest cascade
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- **crates.io:** [`turbo-dom`](https://crates.io/crates/turbo-dom) — the self-contained,
|
|
223
|
+
publishable crate (workspace member `crates/turbo-dom/`) with minimal deps
|
|
224
|
+
(`html5ever` + `rustc-hash`, no napi/wasm) and a runnable `examples/crawl.rs`. 192 tests.
|
|
225
|
+
- **In this repo (parser crate):** the same runtime also lives at `src/rtdom/` behind an
|
|
226
|
+
off-by-default `rust-runtime` cargo feature, so the published npm `.node`/wasm parser artifacts
|
|
227
|
+
stay lean. Build it with `npm run build:rtdom`
|
|
228
|
+
(= `cargo build --release --no-default-features --features rust-runtime`). 100% line coverage,
|
|
229
|
+
a direct html5lib-tests gate at **99.75%** (`npm run conformance:rtdom`).
|
|
230
|
+
|
|
191
231
|
## Limitations (by design)
|
|
192
232
|
|
|
193
233
|
- **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
|
|
@@ -226,14 +266,21 @@ Requires Node ≥ 18 and a Rust toolchain (`rustup`, stable).
|
|
|
226
266
|
|
|
227
267
|
```bash
|
|
228
268
|
npm install
|
|
229
|
-
npm run build
|
|
230
|
-
npm test
|
|
231
|
-
npm run test:rust
|
|
232
|
-
npm run conformance
|
|
233
|
-
npm run
|
|
234
|
-
npm run
|
|
269
|
+
npm run build # native addon (.node) + wasm — the JS-consumer artifacts
|
|
270
|
+
npm test # JS suite (unit, conformance, differential, gauntlets)
|
|
271
|
+
npm run test:rust # Rust tests: parser core + rtdom (uses --features rust-runtime)
|
|
272
|
+
npm run conformance # html5lib-tests report (parser)
|
|
273
|
+
npm run conformance:rtdom # html5lib-tests gate run through the rtdom tree
|
|
274
|
+
npm run bench:all # JS-runtime benchmarks
|
|
275
|
+
npm run build:wasm # wasm32 parser fallback
|
|
276
|
+
npm run build:rtdom # pure-Rust DOM runtime (no napi/wasm) — the Rust-consumer build
|
|
235
277
|
```
|
|
236
278
|
|
|
279
|
+
The JS runtime (`src/runtime/*.mjs`) and the Rust runtime (`src/rtdom/`, gated behind
|
|
280
|
+
`rust-runtime`) are independent — touching one never affects the other. See
|
|
281
|
+
[RUST_PORT_PLAN.md](./RUST_PORT_PLAN.md) for the dual-runtime architecture and
|
|
282
|
+
[RUST_PORT_PERF_HISTORY.md](./RUST_PORT_PERF_HISTORY.md) for how each JS perf win maps to Rust.
|
|
283
|
+
|
|
237
284
|
Contributions welcome — issues and PRs at
|
|
238
285
|
[github.com/miaskiewicz/turbo-dom](https://github.com/miaskiewicz/turbo-dom).
|
|
239
286
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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 --lib",
|
|
79
|
+
"test:rust": "cargo test --lib --features rust-runtime",
|
|
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",
|
|
@@ -90,7 +90,11 @@
|
|
|
90
90
|
"test:vitest": "vitest run -c test-vitest/vitest.config.mjs",
|
|
91
91
|
"test:jest": "jest -c test-jest/jest.config.cjs",
|
|
92
92
|
"bench:query": "node bench/query.mjs",
|
|
93
|
-
"bench:scorecard": "node bench/scorecard.mjs"
|
|
93
|
+
"bench:scorecard": "node bench/scorecard.mjs",
|
|
94
|
+
"conformance:rtdom": "cargo test --lib --features rust-runtime rtdom::conformance::rtdom_html5lib_conformance_full_document -- --nocapture",
|
|
95
|
+
"build:rtdom": "cargo build --release --no-default-features --features rust-runtime",
|
|
96
|
+
"bench:compare": "node bench/dom-compare.mjs",
|
|
97
|
+
"bench:compare:all": "RTDOM_JSON=\"$(cargo test --release --lib --features rust-runtime 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"
|
|
94
98
|
},
|
|
95
99
|
"keywords": [
|
|
96
100
|
"dom",
|
|
Binary file
|
|
Binary file
|
package/src/lib.rs
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
pub mod core;
|
|
8
8
|
|
|
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
|
+
// Phase-1 boundary spike (Rust DOM runtime exported to JS via wasm-bindgen).
|
|
15
|
+
#[cfg(feature = "wasm-runtime")]
|
|
16
|
+
pub mod spike;
|
|
17
|
+
|
|
9
18
|
// ---------------------------------------------------------------------------
|
|
10
19
|
// napi-rs front-end (native addon)
|
|
11
20
|
// ---------------------------------------------------------------------------
|
package/src/spike.rs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
//! Phase-1 boundary spike (feature = "wasm-runtime"). NOT the final runtime.
|
|
2
|
+
//!
|
|
3
|
+
//! Goal: measure the JS<->WASM boundary cost of a Rust-core DOM accessed from JS
|
|
4
|
+
//! on the chattiest hot paths (querySelectorAll + getAttribute + parent-walk +
|
|
5
|
+
//! listener-less dispatch walk), vs the current pure-JS runtime. Read-only tree
|
|
6
|
+
//! over `core::Soa` (handle = node index) — no mutation/COW needed for the gate.
|
|
7
|
+
//!
|
|
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;
|
|
10
|
+
//! structure reads return ints (handles/ids); strings resolved JS-side from
|
|
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
|
|
13
|
+
//! cost the mitigations avoid.
|
|
14
|
+
|
|
15
|
+
use crate::core::{self, Soa};
|
|
16
|
+
use std::cell::RefCell;
|
|
17
|
+
use wasm_bindgen::prelude::*;
|
|
18
|
+
|
|
19
|
+
const ELEMENT_NODE: u8 = 1;
|
|
20
|
+
|
|
21
|
+
struct Doc {
|
|
22
|
+
soa: Soa,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
thread_local! {
|
|
26
|
+
static DOCS: RefCell<Vec<Doc>> = RefCell::new(Vec::new());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn with_doc<R>(doc: u32, f: impl FnOnce(&Doc) -> R) -> R {
|
|
30
|
+
DOCS.with(|d| f(&d.borrow()[doc as usize]))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Parse + register a document. Returns the doc handle. ONE crossing per parse.
|
|
34
|
+
#[wasm_bindgen]
|
|
35
|
+
pub fn create(html: &str) -> u32 {
|
|
36
|
+
let soa = core::parse_html_soa(html);
|
|
37
|
+
DOCS.with(|d| {
|
|
38
|
+
let mut v = d.borrow_mut();
|
|
39
|
+
v.push(Doc { soa });
|
|
40
|
+
(v.len() - 1) as u32
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Drop a doc (free reset path).
|
|
45
|
+
#[wasm_bindgen]
|
|
46
|
+
pub fn destroy(doc: u32) {
|
|
47
|
+
DOCS.with(|d| {
|
|
48
|
+
let mut v = d.borrow_mut();
|
|
49
|
+
if (doc as usize) < v.len() {
|
|
50
|
+
v[doc as usize] = Doc { soa: core::parse_html_soa("") };
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- bulk string blobs: pulled ONCE per doc, split JS-side into a string table ---
|
|
56
|
+
// `\u{1f}` (unit separator) join — safe vs HTML text which never contains it.
|
|
57
|
+
fn join_blob(v: &[String]) -> String {
|
|
58
|
+
v.join("\u{1f}")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[wasm_bindgen]
|
|
62
|
+
pub fn tag_names_blob(doc: u32) -> String {
|
|
63
|
+
with_doc(doc, |d| join_blob(&d.soa.tag_names))
|
|
64
|
+
}
|
|
65
|
+
#[wasm_bindgen]
|
|
66
|
+
pub fn attr_names_blob(doc: u32) -> String {
|
|
67
|
+
with_doc(doc, |d| join_blob(&d.soa.attr_names))
|
|
68
|
+
}
|
|
69
|
+
#[wasm_bindgen]
|
|
70
|
+
pub fn attr_values_blob(doc: u32) -> String {
|
|
71
|
+
with_doc(doc, |d| join_blob(&d.soa.attr_values))
|
|
72
|
+
}
|
|
73
|
+
#[wasm_bindgen]
|
|
74
|
+
pub fn strings_blob(doc: u32) -> String {
|
|
75
|
+
with_doc(doc, |d| join_blob(&d.soa.strings))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[wasm_bindgen]
|
|
79
|
+
pub fn node_count(doc: u32) -> u32 {
|
|
80
|
+
with_doc(doc, |d| d.soa.node_type.len() as u32)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- MITIGATED per-node structure reads (ints only, no string marshaling) ---
|
|
84
|
+
#[wasm_bindgen]
|
|
85
|
+
pub fn node_type(doc: u32, h: u32) -> u8 {
|
|
86
|
+
with_doc(doc, |d| d.soa.node_type[h as usize])
|
|
87
|
+
}
|
|
88
|
+
#[wasm_bindgen]
|
|
89
|
+
pub fn tag_id(doc: u32, h: u32) -> i32 {
|
|
90
|
+
with_doc(doc, |d| {
|
|
91
|
+
let i = h as usize;
|
|
92
|
+
if d.soa.node_type[i] == ELEMENT_NODE { d.soa.tag_id[i] as i32 } else { -1 }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
#[wasm_bindgen]
|
|
96
|
+
pub fn parent(doc: u32, h: u32) -> i32 {
|
|
97
|
+
with_doc(doc, |d| d.soa.parent[h as usize])
|
|
98
|
+
}
|
|
99
|
+
#[wasm_bindgen]
|
|
100
|
+
pub fn first_child(doc: u32, h: u32) -> i32 {
|
|
101
|
+
with_doc(doc, |d| d.soa.first_child[h as usize])
|
|
102
|
+
}
|
|
103
|
+
#[wasm_bindgen]
|
|
104
|
+
pub fn next_sib(doc: u32, h: u32) -> i32 {
|
|
105
|
+
with_doc(doc, |d| d.soa.next_sib[h as usize])
|
|
106
|
+
}
|
|
107
|
+
#[wasm_bindgen]
|
|
108
|
+
pub fn text_id(doc: u32, h: u32) -> i32 {
|
|
109
|
+
with_doc(doc, |d| d.soa.text_id[h as usize])
|
|
110
|
+
}
|
|
111
|
+
|
|
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).
|
|
114
|
+
/// `name` still crosses as &str (RTL passes a literal) — small inbound cost.
|
|
115
|
+
#[wasm_bindgen]
|
|
116
|
+
pub fn get_attr_id(doc: u32, h: u32, name: &str) -> i32 {
|
|
117
|
+
with_doc(doc, |d| attr_value_id(&d.soa, h as usize, name).map_or(-1, |v| v as i32))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// NAIVE getAttribute: returns the value String (per-call UTF-8 marshal).
|
|
121
|
+
#[wasm_bindgen]
|
|
122
|
+
pub fn get_attr_str(doc: u32, h: u32, name: &str) -> Option<String> {
|
|
123
|
+
with_doc(doc, |d| {
|
|
124
|
+
attr_value_id(&d.soa, h as usize, name).map(|v| d.soa.attr_values[v as usize].clone())
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// NAIVE tagName: per-call String marshal.
|
|
129
|
+
#[wasm_bindgen]
|
|
130
|
+
pub fn tag_name_str(doc: u32, h: u32) -> Option<String> {
|
|
131
|
+
with_doc(doc, |d| {
|
|
132
|
+
let i = h as usize;
|
|
133
|
+
if d.soa.node_type[i] == ELEMENT_NODE {
|
|
134
|
+
Some(d.soa.tag_names[d.soa.tag_id[i] as usize].clone())
|
|
135
|
+
} else {
|
|
136
|
+
None
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn attr_value_id(soa: &Soa, i: usize, name: &str) -> Option<u32> {
|
|
142
|
+
let start = soa.attr_start[i];
|
|
143
|
+
if start < 0 {
|
|
144
|
+
return None;
|
|
145
|
+
}
|
|
146
|
+
let start = start as usize;
|
|
147
|
+
let count = soa.attr_count[i] as usize;
|
|
148
|
+
for j in start..start + count {
|
|
149
|
+
let nid = soa.attr_name_id[j] as usize;
|
|
150
|
+
if soa.attr_names[nid] == name {
|
|
151
|
+
return Some(soa.attr_value_id[j]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
None
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// MITIGATED+: prefetch a node's whole record in ONE crossing. Layout:
|
|
158
|
+
/// [tag_id, parent, attr_count, (name_id, value_id)*]. JS caches it on the wrapper
|
|
159
|
+
/// and serves tagName/getAttribute/parentNode from the cache (0 further crossings
|
|
160
|
+
/// until the version bumps). This is the Option-A "cache scalars on the wrapper" path.
|
|
161
|
+
#[wasm_bindgen]
|
|
162
|
+
pub fn node_record(doc: u32, h: u32) -> Vec<i32> {
|
|
163
|
+
with_doc(doc, |d| {
|
|
164
|
+
let i = h as usize;
|
|
165
|
+
let tag = if d.soa.node_type[i] == ELEMENT_NODE { d.soa.tag_id[i] as i32 } else { -1 };
|
|
166
|
+
let start = d.soa.attr_start[i];
|
|
167
|
+
let count = if start < 0 { 0 } else { d.soa.attr_count[i] as usize };
|
|
168
|
+
let mut out = Vec::with_capacity(3 + count * 2);
|
|
169
|
+
out.push(tag);
|
|
170
|
+
out.push(d.soa.parent[i]);
|
|
171
|
+
out.push(count as i32);
|
|
172
|
+
if start >= 0 {
|
|
173
|
+
let s = start as usize;
|
|
174
|
+
for j in s..s + count {
|
|
175
|
+
out.push(d.soa.attr_name_id[j] as i32);
|
|
176
|
+
out.push(d.soa.attr_value_id[j] as i32);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
out
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Listener-less dispatch path: walk ancestors to the root, return path length.
|
|
184
|
+
/// Models the single ancestor-walk dispatch does (no JS callbacks crossed).
|
|
185
|
+
#[wasm_bindgen]
|
|
186
|
+
pub fn dispatch_walk(doc: u32, h: u32) -> u32 {
|
|
187
|
+
with_doc(doc, |d| {
|
|
188
|
+
let mut len = 1u32; // target
|
|
189
|
+
let mut cur = d.soa.parent[h as usize];
|
|
190
|
+
while cur >= 0 {
|
|
191
|
+
len += 1;
|
|
192
|
+
cur = d.soa.parent[cur as usize];
|
|
193
|
+
}
|
|
194
|
+
len
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- MITIGATED querySelectorAll: matched entirely Rust-side, ONE crossing out ---
|
|
199
|
+
// Minimal selector support for the spike: a compound `tag`, `.class`, `#id`, or any
|
|
200
|
+
// concatenation (`div.card`, `.a.b`, `#id.x`). No combinators (enough for `div.card`).
|
|
201
|
+
struct Compound<'a> {
|
|
202
|
+
tag: Option<&'a str>,
|
|
203
|
+
id: Option<&'a str>,
|
|
204
|
+
classes: Vec<&'a str>,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn parse_compound(sel: &str) -> Compound<'_> {
|
|
208
|
+
let s = sel.trim();
|
|
209
|
+
let mut c = Compound { tag: None, id: None, classes: Vec::new() };
|
|
210
|
+
let mut i = 0;
|
|
211
|
+
let bytes = s.as_bytes();
|
|
212
|
+
// leading type selector
|
|
213
|
+
if i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'#' {
|
|
214
|
+
let start = i;
|
|
215
|
+
while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'#' {
|
|
216
|
+
i += 1;
|
|
217
|
+
}
|
|
218
|
+
c.tag = Some(&s[start..i]);
|
|
219
|
+
}
|
|
220
|
+
while i < bytes.len() {
|
|
221
|
+
let kind = bytes[i];
|
|
222
|
+
i += 1;
|
|
223
|
+
let start = i;
|
|
224
|
+
while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'#' {
|
|
225
|
+
i += 1;
|
|
226
|
+
}
|
|
227
|
+
let tok = &s[start..i];
|
|
228
|
+
match kind {
|
|
229
|
+
b'.' => c.classes.push(tok),
|
|
230
|
+
b'#' => c.id = Some(tok),
|
|
231
|
+
_ => {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
c
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// whole-word class scan (alloc-free) — mirrors the JS `hasClass` discipline.
|
|
238
|
+
fn has_class(class_attr: &str, cls: &str) -> bool {
|
|
239
|
+
if cls.is_empty() {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
let mut rest = class_attr;
|
|
243
|
+
loop {
|
|
244
|
+
match rest.find(cls) {
|
|
245
|
+
None => return false,
|
|
246
|
+
Some(pos) => {
|
|
247
|
+
let before_ok = pos == 0 || rest.as_bytes()[pos - 1].is_ascii_whitespace();
|
|
248
|
+
let end = pos + cls.len();
|
|
249
|
+
let after_ok = end == rest.len() || rest.as_bytes()[end].is_ascii_whitespace();
|
|
250
|
+
if before_ok && after_ok {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
rest = &rest[end..];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn matches(soa: &Soa, i: usize, c: &Compound) -> bool {
|
|
260
|
+
if soa.node_type[i] != ELEMENT_NODE {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
if let Some(tag) = c.tag {
|
|
264
|
+
if soa.tag_names[soa.tag_id[i] as usize] != tag {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if let Some(id) = c.id {
|
|
269
|
+
match attr_value_id(soa, i, "id") {
|
|
270
|
+
Some(v) if soa.attr_values[v as usize] == id => {}
|
|
271
|
+
_ => return false,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if !c.classes.is_empty() {
|
|
275
|
+
let cv = match attr_value_id(soa, i, "class") {
|
|
276
|
+
Some(v) => &soa.attr_values[v as usize],
|
|
277
|
+
None => return false,
|
|
278
|
+
};
|
|
279
|
+
for cls in &c.classes {
|
|
280
|
+
if !has_class(cv, cls) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[wasm_bindgen]
|
|
289
|
+
pub fn qsa(doc: u32, selector: &str) -> Vec<u32> {
|
|
290
|
+
with_doc(doc, |d| {
|
|
291
|
+
let c = parse_compound(selector);
|
|
292
|
+
let n = d.soa.node_type.len();
|
|
293
|
+
let mut out = Vec::new();
|
|
294
|
+
for i in 0..n {
|
|
295
|
+
if matches(&d.soa, i, &c) {
|
|
296
|
+
out.push(i as u32);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
out
|
|
300
|
+
})
|
|
301
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|