//! CSS value parsing: convert raw component values into typed property values. //! //! Provides `CssValue` enum and parsing from `ComponentValue` lists. use crate::parser::ComponentValue; // --------------------------------------------------------------------------- // Core value types // --------------------------------------------------------------------------- /// A fully parsed, typed CSS value. #[derive(Debug, Clone, PartialEq)] pub enum CssValue { /// A length with resolved unit. Length(f64, LengthUnit), /// A percentage value. Percentage(f64), /// A color value (r, g, b, a in 0–255 range, alpha 0–255). Color(Color), /// A numeric value (unitless). Number(f64), /// A string value. String(String), /// A keyword (ident). Keyword(String), /// The `auto` keyword. Auto, /// The `inherit` keyword. Inherit, /// The `initial` keyword. Initial, /// The `unset` keyword. Unset, /// The `currentColor` keyword. CurrentColor, /// The `none` keyword (for display, background, etc.). None, /// The `transparent` keyword. Transparent, /// Zero (unitless). Zero, /// A list of values (for multi-value properties like margin shorthand). List(Vec), /// A CSS math function expression: calc(), min(), max(), clamp(). Math(Box), } // --------------------------------------------------------------------------- // CSS math expression AST (calc, min, max, clamp) // --------------------------------------------------------------------------- /// A node in a CSS math expression tree. #[derive(Debug, Clone, PartialEq)] pub enum MathExpr { /// A literal length value. Length(f64, LengthUnit), /// A literal percentage value. Percentage(f64), /// A literal unitless number. Number(f64), /// Addition: `a + b`. Add(Box, Box), /// Subtraction: `a - b`. Sub(Box, Box), /// Multiplication: `a * b` (one operand should be a number). Mul(Box, Box), /// Division: `a / b` (divisor should be a number). Div(Box, Box), /// `min(a, b, ...)` — returns the smallest value. Min(Vec), /// `max(a, b, ...)` — returns the largest value. Max(Vec), /// `clamp(min, val, max)` — equivalent to `max(min, min(val, max))`. Clamp(Box, Box, Box), } /// CSS length unit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LengthUnit { // Absolute Px, Pt, Cm, Mm, In, Pc, // Font-relative Em, Rem, // Viewport Vw, Vh, Vmin, Vmax, } /// A CSS color in RGBA format. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Color { pub r: u8, pub g: u8, pub b: u8, pub a: u8, } impl Color { pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self { Self { r, g, b, a } } pub fn rgb(r: u8, g: u8, b: u8) -> Self { Self { r, g, b, a: 255 } } } // --------------------------------------------------------------------------- // Shorthand expansion result // --------------------------------------------------------------------------- /// A longhand property-value pair produced by shorthand expansion. #[derive(Debug, Clone, PartialEq)] pub struct LonghandDeclaration { pub property: String, pub value: CssValue, pub important: bool, } // --------------------------------------------------------------------------- // Value parsing // --------------------------------------------------------------------------- /// Parse a single `CssValue` from a list of component values. pub fn parse_value(values: &[ComponentValue]) -> CssValue { // Filter out whitespace for easier matching let non_ws: Vec<&ComponentValue> = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace)) .collect(); if non_ws.is_empty() { return CssValue::Keyword(String::new()); } // Single-value case if non_ws.len() == 1 { return parse_single_value(non_ws[0]); } // Multi-value: parse each non-whitespace value let parsed: Vec = non_ws.iter().copied().map(parse_single_value).collect(); CssValue::List(parsed) } /// Parse a single component value into a `CssValue`. pub fn parse_single_value(cv: &ComponentValue) -> CssValue { match cv { ComponentValue::Ident(s) => parse_keyword(s), ComponentValue::String(s) => CssValue::String(s.clone()), ComponentValue::Number(n, _) => { if *n == 0.0 { CssValue::Zero } else { CssValue::Number(*n) } } ComponentValue::Percentage(n) => CssValue::Percentage(*n), ComponentValue::Dimension(n, _, unit) => parse_dimension(*n, unit), ComponentValue::Hash(s, _) => parse_hex_color(s), ComponentValue::Function(name, args) => parse_function(name, args), ComponentValue::Comma => CssValue::Keyword(",".to_string()), ComponentValue::Delim(c) => CssValue::Keyword(c.to_string()), ComponentValue::Whitespace => CssValue::Keyword(" ".to_string()), } } fn parse_keyword(s: &str) -> CssValue { match s.to_ascii_lowercase().as_str() { "auto" => CssValue::Auto, "inherit" => CssValue::Inherit, "initial" => CssValue::Initial, "unset" => CssValue::Unset, "none" => CssValue::None, "transparent" => CssValue::Transparent, "currentcolor" => CssValue::CurrentColor, // Named colors name => { if let Some(color) = named_color(name) { CssValue::Color(color) } else { CssValue::Keyword(s.to_ascii_lowercase()) } } } } fn parse_dimension(n: f64, unit: &str) -> CssValue { let u = unit.to_ascii_lowercase(); match u.as_str() { "px" => CssValue::Length(n, LengthUnit::Px), "pt" => CssValue::Length(n, LengthUnit::Pt), "cm" => CssValue::Length(n, LengthUnit::Cm), "mm" => CssValue::Length(n, LengthUnit::Mm), "in" => CssValue::Length(n, LengthUnit::In), "pc" => CssValue::Length(n, LengthUnit::Pc), "em" => CssValue::Length(n, LengthUnit::Em), "rem" => CssValue::Length(n, LengthUnit::Rem), "vw" => CssValue::Length(n, LengthUnit::Vw), "vh" => CssValue::Length(n, LengthUnit::Vh), "vmin" => CssValue::Length(n, LengthUnit::Vmin), "vmax" => CssValue::Length(n, LengthUnit::Vmax), _ => CssValue::Keyword(format!("{n}{u}")), } } // --------------------------------------------------------------------------- // Color parsing // --------------------------------------------------------------------------- fn parse_hex_color(hex: &str) -> CssValue { let chars: Vec = hex.chars().collect(); match chars.len() { // #rgb 3 => { let r = hex_digit(chars[0]) * 17; let g = hex_digit(chars[1]) * 17; let b = hex_digit(chars[2]) * 17; CssValue::Color(Color::rgb(r, g, b)) } // #rgba 4 => { let r = hex_digit(chars[0]) * 17; let g = hex_digit(chars[1]) * 17; let b = hex_digit(chars[2]) * 17; let a = hex_digit(chars[3]) * 17; CssValue::Color(Color::new(r, g, b, a)) } // #rrggbb 6 => { let r = hex_byte(chars[0], chars[1]); let g = hex_byte(chars[2], chars[3]); let b = hex_byte(chars[4], chars[5]); CssValue::Color(Color::rgb(r, g, b)) } // #rrggbbaa 8 => { let r = hex_byte(chars[0], chars[1]); let g = hex_byte(chars[2], chars[3]); let b = hex_byte(chars[4], chars[5]); let a = hex_byte(chars[6], chars[7]); CssValue::Color(Color::new(r, g, b, a)) } _ => CssValue::Keyword(format!("#{hex}")), } } fn hex_digit(c: char) -> u8 { match c { '0'..='9' => c as u8 - b'0', 'a'..='f' => c as u8 - b'a' + 10, 'A'..='F' => c as u8 - b'A' + 10, _ => 0, } } fn hex_byte(hi: char, lo: char) -> u8 { hex_digit(hi) * 16 + hex_digit(lo) } fn parse_function(name: &str, args: &[ComponentValue]) -> CssValue { match name.to_ascii_lowercase().as_str() { "rgb" => parse_rgb(args, false), "rgba" => parse_rgb(args, true), "calc" => parse_math_calc(args), "min" => parse_math_min(args), "max" => parse_math_max(args), "clamp" => parse_math_clamp(args), _ => CssValue::Keyword(format!("{name}()")), } } // --------------------------------------------------------------------------- // CSS math function parsing (calc, min, max, clamp) // --------------------------------------------------------------------------- fn parse_math_calc(args: &[ComponentValue]) -> CssValue { let tokens = filter_math_tokens(args); match parse_sum(&tokens) { Some((expr, [])) => CssValue::Math(Box::new(expr)), _ => CssValue::Keyword("calc()".to_string()), } } fn parse_math_min(args: &[ComponentValue]) -> CssValue { let exprs = parse_comma_separated_exprs(args); if exprs.is_empty() { return CssValue::Keyword("min()".to_string()); } CssValue::Math(Box::new(MathExpr::Min(exprs))) } fn parse_math_max(args: &[ComponentValue]) -> CssValue { let exprs = parse_comma_separated_exprs(args); if exprs.is_empty() { return CssValue::Keyword("max()".to_string()); } CssValue::Math(Box::new(MathExpr::Max(exprs))) } fn parse_math_clamp(args: &[ComponentValue]) -> CssValue { let exprs = parse_comma_separated_exprs(args); if exprs.len() != 3 { return CssValue::Keyword("clamp()".to_string()); } let mut iter = exprs.into_iter(); let min = iter.next().unwrap(); let val = iter.next().unwrap(); let max = iter.next().unwrap(); CssValue::Math(Box::new(MathExpr::Clamp( Box::new(min), Box::new(val), Box::new(max), ))) } /// Intermediate token for math expression parsing. #[derive(Debug, Clone)] enum MathToken { Number(f64), Dimension(f64, LengthUnit), Percentage(f64), Plus, Minus, Star, Slash, /// A pre-parsed nested math expression (e.g., nested min/max/clamp/calc). Nested(MathExpr), } /// Convert ComponentValues into MathTokens, filtering whitespace but preserving /// it around +/- operators (whitespace is required around + and - per spec). fn filter_math_tokens(args: &[ComponentValue]) -> Vec { let mut tokens = Vec::new(); let mut i = 0; while i < args.len() { match &args[i] { ComponentValue::Number(n, _) => tokens.push(MathToken::Number(*n)), ComponentValue::Percentage(n) => tokens.push(MathToken::Percentage(*n)), ComponentValue::Dimension(n, _, unit) => { if let Some(lu) = parse_length_unit(unit) { tokens.push(MathToken::Dimension(*n, lu)); } } ComponentValue::Delim('+') => tokens.push(MathToken::Plus), ComponentValue::Delim('-') => tokens.push(MathToken::Minus), ComponentValue::Delim('*') => tokens.push(MathToken::Star), ComponentValue::Delim('/') => tokens.push(MathToken::Slash), ComponentValue::Function(name, inner_args) => { let lower = name.to_ascii_lowercase(); let nested = match lower.as_str() { "calc" => { let inner_tokens = filter_math_tokens(inner_args); parse_sum(&inner_tokens).and_then(|(e, r)| { if r.is_empty() { Some(e) } else { Option::None } }) } "min" => { let exprs = parse_comma_separated_exprs(inner_args); if exprs.is_empty() { Option::None } else { Some(MathExpr::Min(exprs)) } } "max" => { let exprs = parse_comma_separated_exprs(inner_args); if exprs.is_empty() { Option::None } else { Some(MathExpr::Max(exprs)) } } "clamp" => { let exprs = parse_comma_separated_exprs(inner_args); if exprs.len() == 3 { let mut it = exprs.into_iter(); Some(MathExpr::Clamp( Box::new(it.next().unwrap()), Box::new(it.next().unwrap()), Box::new(it.next().unwrap()), )) } else { Option::None } } _ => Option::None, }; if let Some(expr) = nested { tokens.push(MathToken::Nested(expr)); } } ComponentValue::Whitespace | ComponentValue::Comma => {} _ => {} } i += 1; } tokens } fn parse_length_unit(unit: &str) -> Option { match unit.to_ascii_lowercase().as_str() { "px" => Some(LengthUnit::Px), "pt" => Some(LengthUnit::Pt), "cm" => Some(LengthUnit::Cm), "mm" => Some(LengthUnit::Mm), "in" => Some(LengthUnit::In), "pc" => Some(LengthUnit::Pc), "em" => Some(LengthUnit::Em), "rem" => Some(LengthUnit::Rem), "vw" => Some(LengthUnit::Vw), "vh" => Some(LengthUnit::Vh), "vmin" => Some(LengthUnit::Vmin), "vmax" => Some(LengthUnit::Vmax), _ => Option::None, } } /// Parse comma-separated math expressions (for min, max, clamp arguments). fn parse_comma_separated_exprs(args: &[ComponentValue]) -> Vec { // Split on commas at the top level, then parse each group as a sum expression. let mut groups: Vec> = vec![vec![]]; for cv in args { if matches!(cv, ComponentValue::Comma) { groups.push(vec![]); } else { groups.last_mut().unwrap().push(cv); } } let mut results = Vec::new(); for group in groups { let owned: Vec = group.into_iter().cloned().collect(); let tokens = filter_math_tokens(&owned); if let Some((expr, rest)) = parse_sum(&tokens) { if rest.is_empty() { results.push(expr); } } } results } /// Parse an additive expression: `product (('+' | '-') product)*`. /// Per spec, whitespace is required around `+` and `-`. fn parse_sum(tokens: &[MathToken]) -> Option<(MathExpr, &[MathToken])> { let (mut left, mut rest) = parse_product(tokens)?; while !rest.is_empty() { match rest.first() { Some(MathToken::Plus) => { let (right, r) = parse_product(&rest[1..])?; left = MathExpr::Add(Box::new(left), Box::new(right)); rest = r; } Some(MathToken::Minus) => { let (right, r) = parse_product(&rest[1..])?; left = MathExpr::Sub(Box::new(left), Box::new(right)); rest = r; } _ => break, } } Some((left, rest)) } /// Parse a multiplicative expression: `atom (('*' | '/') atom)*`. fn parse_product(tokens: &[MathToken]) -> Option<(MathExpr, &[MathToken])> { let (mut left, mut rest) = parse_atom(tokens)?; while !rest.is_empty() { match rest.first() { Some(MathToken::Star) => { let (right, r) = parse_atom(&rest[1..])?; left = MathExpr::Mul(Box::new(left), Box::new(right)); rest = r; } Some(MathToken::Slash) => { let (right, r) = parse_atom(&rest[1..])?; left = MathExpr::Div(Box::new(left), Box::new(right)); rest = r; } _ => break, } } Some((left, rest)) } /// Parse an atom: a number, dimension, percentage, parenthesized expression, /// or nested math function. fn parse_atom(tokens: &[MathToken]) -> Option<(MathExpr, &[MathToken])> { let first = tokens.first()?; match first { MathToken::Number(n) => Some((MathExpr::Number(*n), &tokens[1..])), MathToken::Dimension(n, unit) => Some((MathExpr::Length(*n, *unit), &tokens[1..])), MathToken::Percentage(n) => Some((MathExpr::Percentage(*n), &tokens[1..])), MathToken::Nested(expr) => Some((expr.clone(), &tokens[1..])), // Handle unary minus: e.g. `-20px` tokenized as Minus followed by value. MathToken::Minus => { let (inner, rest) = parse_atom(&tokens[1..])?; let negated = MathExpr::Mul(Box::new(MathExpr::Number(-1.0)), Box::new(inner)); Some((negated, rest)) } _ => Option::None, } } fn parse_rgb(args: &[ComponentValue], _has_alpha: bool) -> CssValue { let nums: Vec = args .iter() .filter_map(|cv| match cv { ComponentValue::Number(n, _) => Some(*n), ComponentValue::Percentage(n) => Some(*n * 2.55), _ => Option::None, }) .collect(); match nums.len() { 3 => CssValue::Color(Color::rgb( clamp_u8(nums[0]), clamp_u8(nums[1]), clamp_u8(nums[2]), )), 4 => { let a = if args .iter() .any(|cv| matches!(cv, ComponentValue::Percentage(_))) { // If any arg is a percentage, treat alpha as 0-1 float clamp_u8(nums[3] / 2.55 * 255.0) } else { // Check if alpha looks like a 0-1 range if nums[3] <= 1.0 { clamp_u8(nums[3] * 255.0) } else { clamp_u8(nums[3]) } }; CssValue::Color(Color::new( clamp_u8(nums[0]), clamp_u8(nums[1]), clamp_u8(nums[2]), a, )) } _ => CssValue::Keyword("rgb()".to_string()), } } fn clamp_u8(n: f64) -> u8 { n.round().clamp(0.0, 255.0) as u8 } // --------------------------------------------------------------------------- // Named colors (CSS Level 1 + transparent) // --------------------------------------------------------------------------- pub fn named_color(name: &str) -> Option { Some(match name { "black" => Color::rgb(0, 0, 0), "silver" => Color::rgb(192, 192, 192), "gray" | "grey" => Color::rgb(128, 128, 128), "white" => Color::rgb(255, 255, 255), "maroon" => Color::rgb(128, 0, 0), "red" => Color::rgb(255, 0, 0), "purple" => Color::rgb(128, 0, 128), "fuchsia" | "magenta" => Color::rgb(255, 0, 255), "green" => Color::rgb(0, 128, 0), "lime" => Color::rgb(0, 255, 0), "olive" => Color::rgb(128, 128, 0), "yellow" => Color::rgb(255, 255, 0), "navy" => Color::rgb(0, 0, 128), "blue" => Color::rgb(0, 0, 255), "teal" => Color::rgb(0, 128, 128), "aqua" | "cyan" => Color::rgb(0, 255, 255), "orange" => Color::rgb(255, 165, 0), _ => return Option::None, }) } // --------------------------------------------------------------------------- // Canvas color string parsing // --------------------------------------------------------------------------- /// Parse a CSS color string as used by the Canvas 2D API (`fillStyle`, /// `strokeStyle`). Supports `#rgb`, `#rrggbb`, `#rgba`, `#rrggbbaa`, /// `rgb()`, `rgba()`, named colors, and `transparent`. /// /// Returns `None` for invalid / unrecognised strings (per spec, the previous /// value should be kept). pub fn parse_color_string(s: &str) -> Option { let s = s.trim(); if s.is_empty() { return None; } // "transparent" if s.eq_ignore_ascii_case("transparent") { return Some(Color::new(0, 0, 0, 0)); } // Hex: #rgb, #rgba, #rrggbb, #rrggbbaa if let Some(hex) = s.strip_prefix('#') { // Validate all characters are hex digits before parsing. if !hex.chars().all(|c| c.is_ascii_hexdigit()) { return None; } return match parse_hex_color(hex) { CssValue::Color(c) => Some(c), _ => None, }; } // rgb(...) / rgba(...) let lower = s.to_ascii_lowercase(); if lower.starts_with("rgb") { // Extract contents between parens if let Some(start) = s.find('(') { if let Some(end) = s.rfind(')') { if start < end { let inner = &s[start + 1..end]; let args = tokenize_color_args(inner); let result = parse_rgb(&args, lower.starts_with("rgba")); return match result { CssValue::Color(c) => Some(c), _ => None, }; } } } return None; } // Named colors named_color(&lower) } /// Tokenize the inner arguments of `rgb()` / `rgba()` into `ComponentValue`s /// that `parse_rgb` expects. fn tokenize_color_args(s: &str) -> Vec { use crate::tokenizer::NumericType; let mut result = Vec::new(); for part in s.split(|c: char| c == ',' || c.is_ascii_whitespace()) { let part = part.trim(); if part.is_empty() { continue; } if let Some(pct) = part.strip_suffix('%') { if let Ok(n) = pct.parse::() { result.push(ComponentValue::Percentage(n)); continue; } } if let Ok(n) = part.parse::() { let nt = if part.contains('.') { NumericType::Number } else { NumericType::Integer }; result.push(ComponentValue::Number(n, nt)); continue; } } result } // --------------------------------------------------------------------------- // Shorthand expansion // --------------------------------------------------------------------------- /// Expand a CSS declaration into longhand declarations. /// Returns `None` if the property is not a known shorthand. pub fn expand_shorthand( property: &str, values: &[ComponentValue], important: bool, ) -> Option> { match property { "margin" => Some(expand_box_shorthand("margin", values, important)), "padding" => Some(expand_box_shorthand("padding", values, important)), "border" => Some(expand_border(values, important)), "border-width" => Some( expand_box_shorthand("border", values, important) .into_iter() .map(|mut d| { d.property = format!("{}-width", d.property); d }) .collect(), ), "border-style" => Some( expand_box_shorthand("border", values, important) .into_iter() .map(|mut d| { d.property = format!("{}-style", d.property); d }) .collect(), ), "border-color" => Some( expand_box_shorthand("border", values, important) .into_iter() .map(|mut d| { d.property = format!("{}-color", d.property); d }) .collect(), ), "background" => Some(expand_background(values, important)), "flex" => Some(expand_flex(values, important)), "flex-flow" => Some(expand_flex_flow(values, important)), "gap" => Some(expand_gap(values, important)), "grid-column" => Some(expand_grid_line("grid-column", values, important)), "grid-row" => Some(expand_grid_line("grid-row", values, important)), "grid-area" => Some(expand_grid_area(values, important)), _ => Option::None, } } /// Expand the `flex` shorthand into `flex-grow`, `flex-shrink`, `flex-basis`. fn expand_flex(values: &[ComponentValue], important: bool) -> Vec { let parsed: Vec = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) .map(parse_single_value) .collect(); let (grow, shrink, basis) = match parsed.as_slice() { [CssValue::None] => (CssValue::Number(0.0), CssValue::Number(0.0), CssValue::Auto), [CssValue::Auto] => (CssValue::Number(1.0), CssValue::Number(1.0), CssValue::Auto), [CssValue::Number(g)] => (CssValue::Number(*g), CssValue::Number(1.0), CssValue::Zero), [CssValue::Zero] => (CssValue::Number(0.0), CssValue::Number(1.0), CssValue::Zero), [CssValue::Number(g), CssValue::Number(s)] => { (CssValue::Number(*g), CssValue::Number(*s), CssValue::Zero) } [CssValue::Number(g), CssValue::Number(s), basis] => { (CssValue::Number(*g), CssValue::Number(*s), basis.clone()) } _ => (CssValue::Number(0.0), CssValue::Number(1.0), CssValue::Auto), }; vec![ LonghandDeclaration { property: "flex-grow".to_string(), value: grow, important, }, LonghandDeclaration { property: "flex-shrink".to_string(), value: shrink, important, }, LonghandDeclaration { property: "flex-basis".to_string(), value: basis, important, }, ] } /// Expand the `flex-flow` shorthand into `flex-direction` and `flex-wrap`. fn expand_flex_flow(values: &[ComponentValue], important: bool) -> Vec { let parsed: Vec = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) .map(parse_single_value) .collect(); let mut direction = CssValue::Keyword("row".to_string()); let mut wrap = CssValue::Keyword("nowrap".to_string()); for val in &parsed { if let CssValue::Keyword(k) = val { match k.as_str() { "row" | "row-reverse" | "column" | "column-reverse" => { direction = val.clone(); } "nowrap" | "wrap" | "wrap-reverse" => { wrap = val.clone(); } _ => {} } } } vec![ LonghandDeclaration { property: "flex-direction".to_string(), value: direction, important, }, LonghandDeclaration { property: "flex-wrap".to_string(), value: wrap, important, }, ] } /// Expand the `gap` shorthand into `row-gap` and `column-gap`. fn expand_gap(values: &[ComponentValue], important: bool) -> Vec { let parsed: Vec = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) .map(parse_single_value) .collect(); let (row, col) = match parsed.as_slice() { [single] => (single.clone(), single.clone()), [r, c] => (r.clone(), c.clone()), _ => (CssValue::Zero, CssValue::Zero), }; vec![ LonghandDeclaration { property: "row-gap".to_string(), value: row, important, }, LonghandDeclaration { property: "column-gap".to_string(), value: col, important, }, ] } /// Expand `grid-column` or `grid-row` shorthand into start/end longhands. /// /// `grid-column: 1 / span 2` → grid-column-start: 1; grid-column-end: span 2; /// `grid-column: 2` → grid-column-start: 2; grid-column-end: auto; fn expand_grid_line( property: &str, values: &[ComponentValue], important: bool, ) -> Vec { let (start_prop, end_prop) = if property == "grid-column" { ("grid-column-start", "grid-column-end") } else { ("grid-row-start", "grid-row-end") }; let groups = split_at_slash(values); let start_value = parse_grid_line_group(&groups[0]); let end_value = if groups.len() > 1 { parse_grid_line_group(&groups[1]) } else { CssValue::Auto }; vec![ LonghandDeclaration { property: start_prop.to_string(), value: start_value, important, }, LonghandDeclaration { property: end_prop.to_string(), value: end_value, important, }, ] } /// Expand `grid-area` shorthand into row-start, column-start, row-end, column-end. /// /// `grid-area: 1 / 2 / 3 / 4` → all four longhands. /// Missing values default to `auto`. fn expand_grid_area(values: &[ComponentValue], important: bool) -> Vec { let groups = split_at_slash(values); let row_start = parse_grid_line_group(&groups[0]); let col_start = if groups.len() > 1 { parse_grid_line_group(&groups[1]) } else { CssValue::Auto }; let row_end = if groups.len() > 2 { parse_grid_line_group(&groups[2]) } else { CssValue::Auto }; let col_end = if groups.len() > 3 { parse_grid_line_group(&groups[3]) } else { CssValue::Auto }; vec![ LonghandDeclaration { property: "grid-row-start".to_string(), value: row_start, important, }, LonghandDeclaration { property: "grid-column-start".to_string(), value: col_start, important, }, LonghandDeclaration { property: "grid-row-end".to_string(), value: row_end, important, }, LonghandDeclaration { property: "grid-column-end".to_string(), value: col_end, important, }, ] } /// Split component values at `/` delimiters into groups. fn split_at_slash(values: &[ComponentValue]) -> Vec> { let mut groups: Vec> = vec![Vec::new()]; for cv in values { if matches!(cv, ComponentValue::Delim('/')) { groups.push(Vec::new()); } else { groups.last_mut().unwrap().push(cv); } } groups } /// Parse a single grid line group (between slashes) into a CssValue. fn parse_grid_line_group(values: &[&ComponentValue]) -> CssValue { let non_ws: Vec<&&ComponentValue> = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace)) .collect(); if non_ws.is_empty() { return CssValue::Auto; } if non_ws.len() == 1 { return parse_single_value(non_ws[0]); } let parsed: Vec = non_ws.iter().map(|v| parse_single_value(v)).collect(); CssValue::List(parsed) } /// Expand a box-model shorthand (margin, padding) using the 1-to-4 value pattern. fn expand_box_shorthand( prefix: &str, values: &[ComponentValue], important: bool, ) -> Vec { let parsed: Vec = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) .map(parse_single_value) .collect(); let (top, right, bottom, left) = match parsed.len() { 1 => ( parsed[0].clone(), parsed[0].clone(), parsed[0].clone(), parsed[0].clone(), ), 2 => ( parsed[0].clone(), parsed[1].clone(), parsed[0].clone(), parsed[1].clone(), ), 3 => ( parsed[0].clone(), parsed[1].clone(), parsed[2].clone(), parsed[1].clone(), ), 4 => ( parsed[0].clone(), parsed[1].clone(), parsed[2].clone(), parsed[3].clone(), ), _ => { let fallback = if parsed.is_empty() { CssValue::Zero } else { parsed[0].clone() }; ( fallback.clone(), fallback.clone(), fallback.clone(), fallback, ) } }; vec![ LonghandDeclaration { property: format!("{prefix}-top"), value: top, important, }, LonghandDeclaration { property: format!("{prefix}-right"), value: right, important, }, LonghandDeclaration { property: format!("{prefix}-bottom"), value: bottom, important, }, LonghandDeclaration { property: format!("{prefix}-left"), value: left, important, }, ] } /// Expand `border` shorthand into border-width, border-style, border-color. fn expand_border(values: &[ComponentValue], important: bool) -> Vec { let parsed: Vec = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace)) .map(parse_single_value) .collect(); let mut width = CssValue::Keyword("medium".to_string()); let mut style = CssValue::None; let mut color = CssValue::CurrentColor; for val in &parsed { match val { CssValue::Length(_, _) | CssValue::Zero => width = val.clone(), CssValue::Color(_) | CssValue::CurrentColor | CssValue::Transparent => { color = val.clone() } CssValue::Keyword(kw) => match kw.as_str() { "thin" | "medium" | "thick" => width = val.clone(), "none" | "hidden" | "dotted" | "dashed" | "solid" | "double" | "groove" | "ridge" | "inset" | "outset" => style = val.clone(), _ => { // Could be a named color if let Some(c) = named_color(kw) { color = CssValue::Color(c); } } }, _ => {} } } vec![ LonghandDeclaration { property: "border-width".to_string(), value: width, important, }, LonghandDeclaration { property: "border-style".to_string(), value: style, important, }, LonghandDeclaration { property: "border-color".to_string(), value: color, important, }, ] } /// Expand `background` shorthand (basic: color only for now). fn expand_background(values: &[ComponentValue], important: bool) -> Vec { let parsed: Vec = values .iter() .filter(|v| !matches!(v, ComponentValue::Whitespace)) .map(parse_single_value) .collect(); let mut bg_color = CssValue::Transparent; for val in &parsed { match val { CssValue::Color(_) | CssValue::CurrentColor | CssValue::Transparent => { bg_color = val.clone() } CssValue::Keyword(kw) => { if let Some(c) = named_color(kw) { bg_color = CssValue::Color(c); } else { match kw.as_str() { "none" => {} // background-image: none _ => bg_color = val.clone(), } } } _ => {} } } vec![LonghandDeclaration { property: "background-color".to_string(), value: bg_color, important, }] } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::tokenizer::{HashType, NumericType}; // -- Length tests -------------------------------------------------------- #[test] fn test_parse_px() { let cv = ComponentValue::Dimension(16.0, NumericType::Integer, "px".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(16.0, LengthUnit::Px) ); } #[test] fn test_parse_em() { let cv = ComponentValue::Dimension(1.5, NumericType::Number, "em".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(1.5, LengthUnit::Em) ); } #[test] fn test_parse_rem() { let cv = ComponentValue::Dimension(2.0, NumericType::Number, "rem".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(2.0, LengthUnit::Rem) ); } #[test] fn test_parse_pt() { let cv = ComponentValue::Dimension(12.0, NumericType::Integer, "pt".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(12.0, LengthUnit::Pt) ); } #[test] fn test_parse_cm() { let cv = ComponentValue::Dimension(2.54, NumericType::Number, "cm".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(2.54, LengthUnit::Cm) ); } #[test] fn test_parse_mm() { let cv = ComponentValue::Dimension(10.0, NumericType::Integer, "mm".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(10.0, LengthUnit::Mm) ); } #[test] fn test_parse_in() { let cv = ComponentValue::Dimension(1.0, NumericType::Integer, "in".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(1.0, LengthUnit::In) ); } #[test] fn test_parse_pc() { let cv = ComponentValue::Dimension(6.0, NumericType::Integer, "pc".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(6.0, LengthUnit::Pc) ); } #[test] fn test_parse_vw() { let cv = ComponentValue::Dimension(50.0, NumericType::Integer, "vw".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(50.0, LengthUnit::Vw) ); } #[test] fn test_parse_vh() { let cv = ComponentValue::Dimension(100.0, NumericType::Integer, "vh".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(100.0, LengthUnit::Vh) ); } #[test] fn test_parse_vmin() { let cv = ComponentValue::Dimension(50.0, NumericType::Integer, "vmin".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(50.0, LengthUnit::Vmin) ); } #[test] fn test_parse_vmax() { let cv = ComponentValue::Dimension(50.0, NumericType::Integer, "vmax".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(50.0, LengthUnit::Vmax) ); } #[test] fn test_parse_percentage() { let cv = ComponentValue::Percentage(50.0); assert_eq!(parse_single_value(&cv), CssValue::Percentage(50.0)); } #[test] fn test_parse_zero() { let cv = ComponentValue::Number(0.0, NumericType::Integer); assert_eq!(parse_single_value(&cv), CssValue::Zero); } #[test] fn test_parse_number() { let cv = ComponentValue::Number(42.0, NumericType::Integer); assert_eq!(parse_single_value(&cv), CssValue::Number(42.0)); } // -- Color tests -------------------------------------------------------- #[test] fn test_hex_color_3() { let cv = ComponentValue::Hash("f00".to_string(), HashType::Id); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(255, 0, 0)) ); } #[test] fn test_hex_color_4() { let cv = ComponentValue::Hash("f00a".to_string(), HashType::Id); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::new(255, 0, 0, 170)) ); } #[test] fn test_hex_color_6() { let cv = ComponentValue::Hash("ff8800".to_string(), HashType::Id); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(255, 136, 0)) ); } #[test] fn test_hex_color_8() { let cv = ComponentValue::Hash("ff880080".to_string(), HashType::Id); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::new(255, 136, 0, 128)) ); } #[test] fn test_named_color_red() { let cv = ComponentValue::Ident("red".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(255, 0, 0)) ); } #[test] fn test_named_color_blue() { let cv = ComponentValue::Ident("blue".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(0, 0, 255)) ); } #[test] fn test_named_color_black() { let cv = ComponentValue::Ident("black".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(0, 0, 0)) ); } #[test] fn test_named_color_white() { let cv = ComponentValue::Ident("white".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(255, 255, 255)) ); } #[test] fn test_transparent() { let cv = ComponentValue::Ident("transparent".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Transparent); } #[test] fn test_current_color() { let cv = ComponentValue::Ident("currentColor".to_string()); assert_eq!(parse_single_value(&cv), CssValue::CurrentColor); } #[test] fn test_rgb_function() { let args = vec![ ComponentValue::Number(255.0, NumericType::Integer), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Number(128.0, NumericType::Integer), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Number(0.0, NumericType::Integer), ]; let cv = ComponentValue::Function("rgb".to_string(), args); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(255, 128, 0)) ); } #[test] fn test_rgba_function() { let args = vec![ ComponentValue::Number(255.0, NumericType::Integer), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Number(0.0, NumericType::Integer), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Number(0.0, NumericType::Integer), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Number(0.5, NumericType::Number), ]; let cv = ComponentValue::Function("rgba".to_string(), args); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::new(255, 0, 0, 128)) ); } // -- Keyword tests ------------------------------------------------------ #[test] fn test_keyword_auto() { let cv = ComponentValue::Ident("auto".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Auto); } #[test] fn test_keyword_inherit() { let cv = ComponentValue::Ident("inherit".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Inherit); } #[test] fn test_keyword_initial() { let cv = ComponentValue::Ident("initial".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Initial); } #[test] fn test_keyword_unset() { let cv = ComponentValue::Ident("unset".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Unset); } #[test] fn test_keyword_none() { let cv = ComponentValue::Ident("none".to_string()); assert_eq!(parse_single_value(&cv), CssValue::None); } #[test] fn test_keyword_display_block() { let cv = ComponentValue::Ident("block".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Keyword("block".to_string()) ); } #[test] fn test_keyword_display_inline() { let cv = ComponentValue::Ident("inline".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Keyword("inline".to_string()) ); } #[test] fn test_keyword_display_flex() { let cv = ComponentValue::Ident("flex".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Keyword("flex".to_string()) ); } #[test] fn test_keyword_position() { for kw in &["static", "relative", "absolute", "fixed"] { let cv = ComponentValue::Ident(kw.to_string()); assert_eq!(parse_single_value(&cv), CssValue::Keyword(kw.to_string())); } } #[test] fn test_keyword_text_align() { for kw in &["left", "center", "right", "justify"] { let cv = ComponentValue::Ident(kw.to_string()); assert_eq!(parse_single_value(&cv), CssValue::Keyword(kw.to_string())); } } #[test] fn test_keyword_font_weight() { let cv = ComponentValue::Ident("bold".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Keyword("bold".to_string()) ); let cv = ComponentValue::Ident("normal".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Keyword("normal".to_string()) ); // Numeric font-weight let cv = ComponentValue::Number(700.0, NumericType::Integer); assert_eq!(parse_single_value(&cv), CssValue::Number(700.0)); } #[test] fn test_keyword_overflow() { for kw in &["visible", "hidden", "scroll", "auto"] { let cv = ComponentValue::Ident(kw.to_string()); let expected = match *kw { "auto" => CssValue::Auto, _ => CssValue::Keyword(kw.to_string()), }; assert_eq!(parse_single_value(&cv), expected); } } // -- parse_value (multi-value) ------------------------------------------ #[test] fn test_parse_value_single() { let values = vec![ComponentValue::Ident("red".to_string())]; assert_eq!(parse_value(&values), CssValue::Color(Color::rgb(255, 0, 0))); } #[test] fn test_parse_value_multi() { let values = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ]; assert_eq!( parse_value(&values), CssValue::List(vec![ CssValue::Length(10.0, LengthUnit::Px), CssValue::Length(20.0, LengthUnit::Px), ]) ); } // -- Shorthand expansion tests ------------------------------------------ #[test] fn test_margin_one_value() { let values = vec![ComponentValue::Dimension( 10.0, NumericType::Integer, "px".to_string(), )]; let result = expand_shorthand("margin", &values, false).unwrap(); assert_eq!(result.len(), 4); for decl in &result { assert_eq!(decl.value, CssValue::Length(10.0, LengthUnit::Px)); } assert_eq!(result[0].property, "margin-top"); assert_eq!(result[1].property, "margin-right"); assert_eq!(result[2].property, "margin-bottom"); assert_eq!(result[3].property, "margin-left"); } #[test] fn test_margin_two_values() { let values = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ]; let result = expand_shorthand("margin", &values, false).unwrap(); assert_eq!(result[0].value, CssValue::Length(10.0, LengthUnit::Px)); // top assert_eq!(result[1].value, CssValue::Length(20.0, LengthUnit::Px)); // right assert_eq!(result[2].value, CssValue::Length(10.0, LengthUnit::Px)); // bottom assert_eq!(result[3].value, CssValue::Length(20.0, LengthUnit::Px)); // left } #[test] fn test_margin_three_values() { let values = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(30.0, NumericType::Integer, "px".to_string()), ]; let result = expand_shorthand("margin", &values, false).unwrap(); assert_eq!(result[0].value, CssValue::Length(10.0, LengthUnit::Px)); // top assert_eq!(result[1].value, CssValue::Length(20.0, LengthUnit::Px)); // right assert_eq!(result[2].value, CssValue::Length(30.0, LengthUnit::Px)); // bottom assert_eq!(result[3].value, CssValue::Length(20.0, LengthUnit::Px)); // left } #[test] fn test_margin_four_values() { let values = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(30.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(40.0, NumericType::Integer, "px".to_string()), ]; let result = expand_shorthand("margin", &values, false).unwrap(); assert_eq!(result[0].value, CssValue::Length(10.0, LengthUnit::Px)); // top assert_eq!(result[1].value, CssValue::Length(20.0, LengthUnit::Px)); // right assert_eq!(result[2].value, CssValue::Length(30.0, LengthUnit::Px)); // bottom assert_eq!(result[3].value, CssValue::Length(40.0, LengthUnit::Px)); // left } #[test] fn test_margin_auto() { let values = vec![ ComponentValue::Number(0.0, NumericType::Integer), ComponentValue::Whitespace, ComponentValue::Ident("auto".to_string()), ]; let result = expand_shorthand("margin", &values, false).unwrap(); assert_eq!(result[0].value, CssValue::Zero); // top assert_eq!(result[1].value, CssValue::Auto); // right assert_eq!(result[2].value, CssValue::Zero); // bottom assert_eq!(result[3].value, CssValue::Auto); // left } #[test] fn test_padding_shorthand() { let values = vec![ComponentValue::Dimension( 5.0, NumericType::Integer, "px".to_string(), )]; let result = expand_shorthand("padding", &values, false).unwrap(); assert_eq!(result.len(), 4); assert_eq!(result[0].property, "padding-top"); assert_eq!(result[1].property, "padding-right"); assert_eq!(result[2].property, "padding-bottom"); assert_eq!(result[3].property, "padding-left"); } #[test] fn test_border_shorthand() { let values = vec![ ComponentValue::Dimension(1.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Ident("solid".to_string()), ComponentValue::Whitespace, ComponentValue::Ident("red".to_string()), ]; let result = expand_shorthand("border", &values, false).unwrap(); assert_eq!(result.len(), 3); assert_eq!(result[0].property, "border-width"); assert_eq!(result[0].value, CssValue::Length(1.0, LengthUnit::Px)); assert_eq!(result[1].property, "border-style"); assert_eq!(result[1].value, CssValue::Keyword("solid".to_string())); assert_eq!(result[2].property, "border-color"); assert_eq!(result[2].value, CssValue::Color(Color::rgb(255, 0, 0))); } #[test] fn test_border_shorthand_defaults() { // Just a width let values = vec![ComponentValue::Dimension( 2.0, NumericType::Integer, "px".to_string(), )]; let result = expand_shorthand("border", &values, false).unwrap(); assert_eq!(result[0].value, CssValue::Length(2.0, LengthUnit::Px)); assert_eq!(result[1].value, CssValue::None); // default style assert_eq!(result[2].value, CssValue::CurrentColor); // default color } #[test] fn test_background_shorthand_color() { let values = vec![ComponentValue::Hash("ff0000".to_string(), HashType::Id)]; let result = expand_shorthand("background", &values, false).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].property, "background-color"); assert_eq!(result[0].value, CssValue::Color(Color::rgb(255, 0, 0))); } #[test] fn test_non_shorthand_returns_none() { let values = vec![ComponentValue::Ident("red".to_string())]; assert!(expand_shorthand("color", &values, false).is_none()); } #[test] fn test_important_propagated() { let values = vec![ComponentValue::Dimension( 10.0, NumericType::Integer, "px".to_string(), )]; let result = expand_shorthand("margin", &values, true).unwrap(); for decl in &result { assert!(decl.important); } } // -- Integration: parse from CSS text ----------------------------------- #[test] fn test_parse_from_css_text() { use crate::parser::Parser; let ss = Parser::parse("p { color: red; margin: 10px 20px; }"); let rule = match &ss.rules[0] { crate::parser::Rule::Style(r) => r, _ => panic!("expected style rule"), }; // color: red let color_val = parse_value(&rule.declarations[0].value); assert_eq!(color_val, CssValue::Color(Color::rgb(255, 0, 0))); // margin: 10px 20px (multi-value) let margin_val = parse_value(&rule.declarations[1].value); assert_eq!( margin_val, CssValue::List(vec![ CssValue::Length(10.0, LengthUnit::Px), CssValue::Length(20.0, LengthUnit::Px), ]) ); } #[test] fn test_shorthand_from_css_text() { use crate::parser::Parser; let ss = Parser::parse("div { margin: 10px 20px 30px 40px; }"); let rule = match &ss.rules[0] { crate::parser::Rule::Style(r) => r, _ => panic!("expected style rule"), }; let longhands = expand_shorthand( &rule.declarations[0].property, &rule.declarations[0].value, rule.declarations[0].important, ) .unwrap(); assert_eq!(longhands[0].property, "margin-top"); assert_eq!(longhands[0].value, CssValue::Length(10.0, LengthUnit::Px)); assert_eq!(longhands[1].property, "margin-right"); assert_eq!(longhands[1].value, CssValue::Length(20.0, LengthUnit::Px)); assert_eq!(longhands[2].property, "margin-bottom"); assert_eq!(longhands[2].value, CssValue::Length(30.0, LengthUnit::Px)); assert_eq!(longhands[3].property, "margin-left"); assert_eq!(longhands[3].value, CssValue::Length(40.0, LengthUnit::Px)); } #[test] fn test_case_insensitive_units() { let cv = ComponentValue::Dimension(16.0, NumericType::Integer, "PX".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Length(16.0, LengthUnit::Px) ); } #[test] fn test_case_insensitive_color_name() { let cv = ComponentValue::Ident("RED".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(255, 0, 0)) ); } #[test] fn test_case_insensitive_keywords() { let cv = ComponentValue::Ident("AUTO".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Auto); let cv = ComponentValue::Ident("INHERIT".to_string()); assert_eq!(parse_single_value(&cv), CssValue::Inherit); } #[test] fn test_named_color_grey_alias() { let cv = ComponentValue::Ident("grey".to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(128, 128, 128)) ); } #[test] fn test_named_color_all_16_plus() { let colors = vec![ ("black", 0, 0, 0), ("silver", 192, 192, 192), ("gray", 128, 128, 128), ("white", 255, 255, 255), ("maroon", 128, 0, 0), ("red", 255, 0, 0), ("purple", 128, 0, 128), ("fuchsia", 255, 0, 255), ("green", 0, 128, 0), ("lime", 0, 255, 0), ("olive", 128, 128, 0), ("yellow", 255, 255, 0), ("navy", 0, 0, 128), ("blue", 0, 0, 255), ("teal", 0, 128, 128), ("aqua", 0, 255, 255), ("orange", 255, 165, 0), ]; for (name, r, g, b) in colors { let cv = ComponentValue::Ident(name.to_string()); assert_eq!( parse_single_value(&cv), CssValue::Color(Color::rgb(r, g, b)), "failed for {name}" ); } } #[test] fn test_border_width_shorthand() { let values = vec![ ComponentValue::Dimension(1.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Dimension(2.0, NumericType::Integer, "px".to_string()), ]; let result = expand_shorthand("border-width", &values, false).unwrap(); assert_eq!(result.len(), 4); assert_eq!(result[0].property, "border-top-width"); assert_eq!(result[1].property, "border-right-width"); } #[test] fn test_border_style_shorthand() { let values = vec![ComponentValue::Ident("solid".to_string())]; let result = expand_shorthand("border-style", &values, false).unwrap(); assert_eq!(result.len(), 4); assert_eq!(result[0].property, "border-top-style"); } #[test] fn test_border_color_shorthand() { let values = vec![ComponentValue::Ident("red".to_string())]; let result = expand_shorthand("border-color", &values, false).unwrap(); assert_eq!(result.len(), 4); assert_eq!(result[0].property, "border-top-color"); } #[test] fn test_string_value() { let cv = ComponentValue::String("hello".to_string()); assert_eq!( parse_single_value(&cv), CssValue::String("hello".to_string()) ); } // -- Math function parsing tests ------------------------------------------ #[test] fn test_calc_simple_px() { let args = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Delim('+'), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Add( Box::new(MathExpr::Length(10.0, LengthUnit::Px)), Box::new(MathExpr::Length(20.0, LengthUnit::Px)), ))) ); } #[test] fn test_calc_percentage_minus_px() { let args = vec![ ComponentValue::Percentage(100.0), ComponentValue::Whitespace, ComponentValue::Delim('-'), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Sub( Box::new(MathExpr::Percentage(100.0)), Box::new(MathExpr::Length(20.0, LengthUnit::Px)), ))) ); } #[test] fn test_calc_multiplication() { // calc(2 * 10px) let args = vec![ ComponentValue::Number(2.0, NumericType::Integer), ComponentValue::Whitespace, ComponentValue::Delim('*'), ComponentValue::Whitespace, ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Mul( Box::new(MathExpr::Number(2.0)), Box::new(MathExpr::Length(10.0, LengthUnit::Px)), ))) ); } #[test] fn test_calc_division() { // calc(100px / 2) let args = vec![ ComponentValue::Dimension(100.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Delim('/'), ComponentValue::Whitespace, ComponentValue::Number(2.0, NumericType::Integer), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Div( Box::new(MathExpr::Length(100.0, LengthUnit::Px)), Box::new(MathExpr::Number(2.0)), ))) ); } #[test] fn test_calc_operator_precedence() { // calc(10px + 2 * 5px) should be Add(10px, Mul(2, 5px)) let args = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Whitespace, ComponentValue::Delim('+'), ComponentValue::Whitespace, ComponentValue::Number(2.0, NumericType::Integer), ComponentValue::Whitespace, ComponentValue::Delim('*'), ComponentValue::Whitespace, ComponentValue::Dimension(5.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Add( Box::new(MathExpr::Length(10.0, LengthUnit::Px)), Box::new(MathExpr::Mul( Box::new(MathExpr::Number(2.0)), Box::new(MathExpr::Length(5.0, LengthUnit::Px)), )), ))) ); } #[test] fn test_min_two_args() { let args = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Dimension(5.0, NumericType::Integer, "vw".to_string()), ]; let cv = ComponentValue::Function("min".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Min(vec![ MathExpr::Length(10.0, LengthUnit::Px), MathExpr::Length(5.0, LengthUnit::Vw), ]))) ); } #[test] fn test_max_two_args() { let args = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Dimension(5.0, NumericType::Integer, "vw".to_string()), ]; let cv = ComponentValue::Function("max".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Max(vec![ MathExpr::Length(10.0, LengthUnit::Px), MathExpr::Length(5.0, LengthUnit::Vw), ]))) ); } #[test] fn test_clamp_three_args() { let args = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Percentage(50.0), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Dimension(200.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("clamp".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Clamp( Box::new(MathExpr::Length(10.0, LengthUnit::Px)), Box::new(MathExpr::Percentage(50.0)), Box::new(MathExpr::Length(200.0, LengthUnit::Px)), ))) ); } #[test] fn test_calc_nested_min() { // calc(min(10px, 5vw) + 20px) let args = vec![ ComponentValue::Function( "min".to_string(), vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Comma, ComponentValue::Whitespace, ComponentValue::Dimension(5.0, NumericType::Integer, "vw".to_string()), ], ), ComponentValue::Whitespace, ComponentValue::Delim('+'), ComponentValue::Whitespace, ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Add( Box::new(MathExpr::Min(vec![ MathExpr::Length(10.0, LengthUnit::Px), MathExpr::Length(5.0, LengthUnit::Vw), ])), Box::new(MathExpr::Length(20.0, LengthUnit::Px)), ))) ); } #[test] fn test_calc_mixed_units() { // calc(50% - 2em) let args = vec![ ComponentValue::Percentage(50.0), ComponentValue::Whitespace, ComponentValue::Delim('-'), ComponentValue::Whitespace, ComponentValue::Dimension(2.0, NumericType::Number, "em".to_string()), ]; let cv = ComponentValue::Function("calc".to_string(), args); let result = parse_single_value(&cv); assert_eq!( result, CssValue::Math(Box::new(MathExpr::Sub( Box::new(MathExpr::Percentage(50.0)), Box::new(MathExpr::Length(2.0, LengthUnit::Em)), ))) ); } #[test] fn test_invalid_calc_empty() { let args = vec![]; let cv = ComponentValue::Function("calc".to_string(), args); assert_eq!( parse_single_value(&cv), CssValue::Keyword("calc()".to_string()) ); } #[test] fn test_invalid_clamp_too_few_args() { let args = vec![ ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), ComponentValue::Comma, ComponentValue::Dimension(50.0, NumericType::Integer, "px".to_string()), ]; let cv = ComponentValue::Function("clamp".to_string(), args); assert_eq!( parse_single_value(&cv), CssValue::Keyword("clamp()".to_string()) ); } #[test] fn test_calc_from_css_text() { use crate::parser::Parser; let ss = Parser::parse("div { width: calc(100% - 20px); }"); let rule = match &ss.rules[0] { crate::parser::Rule::Style(r) => r, _ => panic!("expected style rule"), }; let val = parse_value(&rule.declarations[0].value); assert!(matches!(val, CssValue::Math(_))); } #[test] fn test_min_from_css_text() { use crate::parser::Parser; let ss = Parser::parse("div { width: min(10px, 5vw); }"); let rule = match &ss.rules[0] { crate::parser::Rule::Style(r) => r, _ => panic!("expected style rule"), }; let val = parse_value(&rule.declarations[0].value); assert!(matches!(val, CssValue::Math(_))); } #[test] fn test_clamp_from_css_text() { use crate::parser::Parser; let ss = Parser::parse("div { width: clamp(10px, 50%, 200px); }"); let rule = match &ss.rules[0] { crate::parser::Rule::Style(r) => r, _ => panic!("expected style rule"), }; let val = parse_value(&rule.declarations[0].value); assert!(matches!(val, CssValue::Math(_))); } #[test] fn expand_grid_column_shorthand() { let longhands = expand_shorthand("grid-column", &parse_cv("1 / 3"), false).unwrap(); assert_eq!(longhands.len(), 2); assert_eq!(longhands[0].property, "grid-column-start"); assert!(matches!(longhands[0].value, CssValue::Number(n) if n == 1.0)); assert_eq!(longhands[1].property, "grid-column-end"); assert!(matches!(longhands[1].value, CssValue::Number(n) if n == 3.0)); } #[test] fn expand_grid_column_single_value() { let longhands = expand_shorthand("grid-column", &parse_cv("2"), false).unwrap(); assert_eq!(longhands[0].property, "grid-column-start"); assert!(matches!(longhands[0].value, CssValue::Number(n) if n == 2.0)); assert_eq!(longhands[1].property, "grid-column-end"); assert_eq!(longhands[1].value, CssValue::Auto); } #[test] fn expand_grid_row_shorthand() { let longhands = expand_shorthand("grid-row", &parse_cv("1 / 4"), false).unwrap(); assert_eq!(longhands.len(), 2); assert_eq!(longhands[0].property, "grid-row-start"); assert_eq!(longhands[1].property, "grid-row-end"); } #[test] fn expand_grid_area_shorthand() { let longhands = expand_shorthand("grid-area", &parse_cv("1 / 2 / 3 / 4"), false).unwrap(); assert_eq!(longhands.len(), 4); assert_eq!(longhands[0].property, "grid-row-start"); assert_eq!(longhands[1].property, "grid-column-start"); assert_eq!(longhands[2].property, "grid-row-end"); assert_eq!(longhands[3].property, "grid-column-end"); } /// Helper: parse a CSS value string into component values using the parser. fn parse_cv(input: &str) -> Vec { use crate::parser::Parser; let css = format!("x {{ p: {} }}", input); let ss = Parser::parse(&css); let rule = match &ss.rules[0] { crate::parser::Rule::Style(r) => r, _ => panic!("expected style rule"), }; rule.declarations[0].value.clone() } // -- Canvas color string parsing tests ---------------------------------- #[test] fn canvas_color_hex_rgb() { assert_eq!(parse_color_string("#ff0000"), Some(Color::rgb(255, 0, 0))); } #[test] fn canvas_color_hex_short() { assert_eq!(parse_color_string("#f00"), Some(Color::rgb(255, 0, 0))); } #[test] fn canvas_color_hex_rgba() { assert_eq!( parse_color_string("#ff000080"), Some(Color::new(255, 0, 0, 128)) ); } #[test] fn canvas_color_hex_short_rgba() { assert_eq!( parse_color_string("#f008"), Some(Color::new(255, 0, 0, 136)) ); } #[test] fn canvas_color_named_red() { assert_eq!(parse_color_string("red"), Some(Color::rgb(255, 0, 0))); } #[test] fn canvas_color_named_blue() { assert_eq!(parse_color_string("blue"), Some(Color::rgb(0, 0, 255))); } #[test] fn canvas_color_transparent() { assert_eq!( parse_color_string("transparent"), Some(Color::new(0, 0, 0, 0)) ); } #[test] fn canvas_color_rgb_func() { assert_eq!( parse_color_string("rgb(128, 64, 32)"), Some(Color::rgb(128, 64, 32)) ); } #[test] fn canvas_color_rgba_func() { let c = parse_color_string("rgba(255, 0, 0, 0.5)").unwrap(); assert_eq!(c.r, 255); assert_eq!(c.g, 0); assert_eq!(c.b, 0); assert_eq!(c.a, 128); } #[test] fn canvas_color_invalid_returns_none() { assert_eq!(parse_color_string("notacolor"), None); assert_eq!(parse_color_string(""), None); assert_eq!(parse_color_string("#xyz"), None); } #[test] fn canvas_color_case_insensitive() { assert_eq!(parse_color_string("RED"), Some(Color::rgb(255, 0, 0))); assert_eq!( parse_color_string("Transparent"), Some(Color::new(0, 0, 0, 0)) ); } #[test] fn canvas_color_whitespace_trimmed() { assert_eq!(parse_color_string(" red "), Some(Color::rgb(255, 0, 0))); } }