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::facet::FACET_CLASSES;
38use crate::figure::FIGURE_CLASSES;
39use crate::form::{FIELD_CLASSES, FIELD_STATE_CLASSES};
40use crate::list::{
41    CELL_DROP_CLASSES, CELL_PART_CLASSES, CELL_WIDTH_CLASSES, FLOW_CLASSES, NESTING_CLASSES,
42    ROW_PART_CLASSES,
43};
44use crate::meter::METER_CLASSES;
45use crate::placeholder::PLACEHOLDER_CLASSES;
46use crate::{Emit, option_class};
47use makeover_layout::Selector;
48use std::collections::{BTreeMap, BTreeSet};
49
50/// Every class name the generated stylesheet defines a rule for, prefixed the
51/// way `opts` prefixes them.
52///
53/// Includes the state classes a caller never spells alone (`chosen`,
54/// `latched`). Those are deliberately unprefixed: they qualify a prefixed
55/// component (`.mo-tab.chosen`) rather than standing on their own, so a prefix
56/// moves the thing and not its state.
57#[must_use]
58pub fn vocabulary(opts: &Emit) -> BTreeSet<String> {
59    classes_in_css(&crate::stylesheet(opts))
60}
61
62/// Every class this crate can put in markup or in a rule.
63///
64/// [`vocabulary`] plus every class an emitter here writes without the sheet
65/// ruling it. This is the set to check a renderer's emitted markup against: a
66/// class outside it is a name that renderer invented, which is how
67/// quasi-webview came to spell `tabs`, `segmented` and `option` and render
68/// every described selector flat.
69///
70/// # The unruled half is written down, module by module
71///
72/// One list per module that emits markup, each beside its emitters, and this
73/// is their union. That shape is the fix for how the set was wrong until
74/// 0.59.0: it held four deliberate omissions and the emitters had fifteen, so
75/// `cell-fill`, `form-group`, `form-label` and a dozen others came out in
76/// documents that this function said were impossible. An app reading it
77/// concluded its live rules for them were dead and would have deleted them,
78/// which is why quasi-webview carried a `MAKEOVER_UNLISTED` constant of its own
79/// to put them back.
80///
81/// [`crate::corpus`] is what keeps the union honest, and it renders rather than
82/// reading the source: a width class, a drop class and a state appended to an
83/// open attribute are literals nowhere, which is what a reading of the
84/// emitters missed for eleven of the fifteen.
85#[must_use]
86pub fn names(opts: &Emit) -> BTreeSet<String> {
87    let mut all = vocabulary(opts);
88    all.extend(
89        ROW_PART_CLASSES
90            .iter()
91            .chain(CELL_PART_CLASSES)
92            .chain(CELL_WIDTH_CLASSES)
93            .chain(CELL_DROP_CLASSES)
94            .chain(FLOW_CLASSES)
95            .chain(NESTING_CLASSES)
96            .chain(crate::RUN_CLASSES)
97            .chain(FACET_CLASSES)
98            .chain(FIELD_CLASSES)
99            .chain(FIGURE_CLASSES)
100            .chain(METER_CLASSES)
101            .chain(PLACEHOLDER_CLASSES)
102            .map(|name| crate::class(name, opts)),
103    );
104    all.extend(
105        [Selector::Tabs, Selector::Segmented, Selector::Toggle]
106            .into_iter()
107            .map(|s| crate::class(option_class(s), opts)),
108    );
109    // Unprefixed, deliberately, exactly as the `chosen` and `latched` the
110    // scraped half brings in: a state qualifies a prefixed component rather
111    // than standing on its own.
112    all.extend(FIELD_STATE_CLASSES.iter().map(|name| (*name).to_owned()));
113    all
114}
115
116/// Which properties a stylesheet sets on each class it names.
117///
118/// The grain a drift check actually wants. A class name in common is not by
119/// itself a divergence: goingson's `.badge` sets shape and the generated
120/// `.badge` sets fill and edge, and the app's own comment says "do not add
121/// background, border or box-shadow here". That arrangement is settled and
122/// correct, so a check that flagged the shared name would demand deleting it.
123/// A shared *property* is the thing that goes wrong, because app CSS is
124/// unlayered and takes the property from the design system silently.
125///
126/// A property appearing under more than one selector arm collapses into one
127/// entry. That loses a real distinction -- the sort caret's reserved gap is
128/// `content` on the unsorted arm and the generated caret is `content` on the
129/// sorted one, which is a deliberate pairing rather than a clash -- so a
130/// consumer of this needs a way to say a pair was reviewed. Deciding that here
131/// would need a selector matcher, and a check that guesses wrong about
132/// specificity fails correct builds.
133///
134/// A declaration whose value is exactly `revert-layer` is not one of them. It
135/// takes nothing by construction: it is a later layer handing the property back
136/// to the one below, which is the opposite of the thing this reader is looking
137/// for. Counting it made every handoff in a consumer's sheet look like an
138/// override, and the allowlist entry written to silence one went on permitting
139/// a real override on the same pair afterwards. [`deferrals_by_class`] is where
140/// those declarations go instead.
141#[must_use]
142pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
143    by_class(css, |value| !is_handoff(value))
144}
145
146/// Which properties a stylesheet hands back to the layer below, per class.
147///
148/// The other half of [`declarations_by_class`]. A `revert-layer` says "whatever
149/// the design system set here, keep it", so a checker reading a consumer's
150/// sheet wants it as evidence that a clash was already remedied rather than as
151/// a clash of its own.
152#[must_use]
153pub fn deferrals_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
154    by_class(css, is_handoff)
155}
156
157/// [`declarations_by_class`] and [`deferrals_by_class`], which differ only in
158/// which declarations they keep.
159fn by_class(css: &str, keep: impl Fn(&str) -> bool) -> BTreeMap<String, BTreeSet<String>> {
160    let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
161    for (selector, body) in rules(css) {
162        let classes = classes_in_selector(&selector);
163        if classes.is_empty() {
164            continue;
165        }
166        let properties = properties_in_body(&body, &keep);
167        if properties.is_empty() {
168            continue;
169        }
170        for class in classes {
171            out.entry(class).or_default().extend(properties.clone());
172        }
173    }
174    out
175}
176
177/// Which properties a stylesheet sets on each bare element it names.
178///
179/// The blind spot [`declarations_by_class`] has by construction: it keys rules
180/// by the classes in their selectors, so a rule carrying no class at all is
181/// invisible to it. `button { color: var(--content) }` is exactly that, and it
182/// sets the same property the generated `.button` does on every described act
183/// in the app -- including the tone of a destructive one, which is how a delete
184/// came to look like an ordinary button for months with the check reporting
185/// nothing.
186///
187/// Only a selector arm that is one bare compound counts: `button`,
188/// `button:hover`, `input[type="text"]`. A scoped arm (`.page button`) reaches
189/// the elements inside one region rather than every one of them, so whether it
190/// lands on a described act depends on where that act is rendered, and a check
191/// that guessed would fail correct builds. The certain case is the one this
192/// reads.
193///
194/// Pair the result against [`classes_for_element`] to ask the question a
195/// checker wants: does this element rule take a property the design system sets
196/// on a class that element can carry.
197///
198/// The answer carries the strongest arm each property was set on, because the
199/// app's own remedy has to outrank the rule it remedies. `.field` does not beat
200/// `input[type="text"]`: both are the app's, both are in the same layer, and
201/// the attribute makes the element rule the more specific of the two. A check
202/// reading only "the app mentions this pair somewhere" waves that straight
203/// through, which is the shape of every handoff that looked written and was
204/// not.
205#[must_use]
206pub fn declarations_by_element(css: &str) -> BTreeMap<String, BTreeMap<String, Specificity>> {
207    let mut out: BTreeMap<String, BTreeMap<String, Specificity>> = BTreeMap::new();
208    for (selector, body) in rules(css) {
209        let properties = properties_in_body(&body, |value| !is_handoff(value));
210        if properties.is_empty() {
211            continue;
212        }
213        for arm in selector.split(',') {
214            let Some(element) = bare_element(arm) else {
215                continue;
216            };
217            let rank = specificity(arm);
218            let entry = out.entry(element).or_default();
219            for property in &properties {
220                let strongest = entry.entry(property.clone()).or_default();
221                *strongest = (*strongest).max(rank);
222            }
223        }
224    }
225    out
226}
227
228/// What a stylesheet says about each class, and how strongly.
229///
230/// Every property the sheet names on a class, whether it takes it or hands it
231/// back, keyed by the strongest arm that names it. The question it answers is
232/// not "does this collide" -- [`declarations_by_class`] is that -- but "has the
233/// app spoken for this pair, in a rule that wins where it has to".
234#[must_use]
235pub fn mentions_by_class(css: &str) -> BTreeMap<String, BTreeMap<String, Specificity>> {
236    let mut out: BTreeMap<String, BTreeMap<String, Specificity>> = BTreeMap::new();
237    for (selector, body) in rules(css) {
238        let properties = properties_in_body(&body, |_| true);
239        if properties.is_empty() {
240            continue;
241        }
242        for arm in selector.split(',') {
243            let classes = classes_in_selector(arm);
244            if classes.is_empty() {
245                continue;
246            }
247            let rank = specificity(arm);
248            for class in classes {
249                let entry = out.entry(class).or_default();
250                for property in &properties {
251                    let strongest = entry.entry(property.clone()).or_default();
252                    *strongest = (*strongest).max(rank);
253                }
254            }
255        }
256    }
257    out
258}
259
260/// How CSS ranks one selector: ids, then classes, then elements.
261///
262/// Ordered the way the cascade orders it, so the tuple comparison is the
263/// cascade's comparison. It settles a contest between two rules in the same
264/// layer, which is the only contest it is used for here: a layer beats
265/// specificity outright, so nothing in the app's sheet has to be compared
266/// against the generated one this way.
267pub type Specificity = (usize, usize, usize);
268
269/// The specificity of one selector arm.
270///
271/// A functional pseudo-class counts as one class and its argument is not read.
272/// CSS says `:not(.a.b)` takes the specificity of its strongest argument, so
273/// this undercounts a compound inside one -- which puts the error on the side
274/// of reporting a remedy as too weak rather than accepting one that is.
275#[must_use]
276pub fn specificity(selector: &str) -> Specificity {
277    let chars: Vec<char> = selector.chars().collect();
278    let (mut ids, mut classes, mut elements) = (0, 0, 0);
279    let mut i = 0;
280    while i < chars.len() {
281        match chars[i] {
282            '#' => {
283                ids += 1;
284                i = skip_name(&chars, i + 1);
285            }
286            '.' => {
287                classes += 1;
288                i = skip_name(&chars, i + 1);
289            }
290            ':' => {
291                // `::before` is an element, `:hover` is a class.
292                if chars.get(i + 1) == Some(&':') {
293                    elements += 1;
294                    i = skip_name(&chars, i + 2);
295                } else {
296                    classes += 1;
297                    i = skip_name(&chars, i + 1);
298                }
299                if chars.get(i) == Some(&'(') {
300                    i = skip_group(&chars, i);
301                }
302            }
303            '[' => {
304                classes += 1;
305                i = skip_group(&chars, i);
306            }
307            c if c.is_ascii_alphabetic() => {
308                elements += 1;
309                i = skip_name(&chars, i);
310            }
311            // A combinator, whitespace, or the universal selector, none of
312            // which count for anything.
313            _ => i += 1,
314        }
315    }
316    (ids, classes, elements)
317}
318
319/// Past the identifier starting at `from`.
320fn skip_name(chars: &[char], from: usize) -> usize {
321    let mut i = from;
322    while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
323        i += 1;
324    }
325    i
326}
327
328/// Past the bracketed or parenthesised group opening at `from`, nesting and
329/// all.
330fn skip_group(chars: &[char], from: usize) -> usize {
331    let mut depth = 0usize;
332    let mut i = from;
333    while i < chars.len() {
334        match chars[i] {
335            '[' | '(' => depth += 1,
336            ']' | ')' => {
337                depth -= 1;
338                if depth == 0 {
339                    return i + 1;
340                }
341            }
342            _ => {}
343        }
344        i += 1;
345    }
346    i
347}
348
349/// A value that hands the property back rather than taking it.
350///
351/// Bare only. `revert-layer !important` in a later layer inverts layer order
352/// and takes the property from every layer below, which is the opposite
353/// declaration wearing the same word.
354fn is_handoff(value: &str) -> bool {
355    value.trim() == "revert-layer"
356}
357
358/// The property names a declaration block sets, keeping the ones `keep` admits.
359fn properties_in_body(body: &str, keep: impl Fn(&str) -> bool) -> BTreeSet<String> {
360    body.split(';')
361        .filter_map(|decl| decl.split_once(':'))
362        .filter(|(_, value)| keep(value))
363        .map(|(name, _)| name.trim().to_string())
364        .filter(|name| !name.is_empty() && !name.contains(['{', '}']))
365        .collect()
366}
367
368#[must_use]
369pub fn classes_in_css(css: &str) -> BTreeSet<String> {
370    rules(css)
371        .into_iter()
372        .flat_map(|(selector, _)| classes_in_selector(&selector))
373        .collect()
374}
375
376/// `(selector, declaration block)` for every rule in a stylesheet.
377///
378/// One reader for both sides. Comparing what makeover defines against what an
379/// app defines is only meaningful if the two were read the same way, which is
380/// why this is the only place either question is answered from.
381///
382/// A comment is skipped whole: the banner at the top of the generated sheet is
383/// prose about the cascade layer and would otherwise contribute words that look
384/// like selectors. A string is opaque, because `content: "\25B2"` is the sort
385/// caret rather than a selector and a brace inside one would desync the stack.
386/// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than
387/// declarations, so a depth counter alone is not enough and the stack records
388/// what kind of block each brace opened.
389fn rules(css: &str) -> Vec<(String, String)> {
390    let mut out = Vec::new();
391    // One entry per open brace: true when that block holds declarations rather
392    // than nested rules.
393    let mut blocks: Vec<bool> = Vec::new();
394    // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
395    // prelude, and a prelude starting with `@` opens an at-rule.
396    let mut prelude = String::new();
397    // The selector of each open declaration block, and the body so far.
398    let mut open: Vec<(String, String)> = Vec::new();
399
400    let mut chars = css.chars().peekable();
401    while let Some(c) = chars.next() {
402        match c {
403            '/' if chars.peek() == Some(&'*') => {
404                chars.next();
405                let mut star = false;
406                for c in chars.by_ref() {
407                    if star && c == '/' {
408                        break;
409                    }
410                    star = c == '*';
411                }
412                prelude.clear();
413            }
414            '"' | '\'' => {
415                let quote = c;
416                let mut escaped = false;
417                // Keep the quotes in the body: a value is not a property name,
418                // and dropping them would join two declarations into one.
419                if blocks.last().copied().unwrap_or(false)
420                    && let Some((_, body)) = open.last_mut()
421                {
422                    body.push(quote);
423                }
424                for c in chars.by_ref() {
425                    if escaped {
426                        escaped = false;
427                    } else if c == '\\' {
428                        escaped = true;
429                    } else if c == quote {
430                        break;
431                    }
432                }
433                // The closing quote only. A value holding `;` or `:` would
434                // otherwise read as two declarations, and `url("a;b:c")` is a
435                // real thing an app writes.
436                if blocks.last().copied().unwrap_or(false)
437                    && let Some((_, body)) = open.last_mut()
438                {
439                    body.push(quote);
440                }
441            }
442            '{' => {
443                let declarations = !prelude.trim_start().starts_with('@');
444                if declarations {
445                    open.push((prelude.clone(), String::new()));
446                }
447                blocks.push(declarations);
448                prelude.clear();
449            }
450            '}' => {
451                if blocks.pop().unwrap_or(false)
452                    && let Some(rule) = open.pop()
453                {
454                    out.push(rule);
455                }
456                prelude.clear();
457            }
458            _ => {
459                if blocks.last().copied().unwrap_or(false)
460                    && let Some((_, body)) = open.last_mut()
461                {
462                    body.push(c);
463                } else if c == ';' {
464                    prelude.clear();
465                } else {
466                    prelude.push(c);
467                }
468            }
469        }
470    }
471    out
472}
473
474/// Which generated classes each element can plausibly carry.
475///
476/// The half of the element check that CSS cannot answer. A stylesheet says
477/// `button { color: ... }` and `.chip { color: ... }` and nothing in either
478/// text says a chip is rendered as a `<button>`; the renderer knows that, and
479/// this crate is the renderer. So the pairing is declared here rather than
480/// inferred, and [`declarations_by_element`] supplies the other half.
481///
482/// Read it as "may carry", not "does carry". A pairing that never occurs in a
483/// given app costs a check that finds nothing; a pairing left out is a defect
484/// that ships, which is the trade this list is written on the generous side
485/// of.
486///
487/// `div` and `span` are deliberately absent. Nearly every container class in
488/// the vocabulary sits on one of them, so the pairing would be the whole
489/// vocabulary against one rule and would say nothing about which class was
490/// meant. An app writing a bare `div { }` rule has a wider problem than this
491/// check, and the classes it would clobber are containers rather than the
492/// controls whose tone and bevel carry meaning.
493pub const ELEMENT_CLASSES: &[(&str, &[&str])] = &[
494    // The controls. `a` and `button` are interchangeable in markup for most of
495    // these -- a link that posts is a button, an act that navigates is an
496    // anchor -- which is why the two lists overlap as much as they do.
497    (
498        "a",
499        &[
500            "link",
501            "button",
502            "tab",
503            "chip",
504            "badge",
505            "card",
506            "row-activate",
507            "figure-act",
508            "chrome-place",
509        ],
510    ),
511    (
512        "button",
513        &[
514            "button",
515            "chip",
516            "segment",
517            "toggle",
518            "tab",
519            "link",
520            "badge",
521            "card",
522            "facet-take",
523            "facet-prune",
524            "chip-remove",
525            "row-activate",
526        ],
527    ),
528    // A disclosure. quasi-webview renders an ask as `<details>` with a
529    // `<summary>` that is styled as an act.
530    ("details", &["ask"]),
531    ("summary", &["button", "ask-open", "ask-body"]),
532    // The form controls. `.field` is the well every one of them sits in.
533    ("input", &["field", "toggle", "row-select"]),
534    ("select", &["field"]),
535    ("textarea", &["field"]),
536    (
537        "label",
538        &[
539            "form-label",
540            "form-checkbox-label",
541            "form-radio-label",
542            "toggle",
543        ],
544    ),
545    ("form", &["form"]),
546    ("progress", &["progress"]),
547    // Text and lists.
548    ("p", &["text", "facet-name", "placeholder-text"]),
549    ("ul", &["list", "facet-values"]),
550    ("ol", &["list"]),
551    ("li", &["facet-value"]),
552    // A table written in HTML rather than described. quasi-webview renders a
553    // described table as divs carrying the same classes, so both spellings of
554    // the same table answer to the same rules and both are worth checking.
555    ("table", &["table"]),
556    ("thead", &["table-head"]),
557    ("tr", &["table-row"]),
558    ("td", &["cell", "cell-value", "cell-content"]),
559    ("th", &["table-heading"]),
560    // A figure, likewise: the described picture is divs, the hand-written one
561    // is the HTML element that means the same thing.
562    ("figure", &["picture", "figure"]),
563    ("img", &["picture-img"]),
564    ("figcaption", &["picture-caption", "figure-caption"]),
565    ("nav", &["chrome-nav"]),
566];
567
568/// The generated classes `element` can carry, prefixed the way `opts` prefixes
569/// them.
570///
571/// Empty for an element the design system never renders onto, which is the
572/// answer for most of them: a rule on one of those cannot collide with a
573/// generated class because no generated class is ever on it.
574#[must_use]
575pub fn classes_for_element(element: &str, opts: &Emit) -> BTreeSet<String> {
576    ELEMENT_CLASSES
577        .iter()
578        .find(|(name, _)| *name == element)
579        .map(|(_, classes)| classes.iter().map(|c| crate::class(c, opts)).collect())
580        .unwrap_or_default()
581}
582
583/// The element name of one bare compound arm, if that is what it is.
584fn bare_element(arm: &str) -> Option<String> {
585    // An attribute value or a `:not()` argument can hold anything, including
586    // the spaces and dots this then rejects on. Neither changes which element
587    // the arm styles, so both go before the test rather than into it.
588    let mut flat = String::with_capacity(arm.len());
589    let mut depth = 0usize;
590    for c in arm.chars() {
591        match c {
592            '[' | '(' => depth += 1,
593            ']' | ')' => depth = depth.saturating_sub(1),
594            _ if depth == 0 => flat.push(c),
595            _ => {}
596        }
597    }
598    let flat = flat.trim();
599    // A descendant, a child, a class, an id or a universal: not this.
600    if flat.is_empty() || flat.contains(['.', '#', '>', '+', '~', '*']) {
601        return None;
602    }
603    if flat.chars().any(char::is_whitespace) {
604        return None;
605    }
606    let name: String = flat
607        .chars()
608        .take_while(|c| c.is_alphanumeric() || *c == '-')
609        .collect();
610    // A pseudo-element on nothing (`::selection`) or a pseudo-class on nothing
611    // (`:root`) names no element.
612    if !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
613        return None;
614    }
615    Some(name.to_ascii_lowercase())
616}
617
618/// The class names one selector matches on.
619fn classes_in_selector(selector: &str) -> Vec<String> {
620    let chars: Vec<char> = selector.chars().collect();
621    let mut names = Vec::new();
622    let mut i = 0;
623    while i < chars.len() {
624        // A leading digit is a length (`.5rem`), never a class: CSS forbids an
625        // identifier starting with one.
626        if chars[i] == '.'
627            && chars
628                .get(i + 1)
629                .is_some_and(|c| c.is_alphabetic() || *c == '_')
630        {
631            let start = i + 1;
632            let mut end = start;
633            while end < chars.len()
634                && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
635            {
636                end += 1;
637            }
638            names.push(chars[start..end].iter().collect());
639            i = end;
640        } else {
641            i += 1;
642        }
643    }
644    names
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::list::{cell_part_class, part_class};
651    use makeover_layout::{CellPart, RowPart};
652
653    #[test]
654    fn the_scrape_finds_the_components_the_sheet_is_built_from() {
655        let v = vocabulary(&Emit::default());
656        assert!(
657            v.len() > 20,
658            "scraped {} classes, which reads as a parser failure rather than a small sheet",
659            v.len()
660        );
661        for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
662            assert!(
663                v.contains(name),
664                "the sheet defines .{name} and the scan missed it"
665            );
666        }
667    }
668
669    #[test]
670    fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
671        // The two halves of the agreement, checked against each other. A naming
672        // function returning a class outside `names` would put a class in the
673        // markup that nothing downstream can recognise, which is the failure
674        // quasi-webview shipped and phase 1 exists to make impossible.
675        let opts = Emit::default();
676        let all = names(&opts);
677
678        for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
679            let name = option_class(selector);
680            assert!(
681                all.contains(name),
682                "option_class({selector:?}) is .{name}, which nothing admits to"
683            );
684        }
685        for part in [
686            RowPart::Primary,
687            RowPart::Secondary,
688            RowPart::Meta,
689            RowPart::Actions,
690            RowPart::Tokens,
691            RowPart::Proportion,
692        ] {
693            let name = part_class(part);
694            assert!(
695                all.contains(name),
696                "part_class({part:?}) is .{name}, which nothing admits to"
697            );
698        }
699        for part in [
700            CellPart::Value,
701            CellPart::Tokens,
702            CellPart::Actions,
703            CellPart::Link,
704        ] {
705            let name = cell_part_class(part);
706            assert!(
707                all.contains(name),
708                "cell_part_class({part:?}) is .{name}, which nothing admits to"
709            );
710        }
711    }
712
713    #[test]
714    fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
715        // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a
716        // `#[non_exhaustive]` enum cannot be enumerated. This is the test that
717        // stops them drifting from the matches they sit next to.
718        for part in [
719            RowPart::Primary,
720            RowPart::Secondary,
721            RowPart::Meta,
722            RowPart::Actions,
723            RowPart::Tokens,
724            RowPart::Proportion,
725        ] {
726            assert!(
727                ROW_PART_CLASSES.contains(&part_class(part)),
728                "{part:?} is missing from ROW_PART_CLASSES"
729            );
730        }
731        for part in [
732            CellPart::Value,
733            CellPart::Tokens,
734            CellPart::Actions,
735            CellPart::Link,
736        ] {
737            assert!(
738                CELL_PART_CLASSES.contains(&cell_part_class(part)),
739                "{part:?} is missing from CELL_PART_CLASSES"
740            );
741        }
742        // The fallbacks, which are what an upstream addition lands on.
743        assert!(ROW_PART_CLASSES.contains(&"row-part"));
744        assert!(CELL_PART_CLASSES.contains(&"cell-part"));
745    }
746
747    #[test]
748    fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
749        let plain = vocabulary(&Emit::default());
750        let prefixed = vocabulary(&Emit {
751            class_prefix: "mo-",
752            ..Emit::default()
753        });
754        assert_eq!(
755            plain.len(),
756            prefixed.len(),
757            "a prefix changed how many classes exist"
758        );
759        // `chosen` and `latched` never stand alone: the sheet writes
760        // `.mo-tab.chosen`, so the state stays bare while the thing moves.
761        // `current` is the third, and it is the same shape: which child of a
762        // region showing one at a time is the one showing.
763        let states = ["chosen", "latched", "current"];
764        for name in &plain {
765            let expected = if states.contains(&name.as_str()) {
766                name.clone()
767            } else {
768                format!("mo-{name}")
769            };
770            assert!(
771                prefixed.contains(&expected),
772                ".{name} did not move to .{expected} under the prefix"
773            );
774        }
775    }
776
777    #[test]
778    fn a_handoff_is_not_an_override() {
779        // The defect this split fixes. `revert-layer` in a later layer gives
780        // the property back to the design system, so counting it as a taking
781        // made every remedy in a consumer's sheet read as the thing it
782        // remedied -- and the allowlist entry written to silence one went on
783        // permitting a real override on the same pair for good.
784        let css = ".button { background: revert-layer; color: red; }";
785        let taken = declarations_by_class(css);
786        let given = deferrals_by_class(css);
787        assert_eq!(
788            taken.get("button"),
789            Some(&["color".to_string()].into_iter().collect())
790        );
791        assert_eq!(
792            given.get("button"),
793            Some(&["background".to_string()].into_iter().collect())
794        );
795    }
796
797    #[test]
798    fn an_important_handoff_is_an_override() {
799        // `revert-layer !important` in a later layer inverts layer order and
800        // takes the property from every layer below it. Same word, opposite
801        // declaration, and the one shape of it this reader must not wave
802        // through.
803        let css = ".button { background: revert-layer !important; }";
804        assert_eq!(
805            declarations_by_class(css).get("button"),
806            Some(&["background".to_string()].into_iter().collect())
807        );
808        assert!(!deferrals_by_class(css).contains_key("button"));
809    }
810
811    #[test]
812    fn a_class_that_only_hands_properties_back_is_not_in_the_taking_set() {
813        // An empty entry would read as "this class collides on nothing", which
814        // is true, and as "this class is in the map", which is what a caller
815        // iterating the map would act on.
816        let by_class = declarations_by_class(".field { background: revert-layer; }");
817        assert!(!by_class.contains_key("field"), "got {by_class:?}");
818    }
819
820    #[test]
821    fn an_element_rule_is_read_where_a_class_reader_sees_nothing() {
822        let css = "button { color: red; background: blue; }";
823        assert!(declarations_by_class(css).is_empty());
824        let by_element = declarations_by_element(css);
825        let button = by_element.get("button").expect("button is named");
826        assert_eq!(
827            button.keys().cloned().collect::<Vec<_>>(),
828            ["background", "color"]
829        );
830        // One element, nothing else: (0, 0, 1).
831        assert_eq!(button["color"], (0, 0, 1));
832    }
833
834    #[test]
835    fn the_strongest_arm_is_the_one_reported() {
836        // A remedy has to outrank the rule it remedies, so a reader that kept
837        // the weakest arm would call a losing handoff sufficient.
838        let css = "input { color: red; }\ninput[type=\"text\"]:focus { color: blue; }\n";
839        assert_eq!(declarations_by_element(css)["input"]["color"], (0, 2, 1));
840    }
841
842    #[test]
843    fn a_selector_is_ranked_the_way_the_cascade_ranks_it() {
844        for (selector, expected) in [
845            ("button", (0, 0, 1)),
846            ("*", (0, 0, 0)),
847            (".field", (0, 1, 0)),
848            ("input.field", (0, 1, 1)),
849            ("input[type=\"text\"]", (0, 1, 1)),
850            ("button:hover", (0, 1, 1)),
851            ("button::before", (0, 0, 2)),
852            ("#main .card > button:focus-visible", (1, 2, 1)),
853            (".chip.latched[aria-pressed=\"true\"]", (0, 3, 0)),
854            ("button:not(.link)", (0, 1, 1)),
855        ] {
856            assert_eq!(specificity(selector), expected, "{selector}");
857        }
858    }
859
860    #[test]
861    fn what_a_class_is_spoken_for_by_counts_a_handoff_as_speech() {
862        // A handoff takes nothing, so `declarations_by_class` is right to drop
863        // it -- and it is still the app saying what happens to that property on
864        // that class, which is what this reader is for.
865        let css = ".field { background: revert-layer; }\ninput.field:focus { color: red; }\n";
866        let mentions = mentions_by_class(css);
867        assert_eq!(mentions["field"]["background"], (0, 1, 0));
868        assert_eq!(mentions["field"]["color"], (0, 2, 1));
869    }
870
871    #[test]
872    fn only_a_bare_compound_counts_as_an_element_rule() {
873        // Each of these styles a `button` and none of them is the certain
874        // case. A scoped arm reaches one region, and an arm carrying a class
875        // is the class reader's business, not this one's.
876        for selector in [
877            ".page button",
878            "button.link",
879            ".card > button",
880            "button + button",
881            "* button",
882        ] {
883            let css = format!("{selector} {{ color: red; }}");
884            assert!(
885                declarations_by_element(&css).is_empty(),
886                "{selector} was read as a bare element rule"
887            );
888        }
889    }
890
891    #[test]
892    fn a_state_or_an_attribute_does_not_stop_an_arm_being_bare() {
893        // All of these reach every button in the document, which is what makes
894        // them certain to reach a described one.
895        for selector in [
896            "button:hover",
897            "button:focus-visible",
898            "button:disabled",
899            "button[aria-disabled=\"true\"]",
900            "button:not(.link)",
901            "button[data-tone=\"danger\"]:hover",
902        ] {
903            let css = format!("{selector} {{ color: red; }}");
904            assert!(
905                declarations_by_element(&css).contains_key("button"),
906                "{selector} was not read as a bare element rule"
907            );
908        }
909    }
910
911    #[test]
912    fn a_pseudo_element_on_nothing_names_no_element() {
913        for selector in [":root", "::selection", "::backdrop", ":root:not(.x)"] {
914            let css = format!("{selector} {{ color: red; }}");
915            assert!(
916                declarations_by_element(&css).is_empty(),
917                "{selector} named an element"
918            );
919        }
920    }
921
922    #[test]
923    fn every_arm_of_a_list_is_read_on_its_own() {
924        let css = "input, select, .field, .page textarea { color: red; }";
925        let by_element = declarations_by_element(css);
926        assert!(by_element.contains_key("input"));
927        assert!(by_element.contains_key("select"));
928        assert!(!by_element.contains_key("textarea"), "that arm is scoped");
929        assert_eq!(by_element.len(), 2);
930    }
931
932    #[test]
933    fn an_element_handing_a_property_back_is_not_taking_it() {
934        let css = "button { background: revert-layer; }";
935        assert!(declarations_by_element(css).is_empty());
936    }
937
938    #[test]
939    fn the_pairing_map_carries_the_elements_this_crate_renders_onto() {
940        // The map is hand-written and the emitters are not, so this is what
941        // stops the two drifting. Every `<tag class="...">` in this crate's own
942        // source, for a tag the map claims to cover, has to be a pairing the
943        // map declares -- or the check reads a smaller world than the renderer
944        // writes and the gap is silent.
945        let mut checked = 0;
946        for (tag, class) in emitted_pairs() {
947            if !ELEMENT_CLASSES.iter().any(|(name, _)| *name == tag) {
948                continue;
949            }
950            checked += 1;
951            assert!(
952                classes_for_element(&tag, &Emit::default()).contains(&class),
953                "this crate emits <{tag} class=\"{class}\"> and ELEMENT_CLASSES \
954                 does not pair them"
955            );
956        }
957        assert!(
958            checked > 5,
959            "scraped {checked} pairings off the emitters, which reads as the scan \
960             having stopped matching rather than the renderer having shrunk"
961        );
962    }
963
964    /// `(element, class)` for every literal `<tag class="...">` this crate's
965    /// own source emits.
966    ///
967    /// Source rather than rendered markup, because an emitter no test happens
968    /// to call is exactly the one whose pairing nobody wrote down. A class
969    /// built at runtime (an option class, a row part) is not a literal and is
970    /// not seen here; those are declared in the map by hand.
971    fn emitted_pairs() -> Vec<(String, String)> {
972        const OPEN: &str = "class=\\\"";
973        let mut out = Vec::new();
974        for file in std::fs::read_dir("src").expect("read src") {
975            let path = file.expect("dir entry").path();
976            if path.extension().is_none_or(|e| e != "rs") {
977                continue;
978            }
979            let src = std::fs::read_to_string(&path).expect("read source");
980            for (at, _) in src.match_indices(OPEN) {
981                // The tag is the last `<name` before the attribute.
982                let Some(open) = src[..at].rfind('<') else {
983                    continue;
984                };
985                let tag: String = src[open + 1..]
986                    .chars()
987                    .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
988                    .collect();
989                if tag.is_empty() {
990                    continue;
991                }
992                // What is pushed next: `push_class(out, "name", opts)`.
993                let tail = &src[at..(at + 300).min(src.len())];
994                let Some(call) = tail.find("push_class(out, \"") else {
995                    continue;
996                };
997                let name: String = tail[call + "push_class(out, \"".len()..]
998                    .chars()
999                    .take_while(|c| *c != '"')
1000                    .collect();
1001                if !name.is_empty() {
1002                    out.push((tag, name));
1003                }
1004            }
1005        }
1006        out
1007    }
1008
1009    #[test]
1010    fn the_properties_a_class_carries_are_read_per_class() {
1011        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";
1012        let by_class = declarations_by_class(css);
1013        let badge = by_class.get("badge").expect("badge is named");
1014        // Every arm collapses into one entry, including the one inside the
1015        // media block: they are all the same class carrying the same property.
1016        assert!(badge.contains("padding"));
1017        assert!(badge.contains("font-weight"));
1018        assert!(badge.contains("border"));
1019        assert_eq!(badge.len(), 3);
1020    }
1021
1022    #[test]
1023    fn a_value_holding_a_colon_or_a_semicolon_is_not_read_as_a_property() {
1024        let css = ".x { background: url(\"a;b:c\"); color: red; }";
1025        let by_class = declarations_by_class(css);
1026        let x = by_class.get("x").expect("x is named");
1027        assert_eq!(
1028            *x,
1029            ["background".to_string(), "color".to_string()]
1030                .into_iter()
1031                .collect::<BTreeSet<_>>()
1032        );
1033    }
1034
1035    #[test]
1036    fn the_generated_sheet_sets_fill_on_a_badge_and_not_its_shape() {
1037        // The fact goingson's stylesheet states in prose next to its own
1038        // `.badge`: "Fill, edge and text colour come from the generated .badge
1039        // in layout.css... Do not add background, border or box-shadow here."
1040        // A property-grain reader is what turns that comment into a check.
1041        let by_class = declarations_by_class(&crate::stylesheet(&Emit::default()));
1042        let badge = by_class.get("badge").expect("the sheet defines .badge");
1043        // Token::Badge is Depth::Flat, so a badge carries no bevel and no
1044        // fill: what the generated sheet gives it is the text colour, and
1045        // everything about its shape is the app's.
1046        assert!(badge.contains("color"), "got {badge:?}");
1047        assert!(
1048            !badge.contains("padding"),
1049            "shape is the app's, got {badge:?}"
1050        );
1051    }
1052
1053    #[test]
1054    fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
1055        let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
1056        assert_eq!(found, ["real".to_string()].into_iter().collect());
1057    }
1058
1059    #[test]
1060    fn an_at_rule_does_not_hide_the_selectors_inside_it() {
1061        let found = classes_in_css(
1062            "@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
1063        );
1064        assert_eq!(found, ["wide".to_string()].into_iter().collect());
1065    }
1066
1067    #[test]
1068    fn a_string_is_opaque_and_a_comment_contributes_nothing() {
1069        let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
1070        assert_eq!(found, ["caret".to_string()].into_iter().collect());
1071    }
1072
1073    #[test]
1074    fn a_compound_selector_yields_every_class_it_names() {
1075        let found = classes_in_css(
1076            ".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
1077        );
1078        let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
1079            .into_iter()
1080            .map(String::from)
1081            .collect();
1082        assert_eq!(found, expected);
1083    }
1084}