Skip to main content

makeover_webview/
vocabulary.rs

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