@miaskiewicz/turbo-dom 0.3.4 → 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 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.3.4"
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.3.4"
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.3.4"
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
- # 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 = []
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 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`).
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 ≥ 18 and a Rust toolchain (`rustup`, stable).
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 + rtdom (uses --features rust-runtime)
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 (`src/rtdom/`, gated behind
280
- `rust-runtime`) are independent — touching one never affects the other. See
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.4",
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 --lib --features rust-runtime",
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 --lib --features rust-runtime rtdom::conformance::rtdom_html5lib_conformance_full_document -- --nocapture",
95
- "build:rtdom": "cargo build --release --no-default-features --features rust-runtime",
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 --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"
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, local_name, namespace_url, ns, LocalName, QualName};
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!("{} {}", target, contents);
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(|p| p.to_string()).unwrap_or_default();
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, Default::default()),
267
- attr_name_map: rustc_hash::FxHashMap::with_capacity_and_hasher(64, Default::default()),
268
- attr_prefix_map: rustc_hash::FxHashMap::with_capacity_and_hasher(8, Default::default()),
269
- attr_value_map: rustc_hash::FxHashMap::with_capacity_and_hasher(128, Default::default()),
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(|p| p.to_string()).unwrap_or_default(),
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.map(|n| n.namespace == "svg").unwrap_or(false));
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
- #[cfg(feature = "napi-bind")]
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::{Int32Array, Uint16Array, Uint32Array, Uint8Array};
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.iter() { buf.extend_from_slice(&v.to_le_bytes()); } }
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.iter() { buf.extend_from_slice(&v.to_le_bytes()); }
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.iter() { buf.extend_from_slice(&v.to_le_bytes()); }
113
+ for v in col { buf.extend_from_slice(&v.to_le_bytes()); }
115
114
  }
116
- for v in s.attr_count.iter() { buf.extend_from_slice(&v.to_le_bytes()); }
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(|x| x.len()).sum();
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.iter() {
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()
@@ -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;
@@ -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.nextSibling; for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
318
- replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; this.remove(); for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
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.nextSibling; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
697
- replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; this.remove(); for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
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, and :not(), :first-child, :last-child, :only-child,
5
- // :empty, :root. Enough for React Testing Library usage.
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
- const ATTR_RE = /\[\s*([^\]=~^$*|\s]+)\s*(?:([~^$*|]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\]]*?))\s*)?\]/y;
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
- function parseCompound(src, i) {
10
- const compound = { tag: null, id: null, classes: [], attrs: [], pseudos: [] };
11
- let matchedAny = false;
12
- while (i < src.length) {
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 === '*') { compound.tag = '*'; i++; matchedAny = true; continue; }
15
- if (/[a-zA-Z]/.test(c) && compound.tag === null && compound.id === null && !compound.classes.length && !compound.attrs.length && !compound.pseudos.length) {
16
- let j = i;
17
- while (j < src.length && /[a-zA-Z0-9_-]/.test(src[j])) j++;
18
- compound.tag = src.slice(i, j).toLowerCase();
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 < src.length && /[a-zA-Z0-9_-]/.test(src[j])) j++;
24
- compound.id = src.slice(i + 1, j); i = j; matchedAny = true; continue;
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 < src.length && /[a-zA-Z0-9_-]/.test(src[j])) j++;
29
- compound.classes.push(src.slice(i + 1, j)); i = j; matchedAny = true; continue;
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
- ATTR_RE.lastIndex = i;
33
- const m = ATTR_RE.exec(src);
34
- if (!m) throw new SyntaxError(`bad attribute selector at ${src.slice(i)}`);
35
- compound.attrs.push({ name: m[1], op: m[2] || null, value: m[3] ?? m[4] ?? m[5] ?? null });
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 < src.length && /[a-zA-Z-]/.test(src[j])) 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
- let depth = 1, k = j + 1;
45
- while (k < src.length && depth > 0) { if (src[k] === '(') depth++; else if (src[k] === ')') depth--; k++; }
46
- arg = src.slice(j + 1, k - 1);
47
- j = k;
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
- compound.pseudos.push({ name, arg });
50
- i = j; matchedAny = true; continue;
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
- break;
141
+ throw new SyntaxError(`unexpected '${c}' in selector`);
53
142
  }
54
- if (!matchedAny) throw new SyntaxError(`empty compound at ${src.slice(i)}`);
55
- return { compound, i };
143
+ return tokens;
56
144
  }
57
145
 
58
- // Parse one complex selector into compounds[] + combinators[] (combinator[k]
59
- // relates compounds[k] to compounds[k+1], left to right).
60
- function parseComplex(src) {
61
- let i = 0;
62
- const compounds = [];
63
- const combinators = [];
64
- src = src.trim();
65
- while (i < src.length) {
66
- while (src[i] === ' ') i++;
67
- const r = parseCompound(src, i);
68
- compounds.push(r.compound);
69
- i = r.i;
70
- // read optional combinator
71
- let sawSpace = false;
72
- while (src[i] === ' ') { sawSpace = true; i++; }
73
- if (i >= src.length) break;
74
- if (src[i] === '>' || src[i] === '+' || src[i] === '~') {
75
- combinators.push(src[i]); i++;
76
- while (src[i] === ' ') i++;
77
- } else if (sawSpace) {
78
- combinators.push(' ');
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
- throw new SyntaxError(`unexpected '${src[i]}' in selector`);
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 parsed = splitTopLevel(selector, ',').map((s) => parseComplex(s.trim()));
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
- function splitTopLevel(s, sep) {
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 depth = 0, last = 0;
102
- for (let i = 0; i < s.length; i++) {
103
- const c = s[i];
104
- if (c === '(' || c === '[') depth++;
105
- else if (c === ')' || c === ']') depth--;
106
- else if (c === sep && depth === 0) { out.push(s.slice(last, i)); last = i + 1; }
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
- out.push(s.slice(last));
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
- if (a.op === null) return true;
272
+ const m = a.match;
273
+ if (m.op === 'present') return true; // presence carries no value
116
274
  const v = raw;
117
- const t = a.value ?? '';
118
- switch (a.op) {
119
- case '=': return v === t;
120
- case '^=': return t !== '' && v.startsWith(t);
121
- case '$=': return t !== '' && v.endsWith(t);
122
- case '*=': return t !== '' && v.includes(t);
123
- case '~=': return v.split(/\s+/).includes(t);
124
- case '|=': return v === t || v.startsWith(t + '-');
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
- const { compounds, combinators } = cx;
253
- const last = compounds.length - 1;
254
- if (!matchCompound(el, compounds[last])) return false;
426
+ return matchChain(el, cx, cx.rest.length, null);
427
+ }
255
428
 
256
- // walk leftward
257
- let idx = last - 1;
258
- let current = el;
259
- while (idx >= 0) {
260
- const comb = combinators[idx]; // relation between compounds[idx] and compounds[idx+1]
261
- const target = compounds[idx];
262
- if (comb === ' ') {
263
- let anc = current.parentNode;
264
- let matched = false;
265
- while (anc && anc.nodeType === 1) {
266
- if (matchCompound(anc, target)) { current = anc; matched = true; break; }
267
- anc = anc.parentNode;
268
- }
269
- if (!matched) return false;
270
- } else if (comb === '>') {
271
- const parent = current.parentNode;
272
- if (!parent || parent.nodeType !== 1 || !matchCompound(parent, target)) return false;
273
- current = parent;
274
- } else if (comb === '+') {
275
- const prev = previousElement(current);
276
- if (!prev || !matchCompound(prev, target)) return false;
277
- current = prev;
278
- } else if (comb === '~') {
279
- let prev = previousElement(current);
280
- let matched = false;
281
- while (prev) {
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
- idx--;
456
+ return false;
288
457
  }
289
- return true;
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, parent, attr_count, (name_id, value_id)*]. JS caches it on the wrapper
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