Skip to main content

makeover_webview/
vocabulary.rs

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