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