use std::borrow::Cow;
#[derive(Debug, Clone)]
pub struct ClassNameV0 {
raw: String,
decoded: Option<String>,
}
impl ClassNameV0 {
pub fn new(raw: impl Into<String>) -> Self {
let raw = raw.into();
let decoded = match decode_css_identifier_escapes(&raw) {
Cow::Borrowed(_) => None,
Cow::Owned(decoded) => Some(decoded),
};
Self { raw, decoded }
}
pub fn raw(&self) -> &str {
&self.raw
}
pub fn decoded(&self) -> &str {
self.decoded.as_deref().unwrap_or(&self.raw)
}
pub fn into_raw(self) -> String {
self.raw
}
pub fn same_as(&self, other: &Self) -> bool {
self.decoded() == other.decoded()
}
pub fn canonical_key(self) -> CanonicalClassKeyV0 {
let decoded = self.decoded.unwrap_or(self.raw);
CanonicalClassKeyV0(decoded, CanonicalClassKeySealV0(()))
}
fn from_plain(raw: &str) -> Self {
Self {
raw: raw.to_owned(),
decoded: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct CanonicalClassKeySealV0(());
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CanonicalClassKeyV0(String, CanonicalClassKeySealV0);
impl CanonicalClassKeyV0 {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClassSelectorPositionV0 {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone)]
pub struct ClassSelectorNameV0 {
pub name: ClassNameV0,
pub position: ClassSelectorPositionV0,
}
pub fn is_css_name_start(ch: char) -> bool {
ch == '-' || ch == '_' || ch.is_ascii_alphabetic() || !ch.is_ascii()
}
pub fn is_css_name_continue(ch: char) -> bool {
is_css_name_start(ch) || ch.is_ascii_digit()
}
pub fn is_ascii_word_continue(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
pub fn is_safe_css_identifier(value: &str) -> bool {
let mut characters = value.chars();
let Some(first) = characters.next() else {
return false;
};
match first {
character
if character == '_' || character.is_ascii_alphabetic() || !character.is_ascii() => {}
'-' => {
let Some(second) = characters.next() else {
return false;
};
if !(second == '-'
|| second == '_'
|| second.is_ascii_alphabetic()
|| !second.is_ascii())
{
return false;
}
}
_ => return false,
}
characters.all(is_css_name_continue)
}
pub fn decode_css_identifier_escapes(text: &str) -> Cow<'_, str> {
if !text.contains('\\') {
return Cow::Borrowed(text);
}
let mut output = String::with_capacity(text.len());
let mut index = 0usize;
while index < text.len() {
let Some(ch) = text[index..].chars().next() else {
break;
};
if ch != '\\' {
output.push(ch);
index += ch.len_utf8();
continue;
}
let escape_start = index;
index += ch.len_utf8();
let Some(next) = text[index..].chars().next() else {
output.push(char::REPLACEMENT_CHARACTER);
break;
};
if is_css_newline(next) {
output.push_str(&text[escape_start..index + next.len_utf8()]);
index += next.len_utf8();
continue;
}
if next.is_ascii_hexdigit() {
let hex_start = index;
let mut hex_end = index;
let mut digit_count = 0usize;
while hex_end < text.len() && digit_count < 6 {
let Some(candidate) = text[hex_end..].chars().next() else {
break;
};
if !candidate.is_ascii_hexdigit() {
break;
}
hex_end += candidate.len_utf8();
digit_count += 1;
}
let codepoint = u32::from_str_radix(&text[hex_start..hex_end], 16).ok();
output.push(
codepoint
.filter(|value| *value != 0)
.and_then(char::from_u32)
.unwrap_or(char::REPLACEMENT_CHARACTER),
);
index = hex_end;
if let Some(terminator) = text[index..].chars().next()
&& terminator.is_ascii_whitespace()
{
index += terminator.len_utf8();
}
continue;
}
output.push(next);
index += next.len_utf8();
}
Cow::Owned(output)
}
pub fn class_selector_name_end(text: &str, start: usize) -> Option<usize> {
let first = text.get(start..)?.chars().next()?;
let mut index = if first == '\\' {
css_identifier_escape_sequence_end(text, start)?
} else if is_css_name_start(first) {
start + first.len_utf8()
} else {
return None;
};
while index < text.len() {
let Some(ch) = text[index..].chars().next() else {
break;
};
if ch == '\\' {
let Some(end) = css_identifier_escape_sequence_end(text, index) else {
break;
};
index = end;
} else if is_css_name_continue(ch) {
index += ch.len_utf8();
} else {
break;
}
}
Some(index)
}
pub fn class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
if let Some(names) = ascii_class_selector_names(selector) {
return names;
}
general_class_selector_names(selector)
}
fn ascii_class_selector_names(selector: &str) -> Option<Vec<ClassSelectorNameV0>> {
let bytes = selector.as_bytes();
let mut names = Vec::new();
let mut index = 0usize;
let mut paren_depth = 0usize;
let mut bracket_depth = 0usize;
let mut quote = None;
while index < bytes.len() {
let byte = bytes[index];
if !byte.is_ascii() || byte == b'\\' {
return None;
}
if let Some(active_quote) = quote {
if byte == active_quote {
quote = None;
}
index += 1;
continue;
}
match byte {
b'"' | b'\'' => quote = Some(byte),
b'(' => paren_depth += 1,
b')' => paren_depth = paren_depth.saturating_sub(1),
b'[' => bracket_depth += 1,
b']' => bracket_depth = bracket_depth.saturating_sub(1),
b'.' if paren_depth == 0 && bracket_depth == 0 => {
let start = index + 1;
let Some(first) = bytes.get(start).copied() else {
index += 1;
continue;
};
if !ascii_css_name_start(first) {
index += 1;
continue;
}
let mut end = start + 1;
while end < bytes.len() && ascii_css_name_continue(bytes[end]) {
end += 1;
}
names.push(ClassSelectorNameV0 {
name: ClassNameV0::from_plain(&selector[start..end]),
position: ClassSelectorPositionV0 { start, end },
});
index = end;
continue;
}
_ => {}
}
index += 1;
}
Some(names)
}
fn ascii_css_name_start(byte: u8) -> bool {
matches!(byte, b'-' | b'_') || byte.is_ascii_alphabetic()
}
fn ascii_css_name_continue(byte: u8) -> bool {
ascii_css_name_start(byte) || byte.is_ascii_digit()
}
fn general_class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
let mut names = Vec::new();
let mut index = 0usize;
let mut paren_depth = 0usize;
let mut bracket_depth = 0usize;
let mut quote = None;
while index < selector.len() {
let Some(ch) = selector[index..].chars().next() else {
break;
};
if ch == '\\' {
index = css_identifier_escape_sequence_end(selector, index)
.unwrap_or(index + ch.len_utf8());
continue;
}
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
index += ch.len_utf8();
continue;
}
match ch {
'"' | '\'' => quote = Some(ch),
'(' => paren_depth += 1,
')' => paren_depth = paren_depth.saturating_sub(1),
'[' => bracket_depth += 1,
']' => bracket_depth = bracket_depth.saturating_sub(1),
'.' if paren_depth == 0 && bracket_depth == 0 => {
let start = index + ch.len_utf8();
if let Some(end) = class_selector_name_end(selector, start) {
names.push(ClassSelectorNameV0 {
name: ClassNameV0::new(&selector[start..end]),
position: ClassSelectorPositionV0 { start, end },
});
index = end;
continue;
}
}
_ => {}
}
index += ch.len_utf8();
}
names
}
pub fn css_identifier_escape_sequence_end(text: &str, slash_index: usize) -> Option<usize> {
if text[slash_index..].chars().next()? != '\\' {
return None;
}
let mut index = slash_index + '\\'.len_utf8();
let next = text[index..].chars().next()?;
if is_css_newline(next) {
return None;
}
if !next.is_ascii_hexdigit() {
return Some(index + next.len_utf8());
}
let mut digit_count = 0usize;
while index < text.len() && digit_count < 6 {
let Some(candidate) = text[index..].chars().next() else {
break;
};
if !candidate.is_ascii_hexdigit() {
break;
}
index += candidate.len_utf8();
digit_count += 1;
}
if let Some(terminator) = text[index..].chars().next()
&& terminator.is_ascii_whitespace()
{
index += terminator.len_utf8();
}
Some(index)
}
fn is_css_newline(ch: char) -> bool {
matches!(ch, '\n' | '\r' | '\u{c}')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_css_escapes_without_changing_plain_names() {
assert!(matches!(
decode_css_identifier_escapes("plain"),
Cow::Borrowed("plain")
));
assert_eq!(decode_css_identifier_escapes(r"a\.b"), "a.b");
assert_eq!(decode_css_identifier_escapes(r"\31 23"), "123");
assert_eq!(decode_css_identifier_escapes(r"\0"), "\u{fffd}");
assert_eq!(decode_css_identifier_escapes("\\"), "\u{fffd}");
assert_eq!(decode_css_identifier_escapes("\\\n"), "\\\n");
}
#[test]
fn identifier_escape_boundaries_reject_newline_and_end_of_input() {
assert_eq!(css_identifier_escape_sequence_end(r"\31 23", 0), Some(4));
assert_eq!(css_identifier_escape_sequence_end(r"\:", 0), Some(2));
assert_eq!(css_identifier_escape_sequence_end("\\\n", 0), None);
assert_eq!(css_identifier_escape_sequence_end("\\", 0), None);
}
#[test]
fn class_name_identity_is_decoded_but_raw_text_is_preserved() {
let escaped = ClassNameV0::new(r"a\.b");
let plain = ClassNameV0::new("a.b");
assert!(escaped.same_as(&plain));
assert_eq!(escaped.raw(), r"a\.b");
assert_eq!(escaped.canonical_key().as_str(), "a.b");
}
#[test]
fn ascii_class_scanner_matches_the_general_authority() {
let selector = r#".card .title[data-x="a.b"]:is(.nested).plain"#;
let summarize = |names: Vec<ClassSelectorNameV0>| {
names
.into_iter()
.map(|entry| {
(
entry.name.into_raw(),
entry.position.start,
entry.position.end,
)
})
.collect::<Vec<_>>()
};
let fast = ascii_class_selector_names(selector);
assert!(fast.is_some(), "fixture must stay on the fast path");
if let Some(fast) = fast {
assert_eq!(
summarize(fast),
summarize(general_class_selector_names(selector))
);
}
}
#[test]
fn extracts_top_level_class_names_with_byte_positions() {
let selector = r#".card .title[data-x="a.b"]:is(.nested).a\.b.\31 23.카드.café"#;
let names = class_selector_names(selector);
let raw = names
.iter()
.map(|entry| entry.name.raw())
.collect::<Vec<_>>();
assert_eq!(
raw,
vec!["card", "title", r"a\.b", r"\31 23", "카드", "café"]
);
assert_eq!(
names
.iter()
.find(|entry| entry.name.raw() == "café")
.map(|entry| entry.name.decoded().chars().count()),
Some(4)
);
for entry in names {
assert_eq!(
&selector[entry.position.start..entry.position.end],
entry.name.raw()
);
}
}
#[test]
fn distinguishes_css_name_and_ascii_word_boundaries() {
assert!(is_css_name_start('카'));
assert!(is_css_name_continue('é'));
assert!(!is_ascii_word_continue('카'));
assert!(is_ascii_word_continue('9'));
assert!(is_safe_css_identifier("카드"));
assert!(is_safe_css_identifier("--token"));
assert!(!is_safe_css_identifier("-9token"));
assert!(!is_safe_css_identifier("9token"));
}
}