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