rusteerio 0.0.0-stage → 0.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 (206) hide show
  1. package/CONTRIBUTING.md +73 -0
  2. package/Cargo.lock +1051 -0
  3. package/Cargo.toml +36 -0
  4. package/LICENSE +21 -0
  5. package/README.md +252 -2
  6. package/RELEASING.md +138 -0
  7. package/THIRD_PARTY_NOTICES.md +7443 -0
  8. package/build.rs +3 -0
  9. package/dist/chunk-DKULX6VO.mjs +3185 -0
  10. package/dist/chunk-DKULX6VO.mjs.map +1 -0
  11. package/dist/chunk-OHPGEY3E.mjs +32 -0
  12. package/dist/chunk-OHPGEY3E.mjs.map +1 -0
  13. package/dist/index.cjs +3230 -0
  14. package/dist/index.cjs.map +1 -0
  15. package/dist/index.d.mts +113 -0
  16. package/dist/index.d.ts +113 -0
  17. package/dist/index.mjs +38 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/slim.cjs +2441 -0
  20. package/dist/slim.cjs.map +1 -0
  21. package/dist/slim.d.mts +11 -0
  22. package/dist/slim.d.ts +11 -0
  23. package/dist/slim.mjs +19 -0
  24. package/dist/slim.mjs.map +1 -0
  25. package/dist/types-BP6TpxE5.d.mts +532 -0
  26. package/dist/types-BP6TpxE5.d.ts +532 -0
  27. package/dist/utils.cjs +61 -0
  28. package/dist/utils.cjs.map +1 -0
  29. package/dist/utils.d.mts +13 -0
  30. package/dist/utils.d.ts +13 -0
  31. package/dist/utils.mjs +17 -0
  32. package/dist/utils.mjs.map +1 -0
  33. package/native/src/arena.rs +1781 -0
  34. package/native/src/attributes.rs +923 -0
  35. package/native/src/codec.rs +172 -0
  36. package/native/src/diagnostics.rs +1249 -0
  37. package/native/src/extraction.rs +226 -0
  38. package/native/src/gc.rs +166 -0
  39. package/native/src/hook_sink.rs +727 -0
  40. package/native/src/hook_stream.rs +513 -0
  41. package/native/src/lib.rs +2095 -0
  42. package/native/src/location.rs +392 -0
  43. package/native/src/runtime.rs +189 -0
  44. package/native/src/selector.rs +1287 -0
  45. package/native/src/sink.rs +261 -0
  46. package/native/src/stream.rs +334 -0
  47. package/native/src/tokenizer.rs +355 -0
  48. package/native/src/tracker.rs +232 -0
  49. package/native/vendor/README.md +25 -0
  50. package/native/vendor/html5ever-0.35.0/.cargo_vcs_info.json +6 -0
  51. package/native/vendor/html5ever-0.35.0/Cargo.lock +828 -0
  52. package/native/vendor/html5ever-0.35.0/Cargo.toml +85 -0
  53. package/native/vendor/html5ever-0.35.0/LICENSE-APACHE +201 -0
  54. package/native/vendor/html5ever-0.35.0/LICENSE-MIT +25 -0
  55. package/native/vendor/html5ever-0.35.0/README.md +60 -0
  56. package/native/vendor/html5ever-0.35.0/benches/html5ever.rs +86 -0
  57. package/native/vendor/html5ever-0.35.0/data/bench/lipsum-zh.html +19 -0
  58. package/native/vendor/html5ever-0.35.0/data/bench/lipsum.html +40 -0
  59. package/native/vendor/html5ever-0.35.0/data/bench/medium-fragment.html +24 -0
  60. package/native/vendor/html5ever-0.35.0/data/bench/small-fragment.html +7 -0
  61. package/native/vendor/html5ever-0.35.0/data/bench/strong.html +1 -0
  62. package/native/vendor/html5ever-0.35.0/data/bench/tiny-fragment.html +1 -0
  63. package/native/vendor/html5ever-0.35.0/examples/arena.rs +356 -0
  64. package/native/vendor/html5ever-0.35.0/examples/capi/tokenize.c +74 -0
  65. package/native/vendor/html5ever-0.35.0/examples/noop-tokenize.rs +47 -0
  66. package/native/vendor/html5ever-0.35.0/examples/noop-tree-builder.rs +132 -0
  67. package/native/vendor/html5ever-0.35.0/examples/print-tree-actions.rs +185 -0
  68. package/native/vendor/html5ever-0.35.0/examples/tokenize.rs +111 -0
  69. package/native/vendor/html5ever-0.35.0/src/driver.rs +148 -0
  70. package/native/vendor/html5ever-0.35.0/src/lib.rs +30 -0
  71. package/native/vendor/html5ever-0.35.0/src/macros.rs +34 -0
  72. package/native/vendor/html5ever-0.35.0/src/serialize/mod.rs +255 -0
  73. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +451 -0
  74. package/native/vendor/html5ever-0.35.0/src/tokenizer/interface.rs +99 -0
  75. package/native/vendor/html5ever-0.35.0/src/tokenizer/mod.rs +2305 -0
  76. package/native/vendor/html5ever-0.35.0/src/tokenizer/states.rs +97 -0
  77. package/native/vendor/html5ever-0.35.0/src/tree_builder/data.rs +170 -0
  78. package/native/vendor/html5ever-0.35.0/src/tree_builder/mod.rs +1848 -0
  79. package/native/vendor/html5ever-0.35.0/src/tree_builder/rules.rs +1494 -0
  80. package/native/vendor/html5ever-0.35.0/src/tree_builder/tag_sets.rs +114 -0
  81. package/native/vendor/html5ever-0.35.0/src/tree_builder/types.rs +91 -0
  82. package/native/vendor/html5ever-0.35.0/src/util/str.rs +52 -0
  83. package/native/vendor/html5gum-0.8.4/Cargo.lock +1006 -0
  84. package/native/vendor/html5gum-0.8.4/Cargo.toml +109 -0
  85. package/native/vendor/html5gum-0.8.4/LICENSE +19 -0
  86. package/native/vendor/html5gum-0.8.4/README.md +110 -0
  87. package/native/vendor/html5gum-0.8.4/benches/files/0050.html +2131 -0
  88. package/native/vendor/html5gum-0.8.4/benches/files/6D30.html +1004 -0
  89. package/native/vendor/html5gum-0.8.4/benches/files.rs +32 -0
  90. package/native/vendor/html5gum-0.8.4/benches/patterns.rs +31 -0
  91. package/native/vendor/html5gum-0.8.4/src/arrayvec.rs +29 -0
  92. package/native/vendor/html5gum-0.8.4/src/char_validator.rs +134 -0
  93. package/native/vendor/html5gum-0.8.4/src/emitters/callback.rs +676 -0
  94. package/native/vendor/html5gum-0.8.4/src/emitters/default.rs +207 -0
  95. package/native/vendor/html5gum-0.8.4/src/emitters/emitter.rs +344 -0
  96. package/native/vendor/html5gum-0.8.4/src/emitters/html5ever.rs +306 -0
  97. package/native/vendor/html5gum-0.8.4/src/emitters/mod.rs +25 -0
  98. package/native/vendor/html5gum-0.8.4/src/entities.rs +3342 -0
  99. package/native/vendor/html5gum-0.8.4/src/error.rs +96 -0
  100. package/native/vendor/html5gum-0.8.4/src/htmlstring.rs +130 -0
  101. package/native/vendor/html5gum-0.8.4/src/lib.rs +66 -0
  102. package/native/vendor/html5gum-0.8.4/src/machine.rs +2136 -0
  103. package/native/vendor/html5gum-0.8.4/src/machine_helper.rs +277 -0
  104. package/native/vendor/html5gum-0.8.4/src/read_helper.rs +244 -0
  105. package/native/vendor/html5gum-0.8.4/src/reader.rs +440 -0
  106. package/native/vendor/html5gum-0.8.4/src/span.rs +98 -0
  107. package/native/vendor/html5gum-0.8.4/src/state.rs +16 -0
  108. package/native/vendor/html5gum-0.8.4/src/testutils.rs +71 -0
  109. package/native/vendor/html5gum-0.8.4/src/tokenizer.rs +189 -0
  110. package/native/vendor/html5gum-0.8.4/src/utils.rs +88 -0
  111. package/native/vendor/markup5ever-0.35.0/.cargo_vcs_info.json +6 -0
  112. package/native/vendor/markup5ever-0.35.0/Cargo.toml +45 -0
  113. package/native/vendor/markup5ever-0.35.0/LICENSE-APACHE +201 -0
  114. package/native/vendor/markup5ever-0.35.0/LICENSE-MIT +25 -0
  115. package/native/vendor/markup5ever-0.35.0/interface/mod.rs +422 -0
  116. package/native/vendor/markup5ever-0.35.0/interface/tree_builder.rs +280 -0
  117. package/native/vendor/markup5ever-0.35.0/lib.rs +59 -0
  118. package/native/vendor/markup5ever-0.35.0/serialize.rs +77 -0
  119. package/native/vendor/markup5ever-0.35.0/util/buffer_queue.rs +333 -0
  120. package/native/vendor/markup5ever-0.35.0/util/smallcharset.rs +88 -0
  121. package/package.json +137 -4
  122. package/rusteerio.darwin-arm64.node +0 -0
  123. package/rusteerio.darwin-x64.node +0 -0
  124. package/rusteerio.linux-arm64-gnu.node +0 -0
  125. package/rusteerio.linux-arm64-musl.node +0 -0
  126. package/rusteerio.linux-x64-gnu.node +0 -0
  127. package/rusteerio.linux-x64-musl.node +0 -0
  128. package/rusteerio.win32-arm64-msvc.node +0 -0
  129. package/rusteerio.win32-x64-msvc.node +0 -0
  130. package/scripts/check-native.mjs +15 -0
  131. package/scripts/check-release.mjs +235 -0
  132. package/scripts/fix-declarations.mjs +27 -0
  133. package/scripts/generate-notices.mjs +176 -0
  134. package/scripts/notices/README.md +13 -0
  135. package/scripts/notices/cheerio-MIT.txt +21 -0
  136. package/scripts/notices/html5ever-AUTHORS.txt +20 -0
  137. package/scripts/notices/html5ever-COPYRIGHT.txt +8 -0
  138. package/scripts/notices/html5ever-LICENSE-APACHE.txt +201 -0
  139. package/scripts/notices/html5ever-LICENSE-MIT.txt +25 -0
  140. package/scripts/notices/napi-rs-MIT.txt +43 -0
  141. package/scripts/notices/neon-MIT.txt +19 -0
  142. package/scripts/notices/runtime-01c266bced4a434da005.txt +3 -0
  143. package/scripts/notices/runtime-0a54e647fe54104658b5.txt +14 -0
  144. package/scripts/notices/runtime-0b74dfa0bcee5c420c6b.txt +25 -0
  145. package/scripts/notices/runtime-0d542e0c8804e39aa7f3.txt +177 -0
  146. package/scripts/notices/runtime-0f96a83840e146e43c0e.txt +21 -0
  147. package/scripts/notices/runtime-123a331b5dbf04c30097.txt +25 -0
  148. package/scripts/notices/runtime-172020dbfd5b53a226df.txt +34 -0
  149. package/scripts/notices/runtime-209fbbe0ad52d9235e37.txt +26 -0
  150. package/scripts/notices/runtime-23f18e03dc49df91622f.txt +23 -0
  151. package/scripts/notices/runtime-268872b9816f90fd8e85.txt +220 -0
  152. package/scripts/notices/runtime-35242e7a83f69875e6ed.txt +176 -0
  153. package/scripts/notices/runtime-378f5840b258e2779c39.txt +25 -0
  154. package/scripts/notices/runtime-4108245a1f2df9d4e94d.txt +25 -0
  155. package/scripts/notices/runtime-4455bf75a91154108304.txt +21 -0
  156. package/scripts/notices/runtime-553fffcd9b1cb158bc3e.txt +13 -0
  157. package/scripts/notices/runtime-62c7a1e35f56406896d7.txt +176 -0
  158. package/scripts/notices/runtime-6485b8ed310d3f0340bf.txt +25 -0
  159. package/scripts/notices/runtime-69fef7b0f322a6555415.txt +201 -0
  160. package/scripts/notices/runtime-6df43f6f4b5d4587f3d8.txt +187 -0
  161. package/scripts/notices/runtime-799e9ca9d179295ef372.txt +24 -0
  162. package/scripts/notices/runtime-7b63ecd5f1902af1b637.txt +25 -0
  163. package/scripts/notices/runtime-7bc7d5de2a0b793d4e1a.txt +21 -0
  164. package/scripts/notices/runtime-7e12e5df4bae12cb2158.txt +24 -0
  165. package/scripts/notices/runtime-861399f8c21c042b1105.txt +12 -0
  166. package/scripts/notices/runtime-8ada45cd9f843acf64e4.txt +201 -0
  167. package/scripts/notices/runtime-90eb64f0279b0d9432ac.txt +12 -0
  168. package/scripts/notices/runtime-a60eea817514531668d7.txt +201 -0
  169. package/scripts/notices/runtime-a7c936ff1ed8fa340172.txt +25 -0
  170. package/scripts/notices/runtime-ab6eec6caf0fa5775e41.txt +275 -0
  171. package/scripts/notices/runtime-b1181a40b2a7b25cf66f.txt +19 -0
  172. package/scripts/notices/runtime-b71bd43a069ca0641a9e.txt +25 -0
  173. package/scripts/notices/runtime-c16f8dcf1a368b83be78.txt +201 -0
  174. package/scripts/notices/runtime-c2cfccb812fe482101a8.txt +21 -0
  175. package/scripts/notices/runtime-e99d88d232bf57d70f0f.txt +25 -0
  176. package/scripts/notices/runtime-f32fb3b417a194167cfa.txt +9 -0
  177. package/scripts/notices/runtime-f5062c9a188d81dfe66b.txt +39 -0
  178. package/scripts/notices/runtime-f9070a832833fa7488fd.txt +4 -0
  179. package/scripts/notices/runtime-fab3dd6bdab226f1c086.txt +373 -0
  180. package/scripts/notices/runtime-fba3318d0c600380177e.txt +62 -0
  181. package/scripts/notices/runtime-ff8f68cb076caf8cefe7.txt +25 -0
  182. package/scripts/notices/runtime-ff92bed461f50338dd70.txt +74 -0
  183. package/scripts/notices/runtime.json +1632 -0
  184. package/scripts/notices/sources.json +555 -0
  185. package/scripts/notices/xray-MIT.txt +21 -0
  186. package/src/attributes.ts +271 -0
  187. package/src/codec.ts +74 -0
  188. package/src/dom.ts +87 -0
  189. package/src/extract-plan.ts +97 -0
  190. package/src/extract.ts +130 -0
  191. package/src/hooks.ts +220 -0
  192. package/src/index.ts +223 -0
  193. package/src/io.ts +491 -0
  194. package/src/native.ts +135 -0
  195. package/src/nodes.ts +290 -0
  196. package/src/parse.ts +53 -0
  197. package/src/parser-types.ts +157 -0
  198. package/src/router.ts +327 -0
  199. package/src/selection.ts +566 -0
  200. package/src/slim.ts +12 -0
  201. package/src/transport.ts +104 -0
  202. package/src/types.ts +186 -0
  203. package/src/utils.ts +29 -0
  204. package/src/workers.ts +21 -0
  205. package/tsconfig.json +15 -0
  206. package/tsup.config.ts +16 -0
@@ -0,0 +1,1287 @@
1
+ //! Cheerio-compatible CSS and jQuery selection over the native mutable DOM.
2
+
3
+ use napi::{Error, Result, Status};
4
+ use std::collections::HashSet;
5
+
6
+ pub(crate) trait SelectDom {
7
+ fn kind(&self, id: u32) -> &str;
8
+ fn name(&self, id: u32) -> Option<&str>;
9
+ fn attr(&self, id: u32, name: &str) -> Option<&str>;
10
+ fn parent(&self, id: u32) -> Option<u32>;
11
+ fn children(&self, id: u32) -> &[u32];
12
+ fn text(&self, id: u32) -> String;
13
+ fn xml_mode(&self) -> bool;
14
+ fn lower_case_tags(&self) -> bool {
15
+ !self.xml_mode()
16
+ }
17
+ fn lower_case_attribute_names(&self) -> bool {
18
+ !self.xml_mode()
19
+ }
20
+ fn custom_pseudo(&self, _id: u32, _name: &str, _argument: Option<&str>) -> Option<bool> {
21
+ None
22
+ }
23
+ fn pseudo_alias(&self, _name: &str) -> Option<&str> {
24
+ None
25
+ }
26
+ }
27
+
28
+ const KNOWN: &[&str] = &[
29
+ "not",
30
+ "is",
31
+ "where",
32
+ "matches",
33
+ "has",
34
+ "contains",
35
+ "icontains",
36
+ "first",
37
+ "last",
38
+ "eq",
39
+ "nth",
40
+ "lt",
41
+ "gt",
42
+ "even",
43
+ "odd",
44
+ "root",
45
+ "scope",
46
+ "empty",
47
+ "parent",
48
+ "first-child",
49
+ "last-child",
50
+ "only-child",
51
+ "nth-child",
52
+ "nth-last-child",
53
+ "first-of-type",
54
+ "last-of-type",
55
+ "only-of-type",
56
+ "nth-of-type",
57
+ "nth-last-of-type",
58
+ "header",
59
+ "input",
60
+ "button",
61
+ "text",
62
+ "checkbox",
63
+ "file",
64
+ "password",
65
+ "radio",
66
+ "reset",
67
+ "image",
68
+ "submit",
69
+ "selected",
70
+ "checked",
71
+ "disabled",
72
+ "enabled",
73
+ "link",
74
+ "any-link",
75
+ "visited",
76
+ "active",
77
+ "hover",
78
+ "required",
79
+ "optional",
80
+ ];
81
+
82
+ #[derive(Clone, Copy)]
83
+ enum Combinator {
84
+ Descendant,
85
+ Child,
86
+ Next,
87
+ Following,
88
+ }
89
+
90
+ #[derive(Clone)]
91
+ enum Test {
92
+ Tag(String),
93
+ Id(String),
94
+ Class(String),
95
+ Attribute {
96
+ name: String,
97
+ op: String,
98
+ value: String,
99
+ flag: Option<char>,
100
+ },
101
+ Pseudo(String, String),
102
+ }
103
+
104
+ #[derive(Clone)]
105
+ struct Complex {
106
+ parts: Vec<Vec<Test>>,
107
+ combinators: Vec<Combinator>,
108
+ leading: Option<Combinator>,
109
+ }
110
+
111
+ fn validate<D: SelectDom>(dom: &D, complex: &Complex) -> Result<()> {
112
+ for part in &complex.parts {
113
+ for test in part {
114
+ if let Test::Pseudo(name, argument) = test {
115
+ if !KNOWN.contains(&name.as_str())
116
+ && dom.custom_pseudo(0, name, Some(argument)).is_none()
117
+ && dom.pseudo_alias(name).is_none()
118
+ {
119
+ return Err(invalid(format!("Unknown pseudo-class :{name}")));
120
+ }
121
+ if matches!(name.as_str(), "not" | "is" | "where" | "matches" | "has") {
122
+ for nested in parse(argument)? {
123
+ validate(dom, &nested)?;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ Ok(())
130
+ }
131
+
132
+ fn invalid(message: impl Into<String>) -> Error {
133
+ Error::new(Status::InvalidArg, message.into())
134
+ }
135
+
136
+ fn tag<D: SelectDom>(dom: &D, id: u32) -> bool {
137
+ matches!(dom.kind(id), "tag" | "script" | "style")
138
+ }
139
+
140
+ fn escape_end(chars: &[char], start: usize) -> usize {
141
+ let mut i = start + 1;
142
+ if i >= chars.len() {
143
+ return i;
144
+ }
145
+ if chars[i].is_ascii_hexdigit() {
146
+ let end = (i + 6).min(chars.len());
147
+ while i < end && chars[i].is_ascii_hexdigit() {
148
+ i += 1;
149
+ }
150
+ if i < chars.len() && chars[i].is_whitespace() {
151
+ i += 1;
152
+ }
153
+ i
154
+ } else {
155
+ i + 1
156
+ }
157
+ }
158
+
159
+ fn unescape(value: &str) -> String {
160
+ if !value.contains('\\') {
161
+ return value.to_owned();
162
+ }
163
+ let chars: Vec<char> = value.chars().collect();
164
+ let mut result = String::new();
165
+ let mut i = 0;
166
+ while i < chars.len() {
167
+ if chars[i] != '\\' {
168
+ result.push(chars[i]);
169
+ i += 1;
170
+ continue;
171
+ }
172
+ let end = escape_end(&chars, i);
173
+ let hexadecimal: String = chars[i + 1..end]
174
+ .iter()
175
+ .take_while(|c| c.is_ascii_hexdigit())
176
+ .collect();
177
+ if hexadecimal.is_empty() {
178
+ if i + 1 < chars.len() {
179
+ result.push(chars[i + 1]);
180
+ }
181
+ } else {
182
+ let code = u32::from_str_radix(&hexadecimal, 16).unwrap_or(0);
183
+ // css-what decodes escapes with JavaScript String.fromCharCode,
184
+ // including lone surrogate code units and paired escapes.
185
+ let units = if code <= 0xffff {
186
+ vec![code as u16]
187
+ } else {
188
+ let high = code - 0x10000;
189
+ vec![
190
+ ((high >> 10) | 0xd800) as u16,
191
+ ((high & 0x3ff) | 0xdc00) as u16,
192
+ ]
193
+ };
194
+ result.push_str(&crate::codec::from_units(&units));
195
+ }
196
+ i = end;
197
+ }
198
+ if crate::codec::has_encoding(&result) {
199
+ crate::codec::from_units(&crate::codec::to_units(&result))
200
+ } else {
201
+ result
202
+ }
203
+ }
204
+
205
+ fn equal_strings(left: &str, right: &str) -> bool {
206
+ left == right
207
+ || (crate::codec::has_encoding(left) || crate::codec::has_encoding(right))
208
+ && crate::codec::to_units(left) == crate::codec::to_units(right)
209
+ }
210
+
211
+ fn starts_with(text: &str, value: &str) -> bool {
212
+ if crate::codec::has_encoding(text) || crate::codec::has_encoding(value) {
213
+ crate::codec::to_units(text).starts_with(&crate::codec::to_units(value))
214
+ } else {
215
+ text.starts_with(value)
216
+ }
217
+ }
218
+
219
+ fn ends_with(text: &str, value: &str) -> bool {
220
+ if crate::codec::has_encoding(text) || crate::codec::has_encoding(value) {
221
+ crate::codec::to_units(text).ends_with(&crate::codec::to_units(value))
222
+ } else {
223
+ text.ends_with(value)
224
+ }
225
+ }
226
+
227
+ fn contains(text: &str, value: &str) -> bool {
228
+ if crate::codec::has_encoding(text) || crate::codec::has_encoding(value) {
229
+ let text = crate::codec::to_units(text);
230
+ let value = crate::codec::to_units(value);
231
+ value.is_empty() || text.windows(value.len()).any(|window| window == value)
232
+ } else {
233
+ text.contains(value)
234
+ }
235
+ }
236
+
237
+ fn identifier(chars: &[char], i: &mut usize) -> String {
238
+ let start = *i;
239
+ while *i < chars.len() {
240
+ let c = chars[*i];
241
+ if c == '\\' {
242
+ *i = escape_end(chars, *i);
243
+ } else if c.is_alphanumeric() || c == '-' || c == '_' || !c.is_ascii() {
244
+ *i += 1;
245
+ } else {
246
+ break;
247
+ }
248
+ }
249
+ unescape(&chars[start..*i].iter().collect::<String>())
250
+ }
251
+
252
+ fn split_groups(source: &str) -> Result<Vec<String>> {
253
+ let chars: Vec<char> = source.chars().collect();
254
+ let mut groups = Vec::new();
255
+ let (mut i, mut start, mut brackets, mut parentheses) = (0, 0, 0i32, 0i32);
256
+ let mut quote = None;
257
+ while i < chars.len() {
258
+ let c = chars[i];
259
+ if c == '\\' {
260
+ i = escape_end(&chars, i);
261
+ continue;
262
+ }
263
+ if let Some(q) = quote {
264
+ if c == q {
265
+ quote = None;
266
+ }
267
+ i += 1;
268
+ continue;
269
+ }
270
+ match c {
271
+ '\'' | '"' => quote = Some(c),
272
+ '[' => brackets += 1,
273
+ ']' => brackets -= 1,
274
+ '(' if brackets == 0 => parentheses += 1,
275
+ ')' if brackets == 0 => parentheses -= 1,
276
+ ',' if brackets == 0 && parentheses == 0 => {
277
+ groups.push(
278
+ chars[start..i]
279
+ .iter()
280
+ .collect::<String>()
281
+ .trim()
282
+ .to_string(),
283
+ );
284
+ start = i + 1;
285
+ }
286
+ _ => {}
287
+ }
288
+ if brackets < 0 || parentheses < 0 {
289
+ return Err(invalid("Unmatched selector delimiter"));
290
+ }
291
+ i += 1;
292
+ }
293
+ if brackets != 0 || parentheses != 0 || quote.is_some() {
294
+ return Err(invalid("Unmatched selector delimiter"));
295
+ }
296
+ groups.push(chars[start..].iter().collect::<String>().trim().to_string());
297
+ if groups.iter().any(String::is_empty) {
298
+ return Err(invalid("Empty sub-selector"));
299
+ }
300
+ Ok(groups)
301
+ }
302
+
303
+ fn parse_attribute(source: &str) -> Result<Test> {
304
+ let chars: Vec<char> = source.trim().chars().collect();
305
+ let mut i = 0;
306
+ let mut name = identifier(&chars, &mut i);
307
+ if i < chars.len() && chars[i] == ':' {
308
+ i += 1;
309
+ name.push(':');
310
+ name.push_str(&identifier(&chars, &mut i));
311
+ }
312
+ if name.is_empty() {
313
+ return Err(invalid("Malformed attribute selector"));
314
+ }
315
+ while i < chars.len() && chars[i].is_whitespace() {
316
+ i += 1;
317
+ }
318
+ if i == chars.len() {
319
+ return Ok(Test::Attribute {
320
+ name,
321
+ op: String::new(),
322
+ value: String::new(),
323
+ flag: None,
324
+ });
325
+ }
326
+ let mut op = String::new();
327
+ if "!~|^$*".contains(chars[i]) {
328
+ op.push(chars[i]);
329
+ i += 1;
330
+ }
331
+ if chars.get(i) != Some(&'=') {
332
+ return Err(invalid("Attribute selector is missing an operator"));
333
+ }
334
+ op.push('=');
335
+ i += 1;
336
+ while i < chars.len() && chars[i].is_whitespace() {
337
+ i += 1;
338
+ }
339
+ let value;
340
+ if matches!(chars.get(i), Some('\'' | '"')) {
341
+ let quote = chars[i];
342
+ i += 1;
343
+ let start = i;
344
+ while i < chars.len() && chars[i] != quote {
345
+ i = if chars[i] == '\\' {
346
+ escape_end(&chars, i)
347
+ } else {
348
+ i + 1
349
+ };
350
+ }
351
+ if i >= chars.len() {
352
+ return Err(invalid("Unmatched attribute quote"));
353
+ }
354
+ value = unescape(&chars[start..i].iter().collect::<String>());
355
+ i += 1;
356
+ } else {
357
+ let start = i;
358
+ while i < chars.len() && !chars[i].is_whitespace() {
359
+ i = if chars[i] == '\\' {
360
+ escape_end(&chars, i)
361
+ } else {
362
+ i + 1
363
+ };
364
+ }
365
+ value = unescape(&chars[start..i].iter().collect::<String>());
366
+ }
367
+ while i < chars.len() && chars[i].is_whitespace() {
368
+ i += 1;
369
+ }
370
+ let flag = chars.get(i).copied().map(|c| c.to_ascii_lowercase());
371
+ if flag.is_some_and(|c| c != 'i' && c != 's') || i + usize::from(flag.is_some()) < chars.len() {
372
+ return Err(invalid("Invalid attribute selector flag"));
373
+ }
374
+ Ok(Test::Attribute {
375
+ name,
376
+ op,
377
+ value,
378
+ flag,
379
+ })
380
+ }
381
+
382
+ fn parse_compound(source: &str) -> Result<Vec<Test>> {
383
+ let chars: Vec<char> = source.chars().collect();
384
+ let mut tests = Vec::new();
385
+ let mut i = 0;
386
+ if chars.first() == Some(&'*') {
387
+ i += 1;
388
+ } else if chars
389
+ .first()
390
+ .is_some_and(|c| *c != '#' && *c != '.' && *c != '[' && *c != ':')
391
+ {
392
+ let name = identifier(&chars, &mut i);
393
+ if name.is_empty() {
394
+ return Err(invalid(format!("Unexpected selector {source:?}")));
395
+ }
396
+ tests.push(Test::Tag(name));
397
+ }
398
+ while i < chars.len() {
399
+ let marker = chars[i];
400
+ i += 1;
401
+ match marker {
402
+ '#' | '.' => {
403
+ let name = identifier(&chars, &mut i);
404
+ if name.is_empty() {
405
+ return Err(invalid("Expected selector identifier"));
406
+ }
407
+ tests.push(if marker == '#' {
408
+ Test::Id(name)
409
+ } else {
410
+ Test::Class(name)
411
+ });
412
+ }
413
+ '[' => {
414
+ let start = i;
415
+ let mut quote = None;
416
+ while i < chars.len() {
417
+ let c = chars[i];
418
+ if c == '\\' {
419
+ i = escape_end(&chars, i);
420
+ continue;
421
+ }
422
+ if let Some(q) = quote {
423
+ if c == q {
424
+ quote = None;
425
+ }
426
+ } else if c == '\'' || c == '"' {
427
+ quote = Some(c);
428
+ } else if c == ']' {
429
+ break;
430
+ }
431
+ i += 1;
432
+ }
433
+ if i == chars.len() {
434
+ return Err(invalid("Unmatched attribute selector"));
435
+ }
436
+ tests.push(parse_attribute(
437
+ &chars[start..i].iter().collect::<String>(),
438
+ )?);
439
+ i += 1;
440
+ }
441
+ ':' => {
442
+ let name = identifier(&chars, &mut i).to_ascii_lowercase();
443
+ if name.is_empty() {
444
+ return Err(invalid("Expected pseudo-class name"));
445
+ }
446
+ let mut argument = String::new();
447
+ if chars.get(i) == Some(&'(') {
448
+ i += 1;
449
+ let start = i;
450
+ let mut depth = 1;
451
+ let mut quote = None;
452
+ while i < chars.len() {
453
+ let c = chars[i];
454
+ if c == '\\' {
455
+ i = escape_end(&chars, i);
456
+ continue;
457
+ }
458
+ if let Some(q) = quote {
459
+ if c == q {
460
+ quote = None;
461
+ }
462
+ } else if c == '\'' || c == '"' {
463
+ quote = Some(c);
464
+ } else if c == '(' {
465
+ depth += 1;
466
+ } else if c == ')' {
467
+ depth -= 1;
468
+ if depth == 0 {
469
+ break;
470
+ }
471
+ }
472
+ i += 1;
473
+ }
474
+ if depth != 0 {
475
+ return Err(invalid("Unmatched pseudo-class argument"));
476
+ }
477
+ argument = chars[start..i]
478
+ .iter()
479
+ .collect::<String>()
480
+ .trim()
481
+ .to_string();
482
+ i += 1;
483
+ }
484
+ tests.push(Test::Pseudo(name, argument));
485
+ }
486
+ _ => return Err(invalid(format!("Unexpected selector character {marker:?}"))),
487
+ }
488
+ }
489
+ Ok(tests)
490
+ }
491
+
492
+ fn parse(source: &str) -> Result<Vec<Complex>> {
493
+ split_groups(source)?
494
+ .iter()
495
+ .map(|group| {
496
+ let chars: Vec<char> = group.chars().collect();
497
+ let mut parts = Vec::new();
498
+ let mut combinators = Vec::new();
499
+ let mut leading = None;
500
+ let (mut i, mut start, mut brackets, mut parentheses) = (0, 0, 0i32, 0i32);
501
+ let mut quote = None;
502
+ while i < chars.len() {
503
+ let c = chars[i];
504
+ if c == '\\' {
505
+ i = escape_end(&chars, i);
506
+ continue;
507
+ }
508
+ if let Some(q) = quote {
509
+ if c == q {
510
+ quote = None;
511
+ }
512
+ i += 1;
513
+ continue;
514
+ }
515
+ match c {
516
+ '\'' | '"' => quote = Some(c),
517
+ '[' => brackets += 1,
518
+ ']' => brackets -= 1,
519
+ '(' => parentheses += 1,
520
+ ')' => parentheses -= 1,
521
+ _ => {}
522
+ }
523
+ if brackets == 0 && parentheses == 0 && (c.is_whitespace() || ">+~".contains(c)) {
524
+ if start < i {
525
+ parts.push(parse_compound(&chars[start..i].iter().collect::<String>())?);
526
+ }
527
+ let mut combinator = Combinator::Descendant;
528
+ while i < chars.len() && chars[i].is_whitespace() {
529
+ i += 1;
530
+ }
531
+ if i < chars.len() && ">+~".contains(chars[i]) {
532
+ combinator = match chars[i] {
533
+ '>' => Combinator::Child,
534
+ '+' => Combinator::Next,
535
+ _ => Combinator::Following,
536
+ };
537
+ i += 1;
538
+ while i < chars.len() && chars[i].is_whitespace() {
539
+ i += 1;
540
+ }
541
+ if i == chars.len() {
542
+ return Err(invalid("Selector cannot end with a combinator"));
543
+ }
544
+ }
545
+ if parts.is_empty() {
546
+ leading = Some(combinator);
547
+ } else if i < chars.len() {
548
+ combinators.push(combinator);
549
+ }
550
+ start = i;
551
+ continue;
552
+ }
553
+ i += 1;
554
+ }
555
+ if start < i {
556
+ parts.push(parse_compound(&chars[start..i].iter().collect::<String>())?);
557
+ }
558
+ if parts.is_empty() || combinators.len() + 1 != parts.len() {
559
+ return Err(invalid("Invalid combinator in selector"));
560
+ }
561
+ Ok(Complex {
562
+ parts,
563
+ combinators,
564
+ leading,
565
+ })
566
+ })
567
+ .collect()
568
+ }
569
+
570
+ fn unique(ids: impl IntoIterator<Item = u32>) -> Vec<u32> {
571
+ let mut seen = HashSet::new();
572
+ ids.into_iter().filter(|id| seen.insert(*id)).collect()
573
+ }
574
+
575
+ fn descendants<D: SelectDom>(dom: &D, roots: &[u32]) -> Vec<u32> {
576
+ let mut output = Vec::new();
577
+ for root in roots {
578
+ let scope_barrier = tag(dom, *root);
579
+ let mut stack: Vec<u32> = dom.children(*root).iter().rev().copied().collect();
580
+ while let Some(id) = stack.pop() {
581
+ if scope_barrier && dom.kind(id) == "root" {
582
+ continue;
583
+ }
584
+ if tag(dom, id) {
585
+ output.push(id);
586
+ }
587
+ stack.extend(dom.children(id).iter().rev().copied());
588
+ }
589
+ }
590
+ unique(output)
591
+ }
592
+
593
+ fn siblings<D: SelectDom>(dom: &D, id: u32) -> Vec<u32> {
594
+ dom.parent(id).map_or_else(Vec::new, |parent| {
595
+ dom.children(parent)
596
+ .iter()
597
+ .copied()
598
+ .filter(|id| tag(dom, *id))
599
+ .collect()
600
+ })
601
+ }
602
+
603
+ fn expand<D: SelectDom>(dom: &D, ids: &[u32], combinator: Combinator) -> Vec<u32> {
604
+ match combinator {
605
+ Combinator::Descendant => descendants(dom, ids),
606
+ Combinator::Child => unique(
607
+ ids.iter()
608
+ .flat_map(|id| dom.children(*id))
609
+ .copied()
610
+ .filter(|id| tag(dom, *id)),
611
+ ),
612
+ Combinator::Next | Combinator::Following => unique(ids.iter().flat_map(|id| {
613
+ let siblings = siblings(dom, *id);
614
+ let offset = siblings
615
+ .iter()
616
+ .position(|other| other == id)
617
+ .map_or(siblings.len(), |i| i + 1);
618
+ let limit = if matches!(combinator, Combinator::Next) {
619
+ 1
620
+ } else {
621
+ siblings.len()
622
+ };
623
+ siblings
624
+ .into_iter()
625
+ .skip(offset)
626
+ .take(limit)
627
+ .collect::<Vec<_>>()
628
+ })),
629
+ }
630
+ }
631
+
632
+ fn position(name: &str) -> bool {
633
+ matches!(
634
+ name,
635
+ "first" | "last" | "eq" | "nth" | "lt" | "gt" | "even" | "odd"
636
+ )
637
+ }
638
+
639
+ fn has_position(source: &str) -> Result<bool> {
640
+ for complex in parse(source)? {
641
+ for part in complex.parts {
642
+ for test in part {
643
+ if let Test::Pseudo(name, argument) = test {
644
+ if position(&name)
645
+ || (matches!(name.as_str(), "not" | "is" | "where" | "matches")
646
+ && has_position(&argument)?)
647
+ {
648
+ return Ok(true);
649
+ }
650
+ }
651
+ }
652
+ }
653
+ }
654
+ Ok(false)
655
+ }
656
+
657
+ fn nth(argument: &str, index: usize, total: usize, reverse: bool) -> bool {
658
+ let expression = argument
659
+ .to_ascii_lowercase()
660
+ .replace(char::is_whitespace, "");
661
+ let n = if reverse { total - index } else { index + 1 } as i64;
662
+ if expression == "odd" {
663
+ return n % 2 == 1;
664
+ }
665
+ if expression == "even" {
666
+ return n % 2 == 0;
667
+ }
668
+ if let Some(at) = expression.find('n') {
669
+ let coefficient = match &expression[..at] {
670
+ "" | "+" => Some(1),
671
+ "-" => Some(-1),
672
+ other => other.parse::<i64>().ok(),
673
+ };
674
+ let offset = if at + 1 == expression.len() {
675
+ Some(0)
676
+ } else {
677
+ expression[at + 1..].parse::<i64>().ok()
678
+ };
679
+ match (coefficient, offset) {
680
+ (Some(0), Some(b)) => n == b,
681
+ (Some(a), Some(b)) => (n - b) * a >= 0 && (n - b) % a == 0,
682
+ _ => false,
683
+ }
684
+ } else {
685
+ expression.parse::<i64>().ok() == Some(n)
686
+ }
687
+ }
688
+
689
+ fn disabled<D: SelectDom>(dom: &D, id: u32) -> bool {
690
+ let name = dom.name(id).unwrap_or("");
691
+ if name == "option"
692
+ && dom.parent(id).is_some_and(|parent| {
693
+ dom.name(parent) == Some("optgroup") && dom.attr(parent, "disabled").is_some()
694
+ })
695
+ {
696
+ return true;
697
+ }
698
+ if dom.attr(id, "disabled").is_none() {
699
+ return false;
700
+ }
701
+ if matches!(
702
+ name,
703
+ "button" | "input" | "select" | "textarea" | "optgroup" | "option"
704
+ ) {
705
+ return true;
706
+ }
707
+ if name != "fieldset" {
708
+ return false;
709
+ }
710
+ // Follow Cheerio/css-select's alias, including its fieldset/legend rule.
711
+ let mut parent = dom.parent(id);
712
+ while let Some(ancestor) = parent {
713
+ if dom.name(ancestor) == Some("legend") {
714
+ if let Some(fieldset) = dom.parent(ancestor) {
715
+ if dom.name(fieldset) == Some("fieldset")
716
+ && dom.attr(fieldset, "disabled").is_some()
717
+ && dom
718
+ .children(fieldset)
719
+ .iter()
720
+ .copied()
721
+ .find(|id| dom.name(*id) == Some("legend"))
722
+ == Some(ancestor)
723
+ {
724
+ return false;
725
+ }
726
+ }
727
+ }
728
+ parent = dom.parent(ancestor);
729
+ }
730
+ true
731
+ }
732
+
733
+ fn selected<D: SelectDom>(dom: &D, id: u32) -> bool {
734
+ if dom.name(id) != Some("option") {
735
+ return false;
736
+ }
737
+ if dom.attr(id, "selected").is_some() {
738
+ return true;
739
+ }
740
+ let Some(parent) = dom.parent(id) else {
741
+ return false;
742
+ };
743
+ if dom.name(parent) != Some("select") || dom.attr(parent, "multiple").is_some() {
744
+ return false;
745
+ }
746
+ let options: Vec<_> = dom
747
+ .children(parent)
748
+ .iter()
749
+ .copied()
750
+ .filter(|id| dom.name(*id) == Some("option"))
751
+ .collect();
752
+ options.first() == Some(&id) && !options.iter().any(|id| dom.attr(*id, "selected").is_some())
753
+ }
754
+
755
+ fn attr_matches<D: SelectDom>(
756
+ dom: &D,
757
+ id: u32,
758
+ name: &str,
759
+ op: &str,
760
+ value: &str,
761
+ flag: Option<char>,
762
+ ) -> bool {
763
+ let name = if !dom.lower_case_attribute_names() {
764
+ name.to_string()
765
+ } else {
766
+ name.to_ascii_lowercase()
767
+ };
768
+ let attribute = dom.attr(id, &name);
769
+ if op.is_empty() {
770
+ return attribute.is_some();
771
+ }
772
+ let Some(attribute) = attribute else {
773
+ return op == "!=" && !value.is_empty();
774
+ };
775
+ let insensitive = flag == Some('i')
776
+ || (flag != Some('s')
777
+ && !dom.xml_mode()
778
+ && matches!(
779
+ name.as_str(),
780
+ "type"
781
+ | "lang"
782
+ | "dir"
783
+ | "rel"
784
+ | "target"
785
+ | "method"
786
+ | "enctype"
787
+ | "align"
788
+ | "valign"
789
+ | "charset"
790
+ | "hreflang"
791
+ | "media"
792
+ | "http-equiv"
793
+ ));
794
+ let (attribute, value) = if insensitive {
795
+ (attribute.to_lowercase(), value.to_lowercase())
796
+ } else {
797
+ (attribute.to_string(), value.to_string())
798
+ };
799
+ match op {
800
+ "=" => equal_strings(&attribute, &value),
801
+ "!=" => !equal_strings(&attribute, &value),
802
+ "~=" => {
803
+ !value.is_empty()
804
+ && attribute
805
+ .split_whitespace()
806
+ .any(|part| equal_strings(part, &value))
807
+ }
808
+ "|=" => equal_strings(&attribute, &value) || starts_with(&attribute, &(value + "-")),
809
+ "^=" => !value.is_empty() && starts_with(&attribute, &value),
810
+ "$=" => !value.is_empty() && ends_with(&attribute, &value),
811
+ "*=" => !value.is_empty() && contains(&attribute, &value),
812
+ _ => false,
813
+ }
814
+ }
815
+
816
+ fn matches_backwards<D: SelectDom>(
817
+ dom: &D,
818
+ id: u32,
819
+ complex: &Complex,
820
+ index: usize,
821
+ scope: &[u32],
822
+ ) -> Result<bool> {
823
+ if !matches_compound(dom, id, &complex.parts[index], scope)? {
824
+ return Ok(false);
825
+ }
826
+ if index == 0 {
827
+ return Ok(true);
828
+ }
829
+ let relation = complex.combinators[index - 1];
830
+ let candidates = match relation {
831
+ Combinator::Child => dom
832
+ .parent(id)
833
+ .into_iter()
834
+ .filter(|id| tag(dom, *id))
835
+ .collect(),
836
+ Combinator::Descendant => {
837
+ let mut parents = Vec::new();
838
+ let mut parent = dom.parent(id);
839
+ while let Some(id) = parent {
840
+ if !tag(dom, id) {
841
+ break;
842
+ }
843
+ parents.push(id);
844
+ parent = dom.parent(id);
845
+ }
846
+ parents
847
+ }
848
+ Combinator::Next | Combinator::Following => {
849
+ let mut others = siblings(dom, id);
850
+ let offset = others.iter().position(|other| *other == id).unwrap_or(0);
851
+ others.truncate(offset);
852
+ if matches!(relation, Combinator::Next) {
853
+ others.into_iter().rev().take(1).collect()
854
+ } else {
855
+ others
856
+ }
857
+ }
858
+ };
859
+ for candidate in candidates {
860
+ if matches_backwards(dom, candidate, complex, index - 1, scope)? {
861
+ return Ok(true);
862
+ }
863
+ }
864
+ Ok(false)
865
+ }
866
+
867
+ fn matches_expression<D: SelectDom>(dom: &D, id: u32, source: &str, scope: &[u32]) -> Result<bool> {
868
+ for complex in parse(source)? {
869
+ if matches_backwards(dom, id, &complex, complex.parts.len() - 1, scope)? {
870
+ return Ok(true);
871
+ }
872
+ }
873
+ Ok(false)
874
+ }
875
+
876
+ fn pseudo<D: SelectDom>(
877
+ dom: &D,
878
+ id: u32,
879
+ name: &str,
880
+ argument: &str,
881
+ scope: &[u32],
882
+ ) -> Result<bool> {
883
+ if let Some(result) = dom.custom_pseudo(id, name, Some(argument)) {
884
+ return Ok(result);
885
+ }
886
+ if let Some(alias) = dom.pseudo_alias(name) {
887
+ return matches_expression(dom, id, alias, scope);
888
+ }
889
+ let node_name = dom.name(id).unwrap_or("");
890
+ Ok(match name {
891
+ "not" => !matches_expression(dom, id, argument, scope)?,
892
+ "is" | "where" | "matches" => matches_expression(dom, id, argument, scope)?,
893
+ "has" => !select(dom, argument, Some(&[id]))?.is_empty(),
894
+ "contains" | "icontains" => {
895
+ let value = unescape(argument.trim_matches(['\'', '"']));
896
+ let text = dom.text(id);
897
+ if name == "icontains" {
898
+ contains(&text.to_lowercase(), &value.to_lowercase())
899
+ } else {
900
+ contains(&text, &value)
901
+ }
902
+ }
903
+ "root" => dom.parent(id).is_none_or(|parent| !tag(dom, parent)),
904
+ "scope" => scope.contains(&id),
905
+ "empty" | "parent" => {
906
+ let empty = !dom
907
+ .children(id)
908
+ .iter()
909
+ .any(|child| tag(dom, *child) || !dom.text(*child).is_empty());
910
+ if name == "empty" {
911
+ empty
912
+ } else {
913
+ !empty
914
+ }
915
+ }
916
+ "first-child" | "last-child" | "only-child" | "nth-child" | "nth-last-child"
917
+ | "first-of-type" | "last-of-type" | "only-of-type" | "nth-of-type"
918
+ | "nth-last-of-type" => {
919
+ let mut children = siblings(dom, id);
920
+ if name.ends_with("of-type") {
921
+ children.retain(|child| dom.name(*child) == Some(node_name));
922
+ }
923
+ let mut argument = argument;
924
+ if let Some((formula, of)) = argument.split_once(" of ") {
925
+ argument = formula;
926
+ children = filter(dom, of, &children)?;
927
+ }
928
+ let Some(index) = children.iter().position(|child| *child == id) else {
929
+ return Ok(false);
930
+ };
931
+ match name {
932
+ "first-child" | "first-of-type" => index == 0,
933
+ "last-child" | "last-of-type" => index + 1 == children.len(),
934
+ "only-child" | "only-of-type" => children.len() == 1,
935
+ _ => nth(argument, index, children.len(), name.contains("nth-last")),
936
+ }
937
+ }
938
+ "header" => matches!(node_name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6"),
939
+ "input" => matches!(node_name, "input" | "textarea" | "select" | "button"),
940
+ "button" => {
941
+ node_name == "button"
942
+ || (node_name == "input" && attr_matches(dom, id, "type", "=", "button", None))
943
+ }
944
+ "text" => {
945
+ node_name == "input"
946
+ && dom.attr(id, "type").is_none_or(|kind| {
947
+ kind.is_empty()
948
+ || if dom.xml_mode() {
949
+ kind == "text"
950
+ } else {
951
+ kind.eq_ignore_ascii_case("text")
952
+ }
953
+ })
954
+ }
955
+ "checkbox" | "file" | "password" | "radio" | "reset" | "image" | "submit" => {
956
+ attr_matches(dom, id, "type", "=", name, None)
957
+ }
958
+ "selected" => selected(dom, id),
959
+ "checked" => {
960
+ (node_name == "input"
961
+ && dom.attr(id, "checked").is_some()
962
+ && (attr_matches(dom, id, "type", "=", "checkbox", None)
963
+ || attr_matches(dom, id, "type", "=", "radio", None)))
964
+ || selected(dom, id)
965
+ }
966
+ "disabled" => disabled(dom, id),
967
+ "enabled" => !disabled(dom, id),
968
+ "link" | "any-link" => {
969
+ matches!(node_name, "a" | "area" | "link") && dom.attr(id, "href").is_some()
970
+ }
971
+ "visited" | "active" | "hover" | "focus" | "target" => false,
972
+ "required" => {
973
+ matches!(node_name, "input" | "select" | "textarea")
974
+ && dom.attr(id, "required").is_some()
975
+ }
976
+ "optional" => {
977
+ matches!(node_name, "input" | "select" | "textarea")
978
+ && dom.attr(id, "required").is_none()
979
+ }
980
+ "read-only" => {
981
+ dom.attr(id, "readonly").is_some() || !matches!(node_name, "input" | "textarea")
982
+ }
983
+ "read-write" => {
984
+ matches!(node_name, "input" | "textarea")
985
+ && dom.attr(id, "readonly").is_none()
986
+ && !disabled(dom, id)
987
+ }
988
+ "lang" => {
989
+ let language = unescape(argument.trim_matches(['\'', '"'])).to_ascii_lowercase();
990
+ let mut current = Some(id);
991
+ let mut matches = false;
992
+ while let Some(id) = current {
993
+ if let Some(value) = dom.attr(id, "lang").or_else(|| dom.attr(id, "xml:lang")) {
994
+ let value = value.to_ascii_lowercase();
995
+ matches = value == language || value.starts_with(&(language.clone() + "-"));
996
+ break;
997
+ }
998
+ current = dom.parent(id);
999
+ }
1000
+ matches
1001
+ }
1002
+ name if position(name) => true,
1003
+ _ => return Err(invalid(format!("Unknown pseudo-class :{name}"))),
1004
+ })
1005
+ }
1006
+
1007
+ fn matches_compound<D: SelectDom>(dom: &D, id: u32, tests: &[Test], scope: &[u32]) -> Result<bool> {
1008
+ if !tag(dom, id) {
1009
+ return Ok(false);
1010
+ }
1011
+ for test in tests {
1012
+ let matched = match test {
1013
+ Test::Tag(name) => {
1014
+ if !dom.lower_case_tags() {
1015
+ dom.name(id)
1016
+ .is_some_and(|actual| equal_strings(actual, name))
1017
+ } else {
1018
+ dom.name(id)
1019
+ .is_some_and(|actual| equal_strings(actual, &name.to_ascii_lowercase()))
1020
+ }
1021
+ }
1022
+ Test::Id(value) => dom
1023
+ .attr(id, "id")
1024
+ .is_some_and(|actual| equal_strings(actual, value)),
1025
+ Test::Class(value) => dom.attr(id, "class").is_some_and(|classes| {
1026
+ classes
1027
+ .split_ascii_whitespace()
1028
+ .any(|class| equal_strings(class, value))
1029
+ }),
1030
+ Test::Attribute {
1031
+ name,
1032
+ op,
1033
+ value,
1034
+ flag,
1035
+ } => attr_matches(dom, id, name, op, value, *flag),
1036
+ Test::Pseudo(name, argument) => {
1037
+ if matches!(name.as_str(), "not" | "is" | "where" | "matches")
1038
+ && has_position(argument)?
1039
+ {
1040
+ true
1041
+ } else {
1042
+ pseudo(dom, id, name, argument, scope)?
1043
+ }
1044
+ }
1045
+ };
1046
+ if !matched {
1047
+ return Ok(false);
1048
+ }
1049
+ }
1050
+ Ok(true)
1051
+ }
1052
+
1053
+ fn apply<D: SelectDom>(dom: &D, ids: Vec<u32>, tests: &[Test], scope: &[u32]) -> Result<Vec<u32>> {
1054
+ let mut result = Vec::new();
1055
+ for id in ids {
1056
+ if matches_compound(dom, id, tests, scope)? {
1057
+ result.push(id);
1058
+ }
1059
+ }
1060
+ for test in tests {
1061
+ if let Test::Pseudo(name, argument) = test {
1062
+ if position(name) {
1063
+ let len = result.len() as i64;
1064
+ let requested = argument.parse::<i64>().unwrap_or(0);
1065
+ let requested = if requested < 0 {
1066
+ len + requested
1067
+ } else {
1068
+ requested
1069
+ };
1070
+ result = result
1071
+ .into_iter()
1072
+ .enumerate()
1073
+ .filter_map(|(index, id)| {
1074
+ let index = index as i64;
1075
+ let include = match name.as_str() {
1076
+ "first" => index == 0,
1077
+ "last" => index == len - 1,
1078
+ "eq" | "nth" => index == requested,
1079
+ "lt" => index < requested,
1080
+ "gt" => index > requested,
1081
+ "even" => index % 2 == 0,
1082
+ "odd" => index % 2 == 1,
1083
+ _ => true,
1084
+ };
1085
+ include.then_some(id)
1086
+ })
1087
+ .collect();
1088
+ } else if matches!(name.as_str(), "not" | "is" | "where" | "matches")
1089
+ && has_position(argument)?
1090
+ {
1091
+ let wanted: HashSet<_> = filter(dom, argument, &result)?.into_iter().collect();
1092
+ result.retain(|id| wanted.contains(id) != (name == "not"));
1093
+ }
1094
+ }
1095
+ }
1096
+ Ok(result)
1097
+ }
1098
+
1099
+ /// Query descendants of contexts (the root document when omitted).
1100
+ pub(crate) fn select<D: SelectDom>(
1101
+ dom: &D,
1102
+ source: &str,
1103
+ contexts: Option<&[u32]>,
1104
+ ) -> Result<Vec<u32>> {
1105
+ let scope = contexts.unwrap_or(&[0]);
1106
+ let complexes = parse(source)?;
1107
+ // A compound over one subtree already follows DOM order. Avoid walking
1108
+ // the entire document again to sort an already ordered result.
1109
+ let ordered_compound = complexes.len() == 1
1110
+ && complexes[0].parts.len() == 1
1111
+ && complexes[0].leading.is_none()
1112
+ && scope.len() == 1;
1113
+ let mut result = Vec::new();
1114
+ for complex in complexes {
1115
+ validate(dom, &complex)?;
1116
+ let mut candidates = if let Some(leading) = complex.leading {
1117
+ expand(dom, scope, leading)
1118
+ } else {
1119
+ descendants(dom, scope)
1120
+ };
1121
+ if complex.parts[0]
1122
+ .iter()
1123
+ .any(|test| matches!(test,Test::Pseudo(name,_) if name=="scope"))
1124
+ {
1125
+ candidates = unique(scope.iter().copied().chain(candidates));
1126
+ }
1127
+ let mut current = apply(dom, candidates, &complex.parts[0], scope)?;
1128
+ for (index, part) in complex.parts.iter().enumerate().skip(1) {
1129
+ current = apply(
1130
+ dom,
1131
+ expand(dom, &current, complex.combinators[index - 1]),
1132
+ part,
1133
+ scope,
1134
+ )?;
1135
+ }
1136
+ result.extend(current);
1137
+ }
1138
+ if ordered_compound {
1139
+ return Ok(result);
1140
+ }
1141
+ let wanted: HashSet<u32> = result.iter().copied().collect();
1142
+ // Stable IDs are allocation IDs after mutations; obtain actual DOM order.
1143
+ let ordered = descendants(dom, &[0]);
1144
+ let mut sorted: Vec<u32> = ordered
1145
+ .into_iter()
1146
+ .filter(|id| wanted.contains(id))
1147
+ .collect();
1148
+ let attached: HashSet<u32> = sorted.iter().copied().collect();
1149
+ sorted.extend(
1150
+ unique(result)
1151
+ .into_iter()
1152
+ .filter(|id| !attached.contains(id)),
1153
+ );
1154
+ Ok(sorted)
1155
+ }
1156
+
1157
+ /// Filter a supplied selection, applying positional pseudos to that selection.
1158
+ pub(crate) fn filter<D: SelectDom>(dom: &D, source: &str, ids: &[u32]) -> Result<Vec<u32>> {
1159
+ let mut wanted = HashSet::new();
1160
+ for complex in parse(source)? {
1161
+ validate(dom, &complex)?;
1162
+ let candidates = if complex.parts.len() == 1 {
1163
+ apply(dom, ids.to_vec(), &complex.parts[0], ids)?
1164
+ } else {
1165
+ let mut result = Vec::new();
1166
+ for id in ids {
1167
+ if matches_backwards(dom, *id, &complex, complex.parts.len() - 1, ids)? {
1168
+ result.push(*id);
1169
+ }
1170
+ }
1171
+ result
1172
+ };
1173
+ wanted.extend(candidates);
1174
+ }
1175
+ Ok(ids
1176
+ .iter()
1177
+ .copied()
1178
+ .filter(|id| wanted.contains(id))
1179
+ .collect())
1180
+ }
1181
+
1182
+ #[cfg(test)]
1183
+ mod tests {
1184
+ use super::*;
1185
+ use crate::arena::parse_xml;
1186
+ use serde_json::json;
1187
+
1188
+ fn fixture() -> crate::arena::DomArena {
1189
+ parse_xml("<main><ul id='list'><li class='a' data-n='A'>one</li><li class='b'>two <b>B</b></li><li class='a'>three</li><li>four</li></ul><p id='escaped:id'>end</p></main>",json!({"xmlMode":false,"_useHtmlParser2":true}))
1190
+ }
1191
+
1192
+ #[test]
1193
+ fn selectors_and_combinators() {
1194
+ let dom = fixture();
1195
+ for (source, count) in [
1196
+ ("li", 4),
1197
+ ("main > ul > li.a", 2),
1198
+ ("li.a + li", 2),
1199
+ ("li.b ~ li", 2),
1200
+ ("ul:has(b)", 1),
1201
+ ("li:contains(two)", 1),
1202
+ ("li:not(.a)", 2),
1203
+ ("li:is(.a,.b)", 3),
1204
+ ("[data-n='a' i]", 1),
1205
+ ("#escaped\\:id", 1),
1206
+ ] {
1207
+ assert_eq!(select(&dom, source, None).unwrap().len(), count, "{source}");
1208
+ }
1209
+ }
1210
+
1211
+ #[test]
1212
+ fn selectors_compare_logical_utf16_and_css_escape_values() {
1213
+ let high = crate::codec::from_units(&[0xd83d]);
1214
+ let low = crate::codec::from_units(&[0xde00]);
1215
+ let literal = crate::codec::encode_scalar('\u{e03d}');
1216
+ let dom = parse_xml(
1217
+ &format!("<main><p id='emoji' title='😀'>😀</p><p id='lone' title='{high}'>{high}</p><p id='literal' title='{literal}'>{literal}</p><p title='café'>text</p></main>"),
1218
+ json!({"xmlMode":false,"_useHtmlParser2":true,"__losslessUnicode":true}),
1219
+ );
1220
+ assert_eq!(
1221
+ select(&dom, &format!("p:contains('{high}')"), None)
1222
+ .unwrap()
1223
+ .len(),
1224
+ 2
1225
+ );
1226
+ assert_eq!(
1227
+ select(&dom, &format!("p[title^='{high}']"), None)
1228
+ .unwrap()
1229
+ .len(),
1230
+ 2
1231
+ );
1232
+ assert_eq!(
1233
+ select(&dom, &format!("p[title$='{low}']"), None)
1234
+ .unwrap()
1235
+ .len(),
1236
+ 1
1237
+ );
1238
+ assert_eq!(
1239
+ select(&dom, &format!("p[title*='{literal}']"), None)
1240
+ .unwrap()
1241
+ .len(),
1242
+ 1
1243
+ );
1244
+ assert_eq!(
1245
+ select(&dom, "p[title='\\d83d\\de00']", None).unwrap().len(),
1246
+ 1
1247
+ );
1248
+ assert_eq!(select(&dom, "p:contains('\\d83d')", None).unwrap().len(), 2);
1249
+ assert_eq!(select(&dom, "p[title=CAFÉ i]", None).unwrap().len(), 1);
1250
+ }
1251
+
1252
+ #[test]
1253
+ fn positional_and_structural() {
1254
+ let dom = fixture();
1255
+ for (source, count) in [
1256
+ ("li:first", 1),
1257
+ ("li:last", 1),
1258
+ ("li:eq(-1)", 1),
1259
+ ("li:lt(2)", 2),
1260
+ ("li:gt(1)", 2),
1261
+ ("li:even", 2),
1262
+ ("li:not(:first)", 3),
1263
+ ("li:nth-child(2n+1)", 2),
1264
+ ("li:nth-last-child(2)", 1),
1265
+ ("li:first-child", 1),
1266
+ ("li:last-child", 1),
1267
+ ("ul:only-child", 0),
1268
+ ] {
1269
+ assert_eq!(select(&dom, source, None).unwrap().len(), count, "{source}");
1270
+ }
1271
+ }
1272
+
1273
+ #[test]
1274
+ fn scoped_and_relative_selection() {
1275
+ let dom = fixture();
1276
+ let ul = select(&dom, "ul", None).unwrap();
1277
+ assert_eq!(select(&dom, "> li", Some(&ul)).unwrap().len(), 4);
1278
+ assert_eq!(select(&dom, "main li", Some(&ul)).unwrap().len(), 0);
1279
+ assert_eq!(select(&dom, ":scope > li", Some(&ul)).unwrap().len(), 4);
1280
+ let ids = select(&dom, "li", None).unwrap();
1281
+ assert_eq!(filter(&dom, ":odd", &ids).unwrap(), vec![ids[1], ids[3]]);
1282
+ assert_eq!(
1283
+ filter(&dom, "li.a + li", &ids).unwrap(),
1284
+ vec![ids[1], ids[3]]
1285
+ );
1286
+ }
1287
+ }