use crate::list::{CELL_PART_CLASSES, ROW_PART_CLASSES};
use crate::{Emit, option_class};
use makeover_layout::Selector;
use std::collections::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)
.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
}
#[must_use]
pub fn classes_in_css(css: &str) -> BTreeSet<String> {
let mut found = BTreeSet::new();
let mut blocks: Vec<bool> = Vec::new();
let mut prelude = String::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;
for c in chars.by_ref() {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == quote {
break;
}
}
}
'{' => {
let declarations = !prelude.trim_start().starts_with('@');
if declarations {
found.extend(classes_in_selector(&prelude));
}
blocks.push(declarations);
prelude.clear();
}
'}' => {
blocks.pop();
prelude.clear();
}
';' => prelude.clear(),
_ if !blocks.last().copied().unwrap_or(false) => prelude.push(c),
_ => {}
}
}
found
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::list::{cell_part_class, part_class};
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_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"];
for name in &plain {
let expected = if states.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_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 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);
}
}