rusteerio 1.0.1 → 1.1.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.
Files changed (57) hide show
  1. package/Cargo.lock +42 -1
  2. package/Cargo.toml +8 -1
  3. package/README.md +90 -51
  4. package/THIRD_PARTY_NOTICES.md +107 -2
  5. package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
  6. package/dist/chunk-FPRP6UIX.mjs.map +1 -0
  7. package/dist/index.cjs +2232 -750
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.mts +2 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.mjs +1 -1
  12. package/dist/slim.cjs +2226 -745
  13. package/dist/slim.cjs.map +1 -1
  14. package/dist/slim.d.mts +2 -2
  15. package/dist/slim.d.ts +2 -2
  16. package/dist/slim.mjs +1 -1
  17. package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
  18. package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
  19. package/dist/utils.d.mts +1 -1
  20. package/dist/utils.d.ts +1 -1
  21. package/native/src/arena.rs +219 -80
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/document_order.rs +711 -0
  24. package/native/src/extraction.rs +15 -3
  25. package/native/src/gc.rs +12 -3
  26. package/native/src/id_hash.rs +42 -0
  27. package/native/src/immutable.rs +256 -0
  28. package/native/src/lib.rs +1039 -171
  29. package/native/src/runtime.rs +189 -33
  30. package/native/src/selector.rs +346 -44
  31. package/native/src/sink.rs +14 -1
  32. package/native/src/snapshot.rs +509 -0
  33. package/native/src/stream.rs +2 -2
  34. package/native/src/tokenizer.rs +29 -12
  35. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  36. package/package.json +3 -1
  37. package/rusteerio.darwin-arm64.node +0 -0
  38. package/rusteerio.darwin-x64.node +0 -0
  39. package/rusteerio.linux-arm64-gnu.node +0 -0
  40. package/rusteerio.linux-arm64-musl.node +0 -0
  41. package/rusteerio.linux-x64-gnu.node +0 -0
  42. package/rusteerio.linux-x64-musl.node +0 -0
  43. package/rusteerio.win32-arm64-msvc.node +0 -0
  44. package/rusteerio.win32-x64-msvc.node +0 -0
  45. package/src/attributes.ts +78 -44
  46. package/src/codec.ts +22 -3
  47. package/src/extract.ts +86 -2
  48. package/src/hooks.ts +148 -2
  49. package/src/index.ts +51 -40
  50. package/src/match.ts +207 -0
  51. package/src/native.ts +9 -1
  52. package/src/nodes.ts +531 -183
  53. package/src/router.ts +792 -229
  54. package/src/selection.ts +239 -125
  55. package/src/snapshot.ts +255 -0
  56. package/src/transport.ts +51 -42
  57. package/dist/chunk-DMRD3FGC.mjs.map +0 -1
package/native/src/lib.rs CHANGED
@@ -14,19 +14,27 @@ use napi_derive::napi;
14
14
  use scraper::{Html, Node, Selector};
15
15
  use serde_json::{Map, Value};
16
16
 
17
+ #[cfg(target_os = "linux")]
18
+ #[global_allocator]
19
+ static ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
20
+
17
21
  mod arena;
18
22
  mod attributes;
19
23
  mod codec;
20
24
  mod diagnostics;
25
+ mod document_order;
21
26
  mod extraction;
22
27
  mod gc;
23
28
  mod hook_sink;
24
29
  mod hook_stream;
30
+ mod id_hash;
31
+ mod immutable;
25
32
  mod location;
26
33
  mod markup_tokenizer;
27
34
  mod runtime;
28
35
  mod selector;
29
36
  mod sink;
37
+ mod snapshot;
30
38
  mod stream;
31
39
  mod tokenizer;
32
40
  mod tracker;
@@ -125,13 +133,25 @@ pub struct NativeDocument {
125
133
  // Stable public IDs are preorder positions, rather than parser allocation
126
134
  // positions: HTML5 foster parenting can change the latter's ordering.
127
135
  ids: RefCell<Vec<NodeId>>,
128
- reverse: RefCell<HashMap<NodeId, u32>>,
136
+ reverse: RefCell<id_hash::IdMap<NodeId, u32>>,
137
+ /// Parent/child links of the parse tree, built on the first query that needs them.
138
+ tree_index: RefCell<Option<immutable::TreeIndex>>,
129
139
  pub(crate) arena: RefCell<Option<arena::DomArena>>,
130
140
  options: Value,
131
141
  source: RefCell<Option<String>>,
132
142
  retained: RefCell<HashMap<u32, usize>>,
133
143
  needs_collect: Cell<bool>,
134
144
  managed: Cell<bool>,
145
+ /// Slots managed collections keep regardless of pins.
146
+ kept_slots: Cell<u32>,
147
+ /// The document index JavaScript routes this document's node ids by.
148
+ routing_index: Cell<Option<u32>>,
149
+ /// Nodes were removed from the parse tree, so traversal order no longer
150
+ /// matches local ids and parsed nodes may be orphans.
151
+ tree_detached: Cell<bool>,
152
+ /// Parents whose children a parse-tree removal changed since JavaScript last asked.
153
+ tree_touched: RefCell<Vec<u32>>,
154
+ _live: LiveDocument,
135
155
  diagnostics: RefCell<Vec<Value>>,
136
156
  locations: RefCell<HashMap<u32, Value>>,
137
157
  // V8 cannot see native allocations. Reporting them lets its GC collect
@@ -140,12 +160,71 @@ pub struct NativeDocument {
140
160
  reported_heap_bytes: Cell<i64>,
141
161
  }
142
162
 
163
+ /// Node-API calls the finalizers of a module that declares the experimental
164
+ /// version directly from garbage collection, rather than on a later turn of
165
+ /// the event loop, so a synchronous loop over documents frees each native tree
166
+ /// as soon as V8 collects its document. Finalizers must therefore not call
167
+ /// into V8: see `NativeDocument::finalize`.
168
+ #[no_mangle]
169
+ pub extern "C" fn node_api_module_get_api_version_v1() -> i32 {
170
+ NAPI_VERSION_EXPERIMENTAL
171
+ }
172
+
173
+ const NAPI_VERSION_EXPERIMENTAL: i32 = i32::MAX;
174
+
175
+ thread_local! {
176
+ /// External memory of finalized documents not yet returned to V8.
177
+ static RELEASED_HEAP_BYTES: Cell<i64> = const { Cell::new(0) };
178
+ /// Routing indexes of finalized documents, free for new documents.
179
+ static FREE_ROUTING_INDEXES: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) };
180
+ static NEXT_ROUTING_INDEX: Cell<u32> = const { Cell::new(0) };
181
+ }
182
+
183
+ static LIVE_DOCUMENTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
184
+
185
+ /// Counts documents whose native storage has not been dropped yet.
186
+ struct LiveDocument;
187
+
188
+ impl LiveDocument {
189
+ fn new() -> Self {
190
+ LIVE_DOCUMENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
191
+ Self
192
+ }
193
+ }
194
+
195
+ impl Drop for LiveDocument {
196
+ fn drop(&mut self) {
197
+ LIVE_DOCUMENTS.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
198
+ }
199
+ }
200
+
201
+ /// Trees with more nodes than this are freed on the shared pool, so a
202
+ /// collection pause does not wait for them.
203
+ const POOL_RELEASE_NODES: usize = 4096;
204
+
143
205
  impl ObjectFinalize for NativeDocument {
144
- fn finalize(self, mut env: Env) -> Result<()> {
206
+ fn finalize(self, _env: Env) -> Result<()> {
207
+ // Adjusting external memory during garbage collection is not allowed;
208
+ // the next report on this thread returns it.
145
209
  let reported = self.reported_heap_bytes.get();
146
- if reported != 0 {
147
- // Accounting is advisory; a failure must not throw from a finalizer.
148
- let _ = env.adjust_external_memory(-reported);
210
+ RELEASED_HEAP_BYTES.with(|released| released.set(released.get() + reported));
211
+ if let Some(index) = self.routing_index.get() {
212
+ FREE_ROUTING_INDEXES.with(|free| free.borrow_mut().push(index));
213
+ }
214
+ let nodes = self.ids.borrow().len()
215
+ + self
216
+ .arena
217
+ .borrow()
218
+ .as_ref()
219
+ .map_or(0, |arena| arena.nodes.len());
220
+ if nodes > POOL_RELEASE_NODES {
221
+ runtime::release_later((
222
+ self.html.into_inner(),
223
+ self.arena.into_inner(),
224
+ self.ids.into_inner(),
225
+ self.reverse.into_inner(),
226
+ self.tree_index.into_inner(),
227
+ ));
149
228
  }
150
229
  Ok(())
151
230
  }
@@ -171,16 +250,31 @@ const HEAP_REPORT_STEP: i64 = 64 * 1024;
171
250
  impl NativeDocument {
172
251
  pub(crate) fn from_arena(arena: arena::DomArena, options: Value) -> Self {
173
252
  let content_bytes = arena.nodes.iter().map(arena::DomNode::content_bytes).sum();
253
+ Self::from_arena_sized(arena, options, content_bytes)
254
+ }
255
+
256
+ /// A document over `arena` whose text and attribute content takes about
257
+ /// `content_bytes`, such as the length of the source it was parsed from.
258
+ fn from_arena_sized(arena: arena::DomArena, options: Value, content_bytes: usize) -> Self {
259
+ if arena.nodes.len() > POOL_RELEASE_NODES {
260
+ runtime::start_release_thread();
261
+ }
174
262
  Self {
175
263
  html: RefCell::new(None),
176
264
  ids: RefCell::new(Vec::new()),
177
- reverse: RefCell::new(HashMap::new()),
265
+ reverse: RefCell::new(id_hash::IdMap::default()),
266
+ tree_index: RefCell::new(None),
178
267
  arena: RefCell::new(Some(arena)),
179
268
  options,
180
269
  source: RefCell::new(None),
181
270
  retained: RefCell::new(HashMap::new()),
182
271
  needs_collect: Cell::new(false),
183
272
  managed: Cell::new(false),
273
+ kept_slots: Cell::new(0),
274
+ routing_index: Cell::new(None),
275
+ tree_detached: Cell::new(false),
276
+ tree_touched: RefCell::new(Vec::new()),
277
+ _live: LiveDocument::new(),
184
278
  diagnostics: RefCell::new(Vec::new()),
185
279
  locations: RefCell::new(HashMap::new()),
186
280
  content_bytes: Cell::new(content_bytes),
@@ -219,6 +313,10 @@ impl NativeDocument {
219
313
  }
220
314
 
221
315
  fn report_heap_bytes(&self, mut env: Env) {
316
+ let released = RELEASED_HEAP_BYTES.with(|released| released.replace(0));
317
+ if released != 0 && env.adjust_external_memory(-released).is_err() {
318
+ RELEASED_HEAP_BYTES.with(|pending| pending.set(pending.get() + released));
319
+ }
222
320
  let reported = self.reported_heap_bytes.get();
223
321
  let estimated = self.estimated_heap_bytes();
224
322
  let change = estimated - reported;
@@ -247,20 +345,40 @@ impl NativeDocument {
247
345
  .as_object_mut()
248
346
  .and_then(|options| options.remove("__tokenEvents"))
249
347
  {
250
- return Self::from_arena(
348
+ return Self::from_arena_sized(
251
349
  tokenizer::parse(source, &events, parser_options.clone()),
252
350
  parser_options,
351
+ source.len(),
253
352
  );
254
353
  }
255
354
  if arena::xml_mode(&parser_options)
256
355
  || parser_options.get("_useHtmlParser2") == Some(&Value::Bool(true))
257
356
  {
258
- return Self::from_arena(
357
+ return Self::from_arena_sized(
259
358
  arena::parse_xml(source, parser_options.clone()),
260
359
  parser_options,
360
+ source.len(),
261
361
  );
262
362
  }
263
363
 
364
+ let capture = parser_options
365
+ .get("sourceCodeLocationInfo")
366
+ .is_some_and(|value| !matches!(value, Value::Null | Value::Bool(false)))
367
+ || parser_options.get("__collectParseErrors") == Some(&Value::Bool(true));
368
+ // HTML5 input preprocessing turns CRLF and CR into LF; html5ever does
369
+ // it one character at a time, which dominates pages with CRLF endings.
370
+ // Captured source locations must index the original input instead.
371
+ let normalized;
372
+ let source = if !capture && source.contains('\r') {
373
+ normalized = normalize_newlines(source);
374
+ normalized.as_str()
375
+ } else {
376
+ source
377
+ };
378
+ if !is_document && context_name.is_none() && !capture && is_plain_text(source) {
379
+ return Self::from_html(plain_text_fragment(source), false, parser_options, None);
380
+ }
381
+
264
382
  let options = html5ever::driver::ParseOpts {
265
383
  tokenizer: html5ever::tokenizer::TokenizerOpts {
266
384
  discard_bom: false,
@@ -275,7 +393,7 @@ impl NativeDocument {
275
393
  };
276
394
  let html = if is_document {
277
395
  html5ever::driver::parse_document(
278
- sink::CompatibleSink::new(Html::new_document()),
396
+ sink::CompatibleSink::new(sized_html(source, true)),
279
397
  options,
280
398
  )
281
399
  .one(source)
@@ -283,7 +401,7 @@ impl NativeDocument {
283
401
  // parse5's default fragment context is a template. A body context
284
402
  // discards valid standalone table fragments such as <td> and <tr>.
285
403
  html5ever::driver::parse_fragment(
286
- sink::CompatibleSink::new(Html::new_fragment()),
404
+ sink::CompatibleSink::new(sized_html(source, false)),
287
405
  options,
288
406
  html5ever::QualName::new(
289
407
  None,
@@ -301,10 +419,6 @@ impl NativeDocument {
301
419
  .one(source)
302
420
  };
303
421
 
304
- let capture = parser_options
305
- .get("sourceCodeLocationInfo")
306
- .is_some_and(|value| !matches!(value, Value::Null | Value::Bool(false)))
307
- || parser_options.get("__collectParseErrors") == Some(&Value::Bool(true));
308
422
  Self::from_html(
309
423
  html,
310
424
  is_document,
@@ -320,18 +434,7 @@ impl NativeDocument {
320
434
  source: Option<String>,
321
435
  ) -> Self {
322
436
  if !is_document {
323
- // html5ever's fragment sink creates a synthetic html element.
324
- // Cheerio exposes the fragment's actual children at its root.
325
- let wrapper = html.tree.root().children().find_map(|node| {
326
- node.value()
327
- .as_element()
328
- .filter(|element| element.name() == "html")
329
- .map(|_| node.id())
330
- });
331
- if let Some(wrapper) = wrapper {
332
- html.tree.root_mut().reparent_from_id_append(wrapper);
333
- html.tree.get_mut(wrapper).unwrap().detach();
334
- }
437
+ unwrap_fragment_root(&mut html);
335
438
  }
336
439
 
337
440
  let mut locations = HashMap::new();
@@ -355,20 +458,7 @@ impl NativeDocument {
355
458
  |_, _| {},
356
459
  );
357
460
  if !is_document {
358
- let wrapper = tracked
359
- .tree
360
- .root()
361
- .children()
362
- .find(|node| {
363
- node.value()
364
- .as_element()
365
- .is_some_and(|element| element.name() == "html")
366
- })
367
- .map(|node| node.id());
368
- if let Some(wrapper) = wrapper {
369
- tracked.tree.root_mut().reparent_from_id_append(wrapper);
370
- tracked.tree.get_mut(wrapper).unwrap().detach();
371
- }
461
+ unwrap_fragment_root(&mut tracked);
372
462
  }
373
463
  let order: HashMap<_, _> = tracked
374
464
  .tree
@@ -414,16 +504,25 @@ impl NativeDocument {
414
504
  })
415
505
  .and(source);
416
506
  let content_bytes = html.tree.values().map(html_node_content_bytes).sum();
507
+ if ids.len() > POOL_RELEASE_NODES {
508
+ runtime::start_release_thread();
509
+ }
417
510
  Self {
418
511
  html: RefCell::new(Some(html)),
419
512
  ids: RefCell::new(ids),
420
513
  reverse: RefCell::new(reverse),
514
+ tree_index: RefCell::new(None),
421
515
  arena: RefCell::new(None),
422
516
  options: parser_options,
423
517
  source: RefCell::new(source),
424
518
  retained: RefCell::new(HashMap::new()),
425
519
  needs_collect: Cell::new(false),
426
520
  managed: Cell::new(false),
521
+ kept_slots: Cell::new(0),
522
+ routing_index: Cell::new(None),
523
+ tree_detached: Cell::new(false),
524
+ tree_touched: RefCell::new(Vec::new()),
525
+ _live: LiveDocument::new(),
427
526
  diagnostics: RefCell::new(diagnostics),
428
527
  locations: RefCell::new(locations),
429
528
  content_bytes: Cell::new(content_bytes),
@@ -431,13 +530,33 @@ impl NativeDocument {
431
530
  }
432
531
  }
433
532
 
533
+ /// The arena borrowed for writing. It drops the name index first: a write
534
+ /// may add, remove or reorder elements.
535
+ pub(crate) fn arena_for_write(&self) -> std::cell::RefMut<'_, Option<arena::DomArena>> {
536
+ let mut arena = self.arena.borrow_mut();
537
+ if let Some(arena) = arena.as_mut() {
538
+ arena.name_index = None;
539
+ }
540
+ arena
541
+ }
542
+
434
543
  pub(crate) fn ensure_arena(&self) -> Result<()> {
435
544
  let missing = self.arena.borrow().is_none();
436
545
  if missing {
437
- let records: Vec<arena::DomNode> = (0..self.ids.borrow().len())
438
- .filter_map(|id| self.immutable_dom_node(id as u32))
439
- .collect();
440
- let mut arena = arena::DomArena::from_records(records, self.options.clone());
546
+ let records = {
547
+ let html = self.html.borrow();
548
+ let ids = self.ids.borrow();
549
+ let reverse = self.reverse.borrow();
550
+ let tree = SharedTree {
551
+ html: html
552
+ .as_ref()
553
+ .ok_or_else(|| Error::new(Status::GenericFailure, "Missing parse tree"))?,
554
+ ids: &ids,
555
+ reverse: &reverse,
556
+ };
557
+ runtime::chunked_map(ids.len(), CONVERSION_CHUNK, |range| tree.convert(range))
558
+ };
559
+ let mut arena = arena::DomArena::from_linked_records(records, self.options.clone());
441
560
  if let Some(source) = self.source.borrow().as_ref() {
442
561
  location::annotate(
443
562
  source,
@@ -463,10 +582,17 @@ impl NativeDocument {
463
582
  }
464
583
  }
465
584
  *self.arena.borrow_mut() = Some(arena);
466
- self.html.borrow_mut().take();
585
+ let html = self.html.borrow_mut().take();
586
+ let reverse = std::mem::take(&mut *self.reverse.borrow_mut());
587
+ let ids = std::mem::take(&mut *self.ids.borrow_mut());
588
+ // Freeing a large parse tree takes as long as copying it; the
589
+ // JavaScript thread need not wait for it. Waking a worker costs
590
+ // more than freeing a small one.
591
+ if ids.len() > POOL_RELEASE_NODES {
592
+ runtime::release_later((html, reverse, ids));
593
+ }
467
594
  self.source.borrow_mut().take();
468
- *self.ids.borrow_mut() = Vec::new();
469
- *self.reverse.borrow_mut() = HashMap::new();
595
+ *self.tree_index.borrow_mut() = None;
470
596
  }
471
597
  Ok(())
472
598
  }
@@ -482,127 +608,246 @@ impl NativeDocument {
482
608
  Some(operation(node, html))
483
609
  }
484
610
 
485
- fn immutable_dom_node(&self, id: u32) -> Option<arena::DomNode> {
486
- self.with_node(id, |node, html| {
487
- let pointer = |node: Option<NodeRef<'_, Node>>| {
488
- node.and_then(|node| self.reverse.borrow().get(&node.id()).copied())
611
+ /// Runs `operation` over the parse tree, or returns `None` once the
612
+ /// document has moved to the mutable arena.
613
+ fn with_tree_dom<T>(
614
+ &self,
615
+ options: &Value,
616
+ operation: impl FnOnce(&immutable::TreeDom<'_>) -> T,
617
+ ) -> Option<T> {
618
+ if self.arena.borrow().is_some() {
619
+ return None;
620
+ }
621
+ let html = self.html.borrow();
622
+ let html = html.as_ref()?;
623
+ let ids = self.ids.borrow();
624
+ if self.tree_index.borrow().is_none() {
625
+ let index = if self.tree_detached.get() {
626
+ immutable::TreeIndex::build_mapped(html, &ids, &self.reverse.borrow())
627
+ } else {
628
+ immutable::TreeIndex::build(html, ids.len())
489
629
  };
490
- let mut record = arena::DomNode::new("root");
491
- record.id = id;
492
- record.children = node
493
- .children()
494
- .filter_map(|child| self.reverse.borrow().get(&child.id()).copied())
495
- .collect();
496
- record.parent = pointer(node.parent());
497
- record.prev = pointer(node.prev_sibling());
498
- record.next = pointer(node.next_sibling());
499
- record.extras.insert("startIndex".into(), Value::Null);
500
- record.extras.insert("endIndex".into(), Value::Null);
501
- match node.value() {
502
- Node::Document => {
503
- record.extras.insert(
504
- "x-mode".into(),
505
- Value::String(
506
- match html.quirks_mode {
507
- html5ever::tree_builder::QuirksMode::Quirks => "quirks",
508
- html5ever::tree_builder::QuirksMode::LimitedQuirks => {
509
- "limited-quirks"
510
- }
511
- html5ever::tree_builder::QuirksMode::NoQuirks => "no-quirks",
512
- }
513
- .into(),
514
- ),
515
- );
516
- }
517
- Node::Fragment => {}
518
- Node::Text(data) => {
519
- record.kind = "text".into();
520
- record.data = Some(data.to_string());
521
- }
522
- Node::Comment(data) => {
523
- record.kind = "comment".into();
524
- record.data = Some(data.to_string());
630
+ *self.tree_index.borrow_mut() = Some(index);
631
+ }
632
+ let index = self.tree_index.borrow();
633
+ let dom = immutable::TreeDom {
634
+ html,
635
+ ids: &ids,
636
+ index: index.as_ref()?,
637
+ options,
638
+ };
639
+ Some(operation(&dom))
640
+ }
641
+
642
+ fn immutable_dom_node(&self, id: u32) -> Option<arena::DomNode> {
643
+ let html = self.html.borrow();
644
+ let ids = self.ids.borrow();
645
+ let reverse = self.reverse.borrow();
646
+ convert_node(html.as_ref()?, &ids, &reverse, id)
647
+ }
648
+
649
+ fn immutable_record(&self, id: u32) -> Option<Value> {
650
+ serde_json::to_value(self.immutable_dom_node(id)?).ok()
651
+ }
652
+
653
+ /// Read commands answered from the parse tree; `None` when the command
654
+ /// needs the mutable arena.
655
+ fn tree_read(
656
+ &self,
657
+ command: &str,
658
+ args: &Value,
659
+ ids: &[u32],
660
+ options: &Value,
661
+ ) -> Option<Result<Value>> {
662
+ match command {
663
+ "select" | "matches" => {
664
+ let mut options = options.clone();
665
+ if let Some(matches) = args.get("pseudoMatches") {
666
+ options["__pseudoMatches"] = matches.clone();
525
667
  }
526
- Node::Doctype(data) => {
527
- record.kind = "directive".into();
528
- record.name = Some("!doctype".into());
529
- record
530
- .extras
531
- .insert("x-name".into(), Value::String(data.name().into()));
532
- record
533
- .extras
534
- .insert("x-publicId".into(), Value::String(data.public_id().into()));
535
- record
536
- .extras
537
- .insert("x-systemId".into(), Value::String(data.system_id().into()));
538
- let mut content = format!("!DOCTYPE {}", data.name());
539
- if !data.public_id().is_empty() {
540
- content.push_str(" PUBLIC ");
541
- content.push_str(&quote_doctype(data.public_id()));
542
- } else if !data.system_id().is_empty() {
543
- content.push_str(" SYSTEM");
544
- }
545
- if !data.system_id().is_empty() {
546
- content.push(' ');
547
- content.push_str(&quote_doctype(data.system_id()));
668
+ let selector = args["selector"].as_str().unwrap_or("");
669
+ let contexts = args.get("contexts").map(arena::value_ids);
670
+ self.with_tree_dom(&options, |dom| {
671
+ if command == "select" {
672
+ selector::select(dom, selector, contexts.as_deref())
673
+ } else {
674
+ selector::filter(dom, selector, ids)
548
675
  }
549
- record.data = Some(content);
550
- }
551
- Node::ProcessingInstruction(data) => {
552
- record.kind = "directive".into();
553
- record.name = Some(data.target.to_string());
554
- record.data = Some(data.data.to_string());
676
+ .map(|ids| serde_json::json!(ids))
677
+ })
678
+ }
679
+ "uniqueSort" => {
680
+ // Removed nodes have no place in the parse tree's order, so
681
+ // the arena orders them.
682
+ if self.tree_detached.get() {
683
+ return None;
555
684
  }
556
- Node::Element(element) => {
557
- record.kind = match element.name() {
558
- "script" => "script",
559
- "style" => "style",
560
- _ => "tag",
685
+ self.with_tree_dom(options, |dom| {
686
+ if !document_order::is_document_ordered(dom, ids) {
687
+ return Ok(serde_json::json!(document_order::unique_sort(dom, ids)));
561
688
  }
562
- .into();
563
- record.name = Some(element.name().into());
564
- record.extras.insert(
565
- "namespace".into(),
566
- Value::String(element.name.ns.to_string()),
567
- );
568
- if let Some(prefix) = &element.name.prefix {
569
- record
570
- .extras
571
- .insert("prefix".into(), Value::String(prefix.to_string()));
572
- }
573
- let (mut namespaces, mut prefixes) = (Map::new(), Map::new());
574
- for (name, value) in &element.attrs {
575
- let key = name.local.to_string();
576
- record
577
- .attribs
578
- .insert(key.clone(), Value::String(value.to_string()));
579
- if !name.ns.is_empty() {
580
- namespaces.insert(key.clone(), Value::String(name.ns.to_string()));
581
- }
582
- if let Some(prefix) = &name.prefix {
583
- prefixes.insert(key, Value::String(prefix.to_string()));
584
- }
585
- }
586
- record
587
- .extras
588
- .insert("attribsNamespace".into(), Value::Object(namespaces));
689
+ // Local ids of a parse tree are preorder positions: document order.
690
+ let mut unique = ids.to_vec();
691
+ unique.sort_unstable();
692
+ unique.dedup();
693
+ Ok(serde_json::json!(unique))
694
+ })
695
+ }
696
+ "hasClass" => {
697
+ let needle = args["name"].as_str().unwrap_or("");
698
+ self.with_tree_dom(options, |dom| {
699
+ Ok(Value::Bool(ids.iter().any(|id| {
700
+ dom.is_element(*id)
701
+ && dom
702
+ .element_attr(*id, "class")
703
+ .is_some_and(|classes| immutable::has_class_token(classes, needle))
704
+ })))
705
+ })
706
+ }
707
+ "contains" => {
708
+ let container = args
709
+ .get("container")
710
+ .or_else(|| args.get("parent"))
711
+ .and_then(Value::as_u64)
712
+ .unwrap_or(0) as u32;
713
+ let contained = args
714
+ .get("contained")
715
+ .or_else(|| args.get("child"))
716
+ .and_then(Value::as_u64)? as u32;
717
+ self.with_tree_dom(options, |dom| {
718
+ Ok(Value::Bool(dom.index.contains(container, contained)))
719
+ })
720
+ }
721
+ "extractPlan" => Some(extraction::execute_in(self, &args["plan"], ids)),
722
+ _ => None,
723
+ }
724
+ }
725
+
726
+ /// A node with only the kind, name and attributes the attribute commands read.
727
+ fn attribute_node(&self, id: u32) -> Option<arena::DomNode> {
728
+ self.with_node(id, |node, _| {
729
+ let mut record = arena::DomNode::new("text");
730
+ if let Node::Element(element) = node.value() {
731
+ record.kind = match element.name() {
732
+ "script" => "script",
733
+ "style" => "style",
734
+ _ => "tag",
735
+ }
736
+ .into();
737
+ record.name = Some(element.name().into());
738
+ record.attribs = Map::with_capacity(element.attrs.len());
739
+ for (name, value) in &element.attrs {
589
740
  record
590
- .extras
591
- .insert("attribsPrefix".into(), Value::Object(prefixes));
741
+ .attribs
742
+ .insert(name.local.to_string(), Value::String(value.to_string()));
592
743
  }
593
744
  }
594
745
  record
595
746
  })
596
747
  }
597
748
 
598
- fn immutable_record(&self, id: u32) -> Option<Value> {
599
- serde_json::to_value(self.immutable_dom_node(id)?).ok()
749
+ /// Applies a write that only changes attributes to the parse tree, so the
750
+ /// document is not copied into the mutable arena. The arena's own attribute
751
+ /// logic runs on a temporary arena holding just the target elements.
752
+ fn tree_attribute_write(
753
+ &self,
754
+ command: &str,
755
+ args: &Value,
756
+ ids: &[u32],
757
+ options: &Value,
758
+ ) -> Option<Result<Value>> {
759
+ let is_write = match command {
760
+ "attr" | "css" => args.get("value").is_some(),
761
+ "removeAttr" | "addClass" | "removeClass" | "toggleClass" => true,
762
+ _ => false,
763
+ };
764
+ if !is_write || self.source.borrow().is_some() {
765
+ return None;
766
+ }
767
+ let mut targets: Vec<u32> = Vec::new();
768
+ for id in ids {
769
+ if !targets.contains(id) {
770
+ targets.push(*id);
771
+ }
772
+ }
773
+ let nodes: Vec<arena::DomNode> = targets
774
+ .iter()
775
+ .map(|id| self.attribute_node(*id))
776
+ .collect::<Option<_>>()?;
777
+ let before: Vec<Map<String, Value>> =
778
+ nodes.iter().map(|node| node.attribs.clone()).collect();
779
+ let mut scratch = arena::DomArena::from_records(nodes, options.clone());
780
+ if self.options.get("__utf16Codec") == Some(&Value::Bool(true)) {
781
+ scratch.options["__utf16Codec"] = Value::Bool(true);
782
+ }
783
+ let mut local_args = args.clone();
784
+ local_args["ids"] = serde_json::json!(ids
785
+ .iter()
786
+ .map(|id| targets.iter().position(|target| target == id).unwrap_or(0))
787
+ .collect::<Vec<_>>());
788
+ let result = match attributes::execute(&mut scratch, command, &local_args) {
789
+ Ok(Some(result)) => result,
790
+ Ok(None) => return None,
791
+ Err(error) => return Some(Err(error)),
792
+ };
793
+ let mut html = self.html.borrow_mut();
794
+ let html = html.as_mut()?;
795
+ let node_ids = self.ids.borrow();
796
+ for (index, id) in targets.iter().enumerate() {
797
+ let after = &scratch.nodes[index].attribs;
798
+ if *after == before[index] {
799
+ continue;
800
+ }
801
+ let mut node = html.tree.get_mut(*node_ids.get(*id as usize)?)?;
802
+ let Node::Element(element) = node.value() else {
803
+ continue;
804
+ };
805
+ let attributes = after
806
+ .iter()
807
+ .map(|(name, value)| {
808
+ let qualified = element
809
+ .attrs
810
+ .keys()
811
+ .rev()
812
+ .find(|key| key.local.as_ref() == name)
813
+ .cloned()
814
+ .unwrap_or_else(|| {
815
+ html5ever::QualName::new(
816
+ None,
817
+ html5ever::ns!(),
818
+ html5ever::LocalName::from(name.as_str()),
819
+ )
820
+ });
821
+ let value = value
822
+ .as_str()
823
+ .map_or_else(|| value.to_string(), str::to_owned);
824
+ html5ever::Attribute {
825
+ name: qualified,
826
+ value: value.into(),
827
+ }
828
+ })
829
+ .collect();
830
+ *element = scraper::node::Element::new(element.name.clone(), attributes);
831
+ }
832
+ Some(Ok(result))
833
+ }
834
+
835
+ /// Cheerio-compatible selection without copying an unchanged document.
836
+ fn select_native(&self, source: &str, contexts: Option<&[u32]>) -> Result<Vec<u32>> {
837
+ if let Some(result) =
838
+ self.with_tree_dom(&self.options, |dom| selector::select(dom, source, contexts))
839
+ {
840
+ return result;
841
+ }
842
+ self.ensure_arena()?;
843
+ selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts)
600
844
  }
601
845
 
602
846
  fn select_ids(&self, source: &str, contexts: Option<&[u32]>) -> Result<Vec<u32>> {
603
847
  self.maybe_collect();
604
848
  let contexts = contexts.filter(|contexts| *contexts != [0]);
605
849
  if self.arena.borrow().is_some()
850
+ || self.tree_detached.get()
606
851
  || contexts.is_some()
607
852
  || self.options.get("__losslessUnicode") == Some(&Value::Bool(true))
608
853
  || codec::has_encoding(source)
@@ -615,17 +860,12 @@ impl NativeDocument {
615
860
  .next()
616
861
  .is_some_and(|ch| matches!(ch, '>' | '+' | '~'))
617
862
  || source.contains("!=")
863
+ || compares_case_insensitive_attribute(source)
618
864
  {
619
- self.ensure_arena()?;
620
- return selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts);
621
- }
622
- let normalized = normalize_attribute_namespaces(source);
623
- let selector = match Selector::parse(&normalized) {
624
- Ok(selector) => selector,
625
- Err(_) => {
626
- self.ensure_arena()?;
627
- return selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts);
628
- }
865
+ return self.select_native(source, contexts);
866
+ }
867
+ let Some(selector) = parsed_selector(source) else {
868
+ return self.select_native(source, contexts);
629
869
  };
630
870
  let mut matched = Vec::new();
631
871
 
@@ -737,6 +977,207 @@ impl NativeDocument {
737
977
  }
738
978
  }
739
979
  }
980
+ /// Every live node that `root` does not reach; a parse tree has none.
981
+ #[napi(js_name = "detachedSlots")]
982
+ pub fn detached_slots(&self, root: u32) -> Uint32Array {
983
+ if let Some(arena) = self.arena.borrow().as_ref() {
984
+ return Uint32Array::from(arena.detached_slots(root));
985
+ }
986
+ Uint32Array::from(self.tree_detached_slots(root))
987
+ }
988
+
989
+ /// Nodes removed from the parse tree itself, which keeps them in place
990
+ /// but unreachable from `root`.
991
+ fn tree_detached_slots(&self, root: u32) -> Vec<u32> {
992
+ if !self.tree_detached.get() {
993
+ return Vec::new();
994
+ }
995
+ let html = self.html.borrow();
996
+ let ids = self.ids.borrow();
997
+ let reverse = self.reverse.borrow();
998
+ let Some(root) = html
999
+ .as_ref()
1000
+ .zip(ids.get(root as usize))
1001
+ .and_then(|(html, id)| html.tree.get(*id))
1002
+ else {
1003
+ return Vec::new();
1004
+ };
1005
+ let mut attached = vec![false; ids.len()];
1006
+ for node in root.descendants() {
1007
+ if let Some(local) = reverse.get(&node.id()) {
1008
+ attached[*local as usize] = true;
1009
+ }
1010
+ }
1011
+ (0..ids.len() as u32)
1012
+ .filter(|local| !attached[*local as usize])
1013
+ .collect()
1014
+ }
1015
+
1016
+ /// Sets an attribute on elements, or removes it when `value` is `null`,
1017
+ /// exactly as the `attr` command does, without copying an unchanged
1018
+ /// document into the mutable arena.
1019
+ #[napi(js_name = "setAttribute")]
1020
+ pub fn set_attribute(&self, ids: Vec<u32>, name: String, value: Option<String>) -> Result<()> {
1021
+ if self.arena.borrow().is_none() && self.source.borrow().is_none() {
1022
+ if let Some(html) = self.html.borrow_mut().as_mut() {
1023
+ let node_ids = self.ids.borrow();
1024
+ for id in &ids {
1025
+ let Some(mut node) = node_ids
1026
+ .get(*id as usize)
1027
+ .and_then(|node_id| html.tree.get_mut(*node_id))
1028
+ else {
1029
+ continue;
1030
+ };
1031
+ if let Node::Element(element) = node.value() {
1032
+ let attributes = with_attribute(element, &name, value.as_deref());
1033
+ *element = scraper::node::Element::new(element.name.clone(), attributes);
1034
+ }
1035
+ }
1036
+ return Ok(());
1037
+ }
1038
+ }
1039
+ self.ensure_arena()?;
1040
+ if let Some(arena) = self.arena_for_write().as_mut() {
1041
+ arena.set_attr(&ids, &name, value.as_deref());
1042
+ }
1043
+ Ok(())
1044
+ }
1045
+
1046
+ /// This document's routing index, assigned on first call. An index is
1047
+ /// reused only once its document has been finalized, when nothing in
1048
+ /// JavaScript can hold the document's node ids any more.
1049
+ #[napi(js_name = "routingIndex")]
1050
+ pub fn routing_index(&self) -> u32 {
1051
+ if let Some(index) = self.routing_index.get() {
1052
+ return index;
1053
+ }
1054
+ let index = FREE_ROUTING_INDEXES
1055
+ .with(|free| free.borrow_mut().pop())
1056
+ .unwrap_or_else(|| {
1057
+ NEXT_ROUTING_INDEX.with(|next| {
1058
+ let index = next.get();
1059
+ next.set(index.saturating_add(1));
1060
+ index
1061
+ })
1062
+ });
1063
+ self.routing_index.set(Some(index));
1064
+ index
1065
+ }
1066
+
1067
+ /// Documents in this process whose native storage has not been dropped,
1068
+ /// for tests of memory release; resident memory is a noisy signal.
1069
+ #[napi(js_name = "liveDocumentCount")]
1070
+ pub fn live_document_count(&self) -> u32 {
1071
+ LIVE_DOCUMENTS.load(std::sync::atomic::Ordering::Relaxed) as u32
1072
+ }
1073
+
1074
+ /// Whether removed created nodes and arena growth make a collection worthwhile.
1075
+ #[napi(js_name = "isCollectionDue")]
1076
+ pub fn is_collection_due(&self) -> bool {
1077
+ self.arena
1078
+ .borrow()
1079
+ .as_ref()
1080
+ .is_some_and(arena::DomArena::is_collection_due)
1081
+ }
1082
+
1083
+ /// The number of node slots, including released ones.
1084
+ #[napi(js_name = "slotCount")]
1085
+ pub fn slot_count(&self) -> u32 {
1086
+ match self.arena.borrow().as_ref() {
1087
+ Some(arena) => arena.nodes.len() as u32,
1088
+ None => self.ids.borrow().len() as u32,
1089
+ }
1090
+ }
1091
+
1092
+ /// Parents whose child lists changed since the last call; `null` when
1093
+ /// too many changed to list and every parent must be treated as changed.
1094
+ #[napi(js_name = "takeTouched")]
1095
+ pub fn take_touched(&self) -> Option<Uint32Array> {
1096
+ let removed = std::mem::take(&mut *self.tree_touched.borrow_mut());
1097
+ match self.arena_for_write().as_mut() {
1098
+ Some(arena) => arena.take_touched().map(|mut touched| {
1099
+ touched.extend(removed);
1100
+ Uint32Array::from(touched)
1101
+ }),
1102
+ None => Some(Uint32Array::from(removed)),
1103
+ }
1104
+ }
1105
+
1106
+ /// Removes nodes from the parse tree itself: the common clean-up before
1107
+ /// reading text or markup then needs no copy into the mutable arena.
1108
+ fn remove_from_tree(&self, ids: &[u32]) {
1109
+ let mut html = self.html.borrow_mut();
1110
+ let Some(html) = html.as_mut() else {
1111
+ return;
1112
+ };
1113
+ let node_ids = self.ids.borrow();
1114
+ let reverse = self.reverse.borrow();
1115
+ let mut touched = self.tree_touched.borrow_mut();
1116
+ for id in ids {
1117
+ let Some(node_id) = node_ids.get(*id as usize) else {
1118
+ continue;
1119
+ };
1120
+ let Some(mut node) = html.tree.get_mut(*node_id) else {
1121
+ continue;
1122
+ };
1123
+ let Some(parent) = node.parent().map(|parent| parent.id()) else {
1124
+ continue;
1125
+ };
1126
+ node.detach();
1127
+ touched.extend(reverse.get(&parent).copied());
1128
+ }
1129
+ self.tree_detached.set(true);
1130
+ *self.tree_index.borrow_mut() = None;
1131
+ }
1132
+
1133
+ /// Replaces every pin with `pins`, the nodes JavaScript still references,
1134
+ /// then reclaims detached nodes that are neither pinned, below
1135
+ /// `keep_below`, nor connected to such a node. Computing pins at
1136
+ /// collection time lets JavaScript track references without a native call
1137
+ /// per node.
1138
+ #[napi(js_name = "collectPinned")]
1139
+ pub fn collect_pinned(
1140
+ &self,
1141
+ env: Env,
1142
+ pins: Uint32Array,
1143
+ keep_below: Option<u32>,
1144
+ ) -> PinnedCollection {
1145
+ self.kept_slots.set(keep_below.unwrap_or(0));
1146
+ let mut retained = HashMap::new();
1147
+ {
1148
+ let arena = self.arena.borrow();
1149
+ let immutable_count = self.ids.borrow().len();
1150
+ for id in pins.iter() {
1151
+ let exists = arena
1152
+ .as_ref()
1153
+ .map_or((*id as usize) < immutable_count, |arena| {
1154
+ arena.node(*id).is_some()
1155
+ });
1156
+ if exists {
1157
+ *retained.entry(*id).or_insert(0) += 1;
1158
+ }
1159
+ }
1160
+ }
1161
+ *self.retained.borrow_mut() = retained;
1162
+ self.managed.set(true);
1163
+ let stats = self.collect_garbage_and_report(env);
1164
+ let free = self
1165
+ .arena
1166
+ .borrow()
1167
+ .as_ref()
1168
+ .map(|arena| arena.free.clone())
1169
+ .unwrap_or_default();
1170
+ PinnedCollection {
1171
+ live: stats.live,
1172
+ slots: stats.slots,
1173
+ retained: stats.retained,
1174
+ reclaimed: stats.reclaimed,
1175
+ immutable_nodes: stats.immutable_nodes,
1176
+ external_bytes: stats.external_bytes,
1177
+ free: free.into(),
1178
+ }
1179
+ }
1180
+
740
1181
  #[napi(js_name = "collectGarbage")]
741
1182
  pub fn collect_garbage_and_report(&self, env: Env) -> MemoryStats {
742
1183
  let stats = self.collect_garbage();
@@ -749,13 +1190,17 @@ impl NativeDocument {
749
1190
 
750
1191
  pub fn collect_garbage(&self) -> MemoryStats {
751
1192
  self.needs_collect.set(false);
752
- if self.arena.borrow().is_none() && self.managed.get() && self.retained.borrow().is_empty()
1193
+ if self.arena.borrow().is_none()
1194
+ && self.managed.get()
1195
+ && self.kept_slots.get() == 0
1196
+ && self.retained.borrow().is_empty()
753
1197
  {
754
1198
  self.html.borrow_mut().take();
755
1199
  self.source.borrow_mut().take();
756
1200
  let reclaimed = self.ids.borrow().len() as u32;
757
1201
  *self.ids.borrow_mut() = Vec::new();
758
- *self.reverse.borrow_mut() = HashMap::new();
1202
+ *self.reverse.borrow_mut() = id_hash::IdMap::default();
1203
+ *self.tree_index.borrow_mut() = None;
759
1204
  return MemoryStats {
760
1205
  live: 0,
761
1206
  slots: 0,
@@ -765,8 +1210,13 @@ impl NativeDocument {
765
1210
  external_bytes: self.reported_heap_bytes.get(),
766
1211
  };
767
1212
  }
768
- if let Some(arena) = self.arena.borrow_mut().as_mut() {
769
- let stats = gc::collect_managed(arena, &self.retained.borrow(), !self.managed.get());
1213
+ if let Some(arena) = self.arena_for_write().as_mut() {
1214
+ let stats = gc::collect_managed(
1215
+ arena,
1216
+ &self.retained.borrow(),
1217
+ !self.managed.get(),
1218
+ self.kept_slots.get(),
1219
+ );
770
1220
  arena.free = stats.free;
771
1221
  MemoryStats {
772
1222
  live: stats.live,
@@ -857,7 +1307,7 @@ impl NativeDocument {
857
1307
  if (self.tag_name(id).as_deref() == Some("template") || self.arena.borrow().is_some())
858
1308
  && self.ensure_arena().is_ok()
859
1309
  {
860
- return self.arena.borrow_mut().as_mut().unwrap().inner_html_mut(id);
1310
+ return self.arena_for_write().as_mut().unwrap().inner_html_mut(id);
861
1311
  }
862
1312
  self.serialize_nodes(&[id], true)
863
1313
  }
@@ -867,6 +1317,53 @@ impl NativeDocument {
867
1317
  self.serialize_nodes(&ids, false)
868
1318
  }
869
1319
 
1320
+ /// Serializes nodes as the `render` command does, returning the markup
1321
+ /// itself rather than markup encoded as JSON.
1322
+ #[napi(js_name = "render")]
1323
+ pub fn render_and_report(
1324
+ &self,
1325
+ env: Env,
1326
+ ids: Vec<u32>,
1327
+ options_json: Option<String>,
1328
+ ) -> Result<String> {
1329
+ self.maybe_collect();
1330
+ let overrides = match options_json {
1331
+ Some(json) => Some(serde_json::from_str::<Value>(&json).map_err(json_error)?),
1332
+ None => None,
1333
+ }
1334
+ .filter(Value::is_object);
1335
+ let rendered = self.render_nodes(&ids, overrides)?;
1336
+ Ok(self.reported(env, rendered))
1337
+ }
1338
+
1339
+ fn render_nodes(&self, ids: &[u32], overrides: Option<Value>) -> Result<String> {
1340
+ if self.arena.borrow().is_none() {
1341
+ let mut options = self.options.clone();
1342
+ if let Some(Value::Object(overrides)) = &overrides {
1343
+ for (key, value) in overrides {
1344
+ options[key] = value.clone();
1345
+ }
1346
+ }
1347
+ let options = arena::normalized_options(options);
1348
+ if !arena::xml_mode(&options)
1349
+ && options.get("_useHtmlParser2") != Some(&Value::Bool(true))
1350
+ {
1351
+ return Ok(self.serialize_nodes(ids, false));
1352
+ }
1353
+ }
1354
+ self.ensure_arena()?;
1355
+ let arena = self.arena.borrow();
1356
+ let arena = arena.as_ref().unwrap();
1357
+ let mut options = match overrides {
1358
+ Some(overrides) => arena::normalized_options(overrides),
1359
+ None => arena.options.clone(),
1360
+ };
1361
+ if self.options.get("__utf16Codec") == Some(&Value::Bool(true)) {
1362
+ options["__utf16Codec"] = Value::Bool(true);
1363
+ }
1364
+ Ok(arena.serialize(ids, &arena::normalized_options(options)))
1365
+ }
1366
+
870
1367
  #[napi]
871
1368
  pub fn attr(&self, id: u32, name: String) -> Option<String> {
872
1369
  self.attribute(id, &name)
@@ -903,6 +1400,18 @@ impl NativeDocument {
903
1400
  serde_json::to_string(&records).map_err(json_error)
904
1401
  }
905
1402
 
1403
+ /// Every node record at once, in the compact form `snapshot.rs` describes.
1404
+ #[napi]
1405
+ pub fn snapshot(&self, env: Env) -> Result<Object> {
1406
+ self.maybe_collect();
1407
+ if self.source.borrow().is_some() {
1408
+ self.ensure_arena()?;
1409
+ }
1410
+ let built = snapshot::build(self);
1411
+ self.report_heap_bytes(env);
1412
+ built.into_js(&env)
1413
+ }
1414
+
906
1415
  #[napi(js_name = "node")]
907
1416
  pub fn node_and_report(&self, env: Env, id: u32) -> Result<String> {
908
1417
  self.reported(env, self.node(id))
@@ -972,7 +1481,7 @@ impl NativeDocument {
972
1481
  }
973
1482
  self.ensure_arena()?;
974
1483
  source.ensure_arena()?;
975
- let mut source_arena = source.arena.borrow_mut();
1484
+ let mut source_arena = source.arena_for_write();
976
1485
  let source_arena = source_arena.as_mut().unwrap();
977
1486
  // Detach all requested roots first: overlapping roots become siblings
978
1487
  // and each requested descendant is imported exactly once.
@@ -991,7 +1500,7 @@ impl NativeDocument {
991
1500
  stack.extend(node.children.iter().rev().copied());
992
1501
  }
993
1502
  }
994
- let mut target_arena = self.arena.borrow_mut();
1503
+ let mut target_arena = self.arena_for_write();
995
1504
  let target_arena = target_arena.as_mut().unwrap();
996
1505
  target_arena.nodes.reserve(moved.len());
997
1506
  let (roots, mapping) = if detach.unwrap_or(true) {
@@ -1065,6 +1574,14 @@ impl NativeDocument {
1065
1574
  if command == "node" {
1066
1575
  return self.node(args["id"].as_u64().unwrap_or(0) as u32);
1067
1576
  }
1577
+ if command == "remove"
1578
+ && args["keepChildren"].as_bool() != Some(true)
1579
+ && self.arena.borrow().is_none()
1580
+ && self.source.borrow().is_none()
1581
+ {
1582
+ self.remove_from_tree(&arena::value_ids(&args["ids"]));
1583
+ return Ok("null".into());
1584
+ }
1068
1585
  if self.arena.borrow().is_none() {
1069
1586
  let ids = arena::value_ids(&args["ids"]);
1070
1587
  let effective_options = arena::normalized_options(
@@ -1129,6 +1646,14 @@ impl NativeDocument {
1129
1646
  .unwrap_or(Value::Null);
1130
1647
  return serde_json::to_string(&result).map_err(json_error);
1131
1648
  }
1649
+ if let Some(result) = self.tree_read(&command, &args, &ids, &effective_options) {
1650
+ return serde_json::to_string(&result?).map_err(json_error);
1651
+ }
1652
+ if let Some(result) =
1653
+ self.tree_attribute_write(&command, &args, &ids, &effective_options)
1654
+ {
1655
+ return serde_json::to_string(&result?).map_err(json_error);
1656
+ }
1132
1657
  if command == "render" || command == "outerHtml" {
1133
1658
  let mut options = self.options.clone();
1134
1659
  if let Some(overrides) = args.get("options").and_then(Value::as_object) {
@@ -1146,7 +1671,7 @@ impl NativeDocument {
1146
1671
  }
1147
1672
  }
1148
1673
  self.ensure_arena()?;
1149
- let mut arena = self.arena.borrow_mut();
1674
+ let mut arena = self.arena_for_write();
1150
1675
  let arena = arena.as_mut().unwrap();
1151
1676
  let previous_options = arena.options.clone();
1152
1677
  if let Some(options) = args.get("options").filter(|options| options.is_object()) {
@@ -1175,6 +1700,18 @@ impl NativeDocument {
1175
1700
  }
1176
1701
  }
1177
1702
 
1703
+ #[napi(object)]
1704
+ pub struct PinnedCollection {
1705
+ pub live: u32,
1706
+ pub slots: u32,
1707
+ pub retained: u32,
1708
+ pub reclaimed: u32,
1709
+ pub immutable_nodes: u32,
1710
+ pub external_bytes: i64,
1711
+ /// Slots that hold no node after collection.
1712
+ pub free: Uint32Array,
1713
+ }
1714
+
1178
1715
  #[napi(object)]
1179
1716
  pub struct MemoryStats {
1180
1717
  pub live: u32,
@@ -1254,6 +1791,309 @@ fn normalize_attribute_namespaces(source: &str) -> Cow<'_, str> {
1254
1791
  Cow::Owned(normalized)
1255
1792
  }
1256
1793
 
1794
+ /// An element's attributes after setting or removing `name`. Attributes are
1795
+ /// keyed by local name as in the arena: a repeated local name keeps its first
1796
+ /// position, with the name and value of its last occurrence.
1797
+ fn with_attribute(
1798
+ element: &scraper::node::Element,
1799
+ name: &str,
1800
+ value: Option<&str>,
1801
+ ) -> Vec<html5ever::Attribute> {
1802
+ let mut attributes: Vec<html5ever::Attribute> = Vec::with_capacity(element.attrs.len() + 1);
1803
+ for (key, current) in &element.attrs {
1804
+ let attribute = html5ever::Attribute {
1805
+ name: key.clone(),
1806
+ value: current.as_ref().into(),
1807
+ };
1808
+ match attributes
1809
+ .iter_mut()
1810
+ .find(|existing| existing.name.local == key.local)
1811
+ {
1812
+ Some(existing) => *existing = attribute,
1813
+ None => attributes.push(attribute),
1814
+ }
1815
+ }
1816
+ match value {
1817
+ Some(value) => match attributes
1818
+ .iter_mut()
1819
+ .find(|attribute| attribute.name.local.as_ref() == name)
1820
+ {
1821
+ Some(attribute) => attribute.value = value.into(),
1822
+ None => attributes.push(html5ever::Attribute {
1823
+ name: html5ever::QualName::new(
1824
+ None,
1825
+ html5ever::ns!(),
1826
+ html5ever::LocalName::from(name),
1827
+ ),
1828
+ value: value.into(),
1829
+ }),
1830
+ },
1831
+ None => attributes.retain(|attribute| attribute.name.local.as_ref() != name),
1832
+ }
1833
+ attributes
1834
+ }
1835
+
1836
+ thread_local! {
1837
+ /// Parse-tree selectors by source, or `None` for sources they cannot express.
1838
+ static SELECTORS: RefCell<HashMap<String, Option<std::rc::Rc<Selector>>>> = RefCell::new(HashMap::new());
1839
+ }
1840
+
1841
+ /// A selector for the parse tree, parsed once per source per thread.
1842
+ fn parsed_selector(source: &str) -> Option<std::rc::Rc<Selector>> {
1843
+ if let Some(parsed) = SELECTORS.with(|cache| cache.borrow().get(source).cloned()) {
1844
+ return parsed;
1845
+ }
1846
+ let parsed = Selector::parse(&normalize_attribute_namespaces(source))
1847
+ .ok()
1848
+ .map(std::rc::Rc::new);
1849
+ SELECTORS.with(|cache| {
1850
+ let mut cache = cache.borrow_mut();
1851
+ if cache.len() >= 512 {
1852
+ cache.clear();
1853
+ }
1854
+ cache.insert(source.to_owned(), parsed.clone());
1855
+ });
1856
+ parsed
1857
+ }
1858
+
1859
+ /// Whether a selector compares the value of an attribute whose values HTML
1860
+ /// matches without regard to case, which the parse-tree selector engine does not.
1861
+ fn compares_case_insensitive_attribute(source: &str) -> bool {
1862
+ source.contains('=')
1863
+ && source.match_indices('[').any(|(index, _)| {
1864
+ let name: String = source[index + 1..]
1865
+ .trim_start()
1866
+ .chars()
1867
+ .take_while(|character| character.is_ascii_alphanumeric() || *character == '-')
1868
+ .collect();
1869
+ selector::CASE_INSENSITIVE_ATTRIBUTES
1870
+ .iter()
1871
+ .any(|candidate| name.eq_ignore_ascii_case(candidate))
1872
+ })
1873
+ }
1874
+
1875
+ /// `source` with CRLF and CR replaced by LF, as HTML5 input preprocessing does.
1876
+ fn normalize_newlines(source: &str) -> String {
1877
+ let mut output = String::with_capacity(source.len());
1878
+ let mut rest = source;
1879
+ while let Some(index) = rest.find('\r') {
1880
+ output.push_str(&rest[..index]);
1881
+ output.push('\n');
1882
+ rest = &rest[index + 1..];
1883
+ if let Some(stripped) = rest.strip_prefix('\n') {
1884
+ rest = stripped;
1885
+ }
1886
+ }
1887
+ output.push_str(rest);
1888
+ output
1889
+ }
1890
+
1891
+ /// Whether HTML5 parses `source` as a fragment to a single text node holding
1892
+ /// it unchanged: no markup, character references, carriage returns to
1893
+ /// normalize or NUL characters to replace.
1894
+ fn is_plain_text(source: &str) -> bool {
1895
+ !source
1896
+ .bytes()
1897
+ .any(|byte| matches!(byte, b'<' | b'&' | b'\r' | b'\0'))
1898
+ }
1899
+
1900
+ /// The fragment HTML5 builds from plain text, without running the parser,
1901
+ /// whose fixed setup costs more than parsing a short string.
1902
+ fn plain_text_fragment(source: &str) -> Html {
1903
+ let mut html = Html::new_fragment();
1904
+ if !source.is_empty() {
1905
+ html.tree.root_mut().append(Node::Text(scraper::node::Text {
1906
+ text: source.into(),
1907
+ }));
1908
+ }
1909
+ html
1910
+ }
1911
+
1912
+ /// A parse target with node storage sized for `source`: grown one
1913
+ /// reallocation at a time, it copies every node again, which dominates the
1914
+ /// parse of small inputs. Large documents grow from the cap.
1915
+ fn sized_html(source: &str, is_document: bool) -> Html {
1916
+ const MAXIMUM_INITIAL_NODES: usize = 4096;
1917
+ let capacity = (source.len() / 16 + 8).min(MAXIMUM_INITIAL_NODES);
1918
+ let mut html = if is_document {
1919
+ Html::new_document()
1920
+ } else {
1921
+ Html::new_fragment()
1922
+ };
1923
+ let root = if is_document {
1924
+ Node::Document
1925
+ } else {
1926
+ Node::Fragment
1927
+ };
1928
+ html.tree = ego_tree::Tree::with_capacity(root, capacity);
1929
+ html
1930
+ }
1931
+
1932
+ /// html5ever's fragment sink creates a synthetic html element; Cheerio
1933
+ /// exposes the fragment's actual children at its root. Children move one at a
1934
+ /// time because `reparent_from_id_append` only re-points the first and last
1935
+ /// moved child at their new parent.
1936
+ fn unwrap_fragment_root(html: &mut Html) {
1937
+ let wrapper = html.tree.root().children().find_map(|node| {
1938
+ node.value()
1939
+ .as_element()
1940
+ .filter(|element| element.name() == "html")
1941
+ .map(|_| node.id())
1942
+ });
1943
+ let Some(wrapper) = wrapper else { return };
1944
+ let children: Vec<NodeId> = html
1945
+ .tree
1946
+ .get(wrapper)
1947
+ .map(|node| node.children().map(|child| child.id()).collect())
1948
+ .unwrap_or_default();
1949
+ let mut root = html.tree.root_mut();
1950
+ for child in children {
1951
+ root.append_id(child);
1952
+ }
1953
+ html.tree.get_mut(wrapper).unwrap().detach();
1954
+ }
1955
+
1956
+ /// Nodes converted per worker task when copying a parse tree into the arena.
1957
+ const CONVERSION_CHUNK: usize = 2048;
1958
+
1959
+ /// A parse tree read by several workers at once while copying it into the
1960
+ /// mutable arena.
1961
+ struct SharedTree<'a> {
1962
+ html: &'a Html,
1963
+ ids: &'a [NodeId],
1964
+ reverse: &'a id_hash::IdMap<NodeId, u32>,
1965
+ }
1966
+
1967
+ // SAFETY: `Html` is not `Sync` only because elements cache their id and
1968
+ // classes in `OnceCell`s and tendrils keep their header in `Cell`s. Conversion
1969
+ // reads names, attribute lists and text through shared references and never
1970
+ // touches those caches, and the owning thread waits for the workers, so no
1971
+ // write can race these reads.
1972
+ unsafe impl Sync for SharedTree<'_> {}
1973
+
1974
+ impl SharedTree<'_> {
1975
+ fn convert(&self, range: std::ops::Range<usize>) -> Vec<arena::DomNode> {
1976
+ let mut nodes = Vec::with_capacity(range.len());
1977
+ nodes.extend(
1978
+ range.filter_map(|id| convert_node(self.html, self.ids, self.reverse, id as u32)),
1979
+ );
1980
+ nodes
1981
+ }
1982
+ }
1983
+
1984
+ /// A parse-tree node as a mutable-arena record. It reads only names,
1985
+ /// attributes and text, never the tree's lazily filled caches.
1986
+ fn convert_node(
1987
+ html: &Html,
1988
+ ids: &[NodeId],
1989
+ reverse: &id_hash::IdMap<NodeId, u32>,
1990
+ id: u32,
1991
+ ) -> Option<arena::DomNode> {
1992
+ let node = html.tree.get(*ids.get(id as usize)?)?;
1993
+ let pointer =
1994
+ |node: Option<NodeRef<'_, Node>>| node.and_then(|node| reverse.get(&node.id()).copied());
1995
+ let mut record = arena::DomNode::new("root");
1996
+ record.id = id;
1997
+ record.children = node
1998
+ .children()
1999
+ .filter_map(|child| reverse.get(&child.id()).copied())
2000
+ .collect();
2001
+ record.parent = pointer(node.parent());
2002
+ record.prev = pointer(node.prev_sibling());
2003
+ record.next = pointer(node.next_sibling());
2004
+ match node.value() {
2005
+ Node::Document => {
2006
+ record.extras.insert(
2007
+ "x-mode".into(),
2008
+ Value::String(
2009
+ match html.quirks_mode {
2010
+ html5ever::tree_builder::QuirksMode::Quirks => "quirks",
2011
+ html5ever::tree_builder::QuirksMode::LimitedQuirks => "limited-quirks",
2012
+ html5ever::tree_builder::QuirksMode::NoQuirks => "no-quirks",
2013
+ }
2014
+ .into(),
2015
+ ),
2016
+ );
2017
+ }
2018
+ Node::Fragment => {}
2019
+ Node::Text(data) => {
2020
+ record.kind = "text".into();
2021
+ record.data = Some(String::from(&**data));
2022
+ }
2023
+ Node::Comment(data) => {
2024
+ record.kind = "comment".into();
2025
+ record.data = Some(String::from(&**data));
2026
+ }
2027
+ Node::Doctype(data) => {
2028
+ record.kind = "directive".into();
2029
+ record.name = Some("!doctype".into());
2030
+ record
2031
+ .extras
2032
+ .insert("x-name".into(), Value::String(data.name().into()));
2033
+ record
2034
+ .extras
2035
+ .insert("x-publicId".into(), Value::String(data.public_id().into()));
2036
+ record
2037
+ .extras
2038
+ .insert("x-systemId".into(), Value::String(data.system_id().into()));
2039
+ record.data = Some(doctype_content(data));
2040
+ }
2041
+ Node::ProcessingInstruction(data) => {
2042
+ record.kind = "directive".into();
2043
+ record.name = Some(String::from(&*data.target));
2044
+ record.data = Some(String::from(&*data.data));
2045
+ }
2046
+ Node::Element(element) => {
2047
+ record.kind = match element.name() {
2048
+ "script" => "script",
2049
+ "style" => "style",
2050
+ _ => "tag",
2051
+ }
2052
+ .into();
2053
+ record.name = Some(element.name().into());
2054
+ record.namespace = Some(arena::namespace_name(&element.name.ns));
2055
+ if let Some(prefix) = &element.name.prefix {
2056
+ record
2057
+ .extras
2058
+ .insert("prefix".into(), Value::String(String::from(&**prefix)));
2059
+ }
2060
+ let (mut namespaces, mut prefixes) = (Map::new(), Map::new());
2061
+ record.attribs = Map::with_capacity(element.attrs.len());
2062
+ for (name, value) in &element.attrs {
2063
+ let key = String::from(&*name.local);
2064
+ if !name.ns.is_empty() {
2065
+ namespaces.insert(key.clone(), Value::String(String::from(&*name.ns)));
2066
+ }
2067
+ if let Some(prefix) = &name.prefix {
2068
+ prefixes.insert(key.clone(), Value::String(String::from(&**prefix)));
2069
+ }
2070
+ record
2071
+ .attribs
2072
+ .insert(key, Value::String(String::from(&**value)));
2073
+ }
2074
+ record.attribs_namespace = Some(Box::new(namespaces));
2075
+ record.attribs_prefix = Some(Box::new(prefixes));
2076
+ }
2077
+ }
2078
+ Some(record)
2079
+ }
2080
+
2081
+ /// A doctype's `data`, as htmlparser2 reports the declaration.
2082
+ pub(crate) fn doctype_content(data: &scraper::node::Doctype) -> String {
2083
+ let mut content = format!("!DOCTYPE {}", data.name());
2084
+ if !data.public_id().is_empty() {
2085
+ content.push_str(" PUBLIC ");
2086
+ content.push_str(&quote_doctype(data.public_id()));
2087
+ } else if !data.system_id().is_empty() {
2088
+ content.push_str(" SYSTEM");
2089
+ }
2090
+ if !data.system_id().is_empty() {
2091
+ content.push(' ');
2092
+ content.push_str(&quote_doctype(data.system_id()));
2093
+ }
2094
+ content
2095
+ }
2096
+
1257
2097
  fn quote_doctype(value: &str) -> String {
1258
2098
  let quote = if value.contains('"') { '\'' } else { '"' };
1259
2099
  format!("{quote}{value}{quote}")
@@ -1646,6 +2486,32 @@ pub fn extract_plan_many_async(
1646
2486
  mod tests {
1647
2487
  use super::*;
1648
2488
 
2489
+ #[test]
2490
+ fn attribute_writes_keep_the_parse_tree() {
2491
+ let document = NativeDocument::parse(
2492
+ "<main><p id='a' class='x'>one</p><svg><use href='#h' xlink:href='#x'/></svg></main>",
2493
+ false,
2494
+ );
2495
+ let ids = document.select_ids("p, use", None).unwrap();
2496
+ for (command, args) in [
2497
+ (
2498
+ "attr",
2499
+ serde_json::json!({"ids":ids,"name":"data-k","value":"v"}),
2500
+ ),
2501
+ ("addClass", serde_json::json!({"ids":ids,"name":"y z"})),
2502
+ ("removeClass", serde_json::json!({"ids":ids,"name":"x"})),
2503
+ ("removeAttr", serde_json::json!({"ids":ids,"name":"id"})),
2504
+ ] {
2505
+ document.op(command.into(), args.to_string()).unwrap();
2506
+ }
2507
+ assert!(document.arena.borrow().is_none());
2508
+ assert_eq!(
2509
+ document.serialize_nodes(&ids, false),
2510
+ "<p class=\"y z\" data-k=\"v\">one</p><use xlink:href=\"#x\" data-k=\"v\" class=\"y z\"></use>"
2511
+ );
2512
+ assert_eq!(document.select_ids(".z", None).unwrap(), ids);
2513
+ }
2514
+
1649
2515
  #[test]
1650
2516
  fn managed_detached_payloads_are_reclaimed_and_slots_are_reused() {
1651
2517
  let document = NativeDocument::parse("<main></main>", false);
@@ -1653,8 +2519,8 @@ mod tests {
1653
2519
  let main = document.select_ids("main", None).unwrap()[0];
1654
2520
  document
1655
2521
  .op(
1656
- "attr".into(),
1657
- serde_json::json!({"ids":[main],"name":"a","value":"b"}).to_string(),
2522
+ "clone".into(),
2523
+ serde_json::json!({"ids":[main],"deep":false}).to_string(),
1658
2524
  )
1659
2525
  .unwrap();
1660
2526
  assert_eq!(document.collect_garbage().immutable_nodes, 0);
@@ -2066,9 +2932,11 @@ mod tests {
2066
2932
  serde_json::json!({"ids":[detached,ul,detached]}).to_string(),
2067
2933
  )
2068
2934
  .unwrap();
2935
+ // As domutils does: the last of repeated ids is kept, and nodes of
2936
+ // separate trees keep their order.
2069
2937
  assert_eq!(
2070
2938
  serde_json::from_str::<Vec<u32>>(&sorted).unwrap(),
2071
- vec![detached, ul]
2939
+ vec![ul, detached]
2072
2940
  );
2073
2941
  }
2074
2942