omena-scss-eval 0.3.0

Oracle-first SCSS and Less value evaluator rail for Omena CSS
Documentation
use omena_value_lattice::{
    NumericValueV0, parse_numeric_value_with_unit, parse_whole_function_value_arguments,
    split_top_level_whitespace_value_components_owned,
};

use super::less_strings::static_less_quoted_string_contents;
use super::{
    format_static_less_channel_number, format_static_less_math_number, format_static_less_number,
};

pub(super) fn parse_static_less_unit_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "unit")?;
    match arguments.as_slice() {
        [number] => {
            let parsed = parse_numeric_value_with_unit(number.trim())?;
            Some(format_static_less_number(parsed.value))
        }
        [number, unit] => {
            let parsed = parse_numeric_value_with_unit(number.trim())?;
            let unit = parse_static_less_unit_argument(unit.trim())?;
            Some(format!(
                "{}{}",
                format_static_less_number(parsed.value),
                unit
            ))
        }
        _ => None,
    }
}

pub(super) fn parse_static_less_get_unit_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "get-unit")?;
    let [number] = arguments.as_slice() else {
        return None;
    };
    let parsed = parse_numeric_value_with_unit(number.trim())?;
    Some(parsed.unit.to_string())
}

pub(super) fn parse_static_less_convert_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "convert")?;
    let [number, target_unit] = arguments.as_slice() else {
        return None;
    };
    let parsed = parse_numeric_value_with_unit(number.trim())?;
    let target_unit = parse_static_less_convert_unit_argument(target_unit.trim())?;
    let original = || {
        format!(
            "{}{}",
            format_static_less_channel_number(parsed.value),
            parsed.unit
        )
    };
    let Some(source_unit) = static_less_convertible_unit(parsed.unit) else {
        return Some(original());
    };
    let Some(target_unit) = static_less_convertible_unit(target_unit.as_str()) else {
        return Some(original());
    };
    if source_unit.family != target_unit.family {
        return Some(original());
    }
    let converted = parsed.value * source_unit.base_factor / target_unit.base_factor;
    Some(format!(
        "{}{}",
        format_static_less_channel_number(converted),
        target_unit.unit
    ))
}

pub(super) fn parse_static_less_percentage_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "percentage")?;
    let [number] = arguments.as_slice() else {
        return None;
    };
    let parsed = parse_numeric_value_with_unit(number.trim())?;
    Some(format!(
        "{}%",
        format_static_less_number(parsed.value * 100.0)
    ))
}

pub(super) fn parse_static_less_round_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "round")?;
    let (number, decimal_places) = match arguments.as_slice() {
        [number] => (number.as_str(), 0usize),
        [number, decimal_places] => {
            let decimal_places = parse_static_less_unitless_integer(decimal_places.trim())?;
            (number.as_str(), decimal_places)
        }
        _ => return None,
    };
    let parsed = parse_numeric_value_with_unit(number.trim())?;
    let factor = 10_f64.powi(i32::try_from(decimal_places).ok()?);
    let rounded = (parsed.value * factor).round() / factor;
    Some(format!(
        "{}{}",
        format_static_less_number(rounded),
        parsed.unit
    ))
}

pub(super) fn parse_static_less_pi_value(value: &str) -> Option<String> {
    value
        .trim()
        .eq_ignore_ascii_case("pi()")
        .then(|| format_static_less_math_number(std::f64::consts::PI))
        .flatten()
}

pub(super) fn parse_static_less_sin_value(value: &str) -> Option<String> {
    parse_static_less_trig_value(value, "sin", f64::sin)
}

pub(super) fn parse_static_less_cos_value(value: &str) -> Option<String> {
    parse_static_less_trig_value(value, "cos", f64::cos)
}

pub(super) fn parse_static_less_tan_value(value: &str) -> Option<String> {
    parse_static_less_trig_value(value, "tan", f64::tan)
}

pub(super) fn parse_static_less_asin_value(value: &str) -> Option<String> {
    parse_static_less_inverse_trig_value(value, "asin", f64::asin, true)
}

pub(super) fn parse_static_less_acos_value(value: &str) -> Option<String> {
    parse_static_less_inverse_trig_value(value, "acos", f64::acos, true)
}

pub(super) fn parse_static_less_atan_value(value: &str) -> Option<String> {
    parse_static_less_inverse_trig_value(value, "atan", f64::atan, false)
}

fn parse_static_less_trig_value(
    value: &str,
    function_name: &str,
    evaluate: fn(f64) -> f64,
) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, function_name)?;
    let [angle] = arguments.as_slice() else {
        return None;
    };
    format_static_less_math_number(evaluate(parse_static_less_angle_radians(angle.trim())?))
}

fn parse_static_less_inverse_trig_value(
    value: &str,
    function_name: &str,
    evaluate: fn(f64) -> f64,
    requires_unit_interval: bool,
) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, function_name)?;
    let [number] = arguments.as_slice() else {
        return None;
    };
    let parsed = parse_numeric_value_with_unit(number.trim())?;
    if !parsed.unit.is_empty() {
        return None;
    }
    if requires_unit_interval && !(-1.0..=1.0).contains(&parsed.value) {
        return None;
    }
    let radians = evaluate(parsed.value);
    if !radians.is_finite() {
        return None;
    }
    Some(format!("{}rad", format_static_less_math_number(radians)?))
}

fn parse_static_less_angle_radians(value: &str) -> Option<f64> {
    let parsed = parse_numeric_value_with_unit(value)?;
    if !parsed.value.is_finite() {
        return None;
    }
    match parsed.unit {
        "" | "rad" => Some(parsed.value),
        "deg" => Some(parsed.value.to_radians()),
        "grad" => Some(parsed.value * std::f64::consts::PI / 200.0),
        "turn" => Some(parsed.value * std::f64::consts::TAU),
        _ => None,
    }
}

pub(super) fn parse_static_less_length_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "length")?;
    let items = static_less_list_items_from_arguments(arguments.as_slice())?;
    Some(items.len().to_string())
}

pub(super) fn parse_static_less_extract_value(value: &str) -> Option<String> {
    let arguments = parse_whole_function_value_arguments(value, "extract")?;
    if arguments.len() < 2 {
        return None;
    }
    let (index, list_arguments) = arguments.split_last()?;
    let index = parse_static_less_unitless_integer(index.trim())?;
    let items = static_less_list_items_from_arguments(list_arguments)?;
    items.get(index.checked_sub(1)?).cloned()
}

pub(super) fn parse_static_less_range_value(value: &str) -> Option<String> {
    const MAX_STATIC_LESS_RANGE_ITEMS: usize = 1024;

    let arguments = parse_whole_function_value_arguments(value, "range")?;
    let (start, end, step) = match arguments.as_slice() {
        [end] => {
            let end = parse_numeric_value_with_unit(end.trim())?;
            let start = StaticLessRangeEndpoint {
                value: 1.0,
                unit: end.unit,
            };
            let step = StaticLessRangeEndpoint {
                value: 1.0,
                unit: "",
            };
            (start, static_less_range_endpoint_from_numeric(end)?, step)
        }
        [start, end] => {
            let start = static_less_range_endpoint(start.trim())?;
            let end = static_less_range_endpoint(end.trim())?;
            let step = StaticLessRangeEndpoint {
                value: 1.0,
                unit: "",
            };
            (start, end, step)
        }
        [start, end, step] => (
            static_less_range_endpoint(start.trim())?,
            static_less_range_endpoint(end.trim())?,
            static_less_range_endpoint(step.trim())?,
        ),
        _ => return None,
    };

    if step.value <= 0.0 {
        return None;
    }
    if start.value > end.value {
        return Some(String::new());
    }

    let mut items = Vec::new();
    let mut current = start.value;
    while current <= end.value + f64::EPSILON {
        if items.len() >= MAX_STATIC_LESS_RANGE_ITEMS {
            return None;
        }
        items.push(format!(
            "{}{}",
            format_static_less_number(current),
            end.unit
        ));
        current += step.value;
    }
    Some(items.join(" "))
}

#[derive(Debug, Clone, Copy)]
struct StaticLessRangeEndpoint<'a> {
    value: f64,
    unit: &'a str,
}

fn static_less_range_endpoint(value: &str) -> Option<StaticLessRangeEndpoint<'_>> {
    static_less_range_endpoint_from_numeric(parse_numeric_value_with_unit(value)?)
}

fn static_less_range_endpoint_from_numeric(
    parsed: NumericValueV0<'_>,
) -> Option<StaticLessRangeEndpoint<'_>> {
    parsed.value.is_finite().then_some(StaticLessRangeEndpoint {
        value: parsed.value,
        unit: parsed.unit,
    })
}

fn static_less_list_items_from_arguments(arguments: &[String]) -> Option<Vec<String>> {
    if arguments.len() == 1 {
        return split_top_level_whitespace_value_components_owned(arguments[0].as_str())
            .filter(|items| !items.is_empty());
    }
    Some(
        arguments
            .iter()
            .map(|argument| argument.trim().to_string())
            .filter(|argument| !argument.is_empty())
            .collect::<Vec<_>>(),
    )
    .filter(|items| !items.is_empty())
}

fn parse_static_less_unitless_integer(value: &str) -> Option<usize> {
    let parsed = parse_numeric_value_with_unit(value)?;
    if !parsed.unit.is_empty() || !parsed.value.is_finite() || parsed.value.fract() != 0.0 {
        return None;
    }
    usize::try_from(parsed.value as i64).ok()
}

fn parse_static_less_unit_argument(unit: &str) -> Option<&str> {
    if unit == "%" {
        return Some(unit);
    }
    if unit.is_empty()
        || !unit
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
    {
        return None;
    }
    Some(unit)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StaticLessConvertibleUnitFamily {
    Length,
    Time,
    Angle,
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct StaticLessConvertibleUnit {
    family: StaticLessConvertibleUnitFamily,
    unit: &'static str,
    base_factor: f64,
}

fn static_less_convertible_unit(unit: &str) -> Option<StaticLessConvertibleUnit> {
    match unit {
        "px" => Some(static_less_convertible_length_unit("px", 1.0)),
        "in" => Some(static_less_convertible_length_unit("in", 96.0)),
        "cm" => Some(static_less_convertible_length_unit("cm", 96.0 / 2.54)),
        "mm" => Some(static_less_convertible_length_unit("mm", 96.0 / 25.4)),
        "pt" => Some(static_less_convertible_length_unit("pt", 96.0 / 72.0)),
        "pc" => Some(static_less_convertible_length_unit("pc", 16.0)),
        "s" => Some(StaticLessConvertibleUnit {
            family: StaticLessConvertibleUnitFamily::Time,
            unit: "s",
            base_factor: 1.0,
        }),
        "ms" => Some(StaticLessConvertibleUnit {
            family: StaticLessConvertibleUnitFamily::Time,
            unit: "ms",
            base_factor: 0.001,
        }),
        "deg" => Some(static_less_convertible_angle_unit("deg", 1.0)),
        "rad" => Some(static_less_convertible_angle_unit(
            "rad",
            180.0 / std::f64::consts::PI,
        )),
        "grad" => Some(static_less_convertible_angle_unit("grad", 0.9)),
        "turn" => Some(static_less_convertible_angle_unit("turn", 360.0)),
        _ => None,
    }
}

fn static_less_convertible_length_unit(
    unit: &'static str,
    base_factor: f64,
) -> StaticLessConvertibleUnit {
    StaticLessConvertibleUnit {
        family: StaticLessConvertibleUnitFamily::Length,
        unit,
        base_factor,
    }
}

fn static_less_convertible_angle_unit(
    unit: &'static str,
    base_factor: f64,
) -> StaticLessConvertibleUnit {
    StaticLessConvertibleUnit {
        family: StaticLessConvertibleUnitFamily::Angle,
        unit,
        base_factor,
    }
}

fn parse_static_less_convert_unit_argument(unit: &str) -> Option<String> {
    static_less_quoted_string_contents(unit).or_else(|| {
        parse_static_less_unit_argument(unit)
            .map(str::to_string)
            .filter(|unit| unit != "%")
    })
}