#[derive(Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "build"), allow(dead_code))]
pub(crate) enum Lang {
Css,
Js,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Role {
Code,
Comment,
Text,
}
pub(crate) fn lex(src: &str, lang: Lang) -> Vec<(char, Role)> {
#[derive(Clone, Copy)]
enum State {
Code,
Block,
Line,
Quoted(char),
Regex,
RawUrl,
}
let chars: Vec<char> = src.chars().collect();
let mut out = Vec::with_capacity(chars.len());
let mut state = State::Code;
let mut prev: Option<char> = None;
let mut in_class = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' && !matches!(state, State::Block | State::Line) {
let role = if matches!(state, State::Quoted(_)) {
Role::Text
} else {
Role::Code
};
out.extend(chars[i..chars.len().min(i + 2)].iter().map(|&c| (c, role)));
i += 2;
continue;
}
match state {
State::Code => {
let at_url = lang == Lang::Css
&& chars[i..].len() >= 4
&& chars[i..i + 3]
.iter()
.collect::<String>()
.eq_ignore_ascii_case("url")
&& chars[i + 3] == '('
&& !(i > 0 && is_ident(chars[i - 1]));
if at_url {
out.extend(chars[i..i + 4].iter().map(|&c| (c, Role::Code)));
i += 4;
while i < chars.len() && chars[i].is_ascii_whitespace() {
out.push((chars[i], Role::Code));
i += 1;
}
match chars.get(i) {
Some(&q) if q == '"' || q == '\'' => {
out.push((q, Role::Code));
i += 1;
state = State::Quoted(q);
}
_ => state = State::RawUrl,
}
continue;
}
match c {
'/' if chars.get(i + 1) == Some(&'*') => {
out.extend([('/', Role::Comment), ('*', Role::Comment)]);
i += 2;
state = State::Block;
continue;
}
'/' if lang == Lang::Js && chars.get(i + 1) == Some(&'/') => {
out.extend([('/', Role::Comment), ('/', Role::Comment)]);
i += 2;
state = State::Line;
continue;
}
'/' if lang == Lang::Js && regex_can_follow(prev) => {
in_class = false;
state = State::Regex;
}
'"' | '\'' | '`' if lang == Lang::Js || c != '`' => state = State::Quoted(c),
_ if !c.is_whitespace() => prev = Some(c),
_ => {}
}
out.push((c, Role::Code));
}
State::Regex => {
if c == '[' && !in_class {
in_class = true;
} else if c == '/' && !in_class {
prev = Some('/');
state = State::Code;
} else if c == '\n' {
state = State::Code;
} else if c == ']' {
in_class = false;
}
out.push((c, Role::Code));
}
State::RawUrl => {
if c == ')' {
prev = Some(')');
state = State::Code;
} else if c == '\n' {
state = State::Code;
}
out.push((c, Role::Code));
}
State::Block => {
if c == '*' && chars.get(i + 1) == Some(&'/') {
out.extend([('*', Role::Comment), ('/', Role::Comment)]);
i += 2;
state = State::Code;
continue;
}
out.push((c, Role::Comment));
}
State::Line => {
if c == '\n' {
state = State::Code;
}
out.push((c, Role::Comment));
}
State::Quoted(quote) => {
if c == quote {
out.push((c, Role::Code));
prev = Some(c);
state = State::Code;
} else {
if lang == Lang::Css && c == '\n' {
state = State::Code;
}
out.push((c, Role::Text));
}
}
}
i += 1;
}
out
}
fn regex_can_follow(prev: Option<char>) -> bool {
prev.is_none_or(|c| {
!(c.is_alphanumeric() || matches!(c, '_' | '$' | ')' | ']' | '}' | '"' | '\'' | '`'))
})
}
pub(crate) fn matching_close<T: PartialEq + Copy>(
src: &[T],
open_idx: usize,
open: T,
close: T,
) -> Option<usize> {
let mut depth = 0usize;
for (k, &x) in src.iter().enumerate().skip(open_idx) {
if x == open {
depth += 1;
} else if x == close {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(k);
}
}
}
None
}
pub(crate) fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '-' || c == '_'
}
pub(crate) fn top_level(selector: &str) -> impl Iterator<Item = (usize, char)> + '_ {
let mut depth = 0usize;
let mut quote: Option<char> = None;
selector.char_indices().filter(move |&(_, c)| {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '(' | '[') => depth += 1,
(None, ')' | ']') => depth = depth.saturating_sub(1),
(None, _) => return depth == 0,
}
false
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_group_closes_at_its_own_bracket() {
let s: Vec<char> = "[a=\")\"](b(c))".chars().collect();
assert_eq!(matching_close(&s, 0, '[', ']'), Some(6));
assert_eq!(matching_close(&s, 7, '(', ')'), Some(12));
assert_eq!(matching_close(b"{ { }", 0, b'{', b'}'), None);
}
#[test]
fn an_escape_outside_a_string_opens_none() {
let roles: Vec<Role> = lex(r".a\'b { x: 'y' }", Lang::Css)
.into_iter()
.map(|(_, r)| r)
.collect();
assert_eq!(roles.iter().filter(|r| **r == Role::Text).count(), 1);
}
#[test]
fn top_level_skips_groups_and_strings() {
let kept: String = top_level(".a:is(.b, .c)[t=\"x,y\"], .d")
.map(|(_, c)| c)
.collect();
assert_eq!(kept, ".a:is, .d");
}
}