use crate::cel::time::parse_date;
use crate::named::named;
named! {
pub enum Form {
Length = "length",
Color = "color",
Border = "border",
Tracks = "tracks",
Areas = "areas",
AreaName = "area-name",
Ratio = "ratio",
Language = "language",
Number = "number",
Date = "date",
Array = "array",
Object = "object",
Name = "name",
Text = "text",
Any = "any",
}
}
impl serde::Serialize for Form {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl Form {
pub(crate) fn accepts(self, text: &str) -> bool {
match self {
Self::Length => is_length(text),
Self::Color => is_color(text),
Self::Border => is_border(text),
Self::Tracks => is_tracks(text),
Self::Areas => is_areas(text),
Self::AreaName => is_css_ident(text),
Self::Ratio => is_ratio(text),
Self::Language => is_language(text),
Self::Number => text.parse::<f64>().is_ok_and(f64::is_finite),
Self::Date => parse_date(text).is_some(),
Self::Name => is_identifier(text),
Self::Text | Self::Any => true,
Self::Array | Self::Object => false,
}
}
pub(crate) fn takes_json(self, json: &str) -> bool {
match self {
Self::Array => json.starts_with('['),
Self::Object => json.starts_with('{'),
Self::Any => true,
_ => false,
}
}
#[must_use]
pub fn phrase(self) -> &'static str {
match self {
Self::Length => "a length in `px`, `rem`, `em` or `%`",
Self::Color => "a CSS color",
Self::Border => "a CSS border",
Self::Tracks => "grid tracks",
Self::Areas => "quoted rows of area names, all as long",
Self::AreaName => "an area name",
Self::Ratio => "a width over a height",
Self::Language => "a language tag",
Self::Number => "a number",
Self::Date => "a date (`YYYY-MM-DD`)",
Self::Array => "an array, as JSON",
Self::Object => "an object, as JSON",
Self::Name => "a name, written as is",
Self::Text => "text",
Self::Any => "any value",
}
}
pub(crate) fn has_color_names(self) -> bool {
matches!(self, Self::Color | Self::Border)
}
}
pub(crate) fn is_identifier(text: &str) -> bool {
let mut chars = text.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn number(text: &str) -> Option<f64> {
let digits = text.strip_prefix(['-', '+']).unwrap_or(text);
let plain = !digits.is_empty()
&& digits.bytes().all(|b| b.is_ascii_digit() || b == b'.')
&& digits.bytes().filter(|b| *b == b'.').count() <= 1
&& digits != ".";
plain.then(|| text.parse().ok()).flatten()
}
fn with_unit(text: &str, units: &[&str]) -> bool {
units.iter().any(|unit| {
text.strip_suffix(unit)
.is_some_and(|amount| number(amount).is_some())
})
}
pub(crate) fn is_length(text: &str) -> bool {
text == "0" || with_unit(text, &["px", "rem", "em", "%"])
}
fn is_ratio(text: &str) -> bool {
let positive = |part: &str| number(part.trim()).is_some_and(|n| n > 0.0);
match text.split_once('/') {
Some((width, height)) => positive(width) && positive(height),
None => positive(text),
}
}
fn is_css_ident(text: &str) -> bool {
let mut chars = text.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn is_language(text: &str) -> bool {
let mut parts = text.split('-');
let first = parts.next().unwrap_or("");
(2..=8).contains(&first.len())
&& first.bytes().all(|b| b.is_ascii_alphabetic())
&& parts.all(|part| {
(1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric())
})
}
fn is_areas(text: &str) -> bool {
let mut rows = Vec::new();
let mut rest = text.trim();
while !rest.is_empty() {
let Some(quote) = rest.chars().next().filter(|c| *c == '\'' || *c == '"') else {
return false;
};
let Some(end) = rest[1..].find(quote) else {
return false;
};
rows.push(&rest[1..=end]);
rest = rest[end + 2..].trim_start();
}
let lengths: Vec<usize> = rows
.iter()
.map(|row| {
let cells: Vec<&str> = row.split_whitespace().collect();
if cells.iter().all(|cell| *cell == "." || is_css_ident(cell)) {
cells.len()
} else {
0
}
})
.collect();
lengths
.first()
.is_some_and(|first| *first > 0 && lengths.iter().all(|n| n == first))
}
fn split_top(text: &str, commas: bool) -> Option<Vec<&str>> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0;
for (i, c) in text.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.checked_sub(1)?,
',' if commas && depth == 0 => {
parts.push(text[start..i].trim());
start = i + 1;
}
c if !commas && c.is_whitespace() && depth == 0 => {
if !text[start..i].trim().is_empty() {
parts.push(text[start..i].trim());
}
start = i + 1;
}
_ => {}
}
}
if depth != 0 {
return None;
}
if commas || !text[start..].trim().is_empty() {
parts.push(text[start..].trim());
}
Some(parts)
}
fn function(text: &str) -> Option<(&str, &str)> {
let open = text.find('(')?;
let inner = text.strip_suffix(')')?.get(open + 1..)?;
Some((&text[..open], inner))
}
fn is_tracks(text: &str) -> bool {
split_top(text, false)
.is_some_and(|tracks| !tracks.is_empty() && tracks.iter().all(|track| is_track(track)))
}
fn is_breadth(text: &str) -> bool {
["auto", "min-content", "max-content"].contains(&text)
|| is_length(text)
|| with_unit(text, &["fr"])
}
fn is_track(text: &str) -> bool {
match function(text) {
Some(("minmax", inner)) => split_top(inner, true).is_some_and(
|args| matches!(args[..], [min, max] if is_breadth(min) && is_breadth(max)),
),
Some(("repeat", inner)) => split_top(inner, true).is_some_and(|args| match args[..] {
[count, tracks] => {
let count_ok = count == "auto-fill"
|| count == "auto-fit"
|| count.parse::<u32>().is_ok_and(|n| n > 0);
count_ok && is_tracks(tracks)
}
_ => false,
}),
Some(_) => false,
None => is_breadth(text),
}
}
const BORDER_STYLES: [&str; 10] = [
"none", "hidden", "dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset",
];
fn is_border(text: &str) -> bool {
let Some(parts) = split_top(text, false) else {
return false;
};
let (mut width, mut style, mut color) = (false, false, false);
for part in parts {
let slot = if !width && (["thin", "medium", "thick"].contains(&part) || is_length(part)) {
&mut width
} else if !style && BORDER_STYLES.contains(&part) {
&mut style
} else if !color && is_color(part) {
&mut color
} else {
return false;
};
*slot = true;
}
width || style || color
}
const COLOR_FUNCTIONS: [&str; 10] = [
"rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color",
];
fn is_color(text: &str) -> bool {
if let Some(hex) = text.strip_prefix('#') {
return [3, 4, 6, 8].contains(&hex.len()) && hex.bytes().all(|b| b.is_ascii_hexdigit());
}
match function(text) {
Some(("color-mix", inner)) => is_color_mix(inner),
Some((name, inner)) if COLOR_FUNCTIONS.contains(&name) => is_color_arguments(name, inner),
Some(_) => false,
None => {
let lower = text.to_ascii_lowercase();
lower == "transparent"
|| lower == "currentcolor"
|| NAMED_COLORS.contains(&lower.as_str())
}
}
}
fn is_component(text: &str) -> bool {
text == "none"
|| number(text).is_some()
|| with_unit(text, &["%", "deg", "rad", "grad", "turn"])
}
fn is_color_arguments(name: &str, inner: &str) -> bool {
let (inner, space) = match name {
"color" => {
let Some((space, rest)) = inner.trim().split_once(char::is_whitespace) else {
return false;
};
(rest, Some(space))
}
_ => (inner, None),
};
if space.is_some_and(|space| !is_css_ident(space)) {
return false;
}
if inner.contains(',') {
let legacy = ["rgb", "rgba", "hsl", "hsla"].contains(&name);
return legacy
&& split_top(inner, true).is_some_and(|parts| {
(3..=4).contains(&parts.len()) && parts.iter().all(|part| is_component(part))
});
}
let (channels, alpha) = match inner.split_once('/') {
Some((channels, alpha)) => (channels, Some(alpha.trim())),
None => (inner, None),
};
let channels: Vec<&str> = channels.split_whitespace().collect();
channels.len() == 3
&& channels.iter().all(|channel| is_component(channel))
&& alpha.is_none_or(is_component)
}
fn is_color_mix(inner: &str) -> bool {
let Some(parts) = split_top(inner, true) else {
return false;
};
let [method, first, second] = parts[..] else {
return false;
};
let method: Vec<&str> = method.split_whitespace().collect();
let method_ok = match method[..] {
["in", space] => is_css_ident(space),
["in", space, hue, "hue"] => {
is_css_ident(space) && ["shorter", "longer", "increasing", "decreasing"].contains(&hue)
}
_ => false,
};
let mixed = |part: &str| {
let words = split_top(part, false).unwrap_or_default();
match words[..] {
[color] => is_color(color),
[color, amount] | [amount, color] if with_unit(amount, &["%"]) => is_color(color),
_ => false,
}
};
method_ok && mixed(first) && mixed(second)
}
pub(crate) const NAMED_COLORS: [&str; 148] = [
"aliceblue",
"antiquewhite",
"aqua",
"aquamarine",
"azure",
"beige",
"bisque",
"black",
"blanchedalmond",
"blue",
"blueviolet",
"brown",
"burlywood",
"cadetblue",
"chartreuse",
"chocolate",
"coral",
"cornflowerblue",
"cornsilk",
"crimson",
"cyan",
"darkblue",
"darkcyan",
"darkgoldenrod",
"darkgray",
"darkgreen",
"darkgrey",
"darkkhaki",
"darkmagenta",
"darkolivegreen",
"darkorange",
"darkorchid",
"darkred",
"darksalmon",
"darkseagreen",
"darkslateblue",
"darkslategray",
"darkslategrey",
"darkturquoise",
"darkviolet",
"deeppink",
"deepskyblue",
"dimgray",
"dimgrey",
"dodgerblue",
"firebrick",
"floralwhite",
"forestgreen",
"fuchsia",
"gainsboro",
"ghostwhite",
"gold",
"goldenrod",
"gray",
"green",
"greenyellow",
"grey",
"honeydew",
"hotpink",
"indianred",
"indigo",
"ivory",
"khaki",
"lavender",
"lavenderblush",
"lawngreen",
"lemonchiffon",
"lightblue",
"lightcoral",
"lightcyan",
"lightgoldenrodyellow",
"lightgray",
"lightgreen",
"lightgrey",
"lightpink",
"lightsalmon",
"lightseagreen",
"lightskyblue",
"lightslategray",
"lightslategrey",
"lightsteelblue",
"lightyellow",
"lime",
"limegreen",
"linen",
"magenta",
"maroon",
"mediumaquamarine",
"mediumblue",
"mediumorchid",
"mediumpurple",
"mediumseagreen",
"mediumslateblue",
"mediumspringgreen",
"mediumturquoise",
"mediumvioletred",
"midnightblue",
"mintcream",
"mistyrose",
"moccasin",
"navajowhite",
"navy",
"oldlace",
"olive",
"olivedrab",
"orange",
"orangered",
"orchid",
"palegoldenrod",
"palegreen",
"paleturquoise",
"palevioletred",
"papayawhip",
"peachpuff",
"peru",
"pink",
"plum",
"powderblue",
"purple",
"rebeccapurple",
"red",
"rosybrown",
"royalblue",
"saddlebrown",
"salmon",
"sandybrown",
"seagreen",
"seashell",
"sienna",
"silver",
"skyblue",
"slateblue",
"slategray",
"slategrey",
"snow",
"springgreen",
"steelblue",
"tan",
"teal",
"thistle",
"tomato",
"turquoise",
"violet",
"wheat",
"white",
"whitesmoke",
"yellow",
"yellowgreen",
];
#[cfg(test)]
mod tests {
use super::*;
fn check(form: Form, good: &[&str], bad: &[&str]) {
for value in good {
assert!(form.accepts(value), "{form:?} refused {value}");
}
for value in bad {
assert!(!form.accepts(value), "{form:?} took {value}");
}
}
#[test]
fn lengths_and_ratios() {
check(
Form::Length,
&["0", "16px", "2.5rem", ".5em", "40%", "-8px"],
&["", "px", "12", "1.2.3px", "wide", "1vw"],
);
check(
Form::Ratio,
&["16/9", "1.5", "4 / 3"],
&["0/1", "16:9", "wide"],
);
}
#[test]
fn tracks_and_areas() {
check(
Form::Tracks,
&[
"1fr 2fr",
"12rem 1fr",
"repeat(auto-fill, minmax(12rem, 1fr))",
"repeat(3, 1fr auto)",
"min-content",
],
&[
"",
"repeat(0, 1fr)",
"repeat(3)",
"minmax(1fr)",
"calc(1px)",
"1fr (",
"wide",
],
);
check(
Form::Areas,
&["'head head' 'side main'", "\"a . b\""],
&["", "'head' 'side main'", "head head", "'a", "'a-b! c'"],
);
check(Form::AreaName, &["side", "main_2"], &["2col", "a b"]);
}
#[test]
fn colors_and_borders() {
check(
Form::Color,
&[
"#f5f5f5",
"#abc",
"#abcd",
"#11223344",
"RebeccaPurple",
"transparent",
"currentColor",
"rgb(10 20 30)",
"rgb(10 20 30 / 50%)",
"rgba(10, 20, 30, 0.5)",
"hsl(120deg 50% 50%)",
"oklch(0.7 0.1 200)",
"color(display-p3 1 0 0)",
"color-mix(in srgb, currentColor 6%, transparent)",
"color-mix(in oklch longer hue, red, blue 30%)",
],
&[
"#ggg",
"#12345",
"loud",
"rgb(10 20)",
"oklch(0.7, 0.1, 200)",
"rgb(1 2 3 / x)",
"url(x)",
"var(--x)",
"color-mix(srgb, red, blue)",
"color-mix(in srgb, red, blue, green)",
"color-mix(in srgb, red), (blue)",
"color-mix(in srgb, red wide, blue)",
"color(1 0 0 0)",
"color(display-p3)",
],
);
check(
Form::Border,
&["1px solid", "1px dashed #d0d7de", "thick", "red dotted"],
&[
"thick-dotted",
"1px 2px",
"solid solid",
"",
"1px solid red blue",
"1px (solid",
],
);
}
#[test]
fn languages_numbers_dates_names_and_json() {
check(
Form::Language,
&["en", "de-CH", "zh-Hant-TW"],
&["e", "en_US", "de-", "123"],
);
check(Form::Number, &["3", "-0.5"], &["three", "inf"]);
check(Form::Date, &["2026-10-21"], &["2026-13-01"]);
check(Form::Name, &["t", "row_2"], &["a-b", "2x"]);
check(Form::Text, &["", "anything"], &[]);
check(Form::Array, &[], &["[1]"]);
assert!(Form::Array.takes_json("[1]") && !Form::Array.takes_json("{}"));
assert!(Form::Object.takes_json("{}") && !Form::Object.takes_json("[1]"));
assert!(Form::Any.takes_json("[1]") && !Form::Text.takes_json("[1]"));
}
#[test]
fn each_form_has_a_phrase_and_a_name() {
for form in Form::ALL {
assert_ne!(form.phrase(), "");
assert_eq!(Form::from_name(form.name()), Some(*form));
}
assert!(Form::Border.has_color_names() && !Form::Length.has_color_names());
}
}