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
@@ -64,7 +64,7 @@ fn extract(
64
64
  .unwrap_or(Value::Null));
65
65
  }
66
66
  document.ensure_arena()?;
67
- let mut arena = document.arena.borrow_mut();
67
+ let mut arena = document.arena_for_write();
68
68
  let arena = arena
69
69
  .as_mut()
70
70
  .ok_or_else(|| invalid("Missing native document arena"))?;
@@ -73,7 +73,7 @@ fn extract(
73
73
  Some(node) if name == "name" => {
74
74
  node.name.clone().map(Value::String).unwrap_or(Value::Null)
75
75
  }
76
- Some(node) if name == "type" => Value::String(node.kind.clone()),
76
+ Some(node) if name == "type" => Value::String(node.kind.to_string()),
77
77
  Some(_) => json!(1),
78
78
  None => Value::Null,
79
79
  });
@@ -115,6 +115,18 @@ pub(crate) fn execute(document: &NativeDocument, plan: &Value) -> Result<Value>
115
115
  extract(document, entries(plan)?, None, 0)
116
116
  }
117
117
 
118
+ /// Extracts relative to `contexts`, reading the parse tree while it is unchanged.
119
+ pub(crate) fn execute_in(
120
+ document: &NativeDocument,
121
+ plan: &Value,
122
+ contexts: &[u32],
123
+ ) -> Result<Value> {
124
+ if plan.get("version").and_then(Value::as_u64) != Some(1) {
125
+ return Err(invalid("Unsupported extraction plan version"));
126
+ }
127
+ extract(document, entries(plan)?, Some(contexts), 0)
128
+ }
129
+
118
130
  fn extract_arena(
119
131
  arena: &mut crate::arena::DomArena,
120
132
  plan: &[Value],
@@ -152,7 +164,7 @@ fn extract_arena(
152
164
  Some(node) if name == "name" => {
153
165
  node.name.clone().map(Value::String).unwrap_or(Value::Null)
154
166
  }
155
- Some(node) if name == "type" => Value::String(node.kind.clone()),
167
+ Some(node) if name == "type" => Value::String(node.kind.to_string()),
156
168
  Some(_) => json!(1),
157
169
  None => Value::Null,
158
170
  });
package/native/src/gc.rs CHANGED
@@ -14,16 +14,20 @@ pub(crate) struct GcStats {
14
14
 
15
15
  #[cfg(test)]
16
16
  pub(crate) fn collect(arena: &mut DomArena, pins: &HashMap<u32, usize>) -> GcStats {
17
- collect_managed(arena, pins, true)
17
+ collect_managed(arena, pins, true, 0)
18
18
  }
19
19
 
20
+ /// Reclaims nodes connected to neither a pin, the root (with `keep_root`)
21
+ /// nor a slot below `keep_below`.
20
22
  pub(crate) fn collect_managed(
21
23
  arena: &mut DomArena,
22
24
  pins: &HashMap<u32, usize>,
23
25
  keep_root: bool,
26
+ keep_below: u32,
24
27
  ) -> GcStats {
25
28
  let mut marked = vec![false; arena.nodes.len()];
26
- let mut stack = if keep_root { vec![0] } else { Vec::new() };
29
+ let mut stack: Vec<u32> = if keep_root { vec![0] } else { Vec::new() };
30
+ stack.extend(0..keep_below.min(arena.nodes.len() as u32));
27
31
  stack.extend(
28
32
  pins.iter()
29
33
  .filter_map(|(id, count)| (*count > 0).then_some(*id)),
@@ -59,6 +63,9 @@ pub(crate) fn collect_managed(
59
63
  node.attribs = Default::default();
60
64
  node.children = Vec::new();
61
65
  node.extras = Default::default();
66
+ node.namespace = None;
67
+ node.attribs_namespace = None;
68
+ node.attribs_prefix = None;
62
69
  node.parent = None;
63
70
  node.prev = None;
64
71
  node.next = None;
@@ -79,6 +86,8 @@ pub(crate) fn collect_managed(
79
86
  .filter(|node| node.kind == "released")
80
87
  .map(|node| node.id)
81
88
  .collect::<Vec<_>>();
89
+ arena.detached_since_collection = 0;
90
+ arena.collected_live = arena.nodes.len() as u32 - free.len() as u32;
82
91
  GcStats {
83
92
  live: arena.nodes.len() as u32 - free.len() as u32,
84
93
  slots: arena.nodes.len() as u32,
@@ -155,7 +164,7 @@ mod tests {
155
164
  #[test]
156
165
  fn managed_detached_handle_does_not_retain_the_original_document() {
157
166
  let mut arena = fixture();
158
- let stats = collect_managed(&mut arena, &HashMap::from([(3, 1)]), false);
167
+ let stats = collect_managed(&mut arena, &HashMap::from([(3, 1)]), false, 0);
159
168
  assert_eq!((stats.live, stats.retained, stats.reclaimed), (3, 1, 3));
160
169
  assert_eq!(arena.nodes[0].kind, "released");
161
170
  assert_eq!(arena.nodes[1].kind, "released");
@@ -1,7 +1,7 @@
1
1
  //! An active user-supplied tree adapter. Rust runs the HTML5 algorithm; the
2
2
  //! adapter supplies every construction, mutation and tree-inspection decision.
3
3
  use std::borrow::Cow;
4
- use std::cell::{Ref, RefCell};
4
+ use std::cell::{Cell, Ref, RefCell};
5
5
  use std::collections::HashMap;
6
6
  use std::rc::Rc;
7
7
 
@@ -196,6 +196,26 @@ struct HookSink {
196
196
  context: Option<Rc<crate::diagnostics::DiagnosticContext>>,
197
197
  diagnostics: RefCell<Vec<Value>>,
198
198
  locations: Option<crate::tracker::Locations<u32>>,
199
+ pending_escape: Cell<Option<TextTarget>>,
200
+ }
201
+
202
+ /// Where a text chunk is inserted: appended to `parent`, or before `sibling`.
203
+ #[derive(Clone, Copy, PartialEq, Eq)]
204
+ struct TextTarget {
205
+ parent: u32,
206
+ sibling: Option<u32>,
207
+ }
208
+
209
+ /// Whether the final character is a codec escape whose character has not
210
+ /// arrived yet. Escapes pair with the following character.
211
+ fn ends_with_unpaired_escape(text: &str) -> bool {
212
+ let mut chars = text.chars();
213
+ while let Some(ch) = chars.next() {
214
+ if ch == crate::codec::ESCAPE && chars.next().is_none() {
215
+ return true;
216
+ }
217
+ }
218
+ false
199
219
  }
200
220
 
201
221
  impl HookSink {
@@ -203,6 +223,17 @@ impl HookSink {
203
223
  if self.error.borrow().is_some() {
204
224
  return Value::Null;
205
225
  }
226
+ if matches!(
227
+ method,
228
+ "appendChild"
229
+ | "insertBefore"
230
+ | "detachNode"
231
+ | "setDocumentType"
232
+ | "setTemplateContent"
233
+ | "__snapshot"
234
+ ) {
235
+ self.flush_escape();
236
+ }
206
237
  let result = self.bridge.call(method, args);
207
238
  match result {
208
239
  Ok(value) => value,
@@ -215,6 +246,38 @@ impl HookSink {
215
246
  fn node(&self, method: &str, args: Value) -> u32 {
216
247
  self.call(method, args).as_u64().unwrap_or(0) as u32
217
248
  }
249
+ /// The tokenizer may split a codec escape from its character across text
250
+ /// chunks. Adapters decode each chunk separately, so a trailing escape is
251
+ /// held until the next chunk for the same target.
252
+ fn insert_text(&self, target: TextTarget, text: &str) {
253
+ let mut data = String::with_capacity(text.len() + crate::codec::ESCAPE.len_utf8());
254
+ if let Some(pending) = self.pending_escape.take() {
255
+ if pending == target {
256
+ data.push(crate::codec::ESCAPE);
257
+ } else {
258
+ self.send_text(pending, &crate::codec::ESCAPE.to_string());
259
+ }
260
+ }
261
+ data.push_str(text);
262
+ if ends_with_unpaired_escape(&data) {
263
+ data.pop();
264
+ self.pending_escape.set(Some(target));
265
+ }
266
+ if !data.is_empty() {
267
+ self.send_text(target, &data);
268
+ }
269
+ }
270
+ fn send_text(&self, target: TextTarget, data: &str) {
271
+ match target.sibling {
272
+ None => self.call("insertText", json!([target.parent, data])),
273
+ Some(sibling) => self.call("insertTextBefore", json!([target.parent, data, sibling])),
274
+ };
275
+ }
276
+ fn flush_escape(&self) {
277
+ if let Some(pending) = self.pending_escape.take() {
278
+ self.send_text(pending, &crate::codec::ESCAPE.to_string());
279
+ }
280
+ }
218
281
  fn attrs(attrs: Vec<Attribute>) -> Value {
219
282
  Value::Array(
220
283
  attrs
@@ -463,7 +526,11 @@ impl TreeSink for HookSink {
463
526
  }
464
527
  }
465
528
  NodeOrText::AppendText(text) => {
466
- self.call("insertText", json!([parent, text.as_ref()]));
529
+ let target = TextTarget {
530
+ parent: *parent,
531
+ sibling: None,
532
+ };
533
+ self.insert_text(target, text.as_ref());
467
534
  self.mark_text(*parent, None, text.as_ref());
468
535
  }
469
536
  }
@@ -530,7 +597,11 @@ impl TreeSink for HookSink {
530
597
  self.call("insertBefore", json!([parent, node, sibling]));
531
598
  }
532
599
  NodeOrText::AppendText(text) => {
533
- self.call("insertTextBefore", json!([parent, text.as_ref(), sibling]));
600
+ let target = TextTarget {
601
+ parent,
602
+ sibling: Some(*sibling),
603
+ };
604
+ self.insert_text(target, text.as_ref());
534
605
  self.mark_text(parent, Some(*sibling), text.as_ref());
535
606
  }
536
607
  }
@@ -589,6 +660,7 @@ fn parser(
589
660
  context: None,
590
661
  diagnostics: RefCell::new(Vec::new()),
591
662
  locations: None,
663
+ pending_escape: Cell::new(None),
592
664
  };
593
665
  sink.call(
594
666
  if is_document {
@@ -659,6 +731,7 @@ pub(crate) fn parse(
659
731
  diagnostics: RefCell::new(Vec::new()),
660
732
  locations: (options.get("sourceCodeLocationInfo") == Some(&Value::Bool(true)))
661
733
  .then(|| crate::tracker::Locations::new(context.clone())),
734
+ pending_escape: Cell::new(None),
662
735
  };
663
736
  sink.call(
664
737
  if is_document {
@@ -706,6 +779,7 @@ where
706
779
  diagnostics: RefCell::new(Vec::new()),
707
780
  locations: (options.get("sourceCodeLocationInfo") == Some(&Value::Bool(true)))
708
781
  .then(|| crate::tracker::Locations::new(context.clone())),
782
+ pending_escape: Cell::new(None),
709
783
  };
710
784
  sink.call(
711
785
  if is_document {
@@ -0,0 +1,42 @@
1
+ //! A fast hasher for maps keyed by node ids. The default SipHash resists
2
+ //! adversarial keys, which tree-assigned ids cannot be, at several times the cost.
3
+ use std::collections::{HashMap, HashSet};
4
+ use std::hash::{BuildHasherDefault, Hasher};
5
+
6
+ const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
7
+
8
+ #[derive(Default, Clone, Copy)]
9
+ pub(crate) struct IdHasher(u64);
10
+
11
+ impl IdHasher {
12
+ fn add(&mut self, value: u64) {
13
+ self.0 = (self.0.rotate_left(5) ^ value).wrapping_mul(SEED);
14
+ }
15
+ }
16
+
17
+ impl Hasher for IdHasher {
18
+ fn finish(&self) -> u64 {
19
+ self.0
20
+ }
21
+ fn write(&mut self, bytes: &[u8]) {
22
+ let (chunks, remainder) = bytes.as_chunks::<8>();
23
+ for chunk in chunks {
24
+ self.add(u64::from_le_bytes(*chunk));
25
+ }
26
+ for byte in remainder {
27
+ self.add(u64::from(*byte));
28
+ }
29
+ }
30
+ fn write_u32(&mut self, value: u32) {
31
+ self.add(u64::from(value));
32
+ }
33
+ fn write_u64(&mut self, value: u64) {
34
+ self.add(value);
35
+ }
36
+ fn write_usize(&mut self, value: usize) {
37
+ self.add(value as u64);
38
+ }
39
+ }
40
+
41
+ pub(crate) type IdMap<K, V> = HashMap<K, V, BuildHasherDefault<IdHasher>>;
42
+ pub(crate) type IdSet<K> = HashSet<K, BuildHasherDefault<IdHasher>>;
@@ -0,0 +1,256 @@
1
+ //! Read-only selection over a parsed document. Queries, matching and ordering
2
+ //! run on the parse tree, so a document is copied into the mutable arena only
3
+ //! when it is first changed.
4
+ use ego_tree::iter::Edge;
5
+ use ego_tree::NodeId;
6
+ use scraper::{Html, Node};
7
+ use serde_json::Value;
8
+
9
+ use crate::selector::SelectDom;
10
+
11
+ const NONE: u32 = u32::MAX;
12
+
13
+ /// Parent and child links by local id. Local ids are preorder positions, so
14
+ /// ascending order is document order.
15
+ pub(crate) struct TreeIndex {
16
+ parent: Vec<u32>,
17
+ child_start: Vec<u32>,
18
+ child_end: Vec<u32>,
19
+ children: Vec<u32>,
20
+ }
21
+
22
+ impl TreeIndex {
23
+ pub(crate) fn build(html: &Html, count: usize) -> Self {
24
+ let mut parent = vec![NONE; count];
25
+ let mut stack: Vec<u32> = Vec::new();
26
+ let mut next = 0u32;
27
+ for edge in html.tree.root().traverse() {
28
+ match edge {
29
+ Edge::Open(_) => {
30
+ if (next as usize) < count {
31
+ parent[next as usize] = stack.last().copied().unwrap_or(NONE);
32
+ }
33
+ stack.push(next);
34
+ next += 1;
35
+ }
36
+ Edge::Close(_) => {
37
+ stack.pop();
38
+ }
39
+ }
40
+ }
41
+ let mut counts = vec![0u32; count];
42
+ for parent in &parent {
43
+ if let Some(slot) = counts.get_mut(*parent as usize) {
44
+ *slot += 1;
45
+ }
46
+ }
47
+ let mut total = 0u32;
48
+ let child_start: Vec<u32> = counts
49
+ .iter()
50
+ .map(|count| {
51
+ let start = total;
52
+ total += count;
53
+ start
54
+ })
55
+ .collect();
56
+ let mut child_end = child_start.clone();
57
+ let mut children = vec![0u32; total as usize];
58
+ for (local, parent) in parent.iter().enumerate() {
59
+ if let Some(end) = child_end.get_mut(*parent as usize) {
60
+ children[*end as usize] = local as u32;
61
+ *end += 1;
62
+ }
63
+ }
64
+ Self {
65
+ parent,
66
+ child_start,
67
+ child_end,
68
+ children,
69
+ }
70
+ }
71
+
72
+ /// The index of a tree some of whose nodes were detached after parsing,
73
+ /// when traversal order no longer matches local ids.
74
+ pub(crate) fn build_mapped(
75
+ html: &Html,
76
+ ids: &[NodeId],
77
+ reverse: &crate::id_hash::IdMap<NodeId, u32>,
78
+ ) -> Self {
79
+ let local = |node: Option<ego_tree::NodeRef<'_, Node>>| {
80
+ node.and_then(|node| reverse.get(&node.id()).copied())
81
+ };
82
+ let mut parent = vec![NONE; ids.len()];
83
+ let mut child_start = vec![0u32; ids.len()];
84
+ let mut child_end = vec![0u32; ids.len()];
85
+ let mut children = Vec::with_capacity(ids.len());
86
+ for (index, id) in ids.iter().enumerate() {
87
+ let Some(node) = html.tree.get(*id) else {
88
+ continue;
89
+ };
90
+ parent[index] = local(node.parent()).unwrap_or(NONE);
91
+ child_start[index] = children.len() as u32;
92
+ children.extend(node.children().filter_map(|child| local(Some(child))));
93
+ child_end[index] = children.len() as u32;
94
+ }
95
+ Self {
96
+ parent,
97
+ child_start,
98
+ child_end,
99
+ children,
100
+ }
101
+ }
102
+
103
+ pub(crate) fn parent(&self, id: u32) -> Option<u32> {
104
+ self.parent
105
+ .get(id as usize)
106
+ .copied()
107
+ .filter(|parent| *parent != NONE)
108
+ }
109
+
110
+ pub(crate) fn children(&self, id: u32) -> &[u32] {
111
+ match (
112
+ self.child_start.get(id as usize),
113
+ self.child_end.get(id as usize),
114
+ ) {
115
+ (Some(start), Some(end)) => &self.children[*start as usize..*end as usize],
116
+ _ => &[],
117
+ }
118
+ }
119
+
120
+ /// Whether `ancestor` is a proper ancestor of `id`.
121
+ pub(crate) fn contains(&self, ancestor: u32, id: u32) -> bool {
122
+ let mut current = self.parent(id);
123
+ while let Some(node) = current {
124
+ if node == ancestor {
125
+ return true;
126
+ }
127
+ current = self.parent(node);
128
+ }
129
+ false
130
+ }
131
+ }
132
+
133
+ /// The parse tree seen through the same interface as the mutable arena.
134
+ pub(crate) struct TreeDom<'a> {
135
+ pub html: &'a Html,
136
+ pub ids: &'a [NodeId],
137
+ pub index: &'a TreeIndex,
138
+ pub options: &'a Value,
139
+ }
140
+
141
+ impl TreeDom<'_> {
142
+ fn node(&self, id: u32) -> Option<ego_tree::NodeRef<'_, Node>> {
143
+ self.html.tree.get(*self.ids.get(id as usize)?)
144
+ }
145
+
146
+ /// The value the arena would hold: attributes are keyed by local name and
147
+ /// a repeated local name keeps its last value.
148
+ pub(crate) fn element_attr(&self, id: u32, name: &str) -> Option<&str> {
149
+ match self.node(id)?.value() {
150
+ Node::Element(element) => element
151
+ .attrs
152
+ .iter()
153
+ .rev()
154
+ .find(|(key, _)| key.local.as_ref() == name)
155
+ .map(|(_, value)| value.as_ref()),
156
+ _ => None,
157
+ }
158
+ }
159
+
160
+ pub(crate) fn is_element(&self, id: u32) -> bool {
161
+ matches!(
162
+ self.node(id).map(|node| node.value()),
163
+ Some(Node::Element(_))
164
+ )
165
+ }
166
+ }
167
+
168
+ impl SelectDom for TreeDom<'_> {
169
+ fn kind(&self, id: u32) -> &str {
170
+ match self.node(id).map(|node| node.value()) {
171
+ Some(Node::Element(element)) => match element.name() {
172
+ "script" => "script",
173
+ "style" => "style",
174
+ _ => "tag",
175
+ },
176
+ Some(Node::Text(_)) => "text",
177
+ Some(Node::Comment(_)) => "comment",
178
+ Some(Node::Doctype(_) | Node::ProcessingInstruction(_)) => "directive",
179
+ Some(Node::Document | Node::Fragment) => "root",
180
+ None => "",
181
+ }
182
+ }
183
+ fn name(&self, id: u32) -> Option<&str> {
184
+ match self.node(id)?.value() {
185
+ Node::Element(element) => Some(element.name()),
186
+ Node::Doctype(_) => Some("!doctype"),
187
+ Node::ProcessingInstruction(instruction) => Some(&instruction.target),
188
+ _ => None,
189
+ }
190
+ }
191
+ fn attr(&self, id: u32, name: &str) -> Option<&str> {
192
+ self.element_attr(id, name)
193
+ }
194
+ fn parent(&self, id: u32) -> Option<u32> {
195
+ self.index.parent(id)
196
+ }
197
+ fn children(&self, id: u32) -> &[u32] {
198
+ self.index.children(id)
199
+ }
200
+ fn text(&self, id: u32) -> String {
201
+ let mut output = String::new();
202
+ let mut stack = vec![id];
203
+ while let Some(id) = stack.pop() {
204
+ match self.node(id).map(|node| node.value()) {
205
+ Some(Node::Text(text)) => output.push_str(text),
206
+ Some(_) => stack.extend(self.index.children(id).iter().rev().copied()),
207
+ None => {}
208
+ }
209
+ }
210
+ output
211
+ }
212
+ fn xml_mode(&self) -> bool {
213
+ crate::arena::xml_mode(self.options)
214
+ }
215
+ fn lower_case_tags(&self) -> bool {
216
+ self.options
217
+ .get("lowerCaseTags")
218
+ .and_then(Value::as_bool)
219
+ .unwrap_or(!self.xml_mode())
220
+ }
221
+ fn lower_case_attribute_names(&self) -> bool {
222
+ self.options
223
+ .get("lowerCaseAttributeNames")
224
+ .and_then(Value::as_bool)
225
+ .unwrap_or(!self.xml_mode())
226
+ }
227
+ fn custom_pseudo(&self, id: u32, name: &str, arg: Option<&str>) -> Option<bool> {
228
+ let key = format!("{}({})", name, arg.unwrap_or(""));
229
+ self.options
230
+ .get("__pseudoMatches")?
231
+ .get(&key)?
232
+ .as_array()
233
+ .map(|ids| ids.iter().any(|value| value.as_u64() == Some(id as u64)))
234
+ }
235
+ fn pseudo_alias(&self, name: &str) -> Option<&str> {
236
+ self.options.get("pseudos")?.get(name)?.as_str()
237
+ }
238
+ }
239
+
240
+ /// Whether a class attribute contains `needle` as a whitespace-separated token,
241
+ /// exactly as the arena's `hasClass` reads it.
242
+ pub(crate) fn has_class_token(classes: &str, needle: &str) -> bool {
243
+ !needle.is_empty()
244
+ && classes.match_indices(needle).any(|(index, _)| {
245
+ (index == 0
246
+ || classes[..index]
247
+ .chars()
248
+ .next_back()
249
+ .is_some_and(char::is_whitespace))
250
+ && (index + needle.len() == classes.len()
251
+ || classes[index + needle.len()..]
252
+ .chars()
253
+ .next()
254
+ .is_some_and(char::is_whitespace))
255
+ })
256
+ }