//! Every class this crate is responsible for, as a set rather than one name at //! a time. //! //! The naming functions ([`crate::class`], [`crate::option_class`], //! [`crate::list::part_class`], [`crate::list::cell_part_class`]) answer "what //! is this one thing called". That is half the agreement, and 0.27.0 shipped //! it. The other half is the set: a checker cannot ask "is this app rule //! re-specifying something makeover already defines" without the list, and this //! crate is the only place that knows it, because this crate writes the sheet. //! //! # Two sets, because there are two questions //! //! [`vocabulary`] is the classes the generated stylesheet writes a rule for. //! That is the set a drift check wants: an app rule for one of these is a //! restatement of a rule the app already gets, and unlayered app CSS beats //! `@layer makeover`, so the restatement silently wins. //! //! [`names`] is every class this crate can put in markup, which is the first //! set plus the ones it deliberately leaves unruled. `row-actions`, //! `cell-actions`, `cell-tokens` and `cell-link` have no rule on purpose: only //! `.cell-value` takes a colour, because a token carries its own tone and an //! action is a control rather than text. A class that sets no properties is a //! class that means "I thought about this", and this crate does not emit those. //! So a screen renderer legitimately emits names that [`vocabulary`] does not //! contain, and a test asking "is every class this renderer emits one makeover //! knows about" has to read [`names`] or it fails on four correct ones. //! //! # Why the first set is scraped and not listed //! //! A hand-maintained copy of the sheet's contents is the defect being fixed, //! one level up: it can disagree with the sheet, and the day it does, the //! checker reads the list and the browser reads the sheet. So [`vocabulary`] //! parses the CSS this crate generates. There is no second source to drift //! from, and a class added to an emitter enters the vocabulary in the same //! commit that adds it. use crate::list::{CELL_PART_CLASSES, ROW_PART_CLASSES}; use crate::{Emit, option_class}; use makeover_layout::Selector; use std::collections::BTreeSet; /// Every class name the generated stylesheet defines a rule for, prefixed the /// way `opts` prefixes them. /// /// Includes the state classes a caller never spells alone (`chosen`, /// `latched`). Those are deliberately unprefixed: they qualify a prefixed /// component (`.mo-tab.chosen`) rather than standing on their own, so a prefix /// moves the thing and not its state. #[must_use] pub fn vocabulary(opts: &Emit) -> BTreeSet { classes_in_css(&crate::stylesheet(opts)) } /// Every class this crate can put in markup or in a rule. /// /// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is /// the set to check a renderer's emitted markup against: a class outside it is /// a name that renderer invented, which is how quasi-webview came to spell /// `tabs`, `segmented` and `option` and render every described selector flat. #[must_use] pub fn names(opts: &Emit) -> BTreeSet { 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 } /// The class names a stylesheet's selectors match. /// /// Public because the check this exists for reads an app's stylesheet too, and /// comparing what makeover defines against what the app defines is only /// meaningful if both sides were read the same way. /// /// Selector text only. A declaration value can hold a dot (`0.5rem`, /// `transition: .2s`) and none of those are classes, so the scan tracks whether /// it is inside a declaration block and ignores what it finds there. An at-rule /// block (`@layer`, `@media`, `@supports`) contains rules rather than /// declarations, which is why a depth counter alone is not enough: the stack /// records what kind of block each brace opened. #[must_use] pub fn classes_in_css(css: &str) -> BTreeSet { let mut found = BTreeSet::new(); // One entry per open brace: true when that block holds declarations rather // than nested rules. let mut blocks: Vec = Vec::new(); // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's // prelude, and a prelude starting with `@` opens an at-rule. let mut prelude = String::new(); let mut chars = css.chars().peekable(); while let Some(c) = chars.next() { match c { '/' if chars.peek() == Some(&'*') => { // Skip a comment whole. The banner at the top of the sheet is // prose about the cascade layer and would otherwise contribute // words that look like selectors. chars.next(); let mut star = false; for c in chars.by_ref() { if star && c == '/' { break; } star = c == '*'; } prelude.clear(); } '"' | '\'' => { // A string is opaque. `content: "\2191"` is the sort caret, not // a selector, and a brace inside one would desync the stack. 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(), // Only accumulate where a selector can live. Inside a declaration // block the text is properties and values. _ if !blocks.last().copied().unwrap_or(false) => prelude.push(c), _ => {} } } found } /// The class names one selector matches on. fn classes_in_selector(selector: &str) -> Vec { let chars: Vec = selector.chars().collect(); let mut names = Vec::new(); let mut i = 0; while i < chars.len() { // A leading digit is a length (`.5rem`), never a class: CSS forbids an // identifier starting with one. 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() { // The two halves of the agreement, checked against each other. A naming // function returning a class outside `names` would put a class in the // markup that nothing downstream can recognise, which is the failure // quasi-webview shipped and phase 1 exists to make impossible. 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() { // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a // `#[non_exhaustive]` enum cannot be enumerated. This is the test that // stops them drifting from the matches they sit next to. 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" ); } // The fallbacks, which are what an upstream addition lands on. 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" ); // `chosen` and `latched` never stand alone: the sheet writes // `.mo-tab.chosen`, so the state stays bare while the thing moves. 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 = ["tab", "chosen", "label", "muted"] .into_iter() .map(String::from) .collect(); assert_eq!(found, expected); } }