Skip to main content

max / makeover-webview

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