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.
- package/Cargo.lock +42 -1
- package/Cargo.toml +8 -1
- package/README.md +90 -51
- package/THIRD_PARTY_NOTICES.md +107 -2
- package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
- package/dist/chunk-FPRP6UIX.mjs.map +1 -0
- package/dist/index.cjs +2232 -750
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/slim.cjs +2226 -745
- package/dist/slim.cjs.map +1 -1
- package/dist/slim.d.mts +2 -2
- package/dist/slim.d.ts +2 -2
- package/dist/slim.mjs +1 -1
- package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
- package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
- package/dist/utils.d.mts +1 -1
- package/dist/utils.d.ts +1 -1
- package/native/src/arena.rs +219 -80
- package/native/src/attributes.rs +11 -5
- package/native/src/document_order.rs +711 -0
- package/native/src/extraction.rs +15 -3
- package/native/src/gc.rs +12 -3
- package/native/src/id_hash.rs +42 -0
- package/native/src/immutable.rs +256 -0
- package/native/src/lib.rs +1039 -171
- package/native/src/runtime.rs +189 -33
- package/native/src/selector.rs +346 -44
- package/native/src/sink.rs +14 -1
- package/native/src/snapshot.rs +509 -0
- package/native/src/stream.rs +2 -2
- package/native/src/tokenizer.rs +29 -12
- package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
- package/package.json +3 -1
- package/rusteerio.darwin-arm64.node +0 -0
- package/rusteerio.darwin-x64.node +0 -0
- package/rusteerio.linux-arm64-gnu.node +0 -0
- package/rusteerio.linux-arm64-musl.node +0 -0
- package/rusteerio.linux-x64-gnu.node +0 -0
- package/rusteerio.linux-x64-musl.node +0 -0
- package/rusteerio.win32-arm64-msvc.node +0 -0
- package/rusteerio.win32-x64-msvc.node +0 -0
- package/src/attributes.ts +78 -44
- package/src/codec.ts +22 -3
- package/src/extract.ts +86 -2
- package/src/hooks.ts +148 -2
- package/src/index.ts +51 -40
- package/src/match.ts +207 -0
- package/src/native.ts +9 -1
- package/src/nodes.ts +531 -183
- package/src/router.ts +792 -229
- package/src/selection.ts +239 -125
- package/src/snapshot.ts +255 -0
- package/src/transport.ts +51 -42
- package/dist/chunk-DMRD3FGC.mjs.map +0 -1
package/native/src/extraction.rs
CHANGED
|
@@ -64,7 +64,7 @@ fn extract(
|
|
|
64
64
|
.unwrap_or(Value::Null));
|
|
65
65
|
}
|
|
66
66
|
document.ensure_arena()?;
|
|
67
|
-
let mut arena = document.
|
|
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.
|
|
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.
|
|
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");
|
|
@@ -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
|
+
}
|