diff --git a/crates/css/src/lib.rs b/crates/css/src/lib.rs index 9e48e7c..3cc283e 100644 --- a/crates/css/src/lib.rs +++ b/crates/css/src/lib.rs @@ -2,3 +2,4 @@ pub mod parser; pub mod tokenizer; +pub mod values; diff --git a/crates/css/src/values.rs b/crates/css/src/values.rs new file mode 100644 index 0000000..d85220c --- /dev/null +++ b/crates/css/src/values.rs @@ -0,0 +1,1236 @@ +//! 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), +} + +/// 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), + _ => CssValue::Keyword(format!("{name}()")), + } +} + +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) +// --------------------------------------------------------------------------- + +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, + }) +} + +// --------------------------------------------------------------------------- +// 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)), + _ => Option::None, + } +} + +/// 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()) + ); + } +}