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);
}
}