//! 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, FLOW_CLASSES, ROW_PART_CLASSES}; use crate::{Emit, option_class}; use makeover_layout::Selector; use std::collections::{BTreeMap, 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) .chain(FLOW_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. /// Which properties a stylesheet sets on each class it names. /// /// The grain a drift check actually wants. A class name in common is not by /// itself a divergence: goingson's `.badge` sets shape and the generated /// `.badge` sets fill and edge, and the app's own comment says "do not add /// background, border or box-shadow here". That arrangement is settled and /// correct, so a check that flagged the shared name would demand deleting it. /// A shared *property* is the thing that goes wrong, because app CSS is /// unlayered and takes the property from the design system silently. /// /// A property appearing under more than one selector arm collapses into one /// entry. That loses a real distinction -- the sort caret's reserved gap is /// `content` on the unsorted arm and the generated caret is `content` on the /// sorted one, which is a deliberate pairing rather than a clash -- so a /// consumer of this needs a way to say a pair was reviewed. Deciding that here /// would need a selector matcher, and a check that guesses wrong about /// specificity fails correct builds. #[must_use] pub fn declarations_by_class(css: &str) -> BTreeMap> { let mut out: BTreeMap> = BTreeMap::new(); for (selector, body) in rules(css) { let classes = classes_in_selector(&selector); if classes.is_empty() { continue; } let properties = properties_in_body(&body); for class in classes { out.entry(class).or_default().extend(properties.clone()); } } out } /// The property names a declaration block sets. fn properties_in_body(body: &str) -> BTreeSet { body.split(';') .filter_map(|decl| decl.split_once(':')) .map(|(name, _)| name.trim().to_string()) .filter(|name| !name.is_empty() && !name.contains(['{', '}'])) .collect() } #[must_use] pub fn classes_in_css(css: &str) -> BTreeSet { rules(css) .into_iter() .flat_map(|(selector, _)| classes_in_selector(&selector)) .collect() } /// `(selector, declaration block)` for every rule in a stylesheet. /// /// One reader for both sides. Comparing what makeover defines against what an /// app defines is only meaningful if the two were read the same way, which is /// why this is the only place either question is answered from. /// /// A comment is skipped whole: the banner at the top of the generated sheet is /// prose about the cascade layer and would otherwise contribute words that look /// like selectors. A string is opaque, because `content: "\25B2"` is the sort /// caret rather than a selector and a brace inside one would desync the stack. /// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than /// declarations, so a depth counter alone is not enough and the stack records /// what kind of block each brace opened. fn rules(css: &str) -> Vec<(String, String)> { let mut out = Vec::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(); // The selector of each open declaration block, and the body so far. 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; // Keep the quotes in the body: a value is not a property name, // and dropping them would join two declarations into one. if blocks.last().copied().unwrap_or(false) && let Some((_, body)) = open.last_mut() { body.push(quote); } for c in chars.by_ref() { if escaped { escaped = false; } else if c == '\\' { escaped = true; } else if c == quote { break; } } // The closing quote only. A value holding `;` or `:` would // otherwise read as two declarations, and `url("a;b:c")` is a // real thing an app writes. if blocks.last().copied().unwrap_or(false) && let Some((_, body)) = open.last_mut() { body.push(quote); } } '{' => { let declarations = !prelude.trim_start().starts_with('@'); if declarations { open.push((prelude.clone(), String::new())); } blocks.push(declarations); prelude.clear(); } '}' => { if blocks.pop().unwrap_or(false) && let Some(rule) = open.pop() { out.push(rule); } prelude.clear(); } _ => { if blocks.last().copied().unwrap_or(false) && let Some((_, body)) = open.last_mut() { body.push(c); } else if c == ';' { prelude.clear(); } else { prelude.push(c); } } } } out } /// 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. // `current` is the third, and it is the same shape: which child of a // region showing one at a time is the one showing. let states = ["chosen", "latched", "current"]; 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 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"); // Every arm collapses into one entry, including the one inside the // media block: they are all the same class carrying the same property. 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::>() ); } #[test] fn the_generated_sheet_sets_fill_on_a_badge_and_not_its_shape() { // The fact goingson's stylesheet states in prose next to its own // `.badge`: "Fill, edge and text colour come from the generated .badge // in layout.css... Do not add background, border or box-shadow here." // A property-grain reader is what turns that comment into a check. let by_class = declarations_by_class(&crate::stylesheet(&Emit::default())); let badge = by_class.get("badge").expect("the sheet defines .badge"); // Token::Badge is Depth::Flat, so a badge carries no bevel and no // fill: what the generated sheet gives it is the text colour, and // everything about its shape is the app's. assert!(badge.contains("color"), "got {badge:?}"); assert!( !badge.contains("padding"), "shape is the app's, 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 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); } }