Skip to main content

makeover_webview/
vocabulary.rs

1//! Every class this crate is responsible for, as a set rather than one name at
2//! a time.
3//!
4//! The naming functions ([`crate::class`], [`crate::option_class`],
5//! [`crate::list::part_class`], [`crate::list::cell_part_class`]) answer "what
6//! is this one thing called". That is half the agreement, and 0.27.0 shipped
7//! it. The other half is the set: a checker cannot ask "is this app rule
8//! re-specifying something makeover already defines" without the list, and this
9//! crate is the only place that knows it, because this crate writes the sheet.
10//!
11//! # Two sets, because there are two questions
12//!
13//! [`vocabulary`] is the classes the generated stylesheet writes a rule for.
14//! That is the set a drift check wants: an app rule for one of these is a
15//! restatement of a rule the app already gets, and unlayered app CSS beats
16//! `@layer makeover`, so the restatement silently wins.
17//!
18//! [`names`] is every class this crate can put in markup, which is the first
19//! set plus the ones it deliberately leaves unruled. `row-actions`,
20//! `cell-actions`, `cell-tokens` and `cell-link` have no rule on purpose: only
21//! `.cell-value` takes a colour, because a token carries its own tone and an
22//! action is a control rather than text. A class that sets no properties is a
23//! class that means "I thought about this", and this crate does not emit those.
24//! So a screen renderer legitimately emits names that [`vocabulary`] does not
25//! contain, and a test asking "is every class this renderer emits one makeover
26//! knows about" has to read [`names`] or it fails on four correct ones.
27//!
28//! # Why the first set is scraped and not listed
29//!
30//! A hand-maintained copy of the sheet's contents is the defect being fixed,
31//! one level up: it can disagree with the sheet, and the day it does, the
32//! checker reads the list and the browser reads the sheet. So [`vocabulary`]
33//! parses the CSS this crate generates. There is no second source to drift
34//! from, and a class added to an emitter enters the vocabulary in the same
35//! commit that adds it.
36
37use crate::list::{CELL_PART_CLASSES, FLOW_CLASSES, ROW_PART_CLASSES};
38use crate::{Emit, option_class};
39use makeover_layout::Selector;
40use std::collections::{BTreeMap, BTreeSet};
41
42/// Every class name the generated stylesheet defines a rule for, prefixed the
43/// way `opts` prefixes them.
44///
45/// Includes the state classes a caller never spells alone (`chosen`,
46/// `latched`). Those are deliberately unprefixed: they qualify a prefixed
47/// component (`.mo-tab.chosen`) rather than standing on their own, so a prefix
48/// moves the thing and not its state.
49#[must_use]
50pub fn vocabulary(opts: &Emit) -> BTreeSet<String> {
51    classes_in_css(&crate::stylesheet(opts))
52}
53
54/// Every class this crate can put in markup or in a rule.
55///
56/// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is
57/// the set to check a renderer's emitted markup against: a class outside it is
58/// a name that renderer invented, which is how quasi-webview came to spell
59/// `tabs`, `segmented` and `option` and render every described selector flat.
60#[must_use]
61pub fn names(opts: &Emit) -> BTreeSet<String> {
62    let mut all = vocabulary(opts);
63    all.extend(
64        ROW_PART_CLASSES
65            .iter()
66            .chain(CELL_PART_CLASSES)
67            .chain(FLOW_CLASSES)
68            .chain(crate::RUN_CLASSES)
69            .map(|name| crate::class(name, opts)),
70    );
71    all.extend(
72        [Selector::Tabs, Selector::Segmented, Selector::Toggle]
73            .into_iter()
74            .map(|s| crate::class(option_class(s), opts)),
75    );
76    all
77}
78
79/// The class names a stylesheet's selectors match.
80///
81/// Public because the check this exists for reads an app's stylesheet too, and
82/// comparing what makeover defines against what the app defines is only
83/// meaningful if both sides were read the same way.
84///
85/// Selector text only. A declaration value can hold a dot (`0.5rem`,
86/// `transition: .2s`) and none of those are classes, so the scan tracks whether
87/// it is inside a declaration block and ignores what it finds there. An at-rule
88/// block (`@layer`, `@media`, `@supports`) contains rules rather than
89/// declarations, which is why a depth counter alone is not enough: the stack
90/// records what kind of block each brace opened.
91/// Which properties a stylesheet sets on each class it names.
92///
93/// The grain a drift check actually wants. A class name in common is not by
94/// itself a divergence: goingson's `.badge` sets shape and the generated
95/// `.badge` sets fill and edge, and the app's own comment says "do not add
96/// background, border or box-shadow here". That arrangement is settled and
97/// correct, so a check that flagged the shared name would demand deleting it.
98/// A shared *property* is the thing that goes wrong, because app CSS is
99/// unlayered and takes the property from the design system silently.
100///
101/// A property appearing under more than one selector arm collapses into one
102/// entry. That loses a real distinction -- the sort caret's reserved gap is
103/// `content` on the unsorted arm and the generated caret is `content` on the
104/// sorted one, which is a deliberate pairing rather than a clash -- so a
105/// consumer of this needs a way to say a pair was reviewed. Deciding that here
106/// would need a selector matcher, and a check that guesses wrong about
107/// specificity fails correct builds.
108#[must_use]
109pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
110    let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
111    for (selector, body) in rules(css) {
112        let classes = classes_in_selector(&selector);
113        if classes.is_empty() {
114            continue;
115        }
116        let properties = properties_in_body(&body);
117        for class in classes {
118            out.entry(class).or_default().extend(properties.clone());
119        }
120    }
121    out
122}
123
124/// The property names a declaration block sets.
125fn properties_in_body(body: &str) -> BTreeSet<String> {
126    body.split(';')
127        .filter_map(|decl| decl.split_once(':'))
128        .map(|(name, _)| name.trim().to_string())
129        .filter(|name| !name.is_empty() && !name.contains(['{', '}']))
130        .collect()
131}
132
133#[must_use]
134pub fn classes_in_css(css: &str) -> BTreeSet<String> {
135    rules(css)
136        .into_iter()
137        .flat_map(|(selector, _)| classes_in_selector(&selector))
138        .collect()
139}
140
141/// `(selector, declaration block)` for every rule in a stylesheet.
142///
143/// One reader for both sides. Comparing what makeover defines against what an
144/// app defines is only meaningful if the two were read the same way, which is
145/// why this is the only place either question is answered from.
146///
147/// A comment is skipped whole: the banner at the top of the generated sheet is
148/// prose about the cascade layer and would otherwise contribute words that look
149/// like selectors. A string is opaque, because `content: "\25B2"` is the sort
150/// caret rather than a selector and a brace inside one would desync the stack.
151/// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than
152/// declarations, so a depth counter alone is not enough and the stack records
153/// what kind of block each brace opened.
154fn rules(css: &str) -> Vec<(String, String)> {
155    let mut out = Vec::new();
156    // One entry per open brace: true when that block holds declarations rather
157    // than nested rules.
158    let mut blocks: Vec<bool> = Vec::new();
159    // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
160    // prelude, and a prelude starting with `@` opens an at-rule.
161    let mut prelude = String::new();
162    // The selector of each open declaration block, and the body so far.
163    let mut open: Vec<(String, String)> = Vec::new();
164
165    let mut chars = css.chars().peekable();
166    while let Some(c) = chars.next() {
167        match c {
168            '/' if chars.peek() == Some(&'*') => {
169                chars.next();
170                let mut star = false;
171                for c in chars.by_ref() {
172                    if star && c == '/' {
173                        break;
174                    }
175                    star = c == '*';
176                }
177                prelude.clear();
178            }
179            '"' | '\'' => {
180                let quote = c;
181                let mut escaped = false;
182                // Keep the quotes in the body: a value is not a property name,
183                // and dropping them would join two declarations into one.
184                if blocks.last().copied().unwrap_or(false)
185                    && let Some((_, body)) = open.last_mut()
186                {
187                    body.push(quote);
188                }
189                for c in chars.by_ref() {
190                    if escaped {
191                        escaped = false;
192                    } else if c == '\\' {
193                        escaped = true;
194                    } else if c == quote {
195                        break;
196                    }
197                }
198                // The closing quote only. A value holding `;` or `:` would
199                // otherwise read as two declarations, and `url("a;b:c")` is a
200                // real thing an app writes.
201                if blocks.last().copied().unwrap_or(false)
202                    && let Some((_, body)) = open.last_mut()
203                {
204                    body.push(quote);
205                }
206            }
207            '{' => {
208                let declarations = !prelude.trim_start().starts_with('@');
209                if declarations {
210                    open.push((prelude.clone(), String::new()));
211                }
212                blocks.push(declarations);
213                prelude.clear();
214            }
215            '}' => {
216                if blocks.pop().unwrap_or(false)
217                    && let Some(rule) = open.pop()
218                {
219                    out.push(rule);
220                }
221                prelude.clear();
222            }
223            _ => {
224                if blocks.last().copied().unwrap_or(false)
225                    && let Some((_, body)) = open.last_mut()
226                {
227                    body.push(c);
228                } else if c == ';' {
229                    prelude.clear();
230                } else {
231                    prelude.push(c);
232                }
233            }
234        }
235    }
236    out
237}
238
239/// The class names one selector matches on.
240fn classes_in_selector(selector: &str) -> Vec<String> {
241    let chars: Vec<char> = selector.chars().collect();
242    let mut names = Vec::new();
243    let mut i = 0;
244    while i < chars.len() {
245        // A leading digit is a length (`.5rem`), never a class: CSS forbids an
246        // identifier starting with one.
247        if chars[i] == '.'
248            && chars
249                .get(i + 1)
250                .is_some_and(|c| c.is_alphabetic() || *c == '_')
251        {
252            let start = i + 1;
253            let mut end = start;
254            while end < chars.len()
255                && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
256            {
257                end += 1;
258            }
259            names.push(chars[start..end].iter().collect());
260            i = end;
261        } else {
262            i += 1;
263        }
264    }
265    names
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::list::{cell_part_class, part_class};
272    use makeover_layout::{CellPart, RowPart};
273
274    #[test]
275    fn the_scrape_finds_the_components_the_sheet_is_built_from() {
276        let v = vocabulary(&Emit::default());
277        assert!(
278            v.len() > 20,
279            "scraped {} classes, which reads as a parser failure rather than a small sheet",
280            v.len()
281        );
282        for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
283            assert!(
284                v.contains(name),
285                "the sheet defines .{name} and the scan missed it"
286            );
287        }
288    }
289
290    #[test]
291    fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
292        // The two halves of the agreement, checked against each other. A naming
293        // function returning a class outside `names` would put a class in the
294        // markup that nothing downstream can recognise, which is the failure
295        // quasi-webview shipped and phase 1 exists to make impossible.
296        let opts = Emit::default();
297        let all = names(&opts);
298
299        for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
300            let name = option_class(selector);
301            assert!(
302                all.contains(name),
303                "option_class({selector:?}) is .{name}, which nothing admits to"
304            );
305        }
306        for part in [
307            RowPart::Primary,
308            RowPart::Secondary,
309            RowPart::Meta,
310            RowPart::Actions,
311            RowPart::Tokens,
312            RowPart::Proportion,
313        ] {
314            let name = part_class(part);
315            assert!(
316                all.contains(name),
317                "part_class({part:?}) is .{name}, which nothing admits to"
318            );
319        }
320        for part in [
321            CellPart::Value,
322            CellPart::Tokens,
323            CellPart::Actions,
324            CellPart::Link,
325        ] {
326            let name = cell_part_class(part);
327            assert!(
328                all.contains(name),
329                "cell_part_class({part:?}) is .{name}, which nothing admits to"
330            );
331        }
332    }
333
334    #[test]
335    fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
336        // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a
337        // `#[non_exhaustive]` enum cannot be enumerated. This is the test that
338        // stops them drifting from the matches they sit next to.
339        for part in [
340            RowPart::Primary,
341            RowPart::Secondary,
342            RowPart::Meta,
343            RowPart::Actions,
344            RowPart::Tokens,
345            RowPart::Proportion,
346        ] {
347            assert!(
348                ROW_PART_CLASSES.contains(&part_class(part)),
349                "{part:?} is missing from ROW_PART_CLASSES"
350            );
351        }
352        for part in [
353            CellPart::Value,
354            CellPart::Tokens,
355            CellPart::Actions,
356            CellPart::Link,
357        ] {
358            assert!(
359                CELL_PART_CLASSES.contains(&cell_part_class(part)),
360                "{part:?} is missing from CELL_PART_CLASSES"
361            );
362        }
363        // The fallbacks, which are what an upstream addition lands on.
364        assert!(ROW_PART_CLASSES.contains(&"row-part"));
365        assert!(CELL_PART_CLASSES.contains(&"cell-part"));
366    }
367
368    #[test]
369    fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
370        let plain = vocabulary(&Emit::default());
371        let prefixed = vocabulary(&Emit {
372            class_prefix: "mo-",
373            ..Emit::default()
374        });
375        assert_eq!(
376            plain.len(),
377            prefixed.len(),
378            "a prefix changed how many classes exist"
379        );
380        // `chosen` and `latched` never stand alone: the sheet writes
381        // `.mo-tab.chosen`, so the state stays bare while the thing moves.
382        // `current` is the third, and it is the same shape: which child of a
383        // region showing one at a time is the one showing.
384        let states = ["chosen", "latched", "current"];
385        for name in &plain {
386            let expected = if states.contains(&name.as_str()) {
387                name.clone()
388            } else {
389                format!("mo-{name}")
390            };
391            assert!(
392                prefixed.contains(&expected),
393                ".{name} did not move to .{expected} under the prefix"
394            );
395        }
396    }
397
398    #[test]
399    fn the_properties_a_class_carries_are_read_per_class() {
400        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";
401        let by_class = declarations_by_class(css);
402        let badge = by_class.get("badge").expect("badge is named");
403        // Every arm collapses into one entry, including the one inside the
404        // media block: they are all the same class carrying the same property.
405        assert!(badge.contains("padding"));
406        assert!(badge.contains("font-weight"));
407        assert!(badge.contains("border"));
408        assert_eq!(badge.len(), 3);
409    }
410
411    #[test]
412    fn a_value_holding_a_colon_or_a_semicolon_is_not_read_as_a_property() {
413        let css = ".x { background: url(\"a;b:c\"); color: red; }";
414        let by_class = declarations_by_class(css);
415        let x = by_class.get("x").expect("x is named");
416        assert_eq!(
417            *x,
418            ["background".to_string(), "color".to_string()]
419                .into_iter()
420                .collect::<BTreeSet<_>>()
421        );
422    }
423
424    #[test]
425    fn the_generated_sheet_sets_fill_on_a_badge_and_not_its_shape() {
426        // The fact goingson's stylesheet states in prose next to its own
427        // `.badge`: "Fill, edge and text colour come from the generated .badge
428        // in layout.css... Do not add background, border or box-shadow here."
429        // A property-grain reader is what turns that comment into a check.
430        let by_class = declarations_by_class(&crate::stylesheet(&Emit::default()));
431        let badge = by_class.get("badge").expect("the sheet defines .badge");
432        // Token::Badge is Depth::Flat, so a badge carries no bevel and no
433        // fill: what the generated sheet gives it is the text colour, and
434        // everything about its shape is the app's.
435        assert!(badge.contains("color"), "got {badge:?}");
436        assert!(
437            !badge.contains("padding"),
438            "shape is the app's, got {badge:?}"
439        );
440    }
441
442    #[test]
443    fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
444        let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
445        assert_eq!(found, ["real".to_string()].into_iter().collect());
446    }
447
448    #[test]
449    fn an_at_rule_does_not_hide_the_selectors_inside_it() {
450        let found = classes_in_css(
451            "@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
452        );
453        assert_eq!(found, ["wide".to_string()].into_iter().collect());
454    }
455
456    #[test]
457    fn a_string_is_opaque_and_a_comment_contributes_nothing() {
458        let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
459        assert_eq!(found, ["caret".to_string()].into_iter().collect());
460    }
461
462    #[test]
463    fn a_compound_selector_yields_every_class_it_names() {
464        let found = classes_in_css(
465            ".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
466        );
467        let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
468            .into_iter()
469            .map(String::from)
470            .collect();
471        assert_eq!(found, expected);
472    }
473}