rusteerio 1.0.0 → 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 (65) 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 +138 -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 +231 -602
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/codec.rs +62 -18
  24. package/native/src/document_order.rs +711 -0
  25. package/native/src/extraction.rs +15 -3
  26. package/native/src/gc.rs +12 -3
  27. package/native/src/hook_sink.rs +77 -3
  28. package/native/src/id_hash.rs +42 -0
  29. package/native/src/immutable.rs +256 -0
  30. package/native/src/lib.rs +1097 -172
  31. package/native/src/markup_tokenizer.rs +668 -0
  32. package/native/src/runtime.rs +189 -33
  33. package/native/src/selector.rs +346 -44
  34. package/native/src/sink.rs +14 -1
  35. package/native/src/snapshot.rs +509 -0
  36. package/native/src/stream.rs +2 -2
  37. package/native/src/tokenizer.rs +377 -204
  38. package/native/vendor/README.md +3 -2
  39. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  40. package/native/vendor/html5ever-0.35.0/src/tokenizer/mod.rs +23 -13
  41. package/package.json +3 -1
  42. package/rusteerio.darwin-arm64.node +0 -0
  43. package/rusteerio.darwin-x64.node +0 -0
  44. package/rusteerio.linux-arm64-gnu.node +0 -0
  45. package/rusteerio.linux-arm64-musl.node +0 -0
  46. package/rusteerio.linux-x64-gnu.node +0 -0
  47. package/rusteerio.linux-x64-musl.node +0 -0
  48. package/rusteerio.win32-arm64-msvc.node +0 -0
  49. package/rusteerio.win32-x64-msvc.node +0 -0
  50. package/scripts/generate-notices.mjs +11 -0
  51. package/scripts/notices/htmlparser2-MIT.txt +18 -0
  52. package/scripts/notices/sources.json +6 -0
  53. package/src/attributes.ts +78 -44
  54. package/src/codec.ts +22 -3
  55. package/src/extract.ts +86 -2
  56. package/src/hooks.ts +148 -2
  57. package/src/index.ts +51 -40
  58. package/src/match.ts +207 -0
  59. package/src/native.ts +9 -1
  60. package/src/nodes.ts +531 -183
  61. package/src/router.ts +792 -229
  62. package/src/selection.ts +239 -125
  63. package/src/snapshot.ts +255 -0
  64. package/src/transport.ts +51 -42
  65. package/dist/chunk-DMRD3FGC.mjs.map +0 -1
package/native/src/lib.rs CHANGED
@@ -14,18 +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;
33
+ mod markup_tokenizer;
26
34
  mod runtime;
27
35
  mod selector;
28
36
  mod sink;
37
+ mod snapshot;
29
38
  mod stream;
30
39
  mod tokenizer;
31
40
  mod tracker;
@@ -68,19 +77,81 @@ pub(crate) fn push_attribute_name(
68
77
  output.push_str(local);
69
78
  }
70
79
 
80
+ /// Orders attributes the way a JavaScript object enumerates its keys: integer
81
+ /// keys first, ascending, then insertion order. Cheerio serializes attributes
82
+ /// from such an object.
83
+ pub(crate) fn js_property_order<'a, T, I>(entries: I) -> JsPropertyOrder<I>
84
+ where
85
+ I: Iterator<Item = (&'a str, T)> + Clone,
86
+ {
87
+ if !entries.clone().any(|(name, _)| array_index(name).is_some()) {
88
+ return JsPropertyOrder::Unchanged(entries);
89
+ }
90
+ let mut keyed: Vec<_> = entries
91
+ .map(|entry| {
92
+ (
93
+ array_index(entry.0).map_or((1, 0), |index| (0, index)),
94
+ entry,
95
+ )
96
+ })
97
+ .collect();
98
+ keyed.sort_by_key(|(key, _)| *key);
99
+ let sorted: Vec<_> = keyed.into_iter().map(|(_, entry)| entry).collect();
100
+ JsPropertyOrder::Sorted(sorted.into_iter())
101
+ }
102
+
103
+ pub(crate) enum JsPropertyOrder<I: Iterator> {
104
+ Unchanged(I),
105
+ Sorted(std::vec::IntoIter<I::Item>),
106
+ }
107
+
108
+ impl<I: Iterator> Iterator for JsPropertyOrder<I> {
109
+ type Item = I::Item;
110
+ fn next(&mut self) -> Option<Self::Item> {
111
+ match self {
112
+ Self::Unchanged(entries) => entries.next(),
113
+ Self::Sorted(entries) => entries.next(),
114
+ }
115
+ }
116
+ }
117
+
118
+ fn array_index(name: &str) -> Option<u32> {
119
+ let is_canonical = !name.is_empty()
120
+ && name.len() <= 10
121
+ && name.bytes().all(|byte| byte.is_ascii_digit())
122
+ && (name == "0" || !name.starts_with('0'));
123
+ is_canonical
124
+ .then(|| name.parse::<u64>().ok())
125
+ .flatten()
126
+ .filter(|value| *value < u64::from(u32::MAX))
127
+ .map(|value| value as u32)
128
+ }
129
+
71
130
  #[napi(custom_finalize)]
72
131
  pub struct NativeDocument {
73
132
  html: RefCell<Option<Html>>,
74
133
  // Stable public IDs are preorder positions, rather than parser allocation
75
134
  // positions: HTML5 foster parenting can change the latter's ordering.
76
135
  ids: RefCell<Vec<NodeId>>,
77
- 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>>,
78
139
  pub(crate) arena: RefCell<Option<arena::DomArena>>,
79
140
  options: Value,
80
141
  source: RefCell<Option<String>>,
81
142
  retained: RefCell<HashMap<u32, usize>>,
82
143
  needs_collect: Cell<bool>,
83
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,
84
155
  diagnostics: RefCell<Vec<Value>>,
85
156
  locations: RefCell<HashMap<u32, Value>>,
86
157
  // V8 cannot see native allocations. Reporting them lets its GC collect
@@ -89,12 +160,71 @@ pub struct NativeDocument {
89
160
  reported_heap_bytes: Cell<i64>,
90
161
  }
91
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
+
92
205
  impl ObjectFinalize for NativeDocument {
93
- 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.
94
209
  let reported = self.reported_heap_bytes.get();
95
- if reported != 0 {
96
- // Accounting is advisory; a failure must not throw from a finalizer.
97
- 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
+ ));
98
228
  }
99
229
  Ok(())
100
230
  }
@@ -120,16 +250,31 @@ const HEAP_REPORT_STEP: i64 = 64 * 1024;
120
250
  impl NativeDocument {
121
251
  pub(crate) fn from_arena(arena: arena::DomArena, options: Value) -> Self {
122
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
+ }
123
262
  Self {
124
263
  html: RefCell::new(None),
125
264
  ids: RefCell::new(Vec::new()),
126
- reverse: RefCell::new(HashMap::new()),
265
+ reverse: RefCell::new(id_hash::IdMap::default()),
266
+ tree_index: RefCell::new(None),
127
267
  arena: RefCell::new(Some(arena)),
128
268
  options,
129
269
  source: RefCell::new(None),
130
270
  retained: RefCell::new(HashMap::new()),
131
271
  needs_collect: Cell::new(false),
132
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(),
133
278
  diagnostics: RefCell::new(Vec::new()),
134
279
  locations: RefCell::new(HashMap::new()),
135
280
  content_bytes: Cell::new(content_bytes),
@@ -168,6 +313,10 @@ impl NativeDocument {
168
313
  }
169
314
 
170
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
+ }
171
320
  let reported = self.reported_heap_bytes.get();
172
321
  let estimated = self.estimated_heap_bytes();
173
322
  let change = estimated - reported;
@@ -196,20 +345,40 @@ impl NativeDocument {
196
345
  .as_object_mut()
197
346
  .and_then(|options| options.remove("__tokenEvents"))
198
347
  {
199
- return Self::from_arena(
348
+ return Self::from_arena_sized(
200
349
  tokenizer::parse(source, &events, parser_options.clone()),
201
350
  parser_options,
351
+ source.len(),
202
352
  );
203
353
  }
204
354
  if arena::xml_mode(&parser_options)
205
355
  || parser_options.get("_useHtmlParser2") == Some(&Value::Bool(true))
206
356
  {
207
- return Self::from_arena(
357
+ return Self::from_arena_sized(
208
358
  arena::parse_xml(source, parser_options.clone()),
209
359
  parser_options,
360
+ source.len(),
210
361
  );
211
362
  }
212
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
+
213
382
  let options = html5ever::driver::ParseOpts {
214
383
  tokenizer: html5ever::tokenizer::TokenizerOpts {
215
384
  discard_bom: false,
@@ -224,7 +393,7 @@ impl NativeDocument {
224
393
  };
225
394
  let html = if is_document {
226
395
  html5ever::driver::parse_document(
227
- sink::CompatibleSink::new(Html::new_document()),
396
+ sink::CompatibleSink::new(sized_html(source, true)),
228
397
  options,
229
398
  )
230
399
  .one(source)
@@ -232,7 +401,7 @@ impl NativeDocument {
232
401
  // parse5's default fragment context is a template. A body context
233
402
  // discards valid standalone table fragments such as <td> and <tr>.
234
403
  html5ever::driver::parse_fragment(
235
- sink::CompatibleSink::new(Html::new_fragment()),
404
+ sink::CompatibleSink::new(sized_html(source, false)),
236
405
  options,
237
406
  html5ever::QualName::new(
238
407
  None,
@@ -250,10 +419,6 @@ impl NativeDocument {
250
419
  .one(source)
251
420
  };
252
421
 
253
- let capture = parser_options
254
- .get("sourceCodeLocationInfo")
255
- .is_some_and(|value| !matches!(value, Value::Null | Value::Bool(false)))
256
- || parser_options.get("__collectParseErrors") == Some(&Value::Bool(true));
257
422
  Self::from_html(
258
423
  html,
259
424
  is_document,
@@ -269,18 +434,7 @@ impl NativeDocument {
269
434
  source: Option<String>,
270
435
  ) -> Self {
271
436
  if !is_document {
272
- // html5ever's fragment sink creates a synthetic html element.
273
- // Cheerio exposes the fragment's actual children at its root.
274
- let wrapper = html.tree.root().children().find_map(|node| {
275
- node.value()
276
- .as_element()
277
- .filter(|element| element.name() == "html")
278
- .map(|_| node.id())
279
- });
280
- if let Some(wrapper) = wrapper {
281
- html.tree.root_mut().reparent_from_id_append(wrapper);
282
- html.tree.get_mut(wrapper).unwrap().detach();
283
- }
437
+ unwrap_fragment_root(&mut html);
284
438
  }
285
439
 
286
440
  let mut locations = HashMap::new();
@@ -304,20 +458,7 @@ impl NativeDocument {
304
458
  |_, _| {},
305
459
  );
306
460
  if !is_document {
307
- let wrapper = tracked
308
- .tree
309
- .root()
310
- .children()
311
- .find(|node| {
312
- node.value()
313
- .as_element()
314
- .is_some_and(|element| element.name() == "html")
315
- })
316
- .map(|node| node.id());
317
- if let Some(wrapper) = wrapper {
318
- tracked.tree.root_mut().reparent_from_id_append(wrapper);
319
- tracked.tree.get_mut(wrapper).unwrap().detach();
320
- }
461
+ unwrap_fragment_root(&mut tracked);
321
462
  }
322
463
  let order: HashMap<_, _> = tracked
323
464
  .tree
@@ -363,16 +504,25 @@ impl NativeDocument {
363
504
  })
364
505
  .and(source);
365
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
+ }
366
510
  Self {
367
511
  html: RefCell::new(Some(html)),
368
512
  ids: RefCell::new(ids),
369
513
  reverse: RefCell::new(reverse),
514
+ tree_index: RefCell::new(None),
370
515
  arena: RefCell::new(None),
371
516
  options: parser_options,
372
517
  source: RefCell::new(source),
373
518
  retained: RefCell::new(HashMap::new()),
374
519
  needs_collect: Cell::new(false),
375
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(),
376
526
  diagnostics: RefCell::new(diagnostics),
377
527
  locations: RefCell::new(locations),
378
528
  content_bytes: Cell::new(content_bytes),
@@ -380,13 +530,33 @@ impl NativeDocument {
380
530
  }
381
531
  }
382
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
+
383
543
  pub(crate) fn ensure_arena(&self) -> Result<()> {
384
544
  let missing = self.arena.borrow().is_none();
385
545
  if missing {
386
- let records: Vec<arena::DomNode> = (0..self.ids.borrow().len())
387
- .filter_map(|id| self.immutable_dom_node(id as u32))
388
- .collect();
389
- 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());
390
560
  if let Some(source) = self.source.borrow().as_ref() {
391
561
  location::annotate(
392
562
  source,
@@ -412,10 +582,17 @@ impl NativeDocument {
412
582
  }
413
583
  }
414
584
  *self.arena.borrow_mut() = Some(arena);
415
- 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
+ }
416
594
  self.source.borrow_mut().take();
417
- *self.ids.borrow_mut() = Vec::new();
418
- *self.reverse.borrow_mut() = HashMap::new();
595
+ *self.tree_index.borrow_mut() = None;
419
596
  }
420
597
  Ok(())
421
598
  }
@@ -431,127 +608,246 @@ impl NativeDocument {
431
608
  Some(operation(node, html))
432
609
  }
433
610
 
434
- fn immutable_dom_node(&self, id: u32) -> Option<arena::DomNode> {
435
- self.with_node(id, |node, html| {
436
- let pointer = |node: Option<NodeRef<'_, Node>>| {
437
- 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())
438
629
  };
439
- let mut record = arena::DomNode::new("root");
440
- record.id = id;
441
- record.children = node
442
- .children()
443
- .filter_map(|child| self.reverse.borrow().get(&child.id()).copied())
444
- .collect();
445
- record.parent = pointer(node.parent());
446
- record.prev = pointer(node.prev_sibling());
447
- record.next = pointer(node.next_sibling());
448
- record.extras.insert("startIndex".into(), Value::Null);
449
- record.extras.insert("endIndex".into(), Value::Null);
450
- match node.value() {
451
- Node::Document => {
452
- record.extras.insert(
453
- "x-mode".into(),
454
- Value::String(
455
- match html.quirks_mode {
456
- html5ever::tree_builder::QuirksMode::Quirks => "quirks",
457
- html5ever::tree_builder::QuirksMode::LimitedQuirks => {
458
- "limited-quirks"
459
- }
460
- html5ever::tree_builder::QuirksMode::NoQuirks => "no-quirks",
461
- }
462
- .into(),
463
- ),
464
- );
465
- }
466
- Node::Fragment => {}
467
- Node::Text(data) => {
468
- record.kind = "text".into();
469
- record.data = Some(data.to_string());
470
- }
471
- Node::Comment(data) => {
472
- record.kind = "comment".into();
473
- 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();
474
667
  }
475
- Node::Doctype(data) => {
476
- record.kind = "directive".into();
477
- record.name = Some("!doctype".into());
478
- record
479
- .extras
480
- .insert("x-name".into(), Value::String(data.name().into()));
481
- record
482
- .extras
483
- .insert("x-publicId".into(), Value::String(data.public_id().into()));
484
- record
485
- .extras
486
- .insert("x-systemId".into(), Value::String(data.system_id().into()));
487
- let mut content = format!("!DOCTYPE {}", data.name());
488
- if !data.public_id().is_empty() {
489
- content.push_str(" PUBLIC ");
490
- content.push_str(&quote_doctype(data.public_id()));
491
- } else if !data.system_id().is_empty() {
492
- content.push_str(" SYSTEM");
493
- }
494
- if !data.system_id().is_empty() {
495
- content.push(' ');
496
- 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)
497
675
  }
498
- record.data = Some(content);
499
- }
500
- Node::ProcessingInstruction(data) => {
501
- record.kind = "directive".into();
502
- record.name = Some(data.target.to_string());
503
- 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;
504
684
  }
505
- Node::Element(element) => {
506
- record.kind = match element.name() {
507
- "script" => "script",
508
- "style" => "style",
509
- _ => "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)));
510
688
  }
511
- .into();
512
- record.name = Some(element.name().into());
513
- record.extras.insert(
514
- "namespace".into(),
515
- Value::String(element.name.ns.to_string()),
516
- );
517
- if let Some(prefix) = &element.name.prefix {
518
- record
519
- .extras
520
- .insert("prefix".into(), Value::String(prefix.to_string()));
521
- }
522
- let (mut namespaces, mut prefixes) = (Map::new(), Map::new());
523
- for (name, value) in &element.attrs {
524
- let key = name.local.to_string();
525
- record
526
- .attribs
527
- .insert(key.clone(), Value::String(value.to_string()));
528
- if !name.ns.is_empty() {
529
- namespaces.insert(key.clone(), Value::String(name.ns.to_string()));
530
- }
531
- if let Some(prefix) = &name.prefix {
532
- prefixes.insert(key, Value::String(prefix.to_string()));
533
- }
534
- }
535
- record
536
- .extras
537
- .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 {
538
740
  record
539
- .extras
540
- .insert("attribsPrefix".into(), Value::Object(prefixes));
741
+ .attribs
742
+ .insert(name.local.to_string(), Value::String(value.to_string()));
541
743
  }
542
744
  }
543
745
  record
544
746
  })
545
747
  }
546
748
 
547
- fn immutable_record(&self, id: u32) -> Option<Value> {
548
- 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)
549
844
  }
550
845
 
551
846
  fn select_ids(&self, source: &str, contexts: Option<&[u32]>) -> Result<Vec<u32>> {
552
847
  self.maybe_collect();
553
848
  let contexts = contexts.filter(|contexts| *contexts != [0]);
554
849
  if self.arena.borrow().is_some()
850
+ || self.tree_detached.get()
555
851
  || contexts.is_some()
556
852
  || self.options.get("__losslessUnicode") == Some(&Value::Bool(true))
557
853
  || codec::has_encoding(source)
@@ -564,17 +860,12 @@ impl NativeDocument {
564
860
  .next()
565
861
  .is_some_and(|ch| matches!(ch, '>' | '+' | '~'))
566
862
  || source.contains("!=")
863
+ || compares_case_insensitive_attribute(source)
567
864
  {
568
- self.ensure_arena()?;
569
- return selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts);
570
- }
571
- let normalized = normalize_attribute_namespaces(source);
572
- let selector = match Selector::parse(&normalized) {
573
- Ok(selector) => selector,
574
- Err(_) => {
575
- self.ensure_arena()?;
576
- return selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts);
577
- }
865
+ return self.select_native(source, contexts);
866
+ }
867
+ let Some(selector) = parsed_selector(source) else {
868
+ return self.select_native(source, contexts);
578
869
  };
579
870
  let mut matched = Vec::new();
580
871
 
@@ -686,6 +977,207 @@ impl NativeDocument {
686
977
  }
687
978
  }
688
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
+
689
1181
  #[napi(js_name = "collectGarbage")]
690
1182
  pub fn collect_garbage_and_report(&self, env: Env) -> MemoryStats {
691
1183
  let stats = self.collect_garbage();
@@ -698,13 +1190,17 @@ impl NativeDocument {
698
1190
 
699
1191
  pub fn collect_garbage(&self) -> MemoryStats {
700
1192
  self.needs_collect.set(false);
701
- 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()
702
1197
  {
703
1198
  self.html.borrow_mut().take();
704
1199
  self.source.borrow_mut().take();
705
1200
  let reclaimed = self.ids.borrow().len() as u32;
706
1201
  *self.ids.borrow_mut() = Vec::new();
707
- *self.reverse.borrow_mut() = HashMap::new();
1202
+ *self.reverse.borrow_mut() = id_hash::IdMap::default();
1203
+ *self.tree_index.borrow_mut() = None;
708
1204
  return MemoryStats {
709
1205
  live: 0,
710
1206
  slots: 0,
@@ -714,8 +1210,13 @@ impl NativeDocument {
714
1210
  external_bytes: self.reported_heap_bytes.get(),
715
1211
  };
716
1212
  }
717
- if let Some(arena) = self.arena.borrow_mut().as_mut() {
718
- 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
+ );
719
1220
  arena.free = stats.free;
720
1221
  MemoryStats {
721
1222
  live: stats.live,
@@ -806,7 +1307,7 @@ impl NativeDocument {
806
1307
  if (self.tag_name(id).as_deref() == Some("template") || self.arena.borrow().is_some())
807
1308
  && self.ensure_arena().is_ok()
808
1309
  {
809
- 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);
810
1311
  }
811
1312
  self.serialize_nodes(&[id], true)
812
1313
  }
@@ -816,6 +1317,53 @@ impl NativeDocument {
816
1317
  self.serialize_nodes(&ids, false)
817
1318
  }
818
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
+
819
1367
  #[napi]
820
1368
  pub fn attr(&self, id: u32, name: String) -> Option<String> {
821
1369
  self.attribute(id, &name)
@@ -852,6 +1400,18 @@ impl NativeDocument {
852
1400
  serde_json::to_string(&records).map_err(json_error)
853
1401
  }
854
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
+
855
1415
  #[napi(js_name = "node")]
856
1416
  pub fn node_and_report(&self, env: Env, id: u32) -> Result<String> {
857
1417
  self.reported(env, self.node(id))
@@ -921,7 +1481,7 @@ impl NativeDocument {
921
1481
  }
922
1482
  self.ensure_arena()?;
923
1483
  source.ensure_arena()?;
924
- let mut source_arena = source.arena.borrow_mut();
1484
+ let mut source_arena = source.arena_for_write();
925
1485
  let source_arena = source_arena.as_mut().unwrap();
926
1486
  // Detach all requested roots first: overlapping roots become siblings
927
1487
  // and each requested descendant is imported exactly once.
@@ -940,7 +1500,7 @@ impl NativeDocument {
940
1500
  stack.extend(node.children.iter().rev().copied());
941
1501
  }
942
1502
  }
943
- let mut target_arena = self.arena.borrow_mut();
1503
+ let mut target_arena = self.arena_for_write();
944
1504
  let target_arena = target_arena.as_mut().unwrap();
945
1505
  target_arena.nodes.reserve(moved.len());
946
1506
  let (roots, mapping) = if detach.unwrap_or(true) {
@@ -1014,6 +1574,14 @@ impl NativeDocument {
1014
1574
  if command == "node" {
1015
1575
  return self.node(args["id"].as_u64().unwrap_or(0) as u32);
1016
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
+ }
1017
1585
  if self.arena.borrow().is_none() {
1018
1586
  let ids = arena::value_ids(&args["ids"]);
1019
1587
  let effective_options = arena::normalized_options(
@@ -1078,6 +1646,14 @@ impl NativeDocument {
1078
1646
  .unwrap_or(Value::Null);
1079
1647
  return serde_json::to_string(&result).map_err(json_error);
1080
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
+ }
1081
1657
  if command == "render" || command == "outerHtml" {
1082
1658
  let mut options = self.options.clone();
1083
1659
  if let Some(overrides) = args.get("options").and_then(Value::as_object) {
@@ -1095,7 +1671,7 @@ impl NativeDocument {
1095
1671
  }
1096
1672
  }
1097
1673
  self.ensure_arena()?;
1098
- let mut arena = self.arena.borrow_mut();
1674
+ let mut arena = self.arena_for_write();
1099
1675
  let arena = arena.as_mut().unwrap();
1100
1676
  let previous_options = arena.options.clone();
1101
1677
  if let Some(options) = args.get("options").filter(|options| options.is_object()) {
@@ -1124,6 +1700,18 @@ impl NativeDocument {
1124
1700
  }
1125
1701
  }
1126
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
+
1127
1715
  #[napi(object)]
1128
1716
  pub struct MemoryStats {
1129
1717
  pub live: u32,
@@ -1203,6 +1791,309 @@ fn normalize_attribute_namespaces(source: &str) -> Cow<'_, str> {
1203
1791
  Cow::Owned(normalized)
1204
1792
  }
1205
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
+
1206
2097
  fn quote_doctype(value: &str) -> String {
1207
2098
  let quote = if value.contains('"') { '\'' } else { '"' };
1208
2099
  format!("{quote}{value}{quote}")
@@ -1323,7 +2214,13 @@ fn serialize_node(node: NodeRef<'_, Node>, output: &mut String) {
1323
2214
  Node::Element(element) => {
1324
2215
  output.push('<');
1325
2216
  output.push_str(element.name());
1326
- for (name, value) in &element.attrs {
2217
+ let attributes = js_property_order(
2218
+ element
2219
+ .attrs
2220
+ .iter()
2221
+ .map(|(name, value)| (name.local.as_ref(), (name, value))),
2222
+ );
2223
+ for (_, (name, value)) in attributes {
1327
2224
  output.push(' ');
1328
2225
  push_attribute_name(
1329
2226
  output,
@@ -1589,6 +2486,32 @@ pub fn extract_plan_many_async(
1589
2486
  mod tests {
1590
2487
  use super::*;
1591
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
+
1592
2515
  #[test]
1593
2516
  fn managed_detached_payloads_are_reclaimed_and_slots_are_reused() {
1594
2517
  let document = NativeDocument::parse("<main></main>", false);
@@ -1596,8 +2519,8 @@ mod tests {
1596
2519
  let main = document.select_ids("main", None).unwrap()[0];
1597
2520
  document
1598
2521
  .op(
1599
- "attr".into(),
1600
- serde_json::json!({"ids":[main],"name":"a","value":"b"}).to_string(),
2522
+ "clone".into(),
2523
+ serde_json::json!({"ids":[main],"deep":false}).to_string(),
1601
2524
  )
1602
2525
  .unwrap();
1603
2526
  assert_eq!(document.collect_garbage().immutable_nodes, 0);
@@ -2009,9 +2932,11 @@ mod tests {
2009
2932
  serde_json::json!({"ids":[detached,ul,detached]}).to_string(),
2010
2933
  )
2011
2934
  .unwrap();
2935
+ // As domutils does: the last of repeated ids is kept, and nodes of
2936
+ // separate trees keep their order.
2012
2937
  assert_eq!(
2013
2938
  serde_json::from_str::<Vec<u32>>(&sorted).unwrap(),
2014
- vec![detached, ul]
2939
+ vec![ul, detached]
2015
2940
  );
2016
2941
  }
2017
2942