|
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, ROW_PART_CLASSES};
|
|
38 |
+ |
use crate::{Emit, option_class};
|
|
39 |
+ |
use makeover_layout::Selector;
|
|
40 |
+ |
use 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]
|
|
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 |
+ |
.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]
|
|
90 |
+ |
pub 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.
|
|
154 |
+ |
fn 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)]
|
|
183 |
+ |
mod 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 |
+ |
}
|