Skip to main content

max / makeover-webview

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