//! CSS parser per CSS Syntax Module Level 3 §5. //! //! Consumes tokens from the tokenizer and produces a structured stylesheet. use crate::tokenizer::{HashType, NumericType, Token, Tokenizer}; // --------------------------------------------------------------------------- // AST types // --------------------------------------------------------------------------- /// A parsed CSS stylesheet: a list of rules. #[derive(Debug, Clone, PartialEq)] pub struct Stylesheet { pub rules: Vec, } /// A top-level rule: either a style rule or an at-rule. #[derive(Debug, Clone, PartialEq)] pub enum Rule { Style(StyleRule), Media(MediaRule), Import(ImportRule), } /// A style rule: selector list + declarations. #[derive(Debug, Clone, PartialEq)] pub struct StyleRule { pub selectors: SelectorList, pub declarations: Vec, } /// A `@media` rule with a prelude (media query text) and nested rules. #[derive(Debug, Clone, PartialEq)] pub struct MediaRule { pub query: String, pub rules: Vec, } /// An `@import` rule with a URL. #[derive(Debug, Clone, PartialEq)] pub struct ImportRule { pub url: String, } /// A comma-separated list of selectors. #[derive(Debug, Clone, PartialEq)] pub struct SelectorList { pub selectors: Vec, } /// A complex selector: a chain of compound selectors joined by combinators. #[derive(Debug, Clone, PartialEq)] pub struct Selector { pub components: Vec, } /// Either a compound selector or a combinator between compounds. #[derive(Debug, Clone, PartialEq)] pub enum SelectorComponent { Compound(CompoundSelector), Combinator(Combinator), } /// A compound selector: a sequence of simple selectors with no combinator. #[derive(Debug, Clone, PartialEq)] pub struct CompoundSelector { pub simple: Vec, } /// A simple selector. #[derive(Debug, Clone, PartialEq)] pub enum SimpleSelector { Type(String), Universal, Class(String), Id(String), Attribute(AttributeSelector), PseudoClass(String), } /// An attribute selector. #[derive(Debug, Clone, PartialEq)] pub struct AttributeSelector { pub name: String, pub op: Option, pub value: Option, } /// Attribute matching operator. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AttributeOp { /// `=` Exact, /// `~=` Includes, /// `|=` DashMatch, /// `^=` Prefix, /// `$=` Suffix, /// `*=` Substring, } /// A combinator between compound selectors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Combinator { /// Descendant (whitespace) Descendant, /// Child (`>`) Child, /// Adjacent sibling (`+`) AdjacentSibling, /// General sibling (`~`) GeneralSibling, } /// A CSS property declaration. #[derive(Debug, Clone, PartialEq)] pub struct Declaration { pub property: String, pub value: Vec, pub important: bool, } /// A component value in a declaration value. We store these as typed tokens /// so downstream code can interpret them without re-tokenizing. #[derive(Debug, Clone, PartialEq)] pub enum ComponentValue { Ident(String), String(String), Number(f64, NumericType), Percentage(f64), Dimension(f64, NumericType, String), Hash(String, HashType), Function(String, Vec), Delim(char), Comma, Whitespace, } // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- /// CSS parser. Consumes tokens and produces AST. pub struct Parser { tokens: Vec, pos: usize, } impl Parser { /// Parse a full stylesheet from CSS source text. pub fn parse(input: &str) -> Stylesheet { let tokens = Tokenizer::tokenize(input); let mut parser = Self { tokens, pos: 0 }; parser.parse_stylesheet() } /// Parse a list of declarations (for `style` attribute values). pub fn parse_declarations(input: &str) -> Vec { let tokens = Tokenizer::tokenize(input); let mut parser = Self { tokens, pos: 0 }; parser.parse_declaration_list() } // -- Token access ------------------------------------------------------- fn peek(&self) -> &Token { self.tokens.get(self.pos).unwrap_or(&Token::Eof) } fn advance(&mut self) -> Token { let tok = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof); if self.pos < self.tokens.len() { self.pos += 1; } tok } fn is_eof(&self) -> bool { self.pos >= self.tokens.len() } fn skip_whitespace(&mut self) { while matches!(self.peek(), Token::Whitespace) { self.advance(); } } // -- Stylesheet parsing ------------------------------------------------- fn parse_stylesheet(&mut self) -> Stylesheet { let mut rules = Vec::new(); loop { self.skip_whitespace(); if self.is_eof() { break; } match self.peek() { Token::AtKeyword(_) => { if let Some(rule) = self.parse_at_rule() { rules.push(rule); } } Token::Cdo | Token::Cdc => { self.advance(); } _ => { if let Some(rule) = self.parse_style_rule() { rules.push(Rule::Style(rule)); } } } } Stylesheet { rules } } // -- At-rules ----------------------------------------------------------- fn parse_at_rule(&mut self) -> Option { let name = match self.advance() { Token::AtKeyword(name) => name, _ => return None, }; match name.to_ascii_lowercase().as_str() { "media" => self.parse_media_rule(), "import" => self.parse_import_rule(), _ => { // Unknown at-rule: skip to end of block or semicolon self.skip_at_rule_body(); None } } } fn parse_media_rule(&mut self) -> Option { self.skip_whitespace(); // Collect prelude tokens as the media query string let mut query = String::new(); loop { match self.peek() { Token::LeftBrace | Token::Eof => break, Token::Whitespace => { if !query.is_empty() { query.push(' '); } self.advance(); } _ => { let tok = self.advance(); query.push_str(&token_to_string(&tok)); } } } // Expect `{` if !matches!(self.peek(), Token::LeftBrace) { return None; } self.advance(); // Parse nested rules until `}` let mut rules = Vec::new(); loop { self.skip_whitespace(); if self.is_eof() { break; } if matches!(self.peek(), Token::RightBrace) { self.advance(); break; } match self.peek() { Token::AtKeyword(_) => { if let Some(rule) = self.parse_at_rule() { rules.push(rule); } } _ => { if let Some(rule) = self.parse_style_rule() { rules.push(Rule::Style(rule)); } } } } Some(Rule::Media(MediaRule { query: query.trim().to_string(), rules, })) } fn parse_import_rule(&mut self) -> Option { self.skip_whitespace(); let url = match self.peek() { Token::String(_) => { if let Token::String(s) = self.advance() { s } else { unreachable!() } } Token::Url(_) => { if let Token::Url(s) = self.advance() { s } else { unreachable!() } } Token::Function(name) if name.eq_ignore_ascii_case("url") => { self.advance(); self.skip_whitespace(); let url = match self.advance() { Token::String(s) => s, _ => { self.skip_to_semicolon(); return None; } }; self.skip_whitespace(); // consume closing paren if matches!(self.peek(), Token::RightParen) { self.advance(); } url } _ => { self.skip_to_semicolon(); return None; } }; // Consume optional trailing tokens until semicolon self.skip_to_semicolon(); Some(Rule::Import(ImportRule { url })) } fn skip_at_rule_body(&mut self) { let mut brace_depth = 0; loop { match self.peek() { Token::Eof => return, Token::Semicolon if brace_depth == 0 => { self.advance(); return; } Token::LeftBrace => { brace_depth += 1; self.advance(); } Token::RightBrace => { if brace_depth == 0 { self.advance(); return; } brace_depth -= 1; self.advance(); if brace_depth == 0 { return; } } _ => { self.advance(); } } } } fn skip_to_semicolon(&mut self) { loop { match self.peek() { Token::Eof | Token::Semicolon => { if matches!(self.peek(), Token::Semicolon) { self.advance(); } return; } _ => { self.advance(); } } } } // -- Style rules -------------------------------------------------------- fn parse_style_rule(&mut self) -> Option { let selectors = self.parse_selector_list(); // Expect `{` self.skip_whitespace(); if !matches!(self.peek(), Token::LeftBrace) { // Error recovery: skip to end of block or next rule self.skip_at_rule_body(); return None; } self.advance(); let declarations = self.parse_declaration_list_until_brace(); // Consume `}` if matches!(self.peek(), Token::RightBrace) { self.advance(); } if selectors.selectors.is_empty() { return None; } Some(StyleRule { selectors, declarations, }) } // -- Selector parsing --------------------------------------------------- fn parse_selector_list(&mut self) -> SelectorList { let mut selectors = Vec::new(); self.skip_whitespace(); if let Some(sel) = self.parse_selector() { selectors.push(sel); } loop { self.skip_whitespace(); if !matches!(self.peek(), Token::Comma) { break; } self.advance(); // consume comma self.skip_whitespace(); if let Some(sel) = self.parse_selector() { selectors.push(sel); } } SelectorList { selectors } } fn parse_selector(&mut self) -> Option { let mut components = Vec::new(); let mut last_was_compound = false; let mut had_whitespace = false; loop { // Check for end of selector match self.peek() { Token::Comma | Token::LeftBrace | Token::RightBrace | Token::Semicolon | Token::Eof => break, Token::Whitespace => { had_whitespace = true; self.advance(); continue; } _ => {} } // Check for explicit combinator let combinator = match self.peek() { Token::Delim('>') => { self.advance(); Some(Combinator::Child) } Token::Delim('+') => { self.advance(); Some(Combinator::AdjacentSibling) } Token::Delim('~') => { self.advance(); Some(Combinator::GeneralSibling) } _ => None, }; if let Some(comb) = combinator { if last_was_compound { components.push(SelectorComponent::Combinator(comb)); last_was_compound = false; had_whitespace = false; } self.skip_whitespace(); continue; } // Implicit descendant combinator if there was whitespace if had_whitespace && last_was_compound { components.push(SelectorComponent::Combinator(Combinator::Descendant)); had_whitespace = false; } // Parse compound selector if let Some(compound) = self.parse_compound_selector() { components.push(SelectorComponent::Compound(compound)); last_was_compound = true; } else { break; } } if components.is_empty() { None } else { Some(Selector { components }) } } fn parse_compound_selector(&mut self) -> Option { let mut simple = Vec::new(); loop { match self.peek() { // Type or universal Token::Ident(_) if simple.is_empty() || !has_type_or_universal(&simple) => { if let Token::Ident(name) = self.advance() { simple.push(SimpleSelector::Type(name.to_ascii_lowercase())); } } Token::Delim('*') if simple.is_empty() || !has_type_or_universal(&simple) => { self.advance(); simple.push(SimpleSelector::Universal); } // Class Token::Delim('.') => { self.advance(); match self.advance() { Token::Ident(name) => simple.push(SimpleSelector::Class(name)), _ => break, } } // ID Token::Hash(_, HashType::Id) => { if let Token::Hash(name, _) = self.advance() { simple.push(SimpleSelector::Id(name)); } } // Attribute Token::LeftBracket => { self.advance(); if let Some(attr) = self.parse_attribute_selector() { simple.push(SimpleSelector::Attribute(attr)); } } // Pseudo-class Token::Colon => { self.advance(); match self.advance() { Token::Ident(name) => { simple.push(SimpleSelector::PseudoClass(name.to_ascii_lowercase())) } Token::Function(name) => { // Parse pseudo-class with arguments: skip to closing paren let mut pname = name.to_ascii_lowercase(); pname.push('('); let mut depth = 1; loop { match self.peek() { Token::Eof => break, Token::LeftParen => { depth += 1; pname.push('('); self.advance(); } Token::RightParen => { depth -= 1; if depth == 0 { self.advance(); break; } pname.push(')'); self.advance(); } _ => { let tok = self.advance(); pname.push_str(&token_to_string(&tok)); } } } pname.push(')'); simple.push(SimpleSelector::PseudoClass(pname)); } _ => break, } } // Ident after already having a type selector → new compound starts Token::Ident(_) => break, _ => break, } } if simple.is_empty() { None } else { Some(CompoundSelector { simple }) } } fn parse_attribute_selector(&mut self) -> Option { self.skip_whitespace(); let name = match self.advance() { Token::Ident(name) => name.to_ascii_lowercase(), _ => { self.skip_to_bracket_close(); return None; } }; self.skip_whitespace(); // Check for close bracket (presence-only selector) if matches!(self.peek(), Token::RightBracket) { self.advance(); return Some(AttributeSelector { name, op: None, value: None, }); } // Parse operator let op = match self.peek() { Token::Delim('=') => { self.advance(); AttributeOp::Exact } Token::Delim('~') => { self.advance(); if matches!(self.peek(), Token::Delim('=')) { self.advance(); } AttributeOp::Includes } Token::Delim('|') => { self.advance(); if matches!(self.peek(), Token::Delim('=')) { self.advance(); } AttributeOp::DashMatch } Token::Delim('^') => { self.advance(); if matches!(self.peek(), Token::Delim('=')) { self.advance(); } AttributeOp::Prefix } Token::Delim('$') => { self.advance(); if matches!(self.peek(), Token::Delim('=')) { self.advance(); } AttributeOp::Suffix } Token::Delim('*') => { self.advance(); if matches!(self.peek(), Token::Delim('=')) { self.advance(); } AttributeOp::Substring } _ => { self.skip_to_bracket_close(); return None; } }; self.skip_whitespace(); // Parse value let value = match self.advance() { Token::Ident(v) => v, Token::String(v) => v, _ => { self.skip_to_bracket_close(); return None; } }; self.skip_whitespace(); // Consume closing bracket if matches!(self.peek(), Token::RightBracket) { self.advance(); } Some(AttributeSelector { name, op: Some(op), value: Some(value), }) } fn skip_to_bracket_close(&mut self) { loop { match self.peek() { Token::RightBracket | Token::Eof => { if matches!(self.peek(), Token::RightBracket) { self.advance(); } return; } _ => { self.advance(); } } } } // -- Declaration parsing ------------------------------------------------ fn parse_declaration_list(&mut self) -> Vec { let mut declarations = Vec::new(); loop { self.skip_whitespace(); if self.is_eof() { break; } if matches!(self.peek(), Token::Semicolon) { self.advance(); continue; } if let Some(decl) = self.parse_declaration() { declarations.push(decl); } } declarations } fn parse_declaration_list_until_brace(&mut self) -> Vec { let mut declarations = Vec::new(); loop { self.skip_whitespace(); if self.is_eof() || matches!(self.peek(), Token::RightBrace) { break; } if matches!(self.peek(), Token::Semicolon) { self.advance(); continue; } if let Some(decl) = self.parse_declaration() { declarations.push(decl); } } declarations } fn parse_declaration(&mut self) -> Option { // Property name let property = match self.peek() { Token::Ident(_) => { if let Token::Ident(name) = self.advance() { name.to_ascii_lowercase() } else { unreachable!() } } _ => { // Error recovery: skip to next semicolon or closing brace self.skip_declaration_error(); return None; } }; self.skip_whitespace(); // Expect colon if !matches!(self.peek(), Token::Colon) { self.skip_declaration_error(); return None; } self.advance(); self.skip_whitespace(); // Parse value let (value, important) = self.parse_declaration_value(); if value.is_empty() { return None; } Some(Declaration { property, value, important, }) } fn parse_declaration_value(&mut self) -> (Vec, bool) { let mut values = Vec::new(); let mut important = false; loop { match self.peek() { Token::Semicolon | Token::RightBrace | Token::Eof => break, Token::Whitespace => { self.advance(); // Only add whitespace if there are already values and we're // not at the end of the declaration if !values.is_empty() && !matches!( self.peek(), Token::Semicolon | Token::RightBrace | Token::Eof ) { values.push(ComponentValue::Whitespace); } } Token::Delim('!') => { self.advance(); self.skip_whitespace(); if let Token::Ident(ref s) = self.peek() { if s.eq_ignore_ascii_case("important") { important = true; self.advance(); } } } _ => { if let Some(cv) = self.parse_component_value() { values.push(cv); } } } } // Trim trailing whitespace while matches!(values.last(), Some(ComponentValue::Whitespace)) { values.pop(); } (values, important) } fn parse_component_value(&mut self) -> Option { match self.advance() { Token::Ident(s) => Some(ComponentValue::Ident(s)), Token::String(s) => Some(ComponentValue::String(s)), Token::Number(n, t) => Some(ComponentValue::Number(n, t)), Token::Percentage(n) => Some(ComponentValue::Percentage(n)), Token::Dimension(n, t, u) => Some(ComponentValue::Dimension(n, t, u)), Token::Hash(s, ht) => Some(ComponentValue::Hash(s, ht)), Token::Comma => Some(ComponentValue::Comma), Token::Delim(c) => Some(ComponentValue::Delim(c)), Token::Function(name) => { let args = self.parse_function_args(); Some(ComponentValue::Function(name, args)) } _ => None, } } fn parse_function_args(&mut self) -> Vec { let mut args = Vec::new(); loop { match self.peek() { Token::RightParen | Token::Eof => { if matches!(self.peek(), Token::RightParen) { self.advance(); } break; } Token::Whitespace => { self.advance(); if !args.is_empty() && !matches!(self.peek(), Token::RightParen | Token::Eof) { args.push(ComponentValue::Whitespace); } } _ => { if let Some(cv) = self.parse_component_value() { args.push(cv); } } } } args } fn skip_declaration_error(&mut self) { loop { match self.peek() { Token::Semicolon => { self.advance(); return; } Token::RightBrace | Token::Eof => return, _ => { self.advance(); } } } } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn has_type_or_universal(selectors: &[SimpleSelector]) -> bool { selectors .iter() .any(|s| matches!(s, SimpleSelector::Type(_) | SimpleSelector::Universal)) } fn token_to_string(token: &Token) -> String { match token { Token::Ident(s) => s.clone(), Token::Function(s) => format!("{s}("), Token::AtKeyword(s) => format!("@{s}"), Token::Hash(s, _) => format!("#{s}"), Token::String(s) => format!("\"{s}\""), Token::Url(s) => format!("url({s})"), Token::Number(n, _) => format!("{n}"), Token::Percentage(n) => format!("{n}%"), Token::Dimension(n, _, u) => format!("{n}{u}"), Token::Whitespace => " ".to_string(), Token::Colon => ":".to_string(), Token::Semicolon => ";".to_string(), Token::Comma => ",".to_string(), Token::LeftBracket => "[".to_string(), Token::RightBracket => "]".to_string(), Token::LeftParen => "(".to_string(), Token::RightParen => ")".to_string(), Token::LeftBrace => "{".to_string(), Token::RightBrace => "}".to_string(), Token::Delim(c) => c.to_string(), Token::Cdo => "".to_string(), Token::BadString | Token::BadUrl | Token::Eof => String::new(), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // -- Selector tests ----------------------------------------------------- #[test] fn test_type_selector() { let ss = Parser::parse("div { }"); assert_eq!(ss.rules.len(), 1); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.selectors.selectors.len(), 1); let sel = &rule.selectors.selectors[0]; assert_eq!(sel.components.len(), 1); match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!(c.simple.len(), 1); assert_eq!(c.simple[0], SimpleSelector::Type("div".into())); } _ => panic!("expected compound"), } } #[test] fn test_universal_selector() { let ss = Parser::parse("* { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!(c.simple[0], SimpleSelector::Universal); } _ => panic!("expected compound"), } } #[test] fn test_class_selector() { let ss = Parser::parse(".foo { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!(c.simple[0], SimpleSelector::Class("foo".into())); } _ => panic!("expected compound"), } } #[test] fn test_id_selector() { let ss = Parser::parse("#main { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!(c.simple[0], SimpleSelector::Id("main".into())); } _ => panic!("expected compound"), } } #[test] fn test_compound_selector() { let ss = Parser::parse("div.foo#bar { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!(c.simple.len(), 3); assert_eq!(c.simple[0], SimpleSelector::Type("div".into())); assert_eq!(c.simple[1], SimpleSelector::Class("foo".into())); assert_eq!(c.simple[2], SimpleSelector::Id("bar".into())); } _ => panic!("expected compound"), } } #[test] fn test_selector_list() { let ss = Parser::parse("h1, h2, h3 { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.selectors.selectors.len(), 3); } #[test] fn test_descendant_combinator() { let ss = Parser::parse("div p { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; assert_eq!(sel.components.len(), 3); assert!(matches!( sel.components[1], SelectorComponent::Combinator(Combinator::Descendant) )); } #[test] fn test_child_combinator() { let ss = Parser::parse("div > p { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; assert_eq!(sel.components.len(), 3); assert!(matches!( sel.components[1], SelectorComponent::Combinator(Combinator::Child) )); } #[test] fn test_adjacent_sibling_combinator() { let ss = Parser::parse("h1 + p { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; assert!(matches!( sel.components[1], SelectorComponent::Combinator(Combinator::AdjacentSibling) )); } #[test] fn test_general_sibling_combinator() { let ss = Parser::parse("h1 ~ p { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; assert!(matches!( sel.components[1], SelectorComponent::Combinator(Combinator::GeneralSibling) )); } #[test] fn test_attribute_presence() { let ss = Parser::parse("[disabled] { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => match &c.simple[0] { SimpleSelector::Attribute(attr) => { assert_eq!(attr.name, "disabled"); assert!(attr.op.is_none()); assert!(attr.value.is_none()); } _ => panic!("expected attribute selector"), }, _ => panic!("expected compound"), } } #[test] fn test_attribute_exact() { let ss = Parser::parse("[type=\"text\"] { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => match &c.simple[0] { SimpleSelector::Attribute(attr) => { assert_eq!(attr.name, "type"); assert_eq!(attr.op, Some(AttributeOp::Exact)); assert_eq!(attr.value, Some("text".into())); } _ => panic!("expected attribute selector"), }, _ => panic!("expected compound"), } } #[test] fn test_attribute_operators() { let ops = vec![ ("[a~=b] { }", AttributeOp::Includes), ("[a|=b] { }", AttributeOp::DashMatch), ("[a^=b] { }", AttributeOp::Prefix), ("[a$=b] { }", AttributeOp::Suffix), ("[a*=b] { }", AttributeOp::Substring), ]; for (input, expected_op) in ops { let ss = Parser::parse(input); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => match &c.simple[0] { SimpleSelector::Attribute(attr) => { assert_eq!(attr.op, Some(expected_op), "failed for {input}"); } _ => panic!("expected attribute selector"), }, _ => panic!("expected compound"), } } } #[test] fn test_pseudo_class() { let ss = Parser::parse("a:hover { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!(c.simple.len(), 2); assert_eq!(c.simple[0], SimpleSelector::Type("a".into())); assert_eq!(c.simple[1], SimpleSelector::PseudoClass("hover".into())); } _ => panic!("expected compound"), } } #[test] fn test_pseudo_class_first_child() { let ss = Parser::parse("p:first-child { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; let sel = &rule.selectors.selectors[0]; match &sel.components[0] { SelectorComponent::Compound(c) => { assert_eq!( c.simple[1], SimpleSelector::PseudoClass("first-child".into()) ); } _ => panic!("expected compound"), } } // -- Declaration tests -------------------------------------------------- #[test] fn test_simple_declaration() { let ss = Parser::parse("p { color: red; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.declarations.len(), 1); assert_eq!(rule.declarations[0].property, "color"); assert_eq!(rule.declarations[0].value.len(), 1); assert_eq!( rule.declarations[0].value[0], ComponentValue::Ident("red".into()) ); assert!(!rule.declarations[0].important); } #[test] fn test_multiple_declarations() { let ss = Parser::parse("p { color: red; font-size: 16px; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.declarations.len(), 2); assert_eq!(rule.declarations[0].property, "color"); assert_eq!(rule.declarations[1].property, "font-size"); } #[test] fn test_important_declaration() { let ss = Parser::parse("p { color: red !important; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert!(rule.declarations[0].important); } #[test] fn test_declaration_with_function() { let ss = Parser::parse("p { color: rgb(255, 0, 0); }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.declarations[0].property, "color"); match &rule.declarations[0].value[0] { ComponentValue::Function(name, args) => { assert_eq!(name, "rgb"); // 255, 0, 0 → Number, Comma, WS, Number, Comma, WS, Number assert!(!args.is_empty()); } _ => panic!("expected function value"), } } #[test] fn test_declaration_with_hash_color() { let ss = Parser::parse("p { color: #ff0000; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!( rule.declarations[0].value[0], ComponentValue::Hash("ff0000".into(), HashType::Id) ); } #[test] fn test_declaration_with_dimension() { let ss = Parser::parse("p { margin: 10px; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!( rule.declarations[0].value[0], ComponentValue::Dimension(10.0, NumericType::Integer, "px".into()) ); } #[test] fn test_declaration_with_percentage() { let ss = Parser::parse("p { width: 50%; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!( rule.declarations[0].value[0], ComponentValue::Percentage(50.0) ); } #[test] fn test_parse_inline_style() { let decls = Parser::parse_declarations("color: red; font-size: 16px"); assert_eq!(decls.len(), 2); assert_eq!(decls[0].property, "color"); assert_eq!(decls[1].property, "font-size"); } // -- Error recovery tests ----------------------------------------------- #[test] fn test_invalid_declaration_skipped() { let ss = Parser::parse("p { ??? ; color: red; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; // The invalid declaration should be skipped, color should remain assert_eq!(rule.declarations.len(), 1); assert_eq!(rule.declarations[0].property, "color"); } #[test] fn test_missing_colon_skipped() { let ss = Parser::parse("p { color red; font-size: 16px; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.declarations.len(), 1); assert_eq!(rule.declarations[0].property, "font-size"); } #[test] fn test_empty_stylesheet() { let ss = Parser::parse(""); assert_eq!(ss.rules.len(), 0); } #[test] fn test_empty_rule() { let ss = Parser::parse("p { }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.declarations.len(), 0); } #[test] fn test_multiple_rules() { let ss = Parser::parse("h1 { color: blue; } p { color: red; }"); assert_eq!(ss.rules.len(), 2); } // -- @-rule tests ------------------------------------------------------- #[test] fn test_import_rule_string() { let ss = Parser::parse("@import \"style.css\";"); assert_eq!(ss.rules.len(), 1); match &ss.rules[0] { Rule::Import(r) => assert_eq!(r.url, "style.css"), _ => panic!("expected import rule"), } } #[test] fn test_import_rule_url() { let ss = Parser::parse("@import url(style.css);"); assert_eq!(ss.rules.len(), 1); match &ss.rules[0] { Rule::Import(r) => assert_eq!(r.url, "style.css"), _ => panic!("expected import rule"), } } #[test] fn test_media_rule() { let ss = Parser::parse("@media screen { p { color: red; } }"); assert_eq!(ss.rules.len(), 1); match &ss.rules[0] { Rule::Media(m) => { assert_eq!(m.query, "screen"); assert_eq!(m.rules.len(), 1); } _ => panic!("expected media rule"), } } #[test] fn test_media_rule_complex_query() { let ss = Parser::parse("@media screen and (max-width: 600px) { p { font-size: 14px; } }"); match &ss.rules[0] { Rule::Media(m) => { assert!(m.query.contains("screen")); assert!(m.query.contains("max-width")); assert_eq!(m.rules.len(), 1); } _ => panic!("expected media rule"), } } #[test] fn test_unknown_at_rule_skipped() { let ss = Parser::parse("@charset \"UTF-8\"; p { color: red; }"); assert_eq!(ss.rules.len(), 1); match &ss.rules[0] { Rule::Style(r) => assert_eq!(r.declarations[0].property, "color"), _ => panic!("expected style rule"), } } // -- Integration tests -------------------------------------------------- #[test] fn test_real_css() { let css = r#" body { margin: 0; font-family: sans-serif; background-color: #fff; } h1 { color: #333; font-size: 24px; } .container { max-width: 960px; margin: 0 auto; } a:hover { color: blue; text-decoration: underline; } "#; let ss = Parser::parse(css); assert_eq!(ss.rules.len(), 4); } #[test] fn test_declaration_no_trailing_semicolon() { let ss = Parser::parse("p { color: red }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; assert_eq!(rule.declarations.len(), 1); assert_eq!(rule.declarations[0].property, "color"); } #[test] fn test_multi_value_declaration() { let ss = Parser::parse("p { margin: 10px 20px 30px 40px; }"); let rule = match &ss.rules[0] { Rule::Style(r) => r, _ => panic!("expected style rule"), }; // 10px WS 20px WS 30px WS 40px assert_eq!(rule.declarations[0].value.len(), 7); } #[test] fn test_cdo_cdc_ignored() { let ss = Parser::parse(""); assert_eq!(ss.rules.len(), 1); } }