AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/css/src/utilities/value.rs

Latest change 69501585a3bd455d335810daf1770edc19a41137 - akurai-css: space + and - inside arbitrary calc/min/max/clamp values by Ólafur Búi Ólafsson

//! Shared value parsers: the spacing scale, fractions, arbitrary `[...]` values
//! and negation. Every family builds its values from these so `p-2`, `w-2` and
//! `top-2` agree on what `2` means.

/// Pixels per spacing step. `p-2` ⇒ `2 * 4 = 8px`.
pub const SPACING_STEP_PX: u32 = 4;

/// A spacing-scale value: `px` ⇒ `1px`, `N` or `N.5` ⇒ `N*4px`, or an arbitrary
/// `[value]`. Integers reject signs and leading zeros (`01`), and the only
/// fractional step is `.5` (`0.5`, `1.5`, …).
pub fn spacing(v: &str) -> Option<String> {
    if v == "px" {
        return Some("1px".into());
    }
    if let Some(px) = scale_px(v) {
        return Some(format!("{px}px"));
    }
    arbitrary(v)
}

/// Scale index → pixels for `N` / `N.5`.
fn scale_px(v: &str) -> Option<u32> {
    let (int, half) = match v.split_once('.') {
        Some((int, "5")) => (int, true),
        Some(_) => return None,
        None => (v, false),
    };
    let n = integer(int)?;
    Some(n * SPACING_STEP_PX + if half { SPACING_STEP_PX / 2 } else { 0 })
}

/// A plain non-negative integer with no sign, leading zeros or other cruft.
pub fn integer(s: &str) -> Option<u32> {
    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    if s.len() > 1 && s.starts_with('0') {
        return None;
    }
    s.parse().ok()
}

/// A positive integer (`1`, `2`, …).
pub fn positive(s: &str) -> Option<u32> {
    integer(s).filter(|&n| n > 0)
}

/// `n/d` ⇒ a percentage (`1/3` ⇒ `33.333333%`), or `full` ⇒ `100%`.
pub fn fraction(v: &str) -> Option<String> {
    if v == "full" {
        return Some("100%".into());
    }
    let (n, d) = v.split_once('/')?;
    let (n, d) = (integer(n)?, positive(d)?);
    Some(format!(
        "{}%",
        trim_float(f64::from(n) * 100.0 / f64::from(d))
    ))
}

/// Format a float with at most 6 decimals and no trailing zeros.
pub fn trim_float(x: f64) -> String {
    let s = format!("{x:.6}");
    let s = s.trim_end_matches('0').trim_end_matches('.');
    s.to_string()
}

/// Spacing or fraction/`full` — the value set shared by inset, basis,
/// translate and the sizing utilities.
pub fn length_or_fraction(v: &str) -> Option<String> {
    spacing(v).or_else(|| fraction(v))
}

/// An arbitrary value `[...]`: brackets stripped, `_` ⇒ space (`\_` keeps a
/// literal underscore). Rejects empty values and anything that could break out
/// of a declaration (`;`, `{`, `}`, unbalanced brackets/parens).
pub fn arbitrary(v: &str) -> Option<String> {
    let inner = v.strip_prefix('[')?.strip_suffix(']')?;
    if inner.is_empty() || !is_safe(inner) {
        return None;
    }
    Some(space_math(&decode_underscores(inner)))
}

/// Inside `calc()`, `min()`, `max()` and `clamp()`, CSS requires spaces
/// around `+` and `-`. Tailwind adds them (`calc(100%-2rem)` ⇒
/// `calc(100% - 2rem)`), so do the same: an operator between an operand end
/// (digit, letter, `%`, `)`) and an operand start (digit, `.`, `(`). Names
/// inside `var()`/`env()` and leading signs (`calc(-1px*2)`) are left alone.
fn space_math(s: &str) -> String {
    if !["calc(", "min(", "max(", "clamp("]
        .iter()
        .any(|f| s.contains(f))
    {
        return s.to_string();
    }
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::with_capacity(s.len() + 8);
    // One entry per open paren: does it allow operator spacing?
    let mut stack: Vec<bool> = Vec::new();
    let mut word = String::new();
    for (i, &c) in chars.iter().enumerate() {
        match c {
            '(' => {
                let math = matches!(word.as_str(), "calc" | "min" | "max" | "clamp")
                    || (word.is_empty() && stack.last().copied().unwrap_or(false));
                stack.push(math);
            }
            ')' => {
                stack.pop();
            }
            '+' | '-' if stack.last().copied().unwrap_or(false) => {
                let prev = i.checked_sub(1).map(|j| chars[j]);
                let next = chars.get(i + 1).copied();
                let ends = prev.is_some_and(|p| p.is_ascii_alphanumeric() || p == '%' || p == ')');
                let starts = next.is_some_and(|n| n.is_ascii_digit() || n == '.' || n == '(');
                if ends && starts {
                    out.push(' ');
                    out.push(c);
                    out.push(' ');
                    word.clear();
                    continue;
                }
            }
            _ => {}
        }
        if c.is_ascii_alphanumeric() || c == '-' {
            word.push(c);
        } else {
            word.clear();
        }
        out.push(c);
    }
    out
}

/// Split an arbitrary value's type hint: `[color:var(--x)]` ⇒
/// `(Some("color"), "var(--x)")`. A hint is a lowercase word before the first
/// `:` (so `[url(http://x)]` is not mistaken for one).
pub fn arbitrary_hinted(v: &str) -> Option<(Option<String>, String)> {
    let value = arbitrary(v)?;
    if let Some((hint, rest)) = value.split_once(':') {
        if !hint.is_empty() && hint.bytes().all(|b| b.is_ascii_lowercase() || b == b'-') {
            if rest.is_empty() {
                return None;
            }
            return Some((Some(hint.to_string()), rest.to_string()));
        }
    }
    Some((None, value))
}

fn is_safe(s: &str) -> bool {
    let mut parens = 0i32;
    let mut brackets = 0i32;
    for b in s.bytes() {
        match b {
            b';' | b'{' | b'}' | b'\n' | b'\r' => return false,
            b'(' => parens += 1,
            b')' => parens -= 1,
            b'[' => brackets += 1,
            b']' => brackets -= 1,
            _ => {}
        }
        if parens < 0 || brackets < 0 {
            return false;
        }
    }
    parens == 0 && brackets == 0
}

fn decode_underscores(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '\\' if chars.peek() == Some(&'_') => {
                out.push('_');
                chars.next();
            }
            '_' => out.push(' '),
            _ => out.push(c),
        }
    }
    out
}

/// True when an arbitrary value is clearly a length/size (`13px`, `1.5rem`,
/// `calc(…)`, …) rather than a colour.
pub fn looks_like_length(v: &str) -> bool {
    let first = v.as_bytes().first().copied().unwrap_or(b' ');
    first.is_ascii_digit()
        || first == b'.'
        || (first == b'-' && v.len() > 1)
        || ["calc(", "min(", "max(", "clamp("]
            .iter()
            .any(|f| v.starts_with(f))
}

/// True when an arbitrary value is clearly a colour (`#123`, `rgb(…)`,
/// `var(--x)`, …).
pub fn looks_like_color(v: &str) -> bool {
    v.starts_with('#')
        || [
            "rgb(",
            "rgba(",
            "hsl(",
            "hsla(",
            "hwb(",
            "lab(",
            "lch(",
            "oklab(",
            "oklch(",
            "color(",
            "color-mix(",
            "light-dark(",
            "var(",
        ]
        .iter()
        .any(|f| v.starts_with(f))
}

/// Negate a value: `8px` ⇒ `-8px`, `50%` ⇒ `-50%`, zero stays zero, anything
/// else becomes `calc(<v> * -1)`. Keywords (`auto`) cannot be negated.
pub fn negate(v: &str) -> Option<String> {
    if v == "auto" || v.chars().all(|c| c.is_ascii_alphabetic() || c == '-') {
        return None;
    }
    if v.starts_with("0px") || v == "0" || v == "0%" {
        return Some(v.to_string());
    }
    let numeric = v
        .trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '%')
        .chars()
        .all(|c| c.is_ascii_digit() || c == '.');
    if numeric {
        Some(format!("-{v}"))
    } else {
        Some(format!("calc({v} * -1)"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn spacing_values() {
        assert_eq!(spacing("0").as_deref(), Some("0px"));
        assert_eq!(spacing("2").as_deref(), Some("8px"));
        assert_eq!(spacing("0.5").as_deref(), Some("2px"));
        assert_eq!(spacing("3.5").as_deref(), Some("14px"));
        assert_eq!(spacing("px").as_deref(), Some("1px"));
        assert_eq!(spacing("[3px]").as_deref(), Some("3px"));
        for bad in ["", "-1", "01", "1.0", "0.25", "x", "[]"] {
            assert_eq!(spacing(bad), None, "{bad}");
        }
    }

    #[test]
    fn fractions() {
        assert_eq!(fraction("1/2").as_deref(), Some("50%"));
        assert_eq!(fraction("1/6").as_deref(), Some("16.666667%"));
        assert_eq!(fraction("full").as_deref(), Some("100%"));
        assert_eq!(fraction("1/0"), None);
        assert_eq!(fraction("a/2"), None);
    }

    #[test]
    fn arbitrary_values() {
        assert_eq!(arbitrary("[1fr_2fr]").as_deref(), Some("1fr 2fr"));
        assert_eq!(arbitrary(r"[a\_b]").as_deref(), Some("a_b"));
        assert_eq!(arbitrary("[a;b]"), None);
        assert_eq!(arbitrary("[a)]"), None);
        assert_eq!(arbitrary("x"), None);
        assert_eq!(
            arbitrary("[calc(100dvh-14rem)]").as_deref(),
            Some("calc(100dvh - 14rem)")
        );
        assert_eq!(
            arbitrary("[calc(100%+(2rem-1px))]").as_deref(),
            Some("calc(100% + (2rem - 1px))")
        );
        assert_eq!(
            arbitrary("[calc(var(--a-1)-1px)]").as_deref(),
            Some("calc(var(--a-1) - 1px)")
        );
        assert_eq!(arbitrary("[calc(-1px*2)]").as_deref(), Some("calc(-1px*2)"));
        assert_eq!(
            arbitrary("[min(10px,_100%-2rem)]").as_deref(),
            Some("min(10px, 100% - 2rem)")
        );
        assert_eq!(arbitrary("[a-1]").as_deref(), Some("a-1"));
        assert_eq!(
            arbitrary_hinted("[color:var(--x)]"),
            Some((Some("color".into()), "var(--x)".into()))
        );
        assert_eq!(
            arbitrary_hinted("[url(http://x)]"),
            Some((None, "url(http://x)".into()))
        );
    }

    #[test]
    fn negation() {
        assert_eq!(negate("8px").as_deref(), Some("-8px"));
        assert_eq!(negate("50%").as_deref(), Some("-50%"));
        assert_eq!(negate("0px").as_deref(), Some("0px"));
        assert_eq!(
            negate("calc(1px + 2px)").as_deref(),
            Some("calc(calc(1px + 2px) * -1)")
        );
        assert_eq!(negate("auto"), None);
    }
}