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