use crate::css_parser::ast::{ComponentValue, CssFunction};
use crate::css_parser::token::{Token, TokenKind};
use crate::css_values::types::length::{
AngleUnit, CalcExpr, CalcValue, LengthUnit, NumericConstant, RoundStrategy,
};
#[derive(Debug)]
pub enum CalcParseError {
Empty,
UnexpectedToken(String),
UnknownFunction(String),
WrongArity {
name: String,
expected: &'static str,
got: usize,
},
InvalidUnit(String),
}
impl std::fmt::Display for CalcParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "empty calc() arguments"),
Self::UnexpectedToken(s) => write!(f, "unexpected token: {s}"),
Self::UnknownFunction(s) => write!(f, "unknown math function: {s}"),
Self::WrongArity {
name,
expected,
got,
} => {
write!(f, "{name}() arity: expected {expected}, got {got}")
}
Self::InvalidUnit(s) => write!(f, "unknown unit: {s}"),
}
}
}
impl std::error::Error for CalcParseError {}
pub fn parse_math_function(f: &CssFunction<'_>) -> Result<Option<CalcExpr>, CalcParseError> {
let name = f.name.to_ascii_lowercase();
match name.as_str() {
"calc" => Ok(Some(parse_sum(&filter_ws(&f.arguments))?)),
"min" | "max" | "hypot" => {
let parts = split_top_level_commas(&f.arguments);
if parts.is_empty() {
return Err(CalcParseError::Empty);
}
let exprs = parts
.into_iter()
.map(|p| parse_sum(&filter_ws(p)))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(match name.as_str() {
"min" => CalcExpr::Min(exprs),
"max" => CalcExpr::Max(exprs),
"hypot" => CalcExpr::Hypot(exprs),
_ => unreachable!(),
}))
}
"clamp" => {
let parts = split_top_level_commas(&f.arguments);
if parts.len() != 3 {
return Err(CalcParseError::WrongArity {
name: "clamp".into(),
expected: "3 (min, val, max)",
got: parts.len(),
});
}
Ok(Some(CalcExpr::Clamp {
min: Box::new(parse_sum(&filter_ws(parts[0]))?),
preferred: Box::new(parse_sum(&filter_ws(parts[1]))?),
max: Box::new(parse_sum(&filter_ws(parts[2]))?),
}))
}
"sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "sqrt" | "exp" | "abs" | "sign" => {
let parts = split_top_level_commas(&f.arguments);
if parts.len() != 1 {
return Err(CalcParseError::WrongArity {
name,
expected: "1",
got: parts.len(),
});
}
let inner = Box::new(parse_sum(&filter_ws(parts[0]))?);
Ok(Some(match name.as_str() {
"sin" => CalcExpr::Sin(inner),
"cos" => CalcExpr::Cos(inner),
"tan" => CalcExpr::Tan(inner),
"asin" => CalcExpr::Asin(inner),
"acos" => CalcExpr::Acos(inner),
"atan" => CalcExpr::Atan(inner),
"sqrt" => CalcExpr::Sqrt(inner),
"exp" => CalcExpr::Exp(inner),
"abs" => CalcExpr::Abs(inner),
"sign" => CalcExpr::Sign(inner),
_ => unreachable!(),
}))
}
"atan2" | "pow" | "mod" | "rem" => {
let parts = split_top_level_commas(&f.arguments);
if parts.len() != 2 {
return Err(CalcParseError::WrongArity {
name,
expected: "2",
got: parts.len(),
});
}
let a = Box::new(parse_sum(&filter_ws(parts[0]))?);
let b = Box::new(parse_sum(&filter_ws(parts[1]))?);
Ok(Some(match name.as_str() {
"atan2" => CalcExpr::Atan2(a, b),
"pow" => CalcExpr::Pow(a, b),
"mod" => CalcExpr::Mod(a, b),
"rem" => CalcExpr::Rem(a, b),
_ => unreachable!(),
}))
}
"log" => {
let parts = split_top_level_commas(&f.arguments);
match parts.len() {
1 => Ok(Some(CalcExpr::Log {
value: Box::new(parse_sum(&filter_ws(parts[0]))?),
base: None,
})),
2 => Ok(Some(CalcExpr::Log {
value: Box::new(parse_sum(&filter_ws(parts[0]))?),
base: Some(Box::new(parse_sum(&filter_ws(parts[1]))?)),
})),
got => Err(CalcParseError::WrongArity {
name: "log".into(),
expected: "1 or 2",
got,
}),
}
}
"round" => {
let parts = split_top_level_commas(&f.arguments);
let (strategy, value_idx) = if let Some(first) = parts.first() {
let toks = filter_ws(first);
if toks.len() == 1 {
if let ComponentValue::Token(Token {
kind: TokenKind::Ident(id),
..
}) = &toks[0]
{
match id.to_ascii_lowercase().as_str() {
"nearest" => (RoundStrategy::Nearest, 1),
"up" => (RoundStrategy::Up, 1),
"down" => (RoundStrategy::Down, 1),
"to-zero" => (RoundStrategy::ToZero, 1),
_ => (RoundStrategy::Nearest, 0),
}
} else {
(RoundStrategy::Nearest, 0)
}
} else {
(RoundStrategy::Nearest, 0)
}
} else {
return Err(CalcParseError::Empty);
};
let value = Box::new(parse_sum(&filter_ws(parts[value_idx]))?);
let step: Box<CalcExpr> = if parts.len() > value_idx + 1 {
Box::new(parse_sum(&filter_ws(parts[value_idx + 1]))?)
} else {
Box::new(CalcExpr::Value(CalcValue::Number(1.0)))
};
Ok(Some(CalcExpr::Round(strategy, value, step)))
}
_ => Ok(None),
}
}
fn filter_ws<'a>(tokens: &'a [ComponentValue<'a>]) -> Vec<&'a ComponentValue<'a>> {
tokens
.iter()
.filter(|cv| {
!matches!(
cv,
ComponentValue::Token(Token {
kind: TokenKind::Whitespace,
..
})
)
})
.collect()
}
fn split_top_level_commas<'a>(tokens: &'a [ComponentValue<'a>]) -> Vec<&'a [ComponentValue<'a>]> {
let mut out = Vec::new();
let mut start = 0usize;
for (i, cv) in tokens.iter().enumerate() {
if matches!(
cv,
ComponentValue::Token(Token {
kind: TokenKind::Comma,
..
})
) {
out.push(&tokens[start..i]);
start = i + 1;
}
}
if start <= tokens.len() {
let tail = &tokens[start..];
if !tail.iter().all(|cv| {
matches!(
cv,
ComponentValue::Token(Token {
kind: TokenKind::Whitespace,
..
})
)
}) {
out.push(tail);
}
}
out
}
fn parse_sum<'a>(tokens: &[&'a ComponentValue<'a>]) -> Result<CalcExpr, CalcParseError> {
if tokens.is_empty() {
return Err(CalcParseError::Empty);
}
let mut pos = 0usize;
let mut left = parse_product(tokens, &mut pos)?;
while pos < tokens.len() {
match tokens[pos] {
ComponentValue::Token(Token {
kind: TokenKind::Delim('+'),
..
}) => {
pos += 1;
let right = parse_product(tokens, &mut pos)?;
left = CalcExpr::Add(Box::new(left), Box::new(right));
}
ComponentValue::Token(Token {
kind: TokenKind::Delim('-'),
..
}) => {
pos += 1;
let right = parse_product(tokens, &mut pos)?;
left = CalcExpr::Sub(Box::new(left), Box::new(right));
}
other => {
return Err(CalcParseError::UnexpectedToken(format!("{:?}", other)));
}
}
}
Ok(left)
}
fn parse_product<'a>(
tokens: &[&'a ComponentValue<'a>],
pos: &mut usize,
) -> Result<CalcExpr, CalcParseError> {
let mut left = parse_unary(tokens, pos)?;
while *pos < tokens.len() {
match tokens[*pos] {
ComponentValue::Token(Token {
kind: TokenKind::Delim('*'),
..
}) => {
*pos += 1;
let right = parse_unary(tokens, pos)?;
left = CalcExpr::Mul(Box::new(left), Box::new(right));
}
ComponentValue::Token(Token {
kind: TokenKind::Delim('/'),
..
}) => {
*pos += 1;
let right = parse_unary(tokens, pos)?;
left = CalcExpr::Div(Box::new(left), Box::new(right));
}
_ => break,
}
}
Ok(left)
}
fn parse_unary<'a>(
tokens: &[&'a ComponentValue<'a>],
pos: &mut usize,
) -> Result<CalcExpr, CalcParseError> {
if *pos >= tokens.len() {
return Err(CalcParseError::Empty);
}
if let ComponentValue::Token(Token {
kind: TokenKind::Delim('-'),
..
}) = tokens[*pos]
{
*pos += 1;
let inner = parse_unary(tokens, pos)?;
return Ok(CalcExpr::Negate(Box::new(inner)));
}
if let ComponentValue::Token(Token {
kind: TokenKind::Delim('+'),
..
}) = tokens[*pos]
{
*pos += 1;
return parse_unary(tokens, pos);
}
parse_atom(tokens, pos)
}
fn parse_atom<'a>(
tokens: &[&'a ComponentValue<'a>],
pos: &mut usize,
) -> Result<CalcExpr, CalcParseError> {
let cv = tokens[*pos];
*pos += 1;
match cv {
ComponentValue::Token(t) => match &t.kind {
TokenKind::Number { value, .. } => Ok(CalcExpr::Value(CalcValue::Number(*value))),
TokenKind::Percentage { value, .. } => {
Ok(CalcExpr::Value(CalcValue::Percentage(*value)))
}
TokenKind::Dimension { value, unit, .. } => {
if let Some(u) = parse_length_unit(unit) {
Ok(CalcExpr::Value(CalcValue::Length(*value, u)))
} else if let Some(u) = parse_angle_unit(unit) {
Ok(CalcExpr::Value(CalcValue::Angle(*value, u)))
} else {
Err(CalcParseError::InvalidUnit((*unit).to_string()))
}
}
TokenKind::Ident(id) => match id.to_ascii_lowercase().as_str() {
"pi" => Ok(CalcExpr::Value(CalcValue::Constant(NumericConstant::Pi))),
"e" => Ok(CalcExpr::Value(CalcValue::Constant(NumericConstant::E))),
"infinity" => Ok(CalcExpr::Value(CalcValue::Constant(
NumericConstant::Infinity,
))),
"-infinity" => Ok(CalcExpr::Value(CalcValue::Constant(
NumericConstant::NegInfinity,
))),
"nan" => Ok(CalcExpr::Value(CalcValue::Constant(NumericConstant::NaN))),
other => Err(CalcParseError::UnexpectedToken(format!("ident `{other}`"))),
},
other => Err(CalcParseError::UnexpectedToken(format!("{:?}", other))),
},
ComponentValue::SimpleBlock(b) if b.token == '(' => {
let inner = filter_ws(&b.value);
parse_sum(&inner)
}
ComponentValue::Function(inner_fn) => {
match parse_math_function(inner_fn)? {
Some(expr) => Ok(expr),
None => Err(CalcParseError::UnknownFunction(inner_fn.name.to_string())),
}
}
other => Err(CalcParseError::UnexpectedToken(format!("{:?}", other))),
}
}
fn parse_length_unit(unit: &str) -> Option<LengthUnit> {
Some(match unit.to_ascii_lowercase().as_str() {
"px" => LengthUnit::Px,
"em" => LengthUnit::Em,
"rem" => LengthUnit::Rem,
"vw" => LengthUnit::Vw,
"vh" => LengthUnit::Vh,
"vmin" => LengthUnit::Vmin,
"vmax" => LengthUnit::Vmax,
"cm" => LengthUnit::Cm,
"mm" => LengthUnit::Mm,
"in" => LengthUnit::In,
"pt" => LengthUnit::Pt,
"pc" => LengthUnit::Pc,
"ch" => LengthUnit::Ch,
"ex" => LengthUnit::Ex,
"cqw" => LengthUnit::Cqw,
"cqh" => LengthUnit::Cqh,
_ => return None,
})
}
fn parse_angle_unit(unit: &str) -> Option<AngleUnit> {
Some(match unit.to_ascii_lowercase().as_str() {
"deg" => AngleUnit::Deg,
"rad" => AngleUnit::Rad,
"grad" => AngleUnit::Grad,
"turn" => AngleUnit::Turn,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::css_values::types::length::CalcContext;
fn parse_calc(src: &str) -> CalcExpr {
let css = format!("width: {src};");
let (decls, _errs) = crate::css_parser::parse_declaration_list(&css);
let decl = decls.first().expect("at least one decl parsed");
for c in &decl.value {
if let ComponentValue::Function(f) = c {
let parsed = parse_math_function(f).expect("parse ok");
return parsed.expect("math fn recognised");
}
}
panic!("no function in {src}");
}
fn approx(a: f64, b: f64) {
assert!((a - b).abs() < 1e-9, "{a} ≉ {b}");
}
#[test]
fn calc_basic_arithmetic() {
let ctx = CalcContext::default();
approx(parse_calc("calc(1 + 2 * 3)").evaluate(&ctx), 7.0);
approx(parse_calc("calc((1 + 2) * 3)").evaluate(&ctx), 9.0);
approx(parse_calc("calc(10 - 4 - 1)").evaluate(&ctx), 5.0);
approx(parse_calc("calc(20 / 4 / 5)").evaluate(&ctx), 1.0);
}
#[test]
fn calc_with_lengths() {
let ctx = CalcContext::default();
approx(parse_calc("calc(10px + 5px)").evaluate(&ctx), 15.0);
approx(parse_calc("calc(2 * 8px)").evaluate(&ctx), 16.0);
}
#[test]
fn min_max_clamp() {
let ctx = CalcContext::default();
approx(parse_calc("min(10, 5, 7)").evaluate(&ctx), 5.0);
approx(parse_calc("max(10, 5, 7)").evaluate(&ctx), 10.0);
approx(parse_calc("clamp(0, 99, 10)").evaluate(&ctx), 10.0);
approx(parse_calc("clamp(0, 5, 10)").evaluate(&ctx), 5.0);
}
#[test]
fn trig_functions() {
let ctx = CalcContext::default();
approx(parse_calc("cos(0)").evaluate(&ctx), 1.0);
approx(parse_calc("sin(0)").evaluate(&ctx), 0.0);
approx(parse_calc("tan(0)").evaluate(&ctx), 0.0);
approx(parse_calc("sin(pi)").evaluate(&ctx), 0.0); approx(parse_calc("cos(pi)").evaluate(&ctx), -1.0);
approx(
parse_calc("atan2(1, 1)").evaluate(&ctx),
std::f64::consts::FRAC_PI_4,
);
}
#[test]
fn power_log_exp() {
let ctx = CalcContext::default();
approx(parse_calc("pow(2, 10)").evaluate(&ctx), 1024.0);
approx(parse_calc("sqrt(81)").evaluate(&ctx), 9.0);
approx(parse_calc("hypot(3, 4)").evaluate(&ctx), 5.0);
approx(parse_calc("hypot(3, 4, 12)").evaluate(&ctx), 13.0);
approx(parse_calc("log(e)").evaluate(&ctx), 1.0);
approx(parse_calc("log(100, 10)").evaluate(&ctx), 2.0);
approx(parse_calc("exp(0)").evaluate(&ctx), 1.0);
}
#[test]
fn round_strategies_via_parser() {
let ctx = CalcContext::default();
approx(parse_calc("round(up, 1.1, 1)").evaluate(&ctx), 2.0);
approx(parse_calc("round(down, 1.9, 1)").evaluate(&ctx), 1.0);
approx(parse_calc("round(to-zero, -1.9, 1)").evaluate(&ctx), -1.0);
approx(parse_calc("round(2.5)").evaluate(&ctx), 2.0); }
#[test]
#[allow(
clippy::approx_constant,
reason = "2.71828 is test input, not std::f64::consts::E"
)]
fn nested_calc_with_trig_and_constants() {
let ctx = CalcContext::default();
let v = parse_calc("calc(1px * (2.71828 * 0.5 + sin(pi / 2)))").evaluate(&ctx);
approx(v, 1.0 * (2.71828 * 0.5 + 1.0));
}
#[test]
fn unary_negation() {
let ctx = CalcContext::default();
approx(parse_calc("calc(-5)").evaluate(&ctx), -5.0);
approx(parse_calc("calc(0 - -5)").evaluate(&ctx), 5.0);
}
#[test]
fn angle_units_in_trig() {
let ctx = CalcContext::default();
approx(parse_calc("sin(90deg)").evaluate(&ctx), 1.0);
approx(parse_calc("cos(180deg)").evaluate(&ctx), -1.0);
approx(parse_calc("sin(0.25turn)").evaluate(&ctx), 1.0);
}
}
use crate::css_values::types::length::CalcContext;
pub fn resolve_computed_value(value: &str, ctx: &CalcContext) -> String {
let trimmed = value.trim();
if !looks_like_math_function(trimmed) {
return value.to_string();
}
let css = format!("__:{};", trimmed);
let (decls, _errs) = crate::css_parser::parse_declaration_list(&css);
let Some(decl) = decls.first() else {
return value.to_string();
};
let non_ws: Vec<&ComponentValue<'_>> = decl
.value
.iter()
.filter(|cv| {
!matches!(
cv,
ComponentValue::Token(Token {
kind: TokenKind::Whitespace,
..
})
)
})
.collect();
if non_ws.len() != 1 {
return value.to_string();
}
if let ComponentValue::Function(f) = non_ws[0] {
match parse_math_function(f) {
Ok(Some(expr)) => {
let v = expr.evaluate(ctx);
return format_resolved_value(v, trimmed);
}
_ => return value.to_string(),
}
}
value.to_string()
}
fn looks_like_math_function(s: &str) -> bool {
const NAMES: &[&str] = &[
"calc(", "min(", "max(", "clamp(", "round(", "mod(", "rem(", "sin(", "cos(", "tan(",
"asin(", "acos(", "atan(", "atan2(", "pow(", "sqrt(", "hypot(", "log(", "exp(", "abs(",
"sign(",
];
let lower = s.to_ascii_lowercase();
NAMES.iter().any(|n| lower.starts_with(n))
}
fn format_resolved_value(v: f64, original: &str) -> String {
let unit = guess_output_unit(original);
if v.fract() == 0.0 && v.is_finite() {
format!("{}{}", v as i64, unit)
} else {
let formatted = format!("{:.6}", v);
let trimmed = formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string();
format!("{trimmed}{unit}")
}
}
fn guess_output_unit(original: &str) -> &'static str {
let css = format!("__:{};", original);
let (decls, _) = crate::css_parser::parse_declaration_list(&css);
let Some(decl) = decls.first() else {
return "";
};
fn has_dim(values: &[ComponentValue<'_>]) -> bool {
for cv in values {
match cv {
ComponentValue::Token(Token {
kind: TokenKind::Dimension { .. },
..
}) => {
return true;
}
ComponentValue::Function(f) if has_dim(&f.arguments) => {
return true;
}
ComponentValue::SimpleBlock(b) if has_dim(&b.value) => {
return true;
}
_ => {}
}
}
false
}
if has_dim(&decl.value) {
"px"
} else {
""
}
}
#[cfg(test)]
mod resolve_tests {
use super::*;
fn ctx() -> CalcContext {
CalcContext::default()
}
#[test]
fn passes_through_plain_values() {
assert_eq!(resolve_computed_value("12px", &ctx()), "12px");
assert_eq!(resolve_computed_value("auto", &ctx()), "auto");
assert_eq!(resolve_computed_value("rgb(0,0,0)", &ctx()), "rgb(0,0,0)");
}
#[test]
fn resolves_basic_calc() {
assert_eq!(resolve_computed_value("calc(10px + 5px)", &ctx()), "15px");
assert_eq!(resolve_computed_value("calc(2 * 8px)", &ctx()), "16px");
}
#[test]
fn resolves_math_functions() {
assert_eq!(resolve_computed_value("min(10px, 5px)", &ctx()), "5px");
assert_eq!(resolve_computed_value("max(10px, 5px)", &ctx()), "10px");
assert_eq!(resolve_computed_value("sin(0)", &ctx()), "0");
}
#[test]
fn resolves_challenge_style_nested() {
let v = resolve_computed_value("calc(1px * (2.71828 * 0.5 + sin(pi / 2)))", &ctx());
assert_eq!(v, "2.35914px");
}
#[test]
fn integer_results_serialize_without_decimals() {
assert_eq!(resolve_computed_value("calc(2px + 3px)", &ctx()), "5px");
assert_eq!(
resolve_computed_value("pow(2, 5) * 1px", &ctx()),
"pow(2, 5) * 1px"
);
}
}