use crate::box_model::Length;
use crate::color::Color;
use crate::error::{CssError, Loc, Result};
use crate::selector::Selector;
use crate::style::{Align, CssStyle, FontStyle, TextDecoration, Weight};
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,
}
#[derive(Debug, Clone, Default)]
pub struct Stylesheet {
rules: Vec<RuleEntry>,
tokens: ThemeTokens,
}
impl Stylesheet {
pub fn new() -> Self {
Self::default()
}
pub fn with_tokens(tokens: ThemeTokens) -> Self {
Self { rules: Vec::new(), tokens }
}
pub fn tokens(&self) -> &ThemeTokens {
&self.tokens
}
pub fn tokens_mut(&mut self) -> &mut ThemeTokens {
&mut self.tokens
}
pub fn rules(&self) -> &[RuleEntry] {
&self.rules
}
pub fn add(&mut self, selectors: &str, style: CssStyle, origin: Origin) -> Result<&mut Self> {
let order_base = self.rules.len();
for sel in Selector::parse_list(selectors)? {
self.rules.push(RuleEntry { selector: sel, style: style.clone(), origin, order: order_base });
}
Ok(self)
}
pub fn add_rule(&mut self, selector: Selector, style: CssStyle, origin: Origin) -> &mut Self {
let order = self.rules.len();
self.rules.push(RuleEntry { selector, style, origin, order });
self
}
pub fn extend(&mut self, other: &Stylesheet) {
self.tokens.merge(&other.tokens);
let offset = self.rules.len();
for r in &other.rules {
self.rules.push(RuleEntry { order: offset + r.order, ..r.clone() });
}
}
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();
let mut rest = cleaned.as_str();
let mut rest_off = 0usize;
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 = rest.find('}').ok_or_else(|| {
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 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))?;
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))?;
}
}
if let Err(mut e) = sheet.add(selector_part, style, Origin::User) {
if e.loc.is_none() {
let loc = line_col(&cleaned, sel_off);
e = e.at(loc.line, loc.column);
}
return Err(e);
}
}
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.get(name).is_none() {
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.get(name).is_none() {
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 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 parse_token_value(value: &str) -> Result<Token> {
if let Ok(Color::Var { name, fallback: None }) = Color::parse(value) {
return Ok(Token::Var { name });
}
match Color::parse(value) {
Ok(c) => Ok(Token::Color(c)),
Err(_) => Length::parse(value).map(Token::Length),
}
}
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::BoxEdges::parse(value)?),
"margin" => style.margin = Some(crate::box_model::BoxEdges::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::BorderStyle::parse_keyword(value)
.ok_or_else(|| CssError::invalid_length(format!("border-style: {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(())
}
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::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::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::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");
}
}