use crate::css::{ComputedStyle, parse_inline_style};
use crate::lints::{FindingKind, Lints};
#[derive(Debug)]
pub(crate) struct Rule {
pub selectors: Vec<Selector>,
pub declarations: ComputedStyle,
pub source_order: u32,
}
#[derive(Debug, Default)]
pub(crate) struct Stylesheet {
rules: Vec<Rule>,
}
impl Stylesheet {
pub(crate) fn cascade(&self, tag: &str, classes: &[&str], id: Option<&str>) -> ComputedStyle {
let mut matches: Vec<(Specificity, u32, &ComputedStyle)> = Vec::new();
for rule in &self.rules {
let mut hit: Option<Specificity> = None;
for sel in &rule.selectors {
if sel.matches(tag, classes, id) {
let spec = sel.specificity();
hit = Some(match hit {
Some(prev) if prev > spec => prev,
_ => spec,
});
}
}
if let Some(spec) = hit {
matches.push((spec, rule.source_order, &rule.declarations));
}
}
matches.sort_by_key(|&(spec, order, _)| (spec, order));
let mut out = ComputedStyle::default();
for (_, _, decls) in matches {
out.merge(decls);
}
out
}
pub(crate) fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
#[derive(Debug, Clone)]
pub(crate) struct Selector {
pub tag: Option<String>,
pub classes: Vec<String>,
pub id: Option<String>,
}
impl Selector {
pub(crate) fn specificity(&self) -> Specificity {
Specificity {
id: u32::from(self.id.is_some()),
class: self.classes.len() as u32,
tag: u32::from(self.tag.is_some()),
}
}
pub(crate) fn matches(&self, tag: &str, classes: &[&str], id: Option<&str>) -> bool {
if let Some(t) = &self.tag
&& !tag.eq_ignore_ascii_case(t)
{
return false;
}
if let Some(my_id) = &self.id
&& id != Some(my_id.as_str())
{
return false;
}
for c in &self.classes {
if !classes.iter().any(|el_class| el_class == c) {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Specificity {
pub id: u32,
pub class: u32,
pub tag: u32,
}
pub(crate) fn parse_stylesheet(input: &str, lints: &Lints) -> Vec<Rule> {
let cleaned = strip_comments(input);
let bytes = cleaned.as_bytes();
let mut out = Vec::new();
let mut order: u32 = 0;
let mut cursor = 0;
while cursor < bytes.len() {
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() {
break;
}
let Some(brace) = find_top_level(&cleaned[cursor..], b'{') else {
break;
};
let abs_brace = cursor + brace;
let prelude = cleaned[cursor..abs_brace].trim();
let Some(close) = find_matching_brace(&cleaned, abs_brace) else {
break;
};
let body = &cleaned[abs_brace + 1..close];
if prelude.starts_with('@') {
cursor = close + 1;
continue;
}
let selectors = parse_selector_list(prelude, lints);
if !selectors.is_empty() {
let declarations = parse_inline_style(body, lints);
out.push(Rule {
selectors,
declarations,
source_order: order,
});
order += 1;
}
cursor = close + 1;
}
out
}
fn strip_comments(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
let mut j = i + 2;
while j + 1 < bytes.len() && !(bytes[j] == b'*' && bytes[j + 1] == b'/') {
j += 1;
}
i = (j + 2).min(bytes.len());
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn find_top_level(s: &str, target: u8) -> Option<usize> {
let bytes = s.as_bytes();
let mut paren_depth: i32 = 0;
let mut brace_depth: i32 = 0;
let mut quote: Option<u8> = None;
for (i, &b) in bytes.iter().enumerate() {
if let Some(q) = quote {
if b == q {
quote = None;
}
continue;
}
match b {
b'"' | b'\'' => quote = Some(b),
b'(' => paren_depth += 1,
b')' => paren_depth = paren_depth.saturating_sub(1),
b'{' if target != b'{' => brace_depth += 1,
b'}' if target != b'{' => brace_depth = brace_depth.saturating_sub(1),
x if x == target && paren_depth == 0 && brace_depth == 0 => return Some(i),
_ => {}
}
}
None
}
fn find_matching_brace(s: &str, open: usize) -> Option<usize> {
let bytes = s.as_bytes();
debug_assert_eq!(bytes[open], b'{');
let mut depth = 1;
let mut quote: Option<u8> = None;
for (offset, &b) in bytes[open + 1..].iter().enumerate() {
if let Some(q) = quote {
if b == q {
quote = None;
}
continue;
}
match b {
b'"' | b'\'' => quote = Some(b),
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(open + 1 + offset);
}
}
_ => {}
}
}
None
}
fn parse_selector_list(input: &str, lints: &Lints) -> Vec<Selector> {
let mut out = Vec::new();
for raw in input.split(',') {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
match parse_compound_selector(trimmed) {
Some(sel) => out.push(sel),
None => lints.push(
FindingKind::UnsupportedSelector,
format!("`{trimmed}` (only tag / class / id / compound selectors are supported)"),
),
}
}
out
}
fn parse_compound_selector(input: &str) -> Option<Selector> {
if input.is_empty() {
return None;
}
for c in input.chars() {
if matches!(c, ' ' | '\t' | '\n' | '>' | '+' | '~' | ':' | '[' | '@') {
return None;
}
}
if input == "*" {
return Some(Selector {
tag: None,
classes: Vec::new(),
id: None,
});
}
let bytes = input.as_bytes();
let mut sel = Selector {
tag: None,
classes: Vec::new(),
id: None,
};
let mut cursor = 0;
if !matches!(bytes[0], b'.' | b'#') {
let end = bytes
.iter()
.position(|b| matches!(*b, b'.' | b'#'))
.unwrap_or(bytes.len());
let tag = input[..end].to_ascii_lowercase();
if !is_valid_ident(&tag) {
return None;
}
sel.tag = Some(tag);
cursor = end;
}
while cursor < bytes.len() {
let sigil = bytes[cursor];
let after = cursor + 1;
let end = bytes[after..]
.iter()
.position(|b| matches!(*b, b'.' | b'#'))
.map(|p| p + after)
.unwrap_or(bytes.len());
let name = &input[after..end];
if !is_valid_ident(name) {
return None;
}
match sigil {
b'.' => sel.classes.push(name.to_string()),
b'#' => {
if sel.id.is_some() {
return None;
}
sel.id = Some(name.to_string());
}
_ => return None,
}
cursor = end;
}
Some(sel)
}
fn is_valid_ident(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
impl Stylesheet {
pub(crate) fn from_blocks<'a>(
blocks: impl IntoIterator<Item = &'a str>,
lints: &Lints,
) -> Self {
let mut rules = Vec::new();
let mut order: u32 = 0;
for block in blocks {
for mut rule in parse_stylesheet(block, lints) {
rule.source_order = order;
order += 1;
rules.push(rule);
}
}
Self { rules }
}
}
#[cfg(test)]
mod tests {
use super::*;
use damascene_core::prelude::*;
#[test]
fn parses_tag_class_id_selectors() {
let s = parse_compound_selector("p").unwrap();
assert_eq!(s.tag.as_deref(), Some("p"));
assert!(s.classes.is_empty());
assert!(s.id.is_none());
let s = parse_compound_selector(".note").unwrap();
assert!(s.tag.is_none());
assert_eq!(s.classes, vec!["note"]);
let s = parse_compound_selector("#main").unwrap();
assert_eq!(s.id.as_deref(), Some("main"));
let s = parse_compound_selector("p.note#main").unwrap();
assert_eq!(s.tag.as_deref(), Some("p"));
assert_eq!(s.classes, vec!["note"]);
assert_eq!(s.id.as_deref(), Some("main"));
let s = parse_compound_selector("*").unwrap();
assert!(s.tag.is_none());
assert!(s.classes.is_empty());
assert!(s.id.is_none());
}
#[test]
fn rejects_unsupported_selectors() {
assert!(parse_compound_selector("p span").is_none());
assert!(parse_compound_selector("p > span").is_none());
assert!(parse_compound_selector("a:hover").is_none());
assert!(parse_compound_selector("input[type]").is_none());
assert!(parse_compound_selector("@media").is_none());
assert!(parse_compound_selector(".foo+.bar").is_none());
assert!(parse_compound_selector("").is_none());
}
#[test]
fn parses_comma_grouped_selectors() {
let lints = Lints::default();
let list = parse_selector_list("p, h1, .note", &lints);
assert_eq!(list.len(), 3);
assert_eq!(list[0].tag.as_deref(), Some("p"));
assert_eq!(list[1].tag.as_deref(), Some("h1"));
assert_eq!(list[2].classes, vec!["note"]);
assert!(lints.into_vec().is_empty());
}
#[test]
fn unsupported_selectors_lint_individually() {
let lints = Lints::default();
let list = parse_selector_list("p > span, .note, a:hover", &lints);
assert_eq!(list.len(), 1);
assert_eq!(list[0].classes, vec!["note"]);
let findings = lints.into_vec();
assert_eq!(findings.len(), 2);
assert!(
findings
.iter()
.all(|f| matches!(f.kind, crate::lints::FindingKind::UnsupportedSelector))
);
}
#[test]
fn specificity_ordering() {
let tag = parse_compound_selector("p").unwrap().specificity();
let class = parse_compound_selector(".foo").unwrap().specificity();
let id = parse_compound_selector("#main").unwrap().specificity();
let compound = parse_compound_selector("p.foo#main").unwrap().specificity();
assert!(class > tag);
assert!(id > class);
assert!(compound > id);
assert!(compound > class);
}
#[test]
fn selector_matches_use_case_rules() {
let s = parse_compound_selector("P").unwrap();
assert!(s.matches("p", &[], None));
assert!(s.matches("P", &[], None));
let c = parse_compound_selector(".Note").unwrap();
assert!(c.matches("div", &["Note"], None));
assert!(!c.matches("div", &["note"], None));
let id = parse_compound_selector("#main").unwrap();
assert!(id.matches("div", &[], Some("main")));
assert!(!id.matches("div", &[], Some("Main")));
}
#[test]
fn stylesheet_skips_at_rules() {
let lints = Lints::default();
let rules = parse_stylesheet(
"@media print { p { color: red } } h1 { color: blue }",
&lints,
);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].selectors[0].tag.as_deref(), Some("h1"));
}
#[test]
fn stylesheet_strips_comments() {
let lints = Lints::default();
let rules = parse_stylesheet("/* skip me */ p { color: red /* inline */ }", &lints);
assert_eq!(rules.len(), 1);
assert_eq!(
rules[0].declarations.text_color,
Some(Color::srgb_u8(255, 0, 0))
);
}
#[test]
fn cascade_picks_higher_specificity_then_source_order() {
let lints = Lints::default();
let sheet = Stylesheet::from_blocks(
["p { color: red } p.note { color: blue } p { color: green }"],
&lints,
);
let s = sheet.cascade("p", &["note"], None);
assert_eq!(s.text_color, Some(Color::srgb_u8(0, 0, 255)));
let s = sheet.cascade("p", &[], None);
assert_eq!(s.text_color, Some(Color::srgb_u8(0, 128, 0)));
}
#[test]
fn cascade_merges_different_props_across_rules() {
let lints = Lints::default();
let sheet = Stylesheet::from_blocks(["p { color: red } p { font-weight: bold }"], &lints);
let s = sheet.cascade("p", &[], None);
assert_eq!(s.text_color, Some(Color::srgb_u8(255, 0, 0)));
assert_eq!(s.font_weight, Some(FontWeight::Bold));
}
#[test]
fn cascade_id_beats_compound_class() {
let lints = Lints::default();
let sheet =
Stylesheet::from_blocks(["#main { color: blue } .a.b.c.d { color: red }"], &lints);
let s = sheet.cascade("div", &["a", "b", "c", "d"], Some("main"));
assert_eq!(s.text_color, Some(Color::srgb_u8(0, 0, 255)));
}
}