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}