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, ROW_PART_CLASSES};
38use crate::{Emit, option_class};
39use makeover_layout::Selector;
40use std::collections::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 .map(|name| crate::class(name, opts)),
68 );
69 all.extend(
70 [Selector::Tabs, Selector::Segmented, Selector::Toggle]
71 .into_iter()
72 .map(|s| crate::class(option_class(s), opts)),
73 );
74 all
75}
76
77/// The class names a stylesheet's selectors match.
78///
79/// Public because the check this exists for reads an app's stylesheet too, and
80/// comparing what makeover defines against what the app defines is only
81/// meaningful if both sides were read the same way.
82///
83/// Selector text only. A declaration value can hold a dot (`0.5rem`,
84/// `transition: .2s`) and none of those are classes, so the scan tracks whether
85/// it is inside a declaration block and ignores what it finds there. An at-rule
86/// block (`@layer`, `@media`, `@supports`) contains rules rather than
87/// declarations, which is why a depth counter alone is not enough: the stack
88/// records what kind of block each brace opened.
89#[must_use]
90pub fn classes_in_css(css: &str) -> BTreeSet<String> {
91 let mut found = BTreeSet::new();
92 // One entry per open brace: true when that block holds declarations rather
93 // than nested rules.
94 let mut blocks: Vec<bool> = Vec::new();
95 // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
96 // prelude, and a prelude starting with `@` opens an at-rule.
97 let mut prelude = String::new();
98
99 let mut chars = css.chars().peekable();
100 while let Some(c) = chars.next() {
101 match c {
102 '/' if chars.peek() == Some(&'*') => {
103 // Skip a comment whole. The banner at the top of the sheet is
104 // prose about the cascade layer and would otherwise contribute
105 // words that look like selectors.
106 chars.next();
107 let mut star = false;
108 for c in chars.by_ref() {
109 if star && c == '/' {
110 break;
111 }
112 star = c == '*';
113 }
114 prelude.clear();
115 }
116 '"' | '\'' => {
117 // A string is opaque. `content: "\2191"` is the sort caret, not
118 // a selector, and a brace inside one would desync the stack.
119 let quote = c;
120 let mut escaped = false;
121 for c in chars.by_ref() {
122 if escaped {
123 escaped = false;
124 } else if c == '\\' {
125 escaped = true;
126 } else if c == quote {
127 break;
128 }
129 }
130 }
131 '{' => {
132 let declarations = !prelude.trim_start().starts_with('@');
133 if declarations {
134 found.extend(classes_in_selector(&prelude));
135 }
136 blocks.push(declarations);
137 prelude.clear();
138 }
139 '}' => {
140 blocks.pop();
141 prelude.clear();
142 }
143 ';' => prelude.clear(),
144 // Only accumulate where a selector can live. Inside a declaration
145 // block the text is properties and values.
146 _ if !blocks.last().copied().unwrap_or(false) => prelude.push(c),
147 _ => {}
148 }
149 }
150 found
151}
152
153/// The class names one selector matches on.
154fn classes_in_selector(selector: &str) -> Vec<String> {
155 let chars: Vec<char> = selector.chars().collect();
156 let mut names = Vec::new();
157 let mut i = 0;
158 while i < chars.len() {
159 // A leading digit is a length (`.5rem`), never a class: CSS forbids an
160 // identifier starting with one.
161 if chars[i] == '.'
162 && chars
163 .get(i + 1)
164 .is_some_and(|c| c.is_alphabetic() || *c == '_')
165 {
166 let start = i + 1;
167 let mut end = start;
168 while end < chars.len()
169 && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
170 {
171 end += 1;
172 }
173 names.push(chars[start..end].iter().collect());
174 i = end;
175 } else {
176 i += 1;
177 }
178 }
179 names
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::list::{cell_part_class, part_class};
186 use makeover_layout::{CellPart, RowPart};
187
188 #[test]
189 fn the_scrape_finds_the_components_the_sheet_is_built_from() {
190 let v = vocabulary(&Emit::default());
191 assert!(
192 v.len() > 20,
193 "scraped {} classes, which reads as a parser failure rather than a small sheet",
194 v.len()
195 );
196 for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
197 assert!(
198 v.contains(name),
199 "the sheet defines .{name} and the scan missed it"
200 );
201 }
202 }
203
204 #[test]
205 fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
206 // The two halves of the agreement, checked against each other. A naming
207 // function returning a class outside `names` would put a class in the
208 // markup that nothing downstream can recognise, which is the failure
209 // quasi-webview shipped and phase 1 exists to make impossible.
210 let opts = Emit::default();
211 let all = names(&opts);
212
213 for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
214 let name = option_class(selector);
215 assert!(
216 all.contains(name),
217 "option_class({selector:?}) is .{name}, which nothing admits to"
218 );
219 }
220 for part in [
221 RowPart::Primary,
222 RowPart::Secondary,
223 RowPart::Meta,
224 RowPart::Actions,
225 RowPart::Tokens,
226 RowPart::Proportion,
227 ] {
228 let name = part_class(part);
229 assert!(
230 all.contains(name),
231 "part_class({part:?}) is .{name}, which nothing admits to"
232 );
233 }
234 for part in [
235 CellPart::Value,
236 CellPart::Tokens,
237 CellPart::Actions,
238 CellPart::Link,
239 ] {
240 let name = cell_part_class(part);
241 assert!(
242 all.contains(name),
243 "cell_part_class({part:?}) is .{name}, which nothing admits to"
244 );
245 }
246 }
247
248 #[test]
249 fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
250 // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a
251 // `#[non_exhaustive]` enum cannot be enumerated. This is the test that
252 // stops them drifting from the matches they sit next to.
253 for part in [
254 RowPart::Primary,
255 RowPart::Secondary,
256 RowPart::Meta,
257 RowPart::Actions,
258 RowPart::Tokens,
259 RowPart::Proportion,
260 ] {
261 assert!(
262 ROW_PART_CLASSES.contains(&part_class(part)),
263 "{part:?} is missing from ROW_PART_CLASSES"
264 );
265 }
266 for part in [
267 CellPart::Value,
268 CellPart::Tokens,
269 CellPart::Actions,
270 CellPart::Link,
271 ] {
272 assert!(
273 CELL_PART_CLASSES.contains(&cell_part_class(part)),
274 "{part:?} is missing from CELL_PART_CLASSES"
275 );
276 }
277 // The fallbacks, which are what an upstream addition lands on.
278 assert!(ROW_PART_CLASSES.contains(&"row-part"));
279 assert!(CELL_PART_CLASSES.contains(&"cell-part"));
280 }
281
282 #[test]
283 fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
284 let plain = vocabulary(&Emit::default());
285 let prefixed = vocabulary(&Emit {
286 class_prefix: "mo-",
287 ..Emit::default()
288 });
289 assert_eq!(
290 plain.len(),
291 prefixed.len(),
292 "a prefix changed how many classes exist"
293 );
294 // `chosen` and `latched` never stand alone: the sheet writes
295 // `.mo-tab.chosen`, so the state stays bare while the thing moves.
296 let states = ["chosen", "latched"];
297 for name in &plain {
298 let expected = if states.contains(&name.as_str()) {
299 name.clone()
300 } else {
301 format!("mo-{name}")
302 };
303 assert!(
304 prefixed.contains(&expected),
305 ".{name} did not move to .{expected} under the prefix"
306 );
307 }
308 }
309
310 #[test]
311 fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
312 let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
313 assert_eq!(found, ["real".to_string()].into_iter().collect());
314 }
315
316 #[test]
317 fn an_at_rule_does_not_hide_the_selectors_inside_it() {
318 let found = classes_in_css(
319 "@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
320 );
321 assert_eq!(found, ["wide".to_string()].into_iter().collect());
322 }
323
324 #[test]
325 fn a_string_is_opaque_and_a_comment_contributes_nothing() {
326 let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
327 assert_eq!(found, ["caret".to_string()].into_iter().collect());
328 }
329
330 #[test]
331 fn a_compound_selector_yields_every_class_it_names() {
332 let found = classes_in_css(
333 ".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
334 );
335 let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
336 .into_iter()
337 .map(String::from)
338 .collect();
339 assert_eq!(found, expected);
340 }
341}