use crate::chart::CHART_CLASSES;
use crate::facet::FACET_CLASSES;
use crate::figure::FIGURE_CLASSES;
use crate::form::{FIELD_CLASSES, FIELD_STATE_CLASSES};
use crate::list::{
CELL_DROP_CLASSES, CELL_PART_CLASSES, CELL_WIDTH_CLASSES, FLOW_CLASSES, NESTING_CLASSES,
ROW_PART_CLASSES,
};
use crate::meter::METER_CLASSES;
use crate::placeholder::PLACEHOLDER_CLASSES;
use crate::{Emit, option_class};
use makeover_layout::Selector;
use std::collections::{BTreeMap, BTreeSet};
#[must_use]
pub fn vocabulary(opts: &Emit) -> BTreeSet<String> {
classes_in_css(&crate::stylesheet(opts))
}
#[must_use]
pub fn names(opts: &Emit) -> BTreeSet<String> {
let mut all = vocabulary(opts);
all.extend(
ROW_PART_CLASSES
.iter()
.chain(CELL_PART_CLASSES)
.chain(CELL_WIDTH_CLASSES)
.chain(CELL_DROP_CLASSES)
.chain(FLOW_CLASSES)
.chain(NESTING_CLASSES)
.chain(crate::RUN_CLASSES)
.chain(FACET_CLASSES)
.chain(FIELD_CLASSES)
.chain(FIGURE_CLASSES)
.chain(METER_CLASSES)
.chain(CHART_CLASSES)
.chain(PLACEHOLDER_CLASSES)
.map(|name| crate::class(name, opts)),
);
all.extend(
[Selector::Tabs, Selector::Segmented, Selector::Toggle]
.into_iter()
.map(|s| crate::class(option_class(s), opts)),
);
all.extend(FIELD_STATE_CLASSES.iter().map(|name| (*name).to_owned()));
all
}
#[must_use]
pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
by_class(css, |value| !is_handoff(value))
}
#[must_use]
pub fn deferrals_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
by_class(css, is_handoff)
}
fn by_class(css: &str, keep: impl Fn(&str) -> bool) -> BTreeMap<String, BTreeSet<String>> {
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (selector, body) in rules(css) {
let classes = classes_matched_by(&selector);
if classes.is_empty() {
continue;
}
let properties = properties_in_body(&body, &keep);
if properties.is_empty() {
continue;
}
for class in classes {
out.entry(class).or_default().extend(properties.clone());
}
}
out
}
#[must_use]
pub fn declarations_by_element(css: &str) -> BTreeMap<String, BTreeMap<String, Specificity>> {
let mut out: BTreeMap<String, BTreeMap<String, Specificity>> = BTreeMap::new();
for (selector, body) in rules(css) {
let properties = properties_in_body(&body, |value| !is_handoff(value));
if properties.is_empty() {
continue;
}
for arm in selector_arms(&selector) {
let Some(element) = bare_element(arm) else {
continue;
};
let rank = specificity(arm);
let entry = out.entry(element).or_default();
for property in &properties {
let strongest = entry.entry(property.clone()).or_default();
*strongest = (*strongest).max(rank);
}
}
}
out
}
pub fn selector_arms(selector: &str) -> Vec<&str> {
let mut arms = Vec::new();
let mut depth = 0usize;
let mut quote: Option<char> = None;
let mut start = 0;
for (i, c) in selector.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '(' | '[') => depth += 1,
(None, ')' | ']') => depth = depth.saturating_sub(1),
(None, ',') if depth == 0 => {
arms.push(&selector[start..i]);
start = i + 1;
}
_ => {}
}
}
arms.push(&selector[start..]);
arms
}
#[must_use]
pub fn mentions_by_class(css: &str) -> BTreeMap<String, BTreeMap<String, Specificity>> {
let mut out: BTreeMap<String, BTreeMap<String, Specificity>> = BTreeMap::new();
for (selector, body) in rules(css) {
let properties = properties_in_body(&body, |_| true);
if properties.is_empty() {
continue;
}
for arm in selector_arms(&selector) {
let classes = classes_matched_by(arm);
if classes.is_empty() {
continue;
}
let rank = specificity(arm);
for class in classes {
let entry = out.entry(class).or_default();
for property in &properties {
let strongest = entry.entry(property.clone()).or_default();
*strongest = (*strongest).max(rank);
}
}
}
}
out
}
#[must_use]
pub fn bare_declarations(css: &str) -> BTreeMap<String, BTreeSet<String>> {
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (selector, body) in rules(css) {
let properties = properties_in_body(&body, |value| !is_handoff(value));
if properties.is_empty() {
continue;
}
for arm in selector_arms(&selector) {
if let Some(class) = bare_class(arm) {
out.entry(class)
.or_default()
.extend(properties.iter().cloned());
}
}
}
out
}
fn bare_class(arm: &str) -> Option<String> {
let name = arm.trim().strip_prefix('.')?;
let is_ident = name.starts_with(|c: char| c.is_alphabetic() || c == '_')
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
is_ident.then(|| name.to_string())
}
#[must_use]
pub fn variants_by_class(css: &str) -> BTreeMap<String, BTreeMap<String, BTreeSet<String>>> {
let mut out: BTreeMap<String, BTreeMap<String, BTreeSet<String>>> = BTreeMap::new();
for (selector, body) in rules(css) {
let properties = properties_in_body(&body, |value| !is_handoff(value));
if properties.is_empty() {
continue;
}
for arm in selector_arms(&selector) {
if arm.contains("::") || bare_class(arm).is_some() {
continue;
}
let spelled = expand_is(&normalize_arm(arm));
for class in classes_matched_by(arm) {
let arms = out.entry(class).or_default();
for alternative in &spelled {
arms.entry(alternative.clone())
.or_default()
.extend(properties.iter().cloned());
}
}
}
}
out
}
#[must_use]
pub fn mentions_by_arm(css: &str) -> BTreeMap<String, BTreeSet<String>> {
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (selector, body) in rules(css) {
let properties = properties_in_body(&body, |_| true);
if properties.is_empty() {
continue;
}
for arm in selector_arms(&selector) {
for alternative in expand_is(&normalize_arm(arm)) {
out.entry(alternative)
.or_default()
.extend(properties.iter().cloned());
}
}
}
out
}
#[must_use]
pub fn expand_is(original: &str) -> Vec<String> {
const LIMIT: usize = 64;
let mut done = Vec::new();
let mut pending = vec![original.to_string()];
while let Some(arm) = pending.pop() {
let Some((start, end)) = top_level_is(&arm) else {
done.push(arm);
continue;
};
let inner = &arm[start + ":is(".len()..end];
for alternative in selector_arms(inner) {
pending.push(format!(
"{}{}{}",
&arm[..start],
alternative,
&arm[end + 1..]
));
}
if done.len() + pending.len() > LIMIT {
return vec![original.to_string()];
}
}
done.sort();
done.dedup();
done
}
fn top_level_is(arm: &str) -> Option<(usize, usize)> {
let bytes = arm.as_bytes();
let mut depth = 0usize;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b':' if depth == 0 && arm[i..].starts_with(":is(") => {
let mut inner = 0usize;
for (k, &b) in bytes.iter().enumerate().skip(i + ":is".len()) {
match b {
b'(' => inner += 1,
b')' => {
inner -= 1;
if inner == 0 {
return Some((i, k));
}
}
_ => {}
}
}
return None;
}
_ => {}
}
i += 1;
}
None
}
#[must_use]
pub fn normalize_arm(arm: &str) -> String {
let mut words = String::new();
for word in arm.split_whitespace() {
if !words.is_empty() {
words.push(' ');
}
words.push_str(word);
}
let words = words.replace('\'', "\"");
let before = ['>', '+', '~', ',', ')'];
let after = ['>', '+', '~', ',', '('];
let chars: Vec<char> = words.chars().collect();
let mut out = String::with_capacity(words.len());
for (i, &c) in chars.iter().enumerate() {
if c == ' '
&& (chars.get(i + 1).is_some_and(|n| before.contains(n))
|| i.checked_sub(1)
.and_then(|p| chars.get(p))
.is_some_and(|p| after.contains(p)))
{
continue;
}
out.push(c);
}
out
}
pub type Specificity = (usize, usize, usize);
#[must_use]
pub fn specificity(selector: &str) -> Specificity {
let chars: Vec<char> = selector.chars().collect();
let (mut ids, mut classes, mut elements) = (0, 0, 0);
let mut i = 0;
while i < chars.len() {
match chars[i] {
'#' => {
ids += 1;
i = skip_name(&chars, i + 1);
}
'.' => {
classes += 1;
i = skip_name(&chars, i + 1);
}
':' => {
if chars.get(i + 1) == Some(&':') {
elements += 1;
i = skip_name(&chars, i + 2);
} else {
classes += 1;
i = skip_name(&chars, i + 1);
}
if chars.get(i) == Some(&'(') {
i = skip_group(&chars, i);
}
}
'[' => {
classes += 1;
i = skip_group(&chars, i);
}
c if c.is_ascii_alphabetic() => {
elements += 1;
i = skip_name(&chars, i);
}
_ => i += 1,
}
}
(ids, classes, elements)
}
fn skip_name(chars: &[char], from: usize) -> usize {
let mut i = from;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
i += 1;
}
i
}
fn skip_group(chars: &[char], from: usize) -> usize {
let mut depth = 0usize;
let mut i = from;
while i < chars.len() {
match chars[i] {
'[' | '(' => depth += 1,
']' | ')' => {
depth -= 1;
if depth == 0 {
return i + 1;
}
}
_ => {}
}
i += 1;
}
i
}
fn is_handoff(value: &str) -> bool {
value.trim() == "revert-layer"
}
fn properties_in_body(body: &str, keep: impl Fn(&str) -> bool) -> BTreeSet<String> {
body.split(';')
.filter_map(|decl| decl.split_once(':'))
.filter(|(_, value)| keep(value))
.map(|(name, _)| {
let name = name.trim();
if name.starts_with("--") {
name.to_string()
} else {
name.to_ascii_lowercase()
}
})
.filter(|name| !name.is_empty() && !name.contains(['{', '}']))
.collect()
}
#[must_use]
pub fn classes_in_css(css: &str) -> BTreeSet<String> {
rules(css)
.into_iter()
.flat_map(|(selector, _)| classes_in_selector(&selector))
.collect()
}
#[must_use]
pub fn attributes_in_css(css: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for (selector, _) in rules(css) {
for arm in selector_arms(&selector) {
for (name, value) in attributes_in_selector(arm) {
out.insert(name.clone());
if let Some(value) = value {
out.insert(format!("{name}={value}"));
}
}
}
}
out
}
fn attributes_in_selector(selector: &str) -> Vec<(String, Option<String>)> {
let chars: Vec<char> = selector.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '[' {
let end = skip_group(&chars, i);
let inner: String = chars[i + 1..end.saturating_sub(1)].iter().collect();
out.push(scan_attribute(&inner));
i = end;
} else {
i += 1;
}
}
out
}
fn scan_attribute(inner: &str) -> (String, Option<String>) {
let inner = inner.trim();
let name_end = inner
.find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_' || c == ':'))
.unwrap_or(inner.len());
let name = inner[..name_end].to_ascii_lowercase();
let value = inner
.split_once('=')
.map(|(_, value)| {
value
.trim()
.trim_end_matches(|c: char| c == 'i' || c == 's' || c.is_whitespace())
.trim()
.trim_matches(['"', '\''])
.to_owned()
})
.filter(|value| !value.is_empty());
(name, value)
}
pub fn rules(css: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut blocks: Vec<bool> = Vec::new();
let mut prelude = String::new();
let mut stmt = String::new();
let mut open: Vec<(String, String)> = Vec::new();
let mut chars = css.chars().peekable();
while let Some(c) = chars.next() {
match c {
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut star = false;
for c in chars.by_ref() {
if star && c == '/' {
break;
}
star = c == '*';
}
prelude.clear();
}
'"' | '\'' => {
let quote = c;
let mut escaped = false;
let in_decl = blocks.last().copied().unwrap_or(false);
prelude.push(quote);
if in_decl {
stmt.push(quote);
}
for c in chars.by_ref() {
prelude.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == quote {
break;
}
}
if in_decl {
stmt.push(quote);
}
}
'{' => {
if prelude.trim_start().starts_with('@') {
blocks.push(false);
} else {
let selector = match open.last() {
Some((parent, _)) => nest(parent, prelude.trim()),
None => prelude.clone(),
};
open.push((selector, String::new()));
blocks.push(true);
}
prelude.clear();
stmt.clear();
}
'}' => {
if blocks.pop().unwrap_or(false) {
if let Some((_, body)) = open.last_mut() {
body.push_str(&stmt);
}
if let Some(rule) = open.pop() {
out.push(rule);
}
}
prelude.clear();
stmt.clear();
}
_ => {
if blocks.last().copied().unwrap_or(false) {
if c == ';' {
if let Some((_, body)) = open.last_mut() {
body.push_str(&stmt);
body.push(';');
}
prelude.clear();
stmt.clear();
} else {
prelude.push(c);
stmt.push(c);
}
} else if c == ';' {
prelude.clear();
} else {
prelude.push(c);
}
}
}
}
out
}
fn nest(parent: &str, nested: &str) -> String {
let mut arms = Vec::new();
for outer in selector_arms(parent) {
let outer = outer.trim();
for inner in selector_arms(nested) {
let inner = inner.trim();
if let Some(rest) = inner.strip_prefix('&') {
arms.push(format!("{outer}{rest}"));
} else if inner.is_empty() {
arms.push(outer.to_string());
} else {
arms.push(format!("{outer} {inner}"));
}
}
}
arms.join(", ")
}
pub const ELEMENT_CLASSES: &[(&str, &[&str])] = &[
(
"a",
&[
"link",
"button",
"tab",
"chip",
"badge",
"card",
"row-activate",
"figure-act",
"chrome-place",
],
),
(
"button",
&[
"button",
"chip",
"segment",
"toggle",
"tab",
"link",
"badge",
"card",
"facet-take",
"facet-prune",
"facet-join-word",
"chip-remove",
"row-activate",
],
),
("details", &["ask"]),
("summary", &["button", "ask-open", "ask-body"]),
("input", &["field", "toggle", "row-select"]),
("select", &["field"]),
("textarea", &["field"]),
(
"label",
&[
"form-label",
"form-checkbox-label",
"form-radio-label",
"toggle",
"card",
],
),
("form", &["form"]),
("progress", &["progress"]),
("p", &["text", "facet-name", "placeholder-text"]),
("ul", &["list", "facet-values"]),
("ol", &["list"]),
("li", &["facet-value"]),
("table", &["table"]),
("thead", &["table-head"]),
("tr", &["table-row"]),
("td", &["cell", "cell-value", "cell-content"]),
("th", &["table-heading"]),
("figure", &["picture", "figure"]),
("img", &["picture-img"]),
("figcaption", &["picture-caption", "figure-caption"]),
("nav", &["chrome-nav"]),
];
#[must_use]
pub fn classes_for_element(element: &str, opts: &Emit) -> BTreeSet<String> {
if element == UNIVERSAL {
return vocabulary(opts);
}
ELEMENT_CLASSES
.iter()
.find(|(name, _)| *name == element)
.map(|(_, classes)| classes.iter().map(|c| crate::class(c, opts)).collect())
.unwrap_or_default()
}
pub const UNIVERSAL: &str = "*";
fn bare_element(arm: &str) -> Option<String> {
let mut flat = String::with_capacity(arm.len());
let mut depth = 0usize;
for c in arm.chars() {
match c {
'[' | '(' => depth += 1,
']' | ')' => depth = depth.saturating_sub(1),
_ if depth == 0 => flat.push(c),
_ => {}
}
}
let flat = flat.trim();
if let Some(rest) = flat.strip_prefix('*') {
let bare = !rest.contains(['.', '#', '>', '+', '~', '*'])
&& !rest.chars().any(char::is_whitespace)
&& !rest.contains("::");
return bare.then(|| UNIVERSAL.to_string());
}
if flat.is_empty() || flat.contains(['.', '#', '>', '+', '~', '*']) {
return None;
}
if flat.chars().any(char::is_whitespace) {
return None;
}
let name: String = flat
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-')
.collect();
if !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
return None;
}
Some(name.to_ascii_lowercase())
}
fn classes_in_selector(selector: &str) -> Vec<String> {
let chars: Vec<char> = selector.chars().collect();
let mut names = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '.'
&& chars
.get(i + 1)
.is_some_and(|c| c.is_alphabetic() || *c == '_')
{
let start = i + 1;
let mut end = start;
while end < chars.len()
&& (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
{
end += 1;
}
names.push(chars[start..end].iter().collect());
i = end;
} else {
i += 1;
}
}
names
}
fn classes_matched_by(selector: &str) -> Vec<String> {
let subjects: Vec<String> = selector_arms(selector)
.into_iter()
.filter(|arm| !arm.contains("::"))
.map(|arm| without_pseudo(&without_pseudo(subject_of(arm), ":not("), ":has("))
.collect();
classes_in_selector(&subjects.join(","))
}
fn subject_of(arm: &str) -> &str {
let arm = arm.trim();
let mut depth = 0usize;
let mut quote: Option<char> = None;
let mut start = 0;
for (i, c) in arm.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '(' | '[') => depth += 1,
(None, ')' | ']') => depth = depth.saturating_sub(1),
(None, c) if depth == 0 && (c.is_whitespace() || matches!(c, '>' | '+' | '~')) => {
start = i + c.len_utf8();
}
_ => {}
}
}
&arm[start..]
}
fn without_pseudo(compound: &str, pseudo: &str) -> String {
let mut kept = String::with_capacity(compound.len());
let mut rest = compound;
while let Some(at) = rest.find(pseudo) {
kept.push_str(&rest[..at]);
let mut depth = 0usize;
let mut end = rest.len();
for (offset, c) in rest[at + pseudo.len() - 1..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
end = at + pseudo.len() - 1 + offset + 1;
break;
}
}
_ => {}
}
}
rest = &rest[end..];
}
kept.push_str(rest);
kept
}
#[cfg(test)]
mod tests {
use super::*;
use crate::list::{cell_part_class, part_class};
#[test]
fn an_arm_is_spelled_one_way_without_changing_what_it_selects() {
assert_eq!(
normalize_arm(" .button:is( :disabled , [aria-disabled='true'] ) "),
".button:is(:disabled,[aria-disabled=\"true\"])"
);
assert_eq!(normalize_arm(".table-row > .cell"), ".table-row>.cell");
assert_eq!(normalize_arm(".a [data-x]"), ".a [data-x]");
assert_eq!(normalize_arm(".a:is(.b) .c"), ".a:is(.b) .c");
}
#[test]
fn a_grouped_arm_is_the_arms_it_groups() {
assert_eq!(
expand_is(".tab:is(:disabled,[aria-disabled=\"true\"])"),
vec![".tab:disabled", ".tab[aria-disabled=\"true\"]"]
);
assert_eq!(expand_is(".b:not(:is(.x,.y))"), vec![".b:not(:is(.x,.y))"]);
assert_eq!(
expand_is(".card:is(a,button):is(:active,:disabled)").len(),
4
);
}
#[test]
fn a_variant_is_any_arm_but_the_bare_class() {
let css = ".button { color: red; }\n\
.button[data-tone=\"danger\"], .run .button { color: blue; }\n\
.button::after { content: \"x\"; }\n";
let variants = variants_by_class(css);
let arms: Vec<&str> = variants["button"].keys().map(String::as_str).collect();
assert_eq!(arms, vec![".button[data-tone=\"danger\"]", ".run .button"]);
let bare = bare_declarations(css);
assert!(bare["button"].contains("color"));
assert!(bare_class("button.button").is_none());
assert!(bare_class(".button.quiet").is_none());
}
#[test]
fn a_quoted_attribute_value_survives_into_the_selector() {
let read = rules(".badge[data-tone=\"danger\"] { color: red; }");
assert_eq!(read.len(), 1, "{read:?}");
assert!(read[0].0.contains(r#"[data-tone="danger"]"#), "{read:?}");
let body = rules(r#".a { background: url("a;b:c"); color: red; }"#);
assert!(body[0].1.contains("color"), "{body:?}");
}
#[test]
fn a_pseudo_element_arm_styles_no_element_carrying_its_class() {
let by_class =
declarations_by_class(".field::file-selector-button { border: none; padding: 1px; }");
assert!(!by_class.contains_key("field"), "{by_class:?}");
let mixed = declarations_by_class(".a::before, .b { color: red; }");
assert!(!mixed.contains_key("a"), "{mixed:?}");
assert!(
mixed.get("b").is_some_and(|p| p.contains("color")),
"{mixed:?}"
);
}
use makeover_layout::{CellPart, RowPart};
#[test]
fn the_scrape_finds_the_components_the_sheet_is_built_from() {
let v = vocabulary(&Emit::default());
assert!(
v.len() > 20,
"scraped {} classes, which reads as a parser failure rather than a small sheet",
v.len()
);
for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
assert!(
v.contains(name),
"the sheet defines .{name} and the scan missed it"
);
}
}
#[test]
fn every_class_this_crate_can_emit_has_a_rule() {
let opts = Emit::default();
let unruled: Vec<String> = names(&opts)
.difference(&vocabulary(&opts))
.cloned()
.collect();
assert!(
unruled.is_empty(),
"{} class(es) this crate can emit and its sheet never rules: {unruled:?}. \
Write the rule beside the emitter, or stop emitting the class.",
unruled.len()
);
}
#[test]
fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
let opts = Emit::default();
let all = names(&opts);
for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
let name = option_class(selector);
assert!(
all.contains(name),
"option_class({selector:?}) is .{name}, which nothing admits to"
);
}
for part in [
RowPart::Primary,
RowPart::Secondary,
RowPart::Meta,
RowPart::Actions,
RowPart::Tokens,
RowPart::Proportion,
] {
let name = part_class(part);
assert!(
all.contains(name),
"part_class({part:?}) is .{name}, which nothing admits to"
);
}
for part in [
CellPart::Value,
CellPart::Tokens,
CellPart::Actions,
CellPart::Link,
] {
let name = cell_part_class(part);
assert!(
all.contains(name),
"cell_part_class({part:?}) is .{name}, which nothing admits to"
);
}
}
#[test]
fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
for part in [
RowPart::Primary,
RowPart::Secondary,
RowPart::Meta,
RowPart::Actions,
RowPart::Tokens,
RowPart::Proportion,
] {
assert!(
ROW_PART_CLASSES.contains(&part_class(part)),
"{part:?} is missing from ROW_PART_CLASSES"
);
}
for part in [
CellPart::Value,
CellPart::Tokens,
CellPart::Actions,
CellPart::Link,
] {
assert!(
CELL_PART_CLASSES.contains(&cell_part_class(part)),
"{part:?} is missing from CELL_PART_CLASSES"
);
}
assert!(ROW_PART_CLASSES.contains(&"row-part"));
assert!(CELL_PART_CLASSES.contains(&"cell-part"));
}
#[test]
fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
let plain = vocabulary(&Emit::default());
let prefixed = vocabulary(&Emit {
class_prefix: "mo-",
..Emit::default()
});
assert_eq!(
plain.len(),
prefixed.len(),
"a prefix changed how many classes exist"
);
let states = ["chosen", "latched", "current"];
for name in &plain {
let expected = if states.contains(&name.as_str())
|| FIELD_STATE_CLASSES.contains(&name.as_str())
{
name.clone()
} else {
format!("mo-{name}")
};
assert!(
prefixed.contains(&expected),
".{name} did not move to .{expected} under the prefix"
);
}
}
#[test]
fn a_handoff_is_not_an_override() {
let css = ".button { background: revert-layer; color: red; }";
let taken = declarations_by_class(css);
let given = deferrals_by_class(css);
assert_eq!(
taken.get("button"),
Some(&["color".to_string()].into_iter().collect())
);
assert_eq!(
given.get("button"),
Some(&["background".to_string()].into_iter().collect())
);
}
#[test]
fn an_important_handoff_is_an_override() {
let css = ".button { background: revert-layer !important; }";
assert_eq!(
declarations_by_class(css).get("button"),
Some(&["background".to_string()].into_iter().collect())
);
assert!(!deferrals_by_class(css).contains_key("button"));
}
#[test]
fn a_class_that_only_hands_properties_back_is_not_in_the_taking_set() {
let by_class = declarations_by_class(".field { background: revert-layer; }");
assert!(!by_class.contains_key("field"), "got {by_class:?}");
}
#[test]
fn an_element_rule_is_read_where_a_class_reader_sees_nothing() {
let css = "button { color: red; background: blue; }";
assert!(declarations_by_class(css).is_empty());
let by_element = declarations_by_element(css);
let button = by_element.get("button").expect("button is named");
assert_eq!(
button.keys().cloned().collect::<Vec<_>>(),
["background", "color"]
);
assert_eq!(button["color"], (0, 0, 1));
}
#[test]
fn the_strongest_arm_is_the_one_reported() {
let css = "input { color: red; }\ninput[type=\"text\"]:focus { color: blue; }\n";
assert_eq!(declarations_by_element(css)["input"]["color"], (0, 2, 1));
}
#[test]
fn a_selector_is_ranked_the_way_the_cascade_ranks_it() {
for (selector, expected) in [
("button", (0, 0, 1)),
("*", (0, 0, 0)),
(".field", (0, 1, 0)),
("input.field", (0, 1, 1)),
("input[type=\"text\"]", (0, 1, 1)),
("button:hover", (0, 1, 1)),
("button::before", (0, 0, 2)),
("#main .card > button:focus-visible", (1, 2, 1)),
(".chip.latched[aria-pressed=\"true\"]", (0, 3, 0)),
("button:not(.link)", (0, 1, 1)),
] {
assert_eq!(specificity(selector), expected, "{selector}");
}
}
#[test]
fn what_a_class_is_spoken_for_by_counts_a_handoff_as_speech() {
let css = ".field { background: revert-layer; }\ninput.field:focus { color: red; }\n";
let mentions = mentions_by_class(css);
assert_eq!(mentions["field"]["background"], (0, 1, 0));
assert_eq!(mentions["field"]["color"], (0, 2, 1));
}
#[test]
fn only_a_bare_compound_counts_as_an_element_rule() {
for selector in [
".page button",
"button.link",
".card > button",
"button + button",
"* button",
] {
let css = format!("{selector} {{ color: red; }}");
assert!(
declarations_by_element(&css).is_empty(),
"{selector} was read as a bare element rule"
);
}
}
#[test]
fn a_state_or_an_attribute_does_not_stop_an_arm_being_bare() {
for selector in [
"button:hover",
"button:focus-visible",
"button:disabled",
"button[aria-disabled=\"true\"]",
"button:not(.link)",
"button[data-tone=\"danger\"]:hover",
] {
let css = format!("{selector} {{ color: red; }}");
assert!(
declarations_by_element(&css).contains_key("button"),
"{selector} was not read as a bare element rule"
);
}
}
#[test]
fn a_universal_rule_reaches_every_generated_class() {
for selector in ["*", "*:hover", "*:not(svg)"] {
let css = format!("{selector} {{ padding: 0; }}");
assert!(
declarations_by_element(&css).contains_key(UNIVERSAL),
"{selector} was not read as reaching every element"
);
}
for selector in ["*::before", "* button", ".page *", "*.card"] {
let css = format!("{selector} {{ padding: 0; }}");
assert!(
!declarations_by_element(&css).contains_key(UNIVERSAL),
"{selector} was read as reaching every element"
);
}
let opts = Emit::default();
assert_eq!(classes_for_element(UNIVERSAL, &opts), vocabulary(&opts));
}
#[test]
fn a_pseudo_element_on_nothing_names_no_element() {
for selector in [":root", "::selection", "::backdrop", ":root:not(.x)"] {
let css = format!("{selector} {{ color: red; }}");
assert!(
declarations_by_element(&css).is_empty(),
"{selector} named an element"
);
}
}
#[test]
fn every_arm_of_a_list_is_read_on_its_own() {
let css = "input, select, .field, .page textarea { color: red; }";
let by_element = declarations_by_element(css);
assert!(by_element.contains_key("input"));
assert!(by_element.contains_key("select"));
assert!(!by_element.contains_key("textarea"), "that arm is scoped");
assert_eq!(by_element.len(), 2);
}
#[test]
fn a_comma_inside_a_group_does_not_split_the_list() {
let css = ".card:is(a, button, label, [data-act]) { cursor: pointer; }\n\
a[title=\"x, y\"], button { color: red; }";
let by_element = declarations_by_element(css);
assert!(!by_element.contains_key("label"), "{by_element:?}");
assert!(
!by_element["button"].contains_key("cursor"),
"{by_element:?}"
);
assert!(by_element["a"].contains_key("color"), "{by_element:?}");
assert!(by_element["button"].contains_key("color"), "{by_element:?}");
assert_eq!(
mentions_by_class(css)["card"]["cursor"],
specificity(".card:is(a, button, label, [data-act])")
);
}
#[test]
fn an_element_handing_a_property_back_is_not_taking_it() {
let css = "button { background: revert-layer; }";
assert!(declarations_by_element(css).is_empty());
}
#[test]
fn the_pairing_map_carries_the_elements_this_crate_renders_onto() {
let mut checked = 0;
for (tag, class) in emitted_pairs() {
if !ELEMENT_CLASSES.iter().any(|(name, _)| *name == tag) {
continue;
}
checked += 1;
assert!(
classes_for_element(&tag, &Emit::default()).contains(&class),
"this crate emits <{tag} class=\"{class}\"> and ELEMENT_CLASSES \
does not pair them"
);
}
assert!(
checked > 5,
"scraped {checked} pairings off the emitters, which reads as the scan \
having stopped matching rather than the renderer having shrunk"
);
}
fn emitted_pairs() -> Vec<(String, String)> {
const OPEN: &str = "class=\\\"";
let mut out = Vec::new();
for file in std::fs::read_dir("src").expect("read src") {
let path = file.expect("dir entry").path();
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("read source");
for (at, _) in src.match_indices(OPEN) {
let Some(open) = src[..at].rfind('<') else {
continue;
};
let tag: String = src[open + 1..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if tag.is_empty() {
continue;
}
let tail = &src[at..(at + 300).min(src.len())];
let Some(call) = tail.find("push_class(out, \"") else {
continue;
};
let name: String = tail[call + "push_class(out, \"".len()..]
.chars()
.take_while(|c| *c != '"')
.collect();
if !name.is_empty() {
out.push((tag, name));
}
}
}
out
}
#[test]
fn the_properties_a_class_carries_are_read_per_class() {
let css = ".badge { padding: 1px; font-weight: 600; }\n .badge[data-color] { border: 1px solid red; }\n @media (min-width: 40rem) { .badge { padding: 2px; } }\n";
let by_class = declarations_by_class(css);
let badge = by_class.get("badge").expect("badge is named");
assert!(badge.contains("padding"));
assert!(badge.contains("font-weight"));
assert!(badge.contains("border"));
assert_eq!(badge.len(), 3);
}
#[test]
fn a_value_holding_a_colon_or_a_semicolon_is_not_read_as_a_property() {
let css = ".x { background: url(\"a;b:c\"); color: red; }";
let by_class = declarations_by_class(css);
let x = by_class.get("x").expect("x is named");
assert_eq!(
*x,
["background".to_string(), "color".to_string()]
.into_iter()
.collect::<BTreeSet<_>>()
);
}
#[test]
fn the_generated_sheet_draws_a_badge_as_a_chip() {
let by_class = declarations_by_class(&crate::stylesheet(&Emit::default()));
let badge = by_class.get("badge").expect("the sheet defines .badge");
for property in ["color", "background", "border", "padding"] {
assert!(badge.contains(property), "no {property}, got {badge:?}");
}
}
#[test]
fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
assert_eq!(found, ["real".to_string()].into_iter().collect());
}
#[test]
fn an_at_rule_does_not_hide_the_selectors_inside_it() {
let found = classes_in_css(
"@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
);
assert_eq!(found, ["wide".to_string()].into_iter().collect());
}
#[test]
fn an_attribute_is_read_with_and_without_its_pinned_value() {
let found = attributes_in_css(
".badge[data-tone=\"danger\"] { color: red; }\n[hidden] { display: none; }\n\
.row[aria-sort=\"ascending\" i] { color: blue; }",
);
let expected: BTreeSet<String> = [
"data-tone",
"data-tone=danger",
"hidden",
"aria-sort",
"aria-sort=ascending",
]
.into_iter()
.map(String::from)
.collect();
assert_eq!(found, expected);
}
#[test]
fn a_declaration_value_is_not_read_as_a_selected_attribute() {
let found = attributes_in_css(".caret::after { content: \"\u{25B2}\"; }");
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_nested_rule_is_a_rule_of_its_own() {
let by_class = declarations_by_class(".page { color: red; .button { background: blue; } }");
assert!(by_class["page"].contains("color"), "{by_class:?}");
assert!(by_class["button"].contains("background"), "{by_class:?}");
}
#[test]
fn a_nested_rule_with_an_ampersand_is_read() {
let descendant = declarations_by_class(".page { & .button { background: blue; } }");
assert!(
descendant["button"].contains("background"),
"{descendant:?}"
);
let compound = declarations_by_class(".page { &.button { color: red; } }");
assert!(compound["button"].contains("color"), "{compound:?}");
let state = declarations_by_class(".page { &:disabled { color: red; } }");
assert!(state["page"].contains("color"), "{state:?}");
}
#[test]
fn a_nested_rule_stays_scoped_to_its_parent() {
let css = ".page { .button { background: blue; } }";
assert!(
!bare_declarations(css).contains_key("button"),
"a scoped rule is not a bare one: {:?}",
bare_declarations(css)
);
assert_eq!(
variants_by_class(css)["button"].keys().collect::<Vec<_>>(),
vec![".page .button"]
);
}
#[test]
fn a_property_name_is_read_without_regard_to_case() {
let by_class = declarations_by_class(".card { BOX-SHADOW: none; }");
assert!(by_class["card"].contains("box-shadow"), "{by_class:?}");
let custom = declarations_by_class(".card { --Gap: 1px; }");
assert!(custom["card"].contains("--Gap"), "{custom:?}");
}
#[test]
fn a_string_is_opaque_and_a_comment_contributes_nothing() {
let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
assert_eq!(found, ["caret".to_string()].into_iter().collect());
}
#[test]
fn a_compound_selector_yields_every_class_it_names() {
let found = classes_in_css(
".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
);
let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(found, expected);
}
#[test]
fn a_class_a_rule_refuses_is_not_a_class_it_styles() {
let css = ".region:not(.group) > .region.group:not(.raised):not(.tiles) > \
.heading:first-child { background: var(--surface-raised); }\n\
.row:is(.chosen):has(.badge) { color: red; }";
let declared = declarations_by_class(css);
for refused in ["raised", "tiles"] {
assert!(!declared.contains_key(refused), "{refused} is only negated");
assert!(!mentions_by_class(css).contains_key(refused));
}
for reached in ["heading", "row", "chosen"] {
assert!(declared.contains_key(reached), "{reached} is matched on");
}
for context in ["region", "group", "badge"] {
assert!(!declared.contains_key(context), "{context} is not styled");
}
assert!(classes_in_css(css).contains("raised"));
}
#[test]
fn a_rule_styles_its_subject_and_not_its_context() {
let css = ".table-row:has(> .table-menu .table-row-menu[open]) > \
.kind-actions:not(.table-menu) { min-inline-size: 10rem; }\n\
.table-row:has(> .kind-actions .cell-actions > *) > .table-menu \
.table-row-menu:not(:has(> .row-menu-items > *)) { display: block; }";
let declared = declarations_by_class(css);
assert_eq!(
declared.keys().map(String::as_str).collect::<Vec<_>>(),
["kind-actions", "table-row-menu"],
"{declared:?}"
);
}
#[test]
fn a_nested_negation_is_skipped_whole() {
assert_eq!(
classes_matched_by(".a:not(:is(.b, .c)) .d"),
["d".to_string()]
);
}
}