1use std::borrow::Cow;
2
3#[derive(Debug, Clone)]
16pub struct ClassNameV0 {
17 raw: String,
18 decoded: Option<String>,
19}
20
21impl ClassNameV0 {
22 pub fn new(raw: impl Into<String>) -> Self {
23 let raw = raw.into();
24 let decoded = match decode_css_identifier_escapes(&raw) {
25 Cow::Borrowed(_) => None,
26 Cow::Owned(decoded) => Some(decoded),
27 };
28 Self { raw, decoded }
29 }
30
31 pub fn raw(&self) -> &str {
32 &self.raw
33 }
34
35 pub fn decoded(&self) -> &str {
36 self.decoded.as_deref().unwrap_or(&self.raw)
37 }
38
39 pub fn into_raw(self) -> String {
40 self.raw
41 }
42
43 pub fn same_as(&self, other: &Self) -> bool {
44 self.decoded() == other.decoded()
45 }
46
47 pub fn canonical_key(self) -> CanonicalClassKeyV0 {
48 let decoded = self.decoded.unwrap_or(self.raw);
49 CanonicalClassKeyV0(decoded, CanonicalClassKeySealV0(()))
50 }
51
52 fn from_plain(raw: &str) -> Self {
53 Self {
54 raw: raw.to_owned(),
55 decoded: None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61struct CanonicalClassKeySealV0(());
62
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct CanonicalClassKeyV0(String, CanonicalClassKeySealV0);
65
66impl CanonicalClassKeyV0 {
67 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct ClassSelectorPositionV0 {
74 pub start: usize,
75 pub end: usize,
76}
77
78#[derive(Debug, Clone)]
79pub struct ClassSelectorNameV0 {
80 pub name: ClassNameV0,
81 pub position: ClassSelectorPositionV0,
82}
83
84pub fn is_css_name_start(ch: char) -> bool {
86 ch == '-' || ch == '_' || ch.is_ascii_alphabetic() || !ch.is_ascii()
87}
88
89pub fn is_css_name_continue(ch: char) -> bool {
91 is_css_name_start(ch) || ch.is_ascii_digit()
92}
93
94pub fn is_ascii_word_continue(ch: char) -> bool {
100 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
101}
102
103pub fn is_safe_css_identifier(value: &str) -> bool {
104 let mut characters = value.chars();
105 let Some(first) = characters.next() else {
106 return false;
107 };
108 match first {
109 character
110 if character == '_' || character.is_ascii_alphabetic() || !character.is_ascii() => {}
111 '-' => {
112 let Some(second) = characters.next() else {
113 return false;
114 };
115 if !(second == '-'
116 || second == '_'
117 || second.is_ascii_alphabetic()
118 || !second.is_ascii())
119 {
120 return false;
121 }
122 }
123 _ => return false,
124 }
125 characters.all(is_css_name_continue)
126}
127
128pub fn decode_css_identifier_escapes(text: &str) -> Cow<'_, str> {
129 if !text.contains('\\') {
130 return Cow::Borrowed(text);
131 }
132
133 let mut output = String::with_capacity(text.len());
134 let mut index = 0usize;
135 while index < text.len() {
136 let Some(ch) = text[index..].chars().next() else {
137 break;
138 };
139 if ch != '\\' {
140 output.push(ch);
141 index += ch.len_utf8();
142 continue;
143 }
144
145 let escape_start = index;
146 index += ch.len_utf8();
147 let Some(next) = text[index..].chars().next() else {
148 output.push(char::REPLACEMENT_CHARACTER);
149 break;
150 };
151 if is_css_newline(next) {
152 output.push_str(&text[escape_start..index + next.len_utf8()]);
153 index += next.len_utf8();
154 continue;
155 }
156 if next.is_ascii_hexdigit() {
157 let hex_start = index;
158 let mut hex_end = index;
159 let mut digit_count = 0usize;
160 while hex_end < text.len() && digit_count < 6 {
161 let Some(candidate) = text[hex_end..].chars().next() else {
162 break;
163 };
164 if !candidate.is_ascii_hexdigit() {
165 break;
166 }
167 hex_end += candidate.len_utf8();
168 digit_count += 1;
169 }
170 let codepoint = u32::from_str_radix(&text[hex_start..hex_end], 16).ok();
171 output.push(
172 codepoint
173 .filter(|value| *value != 0)
174 .and_then(char::from_u32)
175 .unwrap_or(char::REPLACEMENT_CHARACTER),
176 );
177 index = hex_end;
178 if let Some(terminator) = text[index..].chars().next()
179 && terminator.is_ascii_whitespace()
180 {
181 index += terminator.len_utf8();
182 }
183 continue;
184 }
185
186 output.push(next);
187 index += next.len_utf8();
188 }
189
190 Cow::Owned(output)
191}
192
193pub fn class_selector_name_end(text: &str, start: usize) -> Option<usize> {
194 let first = text.get(start..)?.chars().next()?;
195 let mut index = if first == '\\' {
196 css_identifier_escape_sequence_end(text, start)?
197 } else if is_css_name_start(first) {
198 start + first.len_utf8()
199 } else {
200 return None;
201 };
202
203 while index < text.len() {
204 let Some(ch) = text[index..].chars().next() else {
205 break;
206 };
207 if ch == '\\' {
208 let Some(end) = css_identifier_escape_sequence_end(text, index) else {
209 break;
210 };
211 index = end;
212 } else if is_css_name_continue(ch) {
213 index += ch.len_utf8();
214 } else {
215 break;
216 }
217 }
218 Some(index)
219}
220
221pub fn class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
222 if let Some(names) = ascii_class_selector_names(selector) {
223 return names;
224 }
225 general_class_selector_names(selector)
226}
227
228fn ascii_class_selector_names(selector: &str) -> Option<Vec<ClassSelectorNameV0>> {
229 let bytes = selector.as_bytes();
230 let mut names = Vec::new();
231 let mut index = 0usize;
232 let mut paren_depth = 0usize;
233 let mut bracket_depth = 0usize;
234 let mut quote = None;
235
236 while index < bytes.len() {
237 let byte = bytes[index];
238 if !byte.is_ascii() || byte == b'\\' {
239 return None;
240 }
241 if let Some(active_quote) = quote {
242 if byte == active_quote {
243 quote = None;
244 }
245 index += 1;
246 continue;
247 }
248 match byte {
249 b'"' | b'\'' => quote = Some(byte),
250 b'(' => paren_depth += 1,
251 b')' => paren_depth = paren_depth.saturating_sub(1),
252 b'[' => bracket_depth += 1,
253 b']' => bracket_depth = bracket_depth.saturating_sub(1),
254 b'.' if paren_depth == 0 && bracket_depth == 0 => {
255 let start = index + 1;
256 let Some(first) = bytes.get(start).copied() else {
257 index += 1;
258 continue;
259 };
260 if !ascii_css_name_start(first) {
261 index += 1;
262 continue;
263 }
264 let mut end = start + 1;
265 while end < bytes.len() && ascii_css_name_continue(bytes[end]) {
266 end += 1;
267 }
268 names.push(ClassSelectorNameV0 {
269 name: ClassNameV0::from_plain(&selector[start..end]),
270 position: ClassSelectorPositionV0 { start, end },
271 });
272 index = end;
273 continue;
274 }
275 _ => {}
276 }
277 index += 1;
278 }
279 Some(names)
280}
281
282fn ascii_css_name_start(byte: u8) -> bool {
283 matches!(byte, b'-' | b'_') || byte.is_ascii_alphabetic()
284}
285
286fn ascii_css_name_continue(byte: u8) -> bool {
287 ascii_css_name_start(byte) || byte.is_ascii_digit()
288}
289
290fn general_class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
291 let mut names = Vec::new();
292 let mut index = 0usize;
293 let mut paren_depth = 0usize;
294 let mut bracket_depth = 0usize;
295 let mut quote = None;
296
297 while index < selector.len() {
298 let Some(ch) = selector[index..].chars().next() else {
299 break;
300 };
301 if ch == '\\' {
302 index = css_identifier_escape_sequence_end(selector, index)
303 .unwrap_or(index + ch.len_utf8());
304 continue;
305 }
306 if let Some(active_quote) = quote {
307 if ch == active_quote {
308 quote = None;
309 }
310 index += ch.len_utf8();
311 continue;
312 }
313 match ch {
314 '"' | '\'' => quote = Some(ch),
315 '(' => paren_depth += 1,
316 ')' => paren_depth = paren_depth.saturating_sub(1),
317 '[' => bracket_depth += 1,
318 ']' => bracket_depth = bracket_depth.saturating_sub(1),
319 '.' if paren_depth == 0 && bracket_depth == 0 => {
320 let start = index + ch.len_utf8();
321 if let Some(end) = class_selector_name_end(selector, start) {
322 names.push(ClassSelectorNameV0 {
323 name: ClassNameV0::new(&selector[start..end]),
324 position: ClassSelectorPositionV0 { start, end },
325 });
326 index = end;
327 continue;
328 }
329 }
330 _ => {}
331 }
332 index += ch.len_utf8();
333 }
334
335 names
336}
337
338pub fn css_identifier_escape_sequence_end(text: &str, slash_index: usize) -> Option<usize> {
342 if text[slash_index..].chars().next()? != '\\' {
343 return None;
344 }
345 let mut index = slash_index + '\\'.len_utf8();
346 let next = text[index..].chars().next()?;
347 if is_css_newline(next) {
348 return None;
349 }
350 if !next.is_ascii_hexdigit() {
351 return Some(index + next.len_utf8());
352 }
353
354 let mut digit_count = 0usize;
355 while index < text.len() && digit_count < 6 {
356 let Some(candidate) = text[index..].chars().next() else {
357 break;
358 };
359 if !candidate.is_ascii_hexdigit() {
360 break;
361 }
362 index += candidate.len_utf8();
363 digit_count += 1;
364 }
365 if let Some(terminator) = text[index..].chars().next()
366 && terminator.is_ascii_whitespace()
367 {
368 index += terminator.len_utf8();
369 }
370 Some(index)
371}
372
373fn is_css_newline(ch: char) -> bool {
374 matches!(ch, '\n' | '\r' | '\u{c}')
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn decodes_css_escapes_without_changing_plain_names() {
383 assert!(matches!(
386 decode_css_identifier_escapes("plain"),
387 Cow::Borrowed("plain")
388 ));
389 assert_eq!(decode_css_identifier_escapes(r"a\.b"), "a.b");
390 assert_eq!(decode_css_identifier_escapes(r"\31 23"), "123");
391 assert_eq!(decode_css_identifier_escapes(r"\0"), "\u{fffd}");
392 assert_eq!(decode_css_identifier_escapes("\\"), "\u{fffd}");
393 assert_eq!(decode_css_identifier_escapes("\\\n"), "\\\n");
394 }
395
396 #[test]
397 fn identifier_escape_boundaries_reject_newline_and_end_of_input() {
398 assert_eq!(css_identifier_escape_sequence_end(r"\31 23", 0), Some(4));
399 assert_eq!(css_identifier_escape_sequence_end(r"\:", 0), Some(2));
400 assert_eq!(css_identifier_escape_sequence_end("\\\n", 0), None);
401 assert_eq!(css_identifier_escape_sequence_end("\\", 0), None);
402 }
403
404 #[test]
405 fn class_name_identity_is_decoded_but_raw_text_is_preserved() {
406 let escaped = ClassNameV0::new(r"a\.b");
407 let plain = ClassNameV0::new("a.b");
408
409 assert!(escaped.same_as(&plain));
412 assert_eq!(escaped.raw(), r"a\.b");
413 assert_eq!(escaped.canonical_key().as_str(), "a.b");
414 }
415
416 #[test]
417 fn ascii_class_scanner_matches_the_general_authority() {
418 let selector = r#".card .title[data-x="a.b"]:is(.nested).plain"#;
419 let summarize = |names: Vec<ClassSelectorNameV0>| {
420 names
421 .into_iter()
422 .map(|entry| {
423 (
424 entry.name.into_raw(),
425 entry.position.start,
426 entry.position.end,
427 )
428 })
429 .collect::<Vec<_>>()
430 };
431
432 let fast = ascii_class_selector_names(selector);
433 assert!(fast.is_some(), "fixture must stay on the fast path");
434 if let Some(fast) = fast {
435 assert_eq!(
436 summarize(fast),
437 summarize(general_class_selector_names(selector))
438 );
439 }
440 }
441
442 #[test]
443 fn extracts_top_level_class_names_with_byte_positions() {
444 let selector = r#".card .title[data-x="a.b"]:is(.nested).a\.b.\31 23.카드.café"#;
445 let names = class_selector_names(selector);
446 let raw = names
447 .iter()
448 .map(|entry| entry.name.raw())
449 .collect::<Vec<_>>();
450
451 assert_eq!(
454 raw,
455 vec!["card", "title", r"a\.b", r"\31 23", "카드", "café"]
456 );
457 assert_eq!(
458 names
459 .iter()
460 .find(|entry| entry.name.raw() == "café")
461 .map(|entry| entry.name.decoded().chars().count()),
462 Some(4)
463 );
464 for entry in names {
465 assert_eq!(
466 &selector[entry.position.start..entry.position.end],
467 entry.name.raw()
468 );
469 }
470 }
471
472 #[test]
473 fn distinguishes_css_name_and_ascii_word_boundaries() {
474 assert!(is_css_name_start('카'));
477 assert!(is_css_name_continue('é'));
478 assert!(!is_ascii_word_continue('카'));
479 assert!(is_ascii_word_continue('9'));
480 assert!(is_safe_css_identifier("카드"));
481 assert!(is_safe_css_identifier("--token"));
482 assert!(!is_safe_css_identifier("-9token"));
483 assert!(!is_safe_css_identifier("9token"));
484 }
485}