//! CSS parser per CSS Syntax Module Level 3 §5. //! //! Consumes tokens from the tokenizer and produces a structured stylesheet. use crate::animations::{Keyframe, KeyframeSelector}; use crate::media::{parse_media_query_list, MediaQueryList}; 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), Keyframes(KeyframesRule), FontFace(FontFaceRule), } /// A `@keyframes` rule with a name and a list of keyframes. #[derive(Debug, Clone, PartialEq)] pub struct KeyframesRule { pub name: String, pub keyframes: Vec, } /// A style rule: selector list + declarations. #[derive(Debug, Clone, PartialEq)] pub struct StyleRule { pub selectors: SelectorList, pub declarations: Vec, } /// A `@media` rule with parsed media queries and nested rules. #[derive(Debug, Clone, PartialEq)] pub struct MediaRule { pub queries: MediaQueryList, pub rules: Vec, } /// An `@import` rule with a URL. #[derive(Debug, Clone, PartialEq)] pub struct ImportRule { pub url: String, } /// A `@font-face` rule describing a web font. #[derive(Debug, Clone, PartialEq)] pub struct FontFaceRule { /// The font family name declared for this font face. pub family: String, /// `src` descriptor: list of font sources (currently only `url()` references). pub sources: Vec, /// `font-weight`: numeric weight (100–900). Defaults to 400. pub weight: u16, /// `font-style`: normal, italic, or oblique. pub style: FontFaceStyle, /// `font-display`: controls rendering behavior during font load. pub display: FontDisplay, } /// A single source in a `@font-face` `src` descriptor. #[derive(Debug, Clone, PartialEq)] pub struct FontFaceSource { /// URL to the font file. pub url: String, /// Optional `format()` hint (e.g., "truetype", "opentype", "woff2"). pub format: Option, } /// `font-style` descriptor values for `@font-face`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FontFaceStyle { Normal, Italic, Oblique, } /// `font-display` descriptor values for `@font-face`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FontDisplay { Auto, Block, Swap, Fallback, Optional, } /// 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() } /// Parse a selector list from a string (for `querySelector`/`querySelectorAll`). pub fn parse_selectors(input: &str) -> SelectorList { let tokens = Tokenizer::tokenize(input); let mut parser = Self { tokens, pos: 0 }; parser.parse_selector_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(), "keyframes" | "-webkit-keyframes" | "-moz-keyframes" => self.parse_keyframes_rule(), "font-face" => self.parse_font_face_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 for structured media query parsing. let mut prelude_tokens = Vec::new(); loop { match self.peek() { Token::LeftBrace | Token::Eof => break, _ => { let tok = self.advance(); prelude_tokens.push(tok); } } } let queries = parse_media_query_list(&prelude_tokens); // 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 { queries, 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 parse_keyframes_rule(&mut self) -> Option { self.skip_whitespace(); // Parse the animation name (identifier or string) let name = match self.peek() { Token::Ident(_) => { if let Token::Ident(s) = self.advance() { s } else { unreachable!() } } Token::String(_) => { if let Token::String(s) = self.advance() { s } else { unreachable!() } } _ => { self.skip_at_rule_body(); return None; } }; self.skip_whitespace(); // Expect `{` if !matches!(self.peek(), Token::LeftBrace) { self.skip_at_rule_body(); return None; } self.advance(); // Parse keyframe blocks let mut keyframes = Vec::new(); loop { self.skip_whitespace(); if self.is_eof() { break; } if matches!(self.peek(), Token::RightBrace) { self.advance(); break; } if let Some(kf) = self.parse_keyframe_block() { keyframes.push(kf); } } Some(Rule::Keyframes(KeyframesRule { name, keyframes })) } fn parse_font_face_rule(&mut self) -> Option { self.skip_whitespace(); // Expect `{` if !matches!(self.peek(), Token::LeftBrace) { self.skip_at_rule_body(); return None; } self.advance(); // Parse descriptors inside the block. let declarations = self.parse_declaration_list_until_brace(); // Consume `}` if matches!(self.peek(), Token::RightBrace) { self.advance(); } // Extract font-family descriptor (required). let mut family = None; let mut sources = Vec::new(); let mut weight: u16 = 400; let mut style = FontFaceStyle::Normal; let mut display = FontDisplay::Auto; for decl in &declarations { match decl.property.as_str() { "font-family" => { family = extract_font_face_family(&decl.value); } "src" => { sources = parse_font_face_src(&decl.value); } "font-weight" => { weight = parse_font_face_weight(&decl.value); } "font-style" => { style = parse_font_face_style(&decl.value); } "font-display" => { display = parse_font_face_display(&decl.value); } _ => {} } } let family = family?; if sources.is_empty() { return None; } Some(Rule::FontFace(FontFaceRule { family, sources, weight, style, display, })) } fn parse_keyframe_block(&mut self) -> Option { // Parse keyframe selectors (comma-separated: `from`, `to`, or percentages) let mut selectors = Vec::new(); loop { self.skip_whitespace(); match self.peek() { Token::LeftBrace | Token::Eof | Token::RightBrace => break, Token::Comma => { self.advance(); continue; } Token::Ident(s) if s.eq_ignore_ascii_case("from") => { selectors.push(KeyframeSelector::From); self.advance(); } Token::Ident(s) if s.eq_ignore_ascii_case("to") => { selectors.push(KeyframeSelector::To); self.advance(); } Token::Percentage(_) => { if let Token::Percentage(p) = self.advance() { selectors.push(KeyframeSelector::Percentage(p)); } } Token::Number(n, _) if *n == 0.0 => { // Allow bare `0` as 0% self.advance(); selectors.push(KeyframeSelector::Percentage(0.0)); // Check for optional `%` sign if matches!(self.peek(), Token::Delim('%')) { self.advance(); } } _ => { // Unknown token in keyframe selector — skip to next block self.skip_at_rule_body(); return None; } } } if selectors.is_empty() { return None; } // Expect `{` if !matches!(self.peek(), Token::LeftBrace) { return None; } self.advance(); let declarations = self.parse_declaration_list_until_brace(); // Consume `}` if matches!(self.peek(), Token::RightBrace) { self.advance(); } Some(Keyframe { selectors, declarations, }) } 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(), } } // --------------------------------------------------------------------------- // @font-face descriptor helpers // --------------------------------------------------------------------------- /// Extract the family name from a `font-family` descriptor value. fn extract_font_face_family(values: &[ComponentValue]) -> Option { for v in values { match v { ComponentValue::String(s) => return Some(s.clone()), ComponentValue::Ident(s) => return Some(s.clone()), _ => {} } } None } /// Parse the `src` descriptor into a list of font sources. /// /// Supports `url("...")` and `url("...") format("...")` syntax. /// Multiple sources are comma-separated. fn parse_font_face_src(values: &[ComponentValue]) -> Vec { let mut sources = Vec::new(); let mut i = 0; while i < values.len() { match &values[i] { ComponentValue::Function(name, args) if name.eq_ignore_ascii_case("url") => { let url = extract_string_from_args(args); i += 1; // Check for optional format() hint. let format = skip_ws_and_parse_format(values, &mut i); if let Some(url) = url { sources.push(FontFaceSource { url, format }); } } ComponentValue::Comma => { i += 1; } _ => { i += 1; } } } sources } /// Extract a string or ident from function arguments. fn extract_string_from_args(args: &[ComponentValue]) -> Option { for arg in args { match arg { ComponentValue::String(s) => return Some(s.clone()), ComponentValue::Ident(s) => return Some(s.clone()), _ => {} } } None } /// Skip whitespace tokens and try to parse a `format()` function. fn skip_ws_and_parse_format(values: &[ComponentValue], i: &mut usize) -> Option { // Skip whitespace. while *i < values.len() && matches!(values[*i], ComponentValue::Whitespace) { *i += 1; } // Check for format(). if *i < values.len() { if let ComponentValue::Function(name, args) = &values[*i] { if name.eq_ignore_ascii_case("format") { *i += 1; return extract_string_from_args(args); } } } None } /// Parse `font-weight` descriptor: `normal`, `bold`, or numeric (100–900). fn parse_font_face_weight(values: &[ComponentValue]) -> u16 { for v in values { match v { ComponentValue::Ident(s) if s.eq_ignore_ascii_case("normal") => return 400, ComponentValue::Ident(s) if s.eq_ignore_ascii_case("bold") => return 700, ComponentValue::Number(n, _) => { let w = *n as u16; return w.clamp(1, 1000); } _ => {} } } 400 } /// Parse `font-style` descriptor: `normal`, `italic`, `oblique`. fn parse_font_face_style(values: &[ComponentValue]) -> FontFaceStyle { for v in values { if let ComponentValue::Ident(s) = v { if s.eq_ignore_ascii_case("italic") { return FontFaceStyle::Italic; } if s.eq_ignore_ascii_case("oblique") { return FontFaceStyle::Oblique; } if s.eq_ignore_ascii_case("normal") { return FontFaceStyle::Normal; } } } FontFaceStyle::Normal } /// Parse `font-display` descriptor. fn parse_font_face_display(values: &[ComponentValue]) -> FontDisplay { for v in values { if let ComponentValue::Ident(s) = v { match s.to_ascii_lowercase().as_str() { "auto" => return FontDisplay::Auto, "block" => return FontDisplay::Block, "swap" => return FontDisplay::Swap, "fallback" => return FontDisplay::Fallback, "optional" => return FontDisplay::Optional, _ => {} } } } FontDisplay::Auto } // --------------------------------------------------------------------------- // 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() { use crate::media::{MediaType, Modifier}; 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.queries.queries.len(), 1); assert_eq!(m.queries.queries[0].media_type, MediaType::Screen); assert_eq!(m.queries.queries[0].modifier, Modifier::None); assert_eq!(m.rules.len(), 1); } _ => panic!("expected media rule"), } } #[test] fn test_media_rule_complex_query() { use crate::media::{Comparison, MediaFeature, MediaType}; let ss = Parser::parse("@media screen and (max-width: 600px) { p { font-size: 14px; } }"); match &ss.rules[0] { Rule::Media(m) => { assert_eq!(m.queries.queries[0].media_type, MediaType::Screen); assert_eq!(m.queries.queries[0].features.len(), 1); assert_eq!( m.queries.queries[0].features[0], MediaFeature::Width(Comparison::Lte, 600.0) ); 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); } // -- @keyframes tests -- #[test] fn test_keyframes_from_to() { let ss = Parser::parse("@keyframes slidein { from { opacity: 0; } to { opacity: 1; } }"); assert_eq!(ss.rules.len(), 1); let kf = match &ss.rules[0] { Rule::Keyframes(k) => k, _ => panic!("expected keyframes rule"), }; assert_eq!(kf.name, "slidein"); assert_eq!(kf.keyframes.len(), 2); assert_eq!(kf.keyframes[0].selectors, vec![KeyframeSelector::From]); assert_eq!(kf.keyframes[1].selectors, vec![KeyframeSelector::To]); assert_eq!(kf.keyframes[0].declarations.len(), 1); assert_eq!(kf.keyframes[0].declarations[0].property, "opacity"); } #[test] fn test_keyframes_percentages() { let ss = Parser::parse( "@keyframes fade { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0.5; } }", ); let kf = match &ss.rules[0] { Rule::Keyframes(k) => k, _ => panic!("expected keyframes rule"), }; assert_eq!(kf.name, "fade"); assert_eq!(kf.keyframes.len(), 3); assert_eq!( kf.keyframes[0].selectors, vec![KeyframeSelector::Percentage(0.0)] ); assert_eq!( kf.keyframes[1].selectors, vec![KeyframeSelector::Percentage(50.0)] ); assert_eq!( kf.keyframes[2].selectors, vec![KeyframeSelector::Percentage(100.0)] ); } #[test] fn test_keyframes_multiple_selectors() { let ss = Parser::parse("@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }"); let kf = match &ss.rules[0] { Rule::Keyframes(k) => k, _ => panic!("expected keyframes rule"), }; assert_eq!(kf.keyframes.len(), 2); assert_eq!( kf.keyframes[0].selectors, vec![ KeyframeSelector::Percentage(0.0), KeyframeSelector::Percentage(100.0), ] ); } #[test] fn test_keyframes_string_name() { let ss = Parser::parse( "@keyframes \"my animation\" { from { color: red; } to { color: blue; } }", ); let kf = match &ss.rules[0] { Rule::Keyframes(k) => k, _ => panic!("expected keyframes rule"), }; assert_eq!(kf.name, "my animation"); } #[test] fn test_keyframes_webkit_prefix() { let ss = Parser::parse("@-webkit-keyframes spin { from { opacity: 0; } to { opacity: 1; } }"); assert_eq!(ss.rules.len(), 1); let kf = match &ss.rules[0] { Rule::Keyframes(k) => k, _ => panic!("expected keyframes rule"), }; assert_eq!(kf.name, "spin"); } #[test] fn test_keyframes_multiple_declarations() { let ss = Parser::parse( "@keyframes move { from { left: 0px; top: 0px; } to { left: 100px; top: 50px; } }", ); let kf = match &ss.rules[0] { Rule::Keyframes(k) => k, _ => panic!("expected keyframes rule"), }; assert_eq!(kf.keyframes[0].declarations.len(), 2); assert_eq!(kf.keyframes[1].declarations.len(), 2); } #[test] fn test_keyframes_with_style_rules() { let ss = Parser::parse( r#" .box { color: red; } @keyframes fade { from { opacity: 0; } to { opacity: 1; } } p { font-size: 16px; } "#, ); assert_eq!(ss.rules.len(), 3); assert!(matches!(ss.rules[0], Rule::Style(_))); assert!(matches!(ss.rules[1], Rule::Keyframes(_))); assert!(matches!(ss.rules[2], Rule::Style(_))); } // -- @font-face ----------------------------------------------------------- #[test] fn font_face_basic() { let ss = Parser::parse( r#"@font-face { font-family: "MyFont"; src: url("myfont.ttf"); }"#, ); assert_eq!(ss.rules.len(), 1); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.family, "MyFont"); assert_eq!(ff.sources.len(), 1); assert_eq!(ff.sources[0].url, "myfont.ttf"); assert!(ff.sources[0].format.is_none()); assert_eq!(ff.weight, 400); assert_eq!(ff.style, FontFaceStyle::Normal); assert_eq!(ff.display, FontDisplay::Auto); } #[test] fn font_face_all_descriptors() { let ss = Parser::parse( r#"@font-face { font-family: "WebFont"; src: url("webfont.woff2") format("woff2"); font-weight: 700; font-style: italic; font-display: swap; }"#, ); assert_eq!(ss.rules.len(), 1); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.family, "WebFont"); assert_eq!(ff.sources.len(), 1); assert_eq!(ff.sources[0].url, "webfont.woff2"); assert_eq!(ff.sources[0].format.as_deref(), Some("woff2")); assert_eq!(ff.weight, 700); assert_eq!(ff.style, FontFaceStyle::Italic); assert_eq!(ff.display, FontDisplay::Swap); } #[test] fn font_face_bold_keyword() { let ss = Parser::parse( r#"@font-face { font-family: "BoldFont"; src: url("bold.ttf"); font-weight: bold; }"#, ); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.weight, 700); } #[test] fn font_face_normal_weight_keyword() { let ss = Parser::parse( r#"@font-face { font-family: "NormalFont"; src: url("normal.ttf"); font-weight: normal; }"#, ); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.weight, 400); } #[test] fn font_face_oblique_style() { let ss = Parser::parse( r#"@font-face { font-family: "ObliqueFont"; src: url("oblique.ttf"); font-style: oblique; }"#, ); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.style, FontFaceStyle::Oblique); } #[test] fn font_face_multiple_sources() { let ss = Parser::parse( r#"@font-face { font-family: "MultiFmt"; src: url("font.woff2") format("woff2"), url("font.ttf") format("truetype"); }"#, ); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.sources.len(), 2); assert_eq!(ff.sources[0].url, "font.woff2"); assert_eq!(ff.sources[0].format.as_deref(), Some("woff2")); assert_eq!(ff.sources[1].url, "font.ttf"); assert_eq!(ff.sources[1].format.as_deref(), Some("truetype")); } #[test] fn font_face_display_values() { for (keyword, expected) in [ ("auto", FontDisplay::Auto), ("block", FontDisplay::Block), ("swap", FontDisplay::Swap), ("fallback", FontDisplay::Fallback), ("optional", FontDisplay::Optional), ] { let css = format!( r#"@font-face {{ font-family: "F"; src: url("f.ttf"); font-display: {}; }}"#, keyword ); let ss = Parser::parse(&css); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.display, expected, "font-display: {}", keyword); } } #[test] fn font_face_missing_family_skipped() { let ss = Parser::parse( r#"@font-face { src: url("nofamily.ttf"); }"#, ); assert!( ss.rules.is_empty(), "should skip @font-face without font-family" ); } #[test] fn font_face_missing_src_skipped() { let ss = Parser::parse( r#"@font-face { font-family: "NoSrc"; }"#, ); assert!(ss.rules.is_empty(), "should skip @font-face without src"); } #[test] fn font_face_unquoted_family() { let ss = Parser::parse( r#"@font-face { font-family: CustomFont; src: url("custom.ttf"); }"#, ); assert_eq!(ss.rules.len(), 1); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.family, "CustomFont"); } #[test] fn font_face_among_other_rules() { let ss = Parser::parse( r#" body { color: black; } @font-face { font-family: "TestFont"; src: url("test.ttf"); } p { margin: 0; } "#, ); assert_eq!(ss.rules.len(), 3); assert!(matches!(ss.rules[0], Rule::Style(_))); assert!(matches!(ss.rules[1], Rule::FontFace(_))); assert!(matches!(ss.rules[2], Rule::Style(_))); } #[test] fn font_face_numeric_weight() { let ss = Parser::parse( r#"@font-face { font-family: "W300"; src: url("w300.ttf"); font-weight: 300; }"#, ); let ff = match &ss.rules[0] { Rule::FontFace(ff) => ff, _ => panic!("expected FontFace rule"), }; assert_eq!(ff.weight, 300); } }