use crate::box_model::{BorderStyleValue, BoxEdgesValue, Length};
use crate::color::Color;
use crate::error::{CssError, Loc, Result};
use crate::media::MediaQuery;
use crate::selector::Selector;
use crate::style::{Align, CssStyle, FontStyle, TextDecoration, Weight};
use crate::supports::SupportsQuery;
use crate::token::{ThemeTokens, Token};
use ratatui::widgets::Borders;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum Origin {
#[default]
UserAgent,
Theme,
User,
Inline,
}
#[derive(Debug, Clone)]
pub struct RuleEntry {
pub selector: Selector,
pub style: CssStyle,
pub origin: Origin,
pub order: usize,
pub media: Option<MediaQuery>,
pub supports: Option<SupportsQuery>,
}
#[derive(Debug, Clone, Default)]
pub struct Stylesheet {
rules: Vec<RuleEntry>,
tokens: ThemeTokens,
has_combinators: bool,
generation_gen: u64,
}
impl Stylesheet {
pub fn new() -> Self {
Self::default()
}
pub fn with_tokens(tokens: ThemeTokens) -> Self {
Self { rules: Vec::new(), tokens, has_combinators: false, generation_gen: 0 }
}
pub fn tokens(&self) -> &ThemeTokens {
&self.tokens
}
pub fn tokens_mut(&mut self) -> &mut ThemeTokens {
self.bump_gen();
&mut self.tokens
}
pub fn rules(&self) -> &[RuleEntry] {
&self.rules
}
pub fn has_combinators(&self) -> bool {
self.has_combinators
}
pub fn generation(&self) -> u64 {
self.generation_gen
}
fn bump_gen(&mut self) {
self.generation_gen = self.generation_gen.wrapping_add(1);
}
pub fn add(&mut self, selectors: &str, style: CssStyle, origin: Origin) -> Result<&mut Self> {
self.bump_gen();
let order_base = self.rules.len();
for sel in Selector::parse_list(selectors)? {
if sel.ancestor.is_some() {
self.has_combinators = true;
}
self.rules.push(RuleEntry {
selector: sel,
style: style.clone(),
origin,
order: order_base,
media: None,
supports: None,
});
}
self.sort_rules();
Ok(self)
}
pub fn add_rule(&mut self, selector: Selector, style: CssStyle, origin: Origin) -> &mut Self {
self.bump_gen();
if selector.ancestor.is_some() {
self.has_combinators = true;
}
let order = self.rules.len();
self.rules.push(RuleEntry { selector, style, origin, order, media: None, supports: None });
self.sort_rules();
self
}
pub fn extend(&mut self, other: &Stylesheet) {
self.bump_gen();
self.tokens.merge(&other.tokens);
self.has_combinators |= other.has_combinators;
let offset = self.rules.len();
for r in &other.rules {
self.rules.push(RuleEntry { order: offset + r.order, ..r.clone() });
}
self.sort_rules();
}
fn sort_rules(&mut self) {
self.rules
.sort_unstable_by_key(|r| (r.origin, r.selector.specificity(), r.order));
}
pub fn parse(css: &str) -> Result<Self> {
Self::parse_impl(css, false)
}
pub fn parse_strict(css: &str) -> Result<Self> {
Self::parse_impl(css, true)
}
fn parse_impl(css: &str, strict: bool) -> Result<Self> {
let cleaned = strip_comments(css);
let mut sheet = Stylesheet::new();
parse_rule_loop(&cleaned, cleaned.as_str(), 0, strict, None, None, &mut sheet)?;
if strict {
for rule in &sheet.rules {
for color in color_refs(&rule.style) {
if let Some(Color::Var { name, fallback: None }) = color {
if !sheet.tokens.is_defined(name) {
return Err(CssError::undefined_variable(name.as_str()));
}
}
}
for length in length_refs(&rule.style) {
if let Some(Length::Var { name, fallback: None }) = length {
if !sheet.tokens.is_defined(name) {
return Err(CssError::undefined_variable(name.as_str()));
}
}
}
for edges in box_edges_refs(&rule.style) {
if let Some(BoxEdgesValue::Var { name, fallback: None }) = edges {
if !sheet.tokens.is_defined(name) {
return Err(CssError::undefined_variable(name.as_str()));
}
}
}
if let Some(BorderStyleValue::Var { name, fallback: None }) =
border_style_ref(&rule.style)
{
if !sheet.tokens.is_defined(name) {
return Err(CssError::undefined_variable(name.as_str()));
}
}
}
}
Ok(sheet)
}
pub fn parse_with_origin(css: &str, origin: Origin) -> Result<Self> {
let mut sheet = Self::parse(css)?;
for rule in &mut sheet.rules {
rule.origin = origin;
}
Ok(sheet)
}
}
fn parse_rule_loop(
cleaned: &str,
rest_in: &str,
rest_off_in: usize,
strict: bool,
media: Option<&MediaQuery>,
supports: Option<&SupportsQuery>,
sheet: &mut Stylesheet,
) -> Result<()> {
let mut rest = rest_in;
let mut rest_off = rest_off_in;
while let Some(rel) = rest.find('{') {
let brace_off = rest_off + rel;
let selector_part = rest[..rel].trim();
rest = &rest[rel + 1..];
rest_off = brace_off + 1;
let close_rel = match find_matching_brace(rest) {
Some(off) => off,
None => {
return Err(
CssError::invalid_selector("missing closing `}`")
.at(line_col(cleaned, brace_off).line, 1),
);
}
};
let close_off = rest_off + close_rel;
let body = &rest[..close_rel];
let body_offset = rest_off;
rest = &rest[close_rel + 1..];
rest_off = close_off + 1;
if selector_part.is_empty() {
let loc = line_col(cleaned, brace_off);
return Err(
CssError::invalid_selector("rule with no selector").at(loc.line, loc.column),
);
}
let sel_off = brace_off - selector_part.len();
let lowered_head = selector_part
.get(..5)
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();
let is_media_at =
lowered_head == "@medi" && selector_part.len() >= 6 && selector_part.as_bytes()[5] == b'a';
if is_media_at {
let query_str = selector_part[6..].trim();
let inner_query = MediaQuery::parse(query_str).map_err(|mut e| {
if e.loc.is_none() {
let loc = line_col(cleaned, sel_off);
e = e.at(loc.line, loc.column);
}
e
})?;
let combined = match media {
Some(outer) => outer.and(&inner_query),
None => inner_query,
};
parse_rule_loop(cleaned, body, body_offset, strict, Some(&combined), supports, sheet)?;
continue;
}
let lowered_head_9 = selector_part
.get(..9)
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();
let is_supports_at = lowered_head_9 == "@supports";
if is_supports_at {
let query_str = selector_part[9..].trim();
let inner_query = SupportsQuery::parse(query_str).map_err(|mut e| {
if e.loc.is_none() {
let loc = line_col(cleaned, sel_off);
e = e.at(loc.line, loc.column);
}
e
})?;
let effective_supports = supports.unwrap_or(&inner_query);
parse_rule_loop(cleaned, body, body_offset, strict, media, Some(effective_supports), sheet)?;
continue;
}
let is_root = selector_part.split(',').all(|s| s.trim() == ":root");
if is_root {
for decl in split_declarations(body, body_offset) {
if let Some(name) = decl.prop.strip_prefix("--") {
let loc = line_col(cleaned, decl.value_offset);
let token =
parse_token_value(decl.value).map_err(|e| e.at(loc.line, loc.column))?;
match media {
Some(q) => sheet.tokens.insert_media(q.clone(), name.trim(), token),
None => sheet.tokens.insert(name.trim(), token),
}
}
}
continue;
}
let mut style = CssStyle::new();
for decl in split_declarations(body, body_offset) {
let prop = decl.prop.trim();
let value = decl.value.trim();
if prop.is_empty() {
continue;
}
if let Some(name) = prop.strip_prefix("--") {
let loc = line_col(cleaned, decl.value_offset);
let token =
parse_token_value(value).map_err(|e| e.at(loc.line, loc.column))?;
sheet.tokens.insert(name, token);
} else {
if strict && !is_known_property(prop) {
let loc = line_col(cleaned, decl.prop_offset);
return Err(CssError::unknown_property(prop).at(loc.line, loc.column));
}
let loc = line_col(cleaned, decl.value_offset);
apply_decl(&mut style, prop, value).map_err(|e| e.at(loc.line, loc.column))?;
}
}
let sels = match Selector::parse_list(selector_part) {
Ok(v) => v,
Err(mut e) => {
if e.loc.is_none() {
let loc = line_col(cleaned, sel_off);
e = e.at(loc.line, loc.column);
}
return Err(e);
}
};
let order_base = sheet.rules.len();
for sel in sels {
if sel.ancestor.is_some() {
sheet.has_combinators = true;
}
sheet.rules.push(RuleEntry {
selector: sel,
style: style.clone(),
origin: Origin::User,
order: order_base,
media: media.cloned(),
supports: supports.cloned(),
});
}
sheet.sort_rules();
}
Ok(())
}
fn find_matching_brace(rest: &str) -> Option<usize> {
let bytes = rest.as_bytes();
let mut depth: u32 = 0;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'{' => depth += 1,
b'}' => {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
_ => {}
}
}
None
}
fn color_refs(style: &CssStyle) -> [Option<&Color>; 3] {
[style.color.as_ref(), style.background.as_ref(), style.underline_color.as_ref()]
}
fn length_refs(style: &CssStyle) -> [Option<&Length>; 2] {
[style.width.as_ref(), style.height.as_ref()]
}
fn box_edges_refs(style: &CssStyle) -> [Option<&BoxEdgesValue>; 2] {
[style.padding.as_ref(), style.margin.as_ref()]
}
fn border_style_ref(style: &CssStyle) -> Option<&BorderStyleValue> {
style.border.as_ref().map(|b| &b.style)
}
fn parse_token_value(value: &str) -> Result<Token> {
use crate::box_model::{BorderStyleValue, BoxEdgesValue};
if let Ok(Color::Var { name, fallback: None }) = Color::parse(value) {
return Ok(Token::Var { name });
}
if let Ok(c) = Color::parse(value) {
return Ok(Token::Color(c));
}
if let Ok(l) = Length::parse(value) {
return Ok(Token::Length(l));
}
if let Ok(BoxEdgesValue::Edges(e)) = BoxEdgesValue::parse(value) {
return Ok(Token::BoxEdges(e));
}
match BorderStyleValue::parse(value) {
Ok(BorderStyleValue::Fixed(b)) => Ok(Token::BorderStyle(b)),
Ok(_) => {
Err(CssError::invalid_length(format!("invalid token value: {value}")))
}
Err(e) => Err(e),
}
}
pub fn apply_decl(style: &mut CssStyle, prop: &str, value: &str) -> Result<()> {
let prop = prop.trim().to_ascii_lowercase();
match prop.as_str() {
"color" => style.color = Some(Color::parse(value)?),
"background" | "background-color" => style.background = Some(Color::parse(value)?),
"font-weight" => style.weight = Some(Weight::parse(value)?),
"font-style" => style.font_style = Some(FontStyle::parse(value)?),
"text-decoration" => style.decoration = Some(TextDecoration::parse(value)?),
"underline-color" => style.underline_color = Some(Color::parse(value)?),
"padding" => style.padding = Some(crate::box_model::BoxEdgesValue::parse(value)?),
"margin" => style.margin = Some(crate::box_model::BoxEdgesValue::parse(value)?),
"border" => {
let mut spec = crate::box_model::BorderSpec::parse_shorthand(value)?;
spec.edges = Some(ratatui::widgets::Borders::ALL);
style.border = Some(spec);
}
"border-style" => {
let parsed = crate::box_model::BorderStyleValue::parse(value)?;
style.border_mut().style = parsed;
}
"border-color" => {
style.border_mut().color = Some(Color::parse(value)?);
}
"border-top" => apply_per_edge(style, value, Borders::TOP)?,
"border-right" => apply_per_edge(style, value, Borders::RIGHT)?,
"border-bottom" => apply_per_edge(style, value, Borders::BOTTOM)?,
"border-left" => apply_per_edge(style, value, Borders::LEFT)?,
"border-x" => {
apply_per_edge(style, value, Borders::LEFT | Borders::RIGHT)?
}
"border-y" => {
apply_per_edge(style, value, Borders::TOP | Borders::BOTTOM)?
}
"text-align" => style.text_align = Some(Align::parse(value)?),
"width" => style.width = Some(crate::box_model::Length::parse(value)?),
"height" => style.height = Some(crate::box_model::Length::parse(value)?),
_ => { }
}
Ok(())
}
fn apply_per_edge(style: &mut CssStyle, value: &str, edges: Borders) -> Result<()> {
let mut parsed = crate::box_model::BorderSpec::parse_shorthand(value)?;
parsed.edges = Some(edges);
style.border_mut().merge(&parsed);
Ok(())
}
pub(crate) fn is_known_property(prop: &str) -> bool {
let p = prop.trim().to_ascii_lowercase();
matches!(
p.as_str(),
"color"
| "background"
| "background-color"
| "font-weight"
| "font-style"
| "text-decoration"
| "underline-color"
| "padding"
| "margin"
| "border"
| "border-style"
| "border-color"
| "border-top"
| "border-right"
| "border-bottom"
| "border-left"
| "border-x"
| "border-y"
| "text-align"
| "width"
| "height"
)
}
fn strip_comments(css: &str) -> String {
let bytes = css.as_bytes();
let mut out = String::with_capacity(css.len());
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
out.push(' ');
out.push(' ');
i += 2;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'*' && bytes[i + 1] == b'/' {
out.push(' ');
out.push(' ');
i += 2;
break;
}
let b = bytes[i];
out.push(if b == b'\n' { '\n' } else { ' ' });
i += 1;
}
} else {
let ch = css[i..].chars().next().expect("non-empty slice");
out.push(ch);
i += ch.len_utf8();
}
}
out
}
fn line_col(src: &str, byte: usize) -> Loc {
let byte = byte.min(src.len());
let mut line: u32 = 1;
let mut col: u32 = 1;
for (i, b) in src.bytes().enumerate() {
if i >= byte {
break;
}
if b == b'\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
Loc::new(line, col)
}
struct Decl<'a> {
prop: &'a str,
value: &'a str,
prop_offset: usize,
value_offset: usize,
}
fn split_declarations<'a>(body: &'a str, body_offset: usize) -> Vec<Decl<'a>> {
let mut out = Vec::new();
let mut depth: u32 = 0;
let mut start = 0usize;
let bytes = body.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b';' if depth == 0 => {
push_decl(&body[start..i], body_offset + start, &mut out);
start = i + 1;
}
_ => {}
}
}
push_decl(&body[start..], body_offset + start, &mut out);
out
}
fn push_decl<'a>(chunk: &'a str, chunk_offset: usize, out: &mut Vec<Decl<'a>>) {
let leading = chunk.len() - chunk.trim_start().len();
let trimmed = &chunk[leading..];
let trailing = trimmed.len() - trimmed.trim_end().len();
let core = &trimmed[..trimmed.len() - trailing];
if core.is_empty() {
return;
}
let core_offset = chunk_offset + leading;
if let Some(colon) = core.find(':') {
let prop = &core[..colon];
let value = &core[colon + 1..];
out.push(Decl {
prop,
value_offset: core_offset + colon + 1 + (value.len() - value.trim_start().len()),
prop_offset: core_offset,
value: value.trim(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::CssErrorKind;
use crate::media::MediaContext;
use crate::node::OwnedNode;
use ratatui::style::Color as RColor;
#[test]
fn parse_text_stylesheet() {
let css = r#"
:root {
--accent: #00d4ff;
}
/* a comment */
Button.primary {
color: var(--accent);
background: blue;
font-weight: bold;
}
#save:focus { text-decoration: underline; }
"#;
let sheet = Stylesheet::parse(css).unwrap();
assert_eq!(sheet.tokens().get_color("accent"), Some(&Color::literal(RColor::Rgb(0, 212, 255))));
let primary = sheet
.rules()
.iter()
.find(|r| r.selector.classes.iter().any(|c| c == "primary"))
.unwrap();
assert_eq!(primary.style.color, Some(Color::var("accent")));
assert!(sheet.rules().iter().any(|r| r.selector.id.as_deref() == Some("save")));
}
#[test]
fn add_flattens_comma_list() {
let mut sheet = Stylesheet::new();
sheet.add("Text, .muted, #title", CssStyle::new(), Origin::User).unwrap();
assert_eq!(sheet.rules().len(), 3);
}
#[test]
fn parse_with_origin_sets_theme() {
let sheet = Stylesheet::parse_with_origin("Button { color: red; }", Origin::Theme).unwrap();
assert_eq!(sheet.rules()[0].origin, Origin::Theme);
}
#[test]
fn border_style_and_color_compose_through_cascade() {
let sheet = Stylesheet::parse(
r#"
.rounded { border-style: rounded; }
.border-slate-700 { border-color: #334155; }
"#,
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["rounded", "border-slate-700"]);
let computed = sheet.compute(&node, None);
let border = computed.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
);
assert_eq!(border.color, Some(Color::literal(RColor::Rgb(0x33, 0x41, 0x55))));
}
#[test]
fn border_bottom_single_edge() {
let sheet = Stylesheet::parse(".b { border-bottom: rounded; }").unwrap();
let node = OwnedNode::new("Div").with_classes(["b"]);
let computed = sheet.compute(&node, None);
let border = computed.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
);
assert_eq!(border.edges, Some(ratatui::widgets::Borders::BOTTOM));
assert_eq!(border.borders(), ratatui::widgets::Borders::BOTTOM);
}
#[test]
fn per_edge_cascade_accumulates_top_and_bottom() {
let sheet = Stylesheet::parse(
r#"
.bt { border-top: single; }
.bb { border-bottom: single; }
"#,
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["bt", "bb"]);
let computed = sheet.compute(&node, None);
let border = computed.style.border.expect("border present");
assert_eq!(border.edges, Some(ratatui::widgets::Borders::TOP | ratatui::widgets::Borders::BOTTOM));
}
#[test]
fn per_edge_rendered_to_borders() {
let sheet = Stylesheet::parse(".x { border-bottom: rounded red; }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let computed = sheet.compute(&node, None);
let border = computed.style.border.expect("border present");
assert_eq!(border.borders(), ratatui::widgets::Borders::BOTTOM);
assert_eq!(border.color, Some(Color::literal(RColor::Red)));
}
#[test]
fn per_edge_x_and_y_aliases() {
let sheet = Stylesheet::parse(
r#"
.bx { border-x: single; }
.by { border-y: single; }
"#,
)
.unwrap();
let bx = sheet.compute(&OwnedNode::new("Div").with_classes(["bx"]), None);
let by = sheet.compute(&OwnedNode::new("Div").with_classes(["by"]), None);
assert_eq!(
bx.style.border.as_ref().unwrap().edges,
Some(ratatui::widgets::Borders::LEFT | ratatui::widgets::Borders::RIGHT)
);
assert_eq!(
by.style.border.as_ref().unwrap().edges,
Some(ratatui::widgets::Borders::TOP | ratatui::widgets::Borders::BOTTOM)
);
}
#[test]
fn full_border_shorthand_keeps_all_edges_under_compose() {
let sheet = Stylesheet::parse(
r#"
.full { border: rounded; }
.bot { border-bottom: single; }
"#,
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["full", "bot"]);
let computed = sheet.compute(&node, None);
let border = computed.style.border.expect("border present");
assert_eq!(border.edges, Some(ratatui::widgets::Borders::ALL));
}
#[test]
fn strict_mode_accepts_per_edge_properties() {
Stylesheet::parse_strict(".x { border-bottom: rounded; }")
.expect("border-bottom is a known property in strict mode");
}
#[test]
fn located_color_error() {
let css = "Button {\n color: red;\n background: #zzz;\n}\n";
let err = Stylesheet::parse(css).unwrap_err();
let loc = err.loc.expect("error has a location");
assert_eq!(loc.line, 3, "line should point at the bad color's line");
assert!(matches!(err.kind, CssErrorKind::InvalidColor(_)));
}
#[test]
fn comment_positions_preserved() {
let css = "/* a\n multi-line\n comment */\nButton {\n color: #nope;\n}\n";
let cleaned = strip_comments(css);
assert_eq!(cleaned.len(), css.len(), "strip_comments is length-preserving");
let err = Stylesheet::parse(css).unwrap_err();
let loc = err.loc.expect("error has a location");
assert_eq!(loc.line, 5);
assert!(matches!(err.kind, CssErrorKind::InvalidColor(_)));
}
#[test]
fn strip_comments_keeps_length_and_newlines() {
let css = "a { color: red; /* x\ny */ color: blue; }";
let cleaned = strip_comments(css);
assert_eq!(cleaned.len(), css.len());
assert_eq!(cleaned.matches('\n').count(), css.matches('\n').count());
assert!(!cleaned.contains("/*"));
assert!(!cleaned.contains("*/"));
}
#[test]
fn strict_unknown_property() {
let err = Stylesheet::parse_strict("Foo { colr: red; }").unwrap_err();
assert!(matches!(err.kind, CssErrorKind::UnknownProperty(ref p) if p == "colr"));
let loc = err.loc.expect("unknown property has a location");
assert_eq!(loc.line, 1);
}
#[test]
fn strict_known_property_ok() {
Stylesheet::parse_strict("Foo { color: red; }").expect("known property parses in strict mode");
}
#[test]
fn strict_undefined_var() {
let err = Stylesheet::parse_strict("Foo { color: var(--nope); }").unwrap_err();
assert!(matches!(err.kind, CssErrorKind::UndefinedVariable(ref n) if n == "nope"));
}
#[test]
fn strict_defined_var_ok() {
Stylesheet::parse_strict(":root{--x:red;}\nFoo{color:var(--x);}").expect("defined var is fine");
}
#[test]
fn strict_var_with_fallback_ok() {
Stylesheet::parse_strict("Foo { color: var(--nope, #fff); }")
.expect("var with fallback does not error in strict mode");
}
#[test]
fn lenient_parse_still_ignores_unknown() {
Stylesheet::parse("Foo { colr: red; }").expect("lenient parse ignores unknown property");
Stylesheet::parse("Foo { color: var(--nope); }").expect("lenient parse keeps undefined var");
}
#[test]
fn root_parses_length_token() {
let sheet = Stylesheet::parse(":root{--w:22;--c:#fff;}").unwrap();
assert_eq!(sheet.tokens().get_length("w"), Some(&crate::box_model::Length::Cells(22)));
assert_eq!(
sheet.tokens().get_color("c"),
Some(&Color::literal(RColor::Rgb(0xff, 0xff, 0xff)))
);
}
#[test]
fn root_parses_length_percent_token() {
let sheet = Stylesheet::parse(":root{--half:50%}").unwrap();
assert_eq!(sheet.tokens().get_length("half"), Some(&crate::box_model::Length::Percent(50)));
}
#[test]
fn root_rejects_garbage_token_value() {
assert!(Stylesheet::parse(":root{--x: banana;}").is_err());
}
#[test]
fn strict_undefined_length_var() {
let err = Stylesheet::parse_strict(".x{width:var(--nope)}").unwrap_err();
assert!(matches!(err.kind, CssErrorKind::UndefinedVariable(ref n) if n == "nope"));
}
#[test]
fn strict_defined_length_var_ok() {
Stylesheet::parse_strict(":root{--w:10}.x{width:var(--w)}")
.expect("defined length var is fine in strict mode");
}
#[test]
fn media_block_tags_one_rule() {
let sheet =
Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap();
let button_rules: Vec<_> = sheet
.rules()
.iter()
.filter(|r| r.selector.type_name.as_deref() == Some("Button"))
.collect();
assert_eq!(button_rules.len(), 1, "exactly one Button rule");
let media = button_rules[0].media.as_ref().expect("rule is media-gated");
assert_eq!(
media.alternatives,
vec![crate::media::MediaAlternative {
terms: vec![crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::MinWidth(80),
}],
}]
);
}
#[test]
fn media_block_depth_aware_two_rules_tagged() {
let sheet = Stylesheet::parse(
"@media (min-width: 80) { Button { color: red; } .x { padding: 1; } }",
)
.unwrap();
let tagged = sheet.rules().iter().filter(|r| r.media.is_some()).count();
assert_eq!(tagged, 2, "both inner rules carry the media query");
for r in sheet.rules() {
if let Some(m) = &r.media {
assert_eq!(
m.alternatives,
vec![crate::media::MediaAlternative {
terms: vec![crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::MinWidth(80),
}],
}]
);
}
}
}
#[test]
fn media_block_trailing_top_level_rule_untagged() {
let sheet = Stylesheet::parse(
"@media (min-width: 80) { Button { color: red; } } Text { color: blue; }",
)
.unwrap();
let text_rule = sheet
.rules()
.iter()
.find(|r| r.selector.type_name.as_deref() == Some("Text"))
.expect("trailing Text rule parsed");
assert!(text_rule.media.is_none(), "trailing top-level rule is NOT media-gated");
}
#[test]
fn media_block_invalid_color_loc_points_at_value() {
let css = "@media (min-width:1){\n Button {\n background: #zzz;\n }\n}";
let err = Stylesheet::parse(css).unwrap_err();
let loc = err.loc.expect("error has a location");
assert_eq!(
loc.line, 3,
"loc must point at the #zzz line (line 3), got line {}",
loc.line
);
assert!(matches!(err.kind, CssErrorKind::InvalidColor(_)));
}
#[test]
fn root_inside_media_inserts_media_gated_token() {
let sheet = Stylesheet::parse("@media (min-width:1){ :root { --x: #fff; } }").unwrap();
assert_eq!(
sheet.tokens().get_color("x"),
None,
"media-gated :root must NOT land in the default token map"
);
let large = MediaContext { cols: 80, ..Default::default() };
assert_eq!(
sheet.tokens().get_color_with("x", &large),
Some(Color::literal(RColor::Rgb(0xff, 0xff, 0xff))),
"media-gated :root resolves under a matching context"
);
let small = MediaContext { cols: 0, ..Default::default() };
assert_eq!(sheet.tokens().get_color_with("x", &small), None);
}
#[test]
fn nested_media_combines_queries() {
let css = "@media (min-width: 80) { @media (color) { Button { color: red; } } }";
let sheet = Stylesheet::parse(css).unwrap();
let button_rules: Vec<_> =
sheet.rules().iter().filter(|r| r.selector.type_name.as_deref() == Some("Button")).collect();
assert_eq!(button_rules.len(), 1, "exactly one Button rule");
let media = button_rules[0].media.as_ref().expect("Button rule is media-gated");
assert_eq!(
media.alternatives,
vec![crate::media::MediaAlternative {
terms: vec![
crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::MinWidth(80),
},
crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::Color,
},
],
}],
"nested @media AND-combines the queries"
);
use crate::cascade::ComputeScratch;
let node = OwnedNode::new("Button");
let mut scratch = ComputeScratch::new();
let both = MediaContext { cols: 100, no_color: false, ..Default::default() };
let computed = sheet.compute_with_media(&node, None, &mut scratch, &both);
assert_eq!(
computed.style.color,
Some(Color::literal(RColor::Red)),
"both conditions hold → rule applies"
);
let color_off = MediaContext { cols: 100, no_color: true, ..Default::default() };
let computed = sheet.compute_with_media(&node, None, &mut scratch, &color_off);
assert_eq!(
computed.style.color, None,
"color=false → combined query fails → rule does NOT apply"
);
let width_off = MediaContext { cols: 60, no_color: false, ..Default::default() };
let computed = sheet.compute_with_media(&node, None, &mut scratch, &width_off);
assert_eq!(
computed.style.color, None,
"min-width fails → combined query fails → rule does NOT apply"
);
}
#[test]
fn nested_media_three_deep() {
let css = "@media (min-width: 80) {\n @media (color) {\n @media (truecolor) {\n X { color: red; }\n }\n }\n}";
let sheet = Stylesheet::parse(css).unwrap();
let x_rules: Vec<_> =
sheet.rules().iter().filter(|r| r.selector.type_name.as_deref() == Some("X")).collect();
assert_eq!(x_rules.len(), 1);
let media = x_rules[0].media.as_ref().expect("X rule is media-gated");
assert_eq!(
media.alternatives,
vec![crate::media::MediaAlternative {
terms: vec![
crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::MinWidth(80),
},
crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::Color,
},
crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::Truecolor,
},
],
}],
"three-deep nesting AND-combines all three queries"
);
let all = MediaContext {
cols: 100,
no_color: false,
truecolor: true,
..Default::default()
};
assert!(media.matches(&all), "all three hold → matches");
let missing_truecolor = MediaContext {
cols: 100,
no_color: false,
truecolor: false,
..Default::default()
};
assert!(
!media.matches(&missing_truecolor),
"missing truecolor → no match"
);
}
#[test]
fn nested_media_root_inserts_under_combined_query() {
let css = "@media (min-width: 80) { @media (color) { :root { --x: red; } } }";
let sheet = Stylesheet::parse(css).unwrap();
assert_eq!(sheet.tokens().get_color("x"), None);
let width_only = MediaContext { cols: 100, no_color: true, ..Default::default() };
assert_eq!(sheet.tokens().get_color_with("x", &width_only), None);
let both = MediaContext { cols: 100, no_color: false, ..Default::default() };
assert_eq!(
sheet.tokens().get_color_with("x", &both),
Some(Color::literal(RColor::Red))
);
}
#[test]
fn media_query_error_propagates() {
assert!(Stylesheet::parse("@media (foo: 1) { Button { color: red; } }").is_err());
}
#[test]
fn existing_loc_tests_unchanged_by_depth_scan() {
let css = "Button {\n color: red;\n background: #zzz;\n}\n";
let err = Stylesheet::parse(css).unwrap_err();
let loc = err.loc.expect("error has a location");
assert_eq!(loc.line, 3);
let css2 = "/* a\n multi-line\n comment */\nButton {\n color: #nope;\n}\n";
let err2 = Stylesheet::parse(css2).unwrap_err();
let loc2 = err2.loc.expect("error has a location");
assert_eq!(loc2.line, 5);
}
#[test]
fn parses_root_inside_media_into_media_gated_override() {
let css = ":root { --accent: red } @media (min-width: 80) { :root { --accent: blue } }";
let sheet = Stylesheet::parse(css).unwrap();
assert_eq!(
sheet.tokens().get_color("accent"),
Some(&Color::literal(RColor::Red))
);
let large = MediaContext { cols: 100, ..Default::default() };
assert_eq!(
sheet.tokens().get_color_with("accent", &large),
Some(Color::literal(RColor::Blue))
);
let small = MediaContext { cols: 60, ..Default::default() };
assert_eq!(
sheet.tokens().get_color_with("accent", &small),
Some(Color::literal(RColor::Red))
);
}
#[test]
fn strict_accepts_var_defined_only_in_media() {
let css = "@media (min-width: 80) { :root { --x: red; } }\n.a { color: var(--x); }";
Stylesheet::parse_strict(css).expect("var defined only in @media is 'defined'");
}
#[test]
fn strict_still_errors_on_truly_undefined_var() {
Stylesheet::parse_strict(".a { color: var(--nope); }").unwrap_err();
Stylesheet::parse_strict(".a { width: var(--nope); }").unwrap_err();
}
#[test]
fn generation_starts_at_zero() {
assert_eq!(Stylesheet::new().generation(), 0);
assert_eq!(Stylesheet::with_tokens(ThemeTokens::new()).generation(), 0);
let parsed = Stylesheet::parse("Button { color: red; }").unwrap();
assert_eq!(parsed.generation(), 0);
let with_origin = Stylesheet::parse_with_origin("Button { color: red; }", Origin::Theme).unwrap();
assert_eq!(with_origin.generation(), 0);
}
#[test]
fn generation_bumps_on_add() {
let mut s = Stylesheet::new();
let g0 = s.generation();
s.add("Button", CssStyle::new().color(RColor::Red), Origin::User)
.unwrap();
assert_ne!(s.generation(), g0);
let g1 = s.generation();
s.add("Text", CssStyle::new(), Origin::User).unwrap();
assert_ne!(s.generation(), g1);
}
#[test]
fn generation_bumps_on_add_rule() {
let mut s = Stylesheet::new();
let g0 = s.generation();
let sel = Selector::parse_compound("Button").unwrap();
s.add_rule(sel, CssStyle::new(), Origin::User);
assert_ne!(s.generation(), g0);
}
#[test]
fn generation_bumps_on_extend() {
let mut a = Stylesheet::new();
let other = Stylesheet::parse("Text { color: blue; }").unwrap();
let g0 = a.generation();
a.extend(&other);
assert_ne!(a.generation(), g0);
}
#[test]
fn generation_bumps_on_tokens_mut() {
let mut s = Stylesheet::new();
let g0 = s.generation();
let _ = s.tokens_mut();
assert_ne!(s.generation(), g0, "tokens_mut must bump (covers token changes)");
}
#[test]
fn supports_block_tags_one_rule() {
let sheet =
Stylesheet::parse("@supports (truecolor) { Button { color: red; } }").unwrap();
let button_rules: Vec<_> = sheet
.rules()
.iter()
.filter(|r| r.selector.type_name.as_deref() == Some("Button"))
.collect();
assert_eq!(button_rules.len(), 1, "exactly one Button rule");
let supports = button_rules[0].supports.as_ref().expect("rule is supports-gated");
assert_eq!(
supports.alternatives,
vec![crate::supports::SupportsAlternative {
terms: vec![crate::supports::SupportsTerm {
negated: false,
cond: crate::supports::SupportsCondition::Truecolor,
}],
}]
);
assert!(button_rules[0].media.is_none());
}
#[test]
fn supports_block_depth_aware_two_rules_tagged() {
let sheet = Stylesheet::parse(
"@supports (truecolor) { Button { color: red; } .x { padding: 1; } }",
)
.unwrap();
let tagged = sheet.rules().iter().filter(|r| r.supports.is_some()).count();
assert_eq!(tagged, 2, "both inner rules carry the supports query");
}
#[test]
fn supports_block_trailing_top_level_rule_untagged() {
let sheet = Stylesheet::parse(
"@supports (truecolor) { Button { color: red; } } Text { color: blue; }",
)
.unwrap();
let text_rule = sheet
.rules()
.iter()
.find(|r| r.selector.type_name.as_deref() == Some("Text"))
.expect("trailing Text rule parsed");
assert!(text_rule.supports.is_none(), "trailing top-level rule is NOT supports-gated");
}
#[test]
fn supports_block_invalid_color_loc_points_at_value() {
let css = "@supports (truecolor){\n Button {\n background: #zzz;\n }\n}";
let err = Stylesheet::parse(css).unwrap_err();
let loc = err.loc.expect("error has a location");
assert_eq!(
loc.line, 3,
"loc must point at the #zzz line (line 3), got line {}",
loc.line
);
assert!(matches!(err.kind, CssErrorKind::InvalidColor(_)));
}
#[test]
fn supports_nested_inside_media_tags_both() {
let css = "@media (min-width: 80) { @supports (truecolor) { Button { color: red; } } }";
let sheet = Stylesheet::parse(css).unwrap();
let button_rules: Vec<_> =
sheet.rules().iter().filter(|r| r.selector.type_name.as_deref() == Some("Button")).collect();
assert_eq!(button_rules.len(), 1, "exactly one Button rule");
let r = &button_rules[0];
let media = r.media.as_ref().expect("rule is media-gated");
assert_eq!(
media.alternatives,
vec![crate::media::MediaAlternative {
terms: vec![crate::media::MediaTerm {
negated: false,
cond: crate::media::MediaCondition::MinWidth(80),
}],
}]
);
let supports = r.supports.as_ref().expect("rule is supports-gated");
assert_eq!(
supports.alternatives,
vec![crate::supports::SupportsAlternative {
terms: vec![crate::supports::SupportsTerm {
negated: false,
cond: crate::supports::SupportsCondition::Truecolor,
}],
}]
);
}
#[test]
fn media_nested_inside_supports_tags_both() {
let css = "@supports (truecolor) { @media (min-width: 80) { Button { color: red; } } }";
let sheet = Stylesheet::parse(css).unwrap();
let button_rules: Vec<_> =
sheet.rules().iter().filter(|r| r.selector.type_name.as_deref() == Some("Button")).collect();
assert_eq!(button_rules.len(), 1);
let r = &button_rules[0];
assert!(r.media.is_some(), "rule carries media tag");
assert!(r.supports.is_some(), "rule carries supports tag");
let supports = r.supports.as_ref().unwrap();
assert_eq!(
supports.alternatives[0].terms[0].cond,
crate::supports::SupportsCondition::Truecolor
);
}
#[test]
fn supports_query_error_propagates() {
assert!(Stylesheet::parse("@supports (truecolor { Button { color: red; } }").is_err());
}
#[test]
fn supports_property_condition_tagged() {
let sheet =
Stylesheet::parse("@supports (border-style) { .x { border-style: rounded; } }").unwrap();
let x_rules: Vec<_> =
sheet.rules().iter().filter(|r| r.selector.classes.iter().any(|c| c == "x")).collect();
assert_eq!(x_rules.len(), 1);
let supports = x_rules[0].supports.as_ref().expect("supports-gated");
assert_eq!(
supports.alternatives[0].terms[0].cond,
crate::supports::SupportsCondition::Property("border-style".into())
);
}
#[test]
fn supports_case_insensitive_keyword() {
let sheet = Stylesheet::parse("@SUPPORTS (truecolor) { Button { color: red; } }").unwrap();
let button = sheet
.rules()
.iter()
.find(|r| r.selector.type_name.as_deref() == Some("Button"))
.unwrap();
assert!(button.supports.is_some());
}
#[test]
fn parse_padding_var_reference() {
let sheet = Stylesheet::parse(":root{--pad:1} .x { padding: var(--pad); }").unwrap();
let rule = sheet
.rules()
.iter()
.find(|r| r.selector.classes.iter().any(|c| c == "x"))
.unwrap();
assert_eq!(
rule.style.padding,
Some(crate::box_model::BoxEdgesValue::var("pad"))
);
}
#[test]
fn parse_border_style_var_reference() {
let sheet = Stylesheet::parse(":root{--bs:rounded} .x { border-style: var(--bs); }").unwrap();
let rule = sheet
.rules()
.iter()
.find(|r| r.selector.classes.iter().any(|c| c == "x"))
.unwrap();
let border = rule.style.border.as_ref().expect("border present");
assert_eq!(border.style, crate::box_model::BorderStyleValue::var("bs"));
}
#[test]
fn parse_border_shorthand_with_var_style() {
let sheet = Stylesheet::parse(":root{--bs:rounded} .x { border: var(--bs) #f00; }").unwrap();
let rule = sheet
.rules()
.iter()
.find(|r| r.selector.classes.iter().any(|c| c == "x"))
.unwrap();
let border = rule.style.border.as_ref().expect("border present");
assert_eq!(border.style, crate::box_model::BorderStyleValue::var("bs"));
assert_eq!(border.color, Some(Color::literal(RColor::Rgb(0xff, 0, 0))));
}
#[test]
fn strict_undefined_padding_var_errors() {
let err = Stylesheet::parse_strict(".x { padding: var(--nope); }").unwrap_err();
assert!(matches!(err.kind, CssErrorKind::UndefinedVariable(ref n) if n == "nope"));
}
#[test]
fn strict_padding_var_with_fallback_ok() {
Stylesheet::parse_strict(".x { padding: var(--nope, 3); }")
.expect("padding var with fallback is OK in strict mode");
}
#[test]
fn strict_undefined_border_style_var_errors() {
let err = Stylesheet::parse_strict(".x { border-style: var(--nope); }").unwrap_err();
assert!(matches!(err.kind, CssErrorKind::UndefinedVariable(ref n) if n == "nope"));
}
#[test]
fn strict_defined_box_edges_var_ok() {
Stylesheet::parse_strict(":root{--pad:1} .x { padding: var(--pad); }")
.expect("defined box-edges var is fine in strict mode");
}
#[test]
fn strict_defined_border_style_var_ok() {
Stylesheet::parse_strict(":root{--bs:rounded} .x { border-style: var(--bs); }")
.expect("defined border-style var is fine in strict mode");
}
}