Skip to main content

max / makeover-webview

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