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::list::{CELL_PART_CLASSES, FLOW_CLASSES, ROW_PART_CLASSES};
38use crate::{Emit, option_class};
39use makeover_layout::Selector;
40use std::collections::{BTreeMap, BTreeSet};
41
42/// Every class name the generated stylesheet defines a rule for, prefixed the
43/// way `opts` prefixes them.
44///
45/// Includes the state classes a caller never spells alone (`chosen`,
46/// `latched`). Those are deliberately unprefixed: they qualify a prefixed
47/// component (`.mo-tab.chosen`) rather than standing on their own, so a prefix
48/// moves the thing and not its state.
49#[must_use]
50pub fn vocabulary(opts: &Emit) -> BTreeSet<String> {
51 classes_in_css(&crate::stylesheet(opts))
52}
53
54/// Every class this crate can put in markup or in a rule.
55///
56/// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is
57/// the set to check a renderer's emitted markup against: a class outside it is
58/// a name that renderer invented, which is how quasi-webview came to spell
59/// `tabs`, `segmented` and `option` and render every described selector flat.
60#[must_use]
61pub fn names(opts: &Emit) -> BTreeSet<String> {
62 let mut all = vocabulary(opts);
63 all.extend(
64 ROW_PART_CLASSES
65 .iter()
66 .chain(CELL_PART_CLASSES)
67 .chain(FLOW_CLASSES)
68 .map(|name| crate::class(name, opts)),
69 );
70 all.extend(
71 [Selector::Tabs, Selector::Segmented, Selector::Toggle]
72 .into_iter()
73 .map(|s| crate::class(option_class(s), opts)),
74 );
75 all
76}
77
78/// The class names a stylesheet's selectors match.
79///
80/// Public because the check this exists for reads an app's stylesheet too, and
81/// comparing what makeover defines against what the app defines is only
82/// meaningful if both sides were read the same way.
83///
84/// Selector text only. A declaration value can hold a dot (`0.5rem`,
85/// `transition: .2s`) and none of those are classes, so the scan tracks whether
86/// it is inside a declaration block and ignores what it finds there. An at-rule
87/// block (`@layer`, `@media`, `@supports`) contains rules rather than
88/// declarations, which is why a depth counter alone is not enough: the stack
89/// records what kind of block each brace opened.
90/// Which properties a stylesheet sets on each class it names.
91///
92/// The grain a drift check actually wants. A class name in common is not by
93/// itself a divergence: goingson's `.badge` sets shape and the generated
94/// `.badge` sets fill and edge, and the app's own comment says "do not add
95/// background, border or box-shadow here". That arrangement is settled and
96/// correct, so a check that flagged the shared name would demand deleting it.
97/// A shared *property* is the thing that goes wrong, because app CSS is
98/// unlayered and takes the property from the design system silently.
99///
100/// A property appearing under more than one selector arm collapses into one
101/// entry. That loses a real distinction -- the sort caret's reserved gap is
102/// `content` on the unsorted arm and the generated caret is `content` on the
103/// sorted one, which is a deliberate pairing rather than a clash -- so a
104/// consumer of this needs a way to say a pair was reviewed. Deciding that here
105/// would need a selector matcher, and a check that guesses wrong about
106/// specificity fails correct builds.
107#[must_use]
108pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
109 let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
110 for (selector, body) in rules(css) {
111 let classes = classes_in_selector(&selector);
112 if classes.is_empty() {
113 continue;
114 }
115 let properties = properties_in_body(&body);
116 for class in classes {
117 out.entry(class).or_default().extend(properties.clone());
118 }
119 }
120 out
121}
122
123/// The property names a declaration block sets.
124fn properties_in_body(body: &str) -> BTreeSet<String> {
125 body.split(';')
126 .filter_map(|decl| decl.split_once(':'))
127 .map(|(name, _)| name.trim().to_string())
128 .filter(|name| !name.is_empty() && !name.contains(['{', '}']))
129 .collect()
130}
131
132#[must_use]
133pub fn classes_in_css(css: &str) -> BTreeSet<String> {
134 rules(css)
135 .into_iter()
136 .flat_map(|(selector, _)| classes_in_selector(&selector))
137 .collect()
138}
139
140/// `(selector, declaration block)` for every rule in a stylesheet.
141///
142/// One reader for both sides. Comparing what makeover defines against what an
143/// app defines is only meaningful if the two were read the same way, which is
144/// why this is the only place either question is answered from.
145///
146/// A comment is skipped whole: the banner at the top of the generated sheet is
147/// prose about the cascade layer and would otherwise contribute words that look
148/// like selectors. A string is opaque, because `content: "\25B2"` is the sort
149/// caret rather than a selector and a brace inside one would desync the stack.
150/// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than
151/// declarations, so a depth counter alone is not enough and the stack records
152/// what kind of block each brace opened.
153fn rules(css: &str) -> Vec<(String, String)> {
154 let mut out = Vec::new();
155 // One entry per open brace: true when that block holds declarations rather
156 // than nested rules.
157 let mut blocks: Vec<bool> = Vec::new();
158 // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
159 // prelude, and a prelude starting with `@` opens an at-rule.
160 let mut prelude = String::new();
161 // The selector of each open declaration block, and the body so far.
162 let mut open: Vec<(String, String)> = Vec::new();
163
164 let mut chars = css.chars().peekable();
165 while let Some(c) = chars.next() {
166 match c {
167 '/' if chars.peek() == Some(&'*') => {
168 chars.next();
169 let mut star = false;
170 for c in chars.by_ref() {
171 if star && c == '/' {
172 break;
173 }
174 star = c == '*';
175 }
176 prelude.clear();
177 }
178 '"' | '\'' => {
179 let quote = c;
180 let mut escaped = false;
181 // Keep the quotes in the body: a value is not a property name,
182 // and dropping them would join two declarations into one.
183 if blocks.last().copied().unwrap_or(false)
184 && let Some((_, body)) = open.last_mut()
185 {
186 body.push(quote);
187 }
188 for c in chars.by_ref() {
189 if escaped {
190 escaped = false;
191 } else if c == '\\' {
192 escaped = true;
193 } else if c == quote {
194 break;
195 }
196 }
197 // The closing quote only. A value holding `;` or `:` would
198 // otherwise read as two declarations, and `url("a;b:c")` is a
199 // real thing an app writes.
200 if blocks.last().copied().unwrap_or(false)
201 && let Some((_, body)) = open.last_mut()
202 {
203 body.push(quote);
204 }
205 }
206 '{' => {
207 let declarations = !prelude.trim_start().starts_with('@');
208 if declarations {
209 open.push((prelude.clone(), String::new()));
210 }
211 blocks.push(declarations);
212 prelude.clear();
213 }
214 '}' => {
215 if blocks.pop().unwrap_or(false)
216 && let Some(rule) = open.pop()
217 {
218 out.push(rule);
219 }
220 prelude.clear();
221 }
222 _ => {
223 if blocks.last().copied().unwrap_or(false)
224 && let Some((_, body)) = open.last_mut()
225 {
226 body.push(c);
227 } else if c == ';' {
228 prelude.clear();
229 } else {
230 prelude.push(c);
231 }
232 }
233 }
234 }
235 out
236}
237
238/// The class names one selector matches on.
239fn classes_in_selector(selector: &str) -> Vec<String> {
240 let chars: Vec<char> = selector.chars().collect();
241 let mut names = Vec::new();
242 let mut i = 0;
243 while i < chars.len() {
244 // A leading digit is a length (`.5rem`), never a class: CSS forbids an
245 // identifier starting with one.
246 if chars[i] == '.'
247 && chars
248 .get(i + 1)
249 .is_some_and(|c| c.is_alphabetic() || *c == '_')
250 {
251 let start = i + 1;
252 let mut end = start;
253 while end < chars.len()
254 && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
255 {
256 end += 1;
257 }
258 names.push(chars[start..end].iter().collect());
259 i = end;
260 } else {
261 i += 1;
262 }
263 }
264 names
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::list::{cell_part_class, part_class};
271 use makeover_layout::{CellPart, RowPart};
272
273 #[test]
274 fn the_scrape_finds_the_components_the_sheet_is_built_from() {
275 let v = vocabulary(&Emit::default());
276 assert!(
277 v.len() > 20,
278 "scraped {} classes, which reads as a parser failure rather than a small sheet",
279 v.len()
280 );
281 for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
282 assert!(
283 v.contains(name),
284 "the sheet defines .{name} and the scan missed it"
285 );
286 }
287 }
288
289 #[test]
290 fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
291 // The two halves of the agreement, checked against each other. A naming
292 // function returning a class outside `names` would put a class in the
293 // markup that nothing downstream can recognise, which is the failure
294 // quasi-webview shipped and phase 1 exists to make impossible.
295 let opts = Emit::default();
296 let all = names(&opts);
297
298 for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
299 let name = option_class(selector);
300 assert!(
301 all.contains(name),
302 "option_class({selector:?}) is .{name}, which nothing admits to"
303 );
304 }
305 for part in [
306 RowPart::Primary,
307 RowPart::Secondary,
308 RowPart::Meta,
309 RowPart::Actions,
310 RowPart::Tokens,
311 RowPart::Proportion,
312 ] {
313 let name = part_class(part);
314 assert!(
315 all.contains(name),
316 "part_class({part:?}) is .{name}, which nothing admits to"
317 );
318 }
319 for part in [
320 CellPart::Value,
321 CellPart::Tokens,
322 CellPart::Actions,
323 CellPart::Link,
324 ] {
325 let name = cell_part_class(part);
326 assert!(
327 all.contains(name),
328 "cell_part_class({part:?}) is .{name}, which nothing admits to"
329 );
330 }
331 }
332
333 #[test]
334 fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
335 // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a
336 // `#[non_exhaustive]` enum cannot be enumerated. This is the test that
337 // stops them drifting from the matches they sit next to.
338 for part in [
339 RowPart::Primary,
340 RowPart::Secondary,
341 RowPart::Meta,
342 RowPart::Actions,
343 RowPart::Tokens,
344 RowPart::Proportion,
345 ] {
346 assert!(
347 ROW_PART_CLASSES.contains(&part_class(part)),
348 "{part:?} is missing from ROW_PART_CLASSES"
349 );
350 }
351 for part in [
352 CellPart::Value,
353 CellPart::Tokens,
354 CellPart::Actions,
355 CellPart::Link,
356 ] {
357 assert!(
358 CELL_PART_CLASSES.contains(&cell_part_class(part)),
359 "{part:?} is missing from CELL_PART_CLASSES"
360 );
361 }
362 // The fallbacks, which are what an upstream addition lands on.
363 assert!(ROW_PART_CLASSES.contains(&"row-part"));
364 assert!(CELL_PART_CLASSES.contains(&"cell-part"));
365 }
366
367 #[test]
368 fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
369 let plain = vocabulary(&Emit::default());
370 let prefixed = vocabulary(&Emit {
371 class_prefix: "mo-",
372 ..Emit::default()
373 });
374 assert_eq!(
375 plain.len(),
376 prefixed.len(),
377 "a prefix changed how many classes exist"
378 );
379 // `chosen` and `latched` never stand alone: the sheet writes
380 // `.mo-tab.chosen`, so the state stays bare while the thing moves.
381 // `current` is the third, and it is the same shape: which child of a
382 // region showing one at a time is the one showing.
383 let states = ["chosen", "latched", "current"];
384 for name in &plain {
385 let expected = if states.contains(&name.as_str()) {
386 name.clone()
387 } else {
388 format!("mo-{name}")
389 };
390 assert!(
391 prefixed.contains(&expected),
392 ".{name} did not move to .{expected} under the prefix"
393 );
394 }
395 }
396
397 #[test]
398 fn the_properties_a_class_carries_are_read_per_class() {
399 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";
400 let by_class = declarations_by_class(css);
401 let badge = by_class.get("badge").expect("badge is named");
402 // Every arm collapses into one entry, including the one inside the
403 // media block: they are all the same class carrying the same property.
404 assert!(badge.contains("padding"));
405 assert!(badge.contains("font-weight"));
406 assert!(badge.contains("border"));
407 assert_eq!(badge.len(), 3);
408 }
409
410 #[test]
411 fn a_value_holding_a_colon_or_a_semicolon_is_not_read_as_a_property() {
412 let css = ".x { background: url(\"a;b:c\"); color: red; }";
413 let by_class = declarations_by_class(css);
414 let x = by_class.get("x").expect("x is named");
415 assert_eq!(
416 *x,
417 ["background".to_string(), "color".to_string()]
418 .into_iter()
419 .collect::<BTreeSet<_>>()
420 );
421 }
422
423 #[test]
424 fn the_generated_sheet_sets_fill_on_a_badge_and_not_its_shape() {
425 // The fact goingson's stylesheet states in prose next to its own
426 // `.badge`: "Fill, edge and text colour come from the generated .badge
427 // in layout.css... Do not add background, border or box-shadow here."
428 // A property-grain reader is what turns that comment into a check.
429 let by_class = declarations_by_class(&crate::stylesheet(&Emit::default()));
430 let badge = by_class.get("badge").expect("the sheet defines .badge");
431 // Token::Badge is Depth::Flat, so a badge carries no bevel and no
432 // fill: what the generated sheet gives it is the text colour, and
433 // everything about its shape is the app's.
434 assert!(badge.contains("color"), "got {badge:?}");
435 assert!(
436 !badge.contains("padding"),
437 "shape is the app's, got {badge:?}"
438 );
439 }
440
441 #[test]
442 fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
443 let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
444 assert_eq!(found, ["real".to_string()].into_iter().collect());
445 }
446
447 #[test]
448 fn an_at_rule_does_not_hide_the_selectors_inside_it() {
449 let found = classes_in_css(
450 "@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
451 );
452 assert_eq!(found, ["wide".to_string()].into_iter().collect());
453 }
454
455 #[test]
456 fn a_string_is_opaque_and_a_comment_contributes_nothing() {
457 let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
458 assert_eq!(found, ["caret".to_string()].into_iter().collect());
459 }
460
461 #[test]
462 fn a_compound_selector_yields_every_class_it_names() {
463 let found = classes_in_css(
464 ".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
465 );
466 let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
467 .into_iter()
468 .map(String::from)
469 .collect();
470 assert_eq!(found, expected);
471 }
472}