p_css 0.1.9 → 0.2.0.beta1
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.
- checksums.yaml +4 -4
- data/Cargo.lock +282 -0
- data/Cargo.toml +3 -0
- data/ext/css_native/Cargo.toml +12 -0
- data/ext/css_native/extconf.rb +4 -0
- data/ext/css_native/src/lib.rs +117 -0
- data/ext/css_native/src/matcher.rs +356 -0
- data/ext/css_native/src/selectors.rs +411 -0
- data/ext/css_native/src/snapshot.rs +370 -0
- data/ext/css_native/src/state.rs +174 -0
- data/ext/css_native/src/tokenizer.rs +596 -0
- data/lib/css/native.rb +179 -0
- data/lib/css/version.rb +1 -1
- metadata +34 -5
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
use crate::selectors::{AnB, AttrMatcher, CaseFlag, Combinator, Complex, Compound, Pseudo, Selector, Simple};
|
|
2
|
+
use crate::snapshot::{ElementData, Snapshot};
|
|
3
|
+
use crate::state::{State, StatefulKind};
|
|
4
|
+
|
|
5
|
+
// Entry point. Returns true if any of the SelectorList alternatives match.
|
|
6
|
+
// Runs entirely against owned data — safe to call without the GVL.
|
|
7
|
+
pub fn matches(snap: &Snapshot, slot: u32, selector: &Selector, state: Option<&State>) -> bool {
|
|
8
|
+
selector.list().iter().any(|c| match_complex(snap, slot, c, state))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
fn match_complex(snap: &Snapshot, slot: u32, complex: &Complex, state: Option<&State>) -> bool {
|
|
12
|
+
let last = complex.compounds.len().saturating_sub(1);
|
|
13
|
+
match_at(snap, Some(slot), complex, last, state)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fn match_at(snap: &Snapshot, slot: Option<u32>, complex: &Complex, index: usize, state: Option<&State>) -> bool {
|
|
17
|
+
let Some(slot) = slot else { return false };
|
|
18
|
+
|
|
19
|
+
if !match_compound(snap, slot, &complex.compounds[index], state) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if index == 0 {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let prev = index - 1;
|
|
28
|
+
let element = snap.element(slot);
|
|
29
|
+
|
|
30
|
+
match complex.combinators[prev] {
|
|
31
|
+
Combinator::Descendant => walk_until_match(snap, element.parent, complex, prev, Step::Parent, state),
|
|
32
|
+
Combinator::Child => match_at(snap, element.parent, complex, prev, state),
|
|
33
|
+
Combinator::NextSibling => match_at(snap, element.prev_sibling, complex, prev, state),
|
|
34
|
+
Combinator::SubsequentSibling => walk_until_match(snap, element.prev_sibling, complex, prev, Step::PrevSibling, state),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[derive(Clone, Copy)]
|
|
39
|
+
enum Step {
|
|
40
|
+
Parent,
|
|
41
|
+
PrevSibling,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn walk_until_match(
|
|
45
|
+
snap: &Snapshot,
|
|
46
|
+
start: Option<u32>,
|
|
47
|
+
complex: &Complex,
|
|
48
|
+
index: usize,
|
|
49
|
+
step: Step,
|
|
50
|
+
state: Option<&State>,
|
|
51
|
+
) -> bool {
|
|
52
|
+
let mut current = start;
|
|
53
|
+
|
|
54
|
+
while let Some(slot) = current {
|
|
55
|
+
if match_at(snap, Some(slot), complex, index, state) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let e = snap.element(slot);
|
|
60
|
+
current = match step {
|
|
61
|
+
Step::Parent => e.parent,
|
|
62
|
+
Step::PrevSibling => e.prev_sibling,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn match_compound(snap: &Snapshot, slot: u32, compound: &Compound, state: Option<&State>) -> bool {
|
|
70
|
+
let element = snap.element(slot);
|
|
71
|
+
compound.components.iter().all(|s| match_simple(snap, slot, element, s, state))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fn match_simple(snap: &Snapshot, slot: u32, element: &ElementData, simple: &Simple, state: Option<&State>) -> bool {
|
|
75
|
+
match simple {
|
|
76
|
+
Simple::Type(name) => &element.tag == name,
|
|
77
|
+
Simple::Universal => true,
|
|
78
|
+
Simple::Id(name) => element.id.as_deref() == Some(name.as_str()),
|
|
79
|
+
Simple::Class(name) => element.classes.iter().any(|c| c == name),
|
|
80
|
+
Simple::Attribute(attr) => match_attribute(element, attr),
|
|
81
|
+
Simple::PseudoClass(p) => match_pseudo(snap, slot, element, p, state),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn match_pseudo(snap: &Snapshot, slot: u32, element: &ElementData, pseudo: &Pseudo, state: Option<&State>) -> bool {
|
|
86
|
+
match pseudo {
|
|
87
|
+
Pseudo::Root => element.parent.is_none(),
|
|
88
|
+
Pseudo::FirstChild => element.prev_sibling.is_none(),
|
|
89
|
+
Pseudo::LastChild => element.next_sibling.is_none(),
|
|
90
|
+
Pseudo::OnlyChild => element.prev_sibling.is_none() && element.next_sibling.is_none(),
|
|
91
|
+
Pseudo::Empty => element.is_empty,
|
|
92
|
+
Pseudo::Defined => true,
|
|
93
|
+
Pseudo::FirstOfType => element.index_of_type == 1,
|
|
94
|
+
Pseudo::LastOfType => element.last_index_of_type == 1,
|
|
95
|
+
Pseudo::OnlyOfType => element.index_of_type == 1 && element.last_index_of_type == 1,
|
|
96
|
+
|
|
97
|
+
// Pure-Ruby parity: nth-* require a parent. Root elements never
|
|
98
|
+
// satisfy them, even when the index would be 1.
|
|
99
|
+
Pseudo::NthChild(anb) => element.parent.is_some() && match_anb(anb, element.index),
|
|
100
|
+
Pseudo::NthLastChild(anb) => element.parent.is_some() && match_anb(anb, element.last_index),
|
|
101
|
+
Pseudo::NthOfType(anb) => element.parent.is_some() && match_anb(anb, element.index_of_type),
|
|
102
|
+
Pseudo::NthLastOfType(anb) => element.parent.is_some() && match_anb(anb, element.last_index_of_type),
|
|
103
|
+
|
|
104
|
+
Pseudo::Is(list) => list.iter().any(|c| match_complex(snap, slot, c, state)),
|
|
105
|
+
Pseudo::Not(list) => !list.iter().any(|c| match_complex(snap, slot, c, state)),
|
|
106
|
+
|
|
107
|
+
Pseudo::Link => is_link(element),
|
|
108
|
+
Pseudo::Enabled => is_disableable(element) && !is_disabled(snap, slot),
|
|
109
|
+
Pseudo::Disabled => is_disableable(element) && is_disabled(snap, slot),
|
|
110
|
+
Pseudo::Checked => is_checked(element),
|
|
111
|
+
Pseudo::Required => is_input_like(element) && element.attrs.contains_key("required"),
|
|
112
|
+
Pseudo::Optional => is_input_like(element) && !element.attrs.contains_key("required"),
|
|
113
|
+
Pseudo::ReadOnly => is_read_only(snap, slot, element),
|
|
114
|
+
Pseudo::ReadWrite => is_read_write(snap, slot, element),
|
|
115
|
+
Pseudo::PlaceholderShown => is_placeholder_shown(element),
|
|
116
|
+
|
|
117
|
+
Pseudo::Hover => stateful(state, snap, slot, StatefulKind::Hover),
|
|
118
|
+
Pseudo::Focus => stateful(state, snap, slot, StatefulKind::Focus),
|
|
119
|
+
Pseudo::FocusWithin => stateful(state, snap, slot, StatefulKind::FocusWithin),
|
|
120
|
+
Pseudo::FocusVisible => stateful(state, snap, slot, StatefulKind::FocusVisible),
|
|
121
|
+
Pseudo::Active => stateful(state, snap, slot, StatefulKind::Active),
|
|
122
|
+
Pseudo::Visited => stateful(state, snap, slot, StatefulKind::Visited),
|
|
123
|
+
Pseudo::Target => stateful(state, snap, slot, StatefulKind::Target),
|
|
124
|
+
|
|
125
|
+
Pseudo::Has => false,
|
|
126
|
+
Pseudo::Lang(target) => match_lang(snap, slot, target.as_deref()),
|
|
127
|
+
Pseudo::Dir(target) => match_dir(snap, slot, target.as_deref()),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fn match_lang(snap: &Snapshot, slot: u32, target: Option<&str>) -> bool {
|
|
132
|
+
let Some(target) = target else { return false };
|
|
133
|
+
|
|
134
|
+
let mut cur = Some(slot);
|
|
135
|
+
while let Some(s) = cur {
|
|
136
|
+
let el = snap.element(s);
|
|
137
|
+
|
|
138
|
+
let actual = el.attrs.get("lang").or_else(|| el.attrs.get("xml:lang"));
|
|
139
|
+
|
|
140
|
+
if let Some(actual) = actual {
|
|
141
|
+
let actual = actual.to_ascii_lowercase();
|
|
142
|
+
return actual == target
|
|
143
|
+
|| actual.starts_with(target) && actual.as_bytes().get(target.len()) == Some(&b'-');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
cur = el.parent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn match_dir(snap: &Snapshot, slot: u32, target: Option<&str>) -> bool {
|
|
153
|
+
let Some(target) = target else { return false };
|
|
154
|
+
|
|
155
|
+
let mut cur = Some(slot);
|
|
156
|
+
while let Some(s) = cur {
|
|
157
|
+
let el = snap.element(s);
|
|
158
|
+
|
|
159
|
+
if let Some(actual) = el.attrs.get("dir") {
|
|
160
|
+
return actual.eq_ignore_ascii_case(target);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cur = el.parent;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
target == "ltr"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn stateful(state: Option<&State>, snap: &Snapshot, slot: u32, kind: StatefulKind) -> bool {
|
|
170
|
+
state.is_some_and(|s| s.matches(snap, slot, kind))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const LINK_TAGS: &[&str] = &["a", "area", "link"];
|
|
174
|
+
const DISABLEABLE_TAGS: &[&str] = &["button", "input", "select", "textarea", "optgroup", "option", "fieldset"];
|
|
175
|
+
const INPUT_TAGS: &[&str] = &["input", "textarea", "select"];
|
|
176
|
+
const RO_INPUT_TYPES: &[&str] = &["hidden", "range", "color", "checkbox", "radio", "file", "submit", "image", "reset", "button"];
|
|
177
|
+
|
|
178
|
+
fn is_link(element: &ElementData) -> bool {
|
|
179
|
+
LINK_TAGS.contains(&element.tag.as_str()) && element.attrs.contains_key("href")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn is_disableable(element: &ElementData) -> bool {
|
|
183
|
+
DISABLEABLE_TAGS.contains(&element.tag.as_str())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn is_input_like(element: &ElementData) -> bool {
|
|
187
|
+
INPUT_TAGS.contains(&element.tag.as_str())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fn is_checked(element: &ElementData) -> bool {
|
|
191
|
+
match element.tag.as_str() {
|
|
192
|
+
"input" => {
|
|
193
|
+
let ty = element.attrs.get("type").map(String::as_str).unwrap_or("");
|
|
194
|
+
(ty.eq_ignore_ascii_case("checkbox") || ty.eq_ignore_ascii_case("radio"))
|
|
195
|
+
&& element.attrs.contains_key("checked")
|
|
196
|
+
}
|
|
197
|
+
"option" => element.attrs.contains_key("selected"),
|
|
198
|
+
_ => false,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// `:disabled` walks the ancestor chain. A fieldset[disabled] disables every
|
|
203
|
+
// descendant unless that descendant sits inside the fieldset's first <legend>.
|
|
204
|
+
fn is_disabled(snap: &Snapshot, slot: u32) -> bool {
|
|
205
|
+
let element = snap.element(slot);
|
|
206
|
+
if element.attrs.contains_key("disabled") {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let mut ancestor = element.parent;
|
|
211
|
+
while let Some(a_slot) = ancestor {
|
|
212
|
+
let a = snap.element(a_slot);
|
|
213
|
+
if a.tag == "fieldset" && a.attrs.contains_key("disabled") {
|
|
214
|
+
if !is_inside_first_legend(snap, slot, a_slot) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
ancestor = a.parent;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn is_inside_first_legend(snap: &Snapshot, element_slot: u32, fieldset_slot: u32) -> bool {
|
|
225
|
+
let Some(first_legend) = first_legend_child(snap, fieldset_slot) else {
|
|
226
|
+
return false;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let mut cur = Some(element_slot);
|
|
230
|
+
while let Some(s) = cur {
|
|
231
|
+
if s == first_legend { return true; }
|
|
232
|
+
if s == fieldset_slot { return false; }
|
|
233
|
+
cur = snap.element(s).parent;
|
|
234
|
+
}
|
|
235
|
+
false
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn first_legend_child(snap: &Snapshot, parent_slot: u32) -> Option<u32> {
|
|
239
|
+
let mut cur = snap.element(parent_slot).first_child;
|
|
240
|
+
while let Some(s) = cur {
|
|
241
|
+
if snap.element(s).tag == "legend" {
|
|
242
|
+
return Some(s);
|
|
243
|
+
}
|
|
244
|
+
cur = snap.element(s).next_sibling;
|
|
245
|
+
}
|
|
246
|
+
None
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fn is_read_only(snap: &Snapshot, slot: u32, element: &ElementData) -> bool {
|
|
250
|
+
match element.tag.as_str() {
|
|
251
|
+
"input" => {
|
|
252
|
+
let ty = element.attrs.get("type").map(String::as_str).unwrap_or("");
|
|
253
|
+
if RO_INPUT_TYPES.iter().any(|t| ty.eq_ignore_ascii_case(t)) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
element.attrs.contains_key("readonly") || is_disabled(snap, slot)
|
|
257
|
+
}
|
|
258
|
+
"textarea" => element.attrs.contains_key("readonly") || is_disabled(snap, slot),
|
|
259
|
+
_ => {
|
|
260
|
+
let ce = element.attrs.get("contenteditable")
|
|
261
|
+
.map(|s| s.to_ascii_lowercase())
|
|
262
|
+
.unwrap_or_default();
|
|
263
|
+
ce.is_empty() || (ce != "true" && ce != "plaintext-only")
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn is_read_write(snap: &Snapshot, slot: u32, element: &ElementData) -> bool {
|
|
269
|
+
match element.tag.as_str() {
|
|
270
|
+
"input" | "textarea" => !is_read_only(snap, slot, element),
|
|
271
|
+
_ => {
|
|
272
|
+
let ce = element.attrs.get("contenteditable")
|
|
273
|
+
.map(|s| s.to_ascii_lowercase())
|
|
274
|
+
.unwrap_or_default();
|
|
275
|
+
ce == "true" || ce == "plaintext-only"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fn is_placeholder_shown(element: &ElementData) -> bool {
|
|
281
|
+
if element.tag != "input" && element.tag != "textarea" {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
if !element.attrs.contains_key("placeholder") {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
let value = element.attrs.get("value").map(String::as_str).unwrap_or("");
|
|
288
|
+
value.is_empty()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// CSS Selectors §6.6.5.1: matches when there exists a non-negative integer
|
|
292
|
+
// `k` such that `index == step * k + offset`. When step is zero, that
|
|
293
|
+
// collapses to `index == offset`.
|
|
294
|
+
fn match_anb(anb: &AnB, index: u32) -> bool {
|
|
295
|
+
let index = index as i64;
|
|
296
|
+
|
|
297
|
+
if anb.step == 0 {
|
|
298
|
+
return index == anb.offset;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let diff = index - anb.offset;
|
|
302
|
+
diff % anb.step == 0 && (diff / anb.step) >= 0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn match_attribute(element: &ElementData, attr: &crate::selectors::AttrSel) -> bool {
|
|
306
|
+
let actual = match attr.name.as_str() {
|
|
307
|
+
"id" => element.id.as_deref(),
|
|
308
|
+
"class" => element.attrs.get("class").map(String::as_str),
|
|
309
|
+
_ => element.attrs.get(&attr.name).map(String::as_str),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
let Some(actual) = actual else { return false };
|
|
313
|
+
let Some(matcher) = attr.matcher else { return true };
|
|
314
|
+
|
|
315
|
+
let needle = attr.value.as_deref().unwrap_or("");
|
|
316
|
+
let case_insensitive = attr.case_flag == Some(CaseFlag::I);
|
|
317
|
+
|
|
318
|
+
let cmp_eq = |h: &str, n: &str| {
|
|
319
|
+
if case_insensitive { h.eq_ignore_ascii_case(n) } else { h == n }
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
let starts = |h: &str, n: &str| {
|
|
323
|
+
if case_insensitive {
|
|
324
|
+
h.len() >= n.len() && h[..n.len()].eq_ignore_ascii_case(n)
|
|
325
|
+
} else {
|
|
326
|
+
h.starts_with(n)
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
let ends = |h: &str, n: &str| {
|
|
331
|
+
if case_insensitive {
|
|
332
|
+
h.len() >= n.len() && h[h.len() - n.len()..].eq_ignore_ascii_case(n)
|
|
333
|
+
} else {
|
|
334
|
+
h.ends_with(n)
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
let contains = |h: &str, n: &str| {
|
|
339
|
+
if case_insensitive {
|
|
340
|
+
h.to_ascii_lowercase().contains(&n.to_ascii_lowercase())
|
|
341
|
+
} else {
|
|
342
|
+
h.contains(n)
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
match matcher {
|
|
347
|
+
AttrMatcher::Exact => cmp_eq(actual, needle),
|
|
348
|
+
AttrMatcher::Includes => !needle.is_empty()
|
|
349
|
+
&& actual.split_whitespace().any(|w| cmp_eq(w, needle)),
|
|
350
|
+
AttrMatcher::Dash => cmp_eq(actual, needle)
|
|
351
|
+
|| starts(actual, &format!("{}-", needle)),
|
|
352
|
+
AttrMatcher::Prefix => !needle.is_empty() && starts(actual, needle),
|
|
353
|
+
AttrMatcher::Suffix => !needle.is_empty() && ends(actual, needle),
|
|
354
|
+
AttrMatcher::Substring => !needle.is_empty() && contains(actual, needle),
|
|
355
|
+
}
|
|
356
|
+
}
|