| 37 |
37 |
|
use crate::list::{CELL_PART_CLASSES, ROW_PART_CLASSES};
|
| 38 |
38 |
|
use crate::{Emit, option_class};
|
| 39 |
39 |
|
use makeover_layout::Selector;
|
| 40 |
|
- |
use std::collections::BTreeSet;
|
|
40 |
+ |
use std::collections::{BTreeMap, BTreeSet};
|
| 41 |
41 |
|
|
| 42 |
42 |
|
/// Every class name the generated stylesheet defines a rule for, prefixed the
|
| 43 |
43 |
|
/// way `opts` prefixes them.
|
| 86 |
86 |
|
/// block (`@layer`, `@media`, `@supports`) contains rules rather than
|
| 87 |
87 |
|
/// declarations, which is why a depth counter alone is not enough: the stack
|
| 88 |
88 |
|
/// records what kind of block each brace opened.
|
|
89 |
+ |
/// Which properties a stylesheet sets on each class it names.
|
|
90 |
+ |
///
|
|
91 |
+ |
/// The grain a drift check actually wants. A class name in common is not by
|
|
92 |
+ |
/// itself a divergence: goingson's `.badge` sets shape and the generated
|
|
93 |
+ |
/// `.badge` sets fill and edge, and the app's own comment says "do not add
|
|
94 |
+ |
/// background, border or box-shadow here". That arrangement is settled and
|
|
95 |
+ |
/// correct, so a check that flagged the shared name would demand deleting it.
|
|
96 |
+ |
/// A shared *property* is the thing that goes wrong, because app CSS is
|
|
97 |
+ |
/// unlayered and takes the property from the design system silently.
|
|
98 |
+ |
///
|
|
99 |
+ |
/// A property appearing under more than one selector arm collapses into one
|
|
100 |
+ |
/// entry. That loses a real distinction -- the sort caret's reserved gap is
|
|
101 |
+ |
/// `content` on the unsorted arm and the generated caret is `content` on the
|
|
102 |
+ |
/// sorted one, which is a deliberate pairing rather than a clash -- so a
|
|
103 |
+ |
/// consumer of this needs a way to say a pair was reviewed. Deciding that here
|
|
104 |
+ |
/// would need a selector matcher, and a check that guesses wrong about
|
|
105 |
+ |
/// specificity fails correct builds.
|
|
106 |
+ |
#[must_use]
|
|
107 |
+ |
pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
|
|
108 |
+ |
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
109 |
+ |
for (selector, body) in rules(css) {
|
|
110 |
+ |
let classes = classes_in_selector(&selector);
|
|
111 |
+ |
if classes.is_empty() {
|
|
112 |
+ |
continue;
|
|
113 |
+ |
}
|
|
114 |
+ |
let properties = properties_in_body(&body);
|
|
115 |
+ |
for class in classes {
|
|
116 |
+ |
out.entry(class).or_default().extend(properties.clone());
|
|
117 |
+ |
}
|
|
118 |
+ |
}
|
|
119 |
+ |
out
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
/// The property names a declaration block sets.
|
|
123 |
+ |
fn properties_in_body(body: &str) -> BTreeSet<String> {
|
|
124 |
+ |
body.split(';')
|
|
125 |
+ |
.filter_map(|decl| decl.split_once(':'))
|
|
126 |
+ |
.map(|(name, _)| name.trim().to_string())
|
|
127 |
+ |
.filter(|name| !name.is_empty() && !name.contains(['{', '}']))
|
|
128 |
+ |
.collect()
|
|
129 |
+ |
}
|
|
130 |
+ |
|
| 89 |
131 |
|
#[must_use]
|
| 90 |
132 |
|
pub fn classes_in_css(css: &str) -> BTreeSet<String> {
|
| 91 |
|
- |
let mut found = BTreeSet::new();
|
|
133 |
+ |
rules(css)
|
|
134 |
+ |
.into_iter()
|
|
135 |
+ |
.flat_map(|(selector, _)| classes_in_selector(&selector))
|
|
136 |
+ |
.collect()
|
|
137 |
+ |
}
|
|
138 |
+ |
|
|
139 |
+ |
/// `(selector, declaration block)` for every rule in a stylesheet.
|
|
140 |
+ |
///
|
|
141 |
+ |
/// One reader for both sides. Comparing what makeover defines against what an
|
|
142 |
+ |
/// app defines is only meaningful if the two were read the same way, which is
|
|
143 |
+ |
/// why this is the only place either question is answered from.
|
|
144 |
+ |
///
|
|
145 |
+ |
/// A comment is skipped whole: the banner at the top of the generated sheet is
|
|
146 |
+ |
/// prose about the cascade layer and would otherwise contribute words that look
|
|
147 |
+ |
/// like selectors. A string is opaque, because `content: "\2191"` is the sort
|
|
148 |
+ |
/// caret rather than a selector and a brace inside one would desync the stack.
|
|
149 |
+ |
/// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than
|
|
150 |
+ |
/// declarations, so a depth counter alone is not enough and the stack records
|
|
151 |
+ |
/// what kind of block each brace opened.
|
|
152 |
+ |
fn rules(css: &str) -> Vec<(String, String)> {
|
|
153 |
+ |
let mut out = Vec::new();
|
| 92 |
154 |
|
// One entry per open brace: true when that block holds declarations rather
|
| 93 |
155 |
|
// than nested rules.
|
| 94 |
156 |
|
let mut blocks: Vec<bool> = Vec::new();
|
| 95 |
157 |
|
// Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
|
| 96 |
158 |
|
// prelude, and a prelude starting with `@` opens an at-rule.
|
| 97 |
159 |
|
let mut prelude = String::new();
|
|
160 |
+ |
// The selector of each open declaration block, and the body so far.
|
|
161 |
+ |
let mut open: Vec<(String, String)> = Vec::new();
|
| 98 |
162 |
|
|
| 99 |
163 |
|
let mut chars = css.chars().peekable();
|
| 100 |
164 |
|
while let Some(c) = chars.next() {
|
| 101 |
165 |
|
match c {
|
| 102 |
166 |
|
'/' 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 |
167 |
|
chars.next();
|
| 107 |
168 |
|
let mut star = false;
|
| 108 |
169 |
|
for c in chars.by_ref() {
|
| 114 |
175 |
|
prelude.clear();
|
| 115 |
176 |
|
}
|
| 116 |
177 |
|
'"' | '\'' => {
|
| 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 |
178 |
|
let quote = c;
|
| 120 |
179 |
|
let mut escaped = false;
|
|
180 |
+ |
// Keep the quotes in the body: a value is not a property name,
|
|
181 |
+ |
// and dropping them would join two declarations into one.
|
|
182 |
+ |
if blocks.last().copied().unwrap_or(false) {
|
|
183 |
+ |
if let Some((_, body)) = open.last_mut() {
|
|
184 |
+ |
body.push(quote);
|
|
185 |
+ |
}
|
|
186 |
+ |
}
|
| 121 |
187 |
|
for c in chars.by_ref() {
|
| 122 |
188 |
|
if escaped {
|
| 123 |
189 |
|
escaped = false;
|
| 127 |
193 |
|
break;
|
| 128 |
194 |
|
}
|
| 129 |
195 |
|
}
|
|
196 |
+ |
// The closing quote only. A value holding `;` or `:` would
|
|
197 |
+ |
// otherwise read as two declarations, and `url("a;b:c")` is a
|
|
198 |
+ |
// real thing an app writes.
|
|
199 |
+ |
if blocks.last().copied().unwrap_or(false) {
|
|
200 |
+ |
if let Some((_, body)) = open.last_mut() {
|
|
201 |
+ |
body.push(quote);
|
|
202 |
+ |
}
|
|
203 |
+ |
}
|
| 130 |
204 |
|
}
|
| 131 |
205 |
|
'{' => {
|
| 132 |
206 |
|
let declarations = !prelude.trim_start().starts_with('@');
|
| 133 |
207 |
|
if declarations {
|
| 134 |
|
- |
found.extend(classes_in_selector(&prelude));
|
|
208 |
+ |
open.push((prelude.clone(), String::new()));
|
| 135 |
209 |
|
}
|
| 136 |
210 |
|
blocks.push(declarations);
|
| 137 |
211 |
|
prelude.clear();
|
| 138 |
212 |
|
}
|
| 139 |
213 |
|
'}' => {
|
| 140 |
|
- |
blocks.pop();
|
|
214 |
+ |
if blocks.pop().unwrap_or(false) {
|
|
215 |
+ |
if let Some(rule) = open.pop() {
|
|
216 |
+ |
out.push(rule);
|
|
217 |
+ |
}
|
|
218 |
+ |
}
|
| 141 |
219 |
|
prelude.clear();
|
| 142 |
220 |
|
}
|
| 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 |
|
- |
_ => {}
|
|
221 |
+ |
_ => {
|
|
222 |
+ |
if blocks.last().copied().unwrap_or(false) {
|
|
223 |
+ |
if let Some((_, body)) = open.last_mut() {
|
|
224 |
+ |
body.push(c);
|
|
225 |
+ |
}
|
|
226 |
+ |
} else if c == ';' {
|
|
227 |
+ |
prelude.clear();
|
|
228 |
+ |
} else {
|
|
229 |
+ |
prelude.push(c);
|
|
230 |
+ |
}
|
|
231 |
+ |
}
|
| 148 |
232 |
|
}
|
| 149 |
233 |
|
}
|
| 150 |
|
- |
found
|
|
234 |
+ |
out
|
| 151 |
235 |
|
}
|
| 152 |
236 |
|
|
| 153 |
237 |
|
/// The class names one selector matches on.
|
| 307 |
391 |
|
}
|
| 308 |
392 |
|
}
|
| 309 |
393 |
|
|
|
394 |
+ |
#[test]
|
|
395 |
+ |
fn the_properties_a_class_carries_are_read_per_class() {
|
|
396 |
+ |
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";
|
|
397 |
+ |
let by_class = declarations_by_class(css);
|
|
398 |
+ |
let badge = by_class.get("badge").expect("badge is named");
|
|
399 |
+ |
// Every arm collapses into one entry, including the one inside the
|
|
400 |
+ |
// media block: they are all the same class carrying the same property.
|
|
401 |
+ |
assert!(badge.contains("padding"));
|
|
402 |
+ |
assert!(badge.contains("font-weight"));
|
|
403 |
+ |
assert!(badge.contains("border"));
|
|
404 |
+ |
assert_eq!(badge.len(), 3);
|
|
405 |
+ |
}
|
|
406 |
+ |
|
|
407 |
+ |
#[test]
|
|
408 |
+ |
fn a_value_holding_a_colon_or_a_semicolon_is_not_read_as_a_property() {
|
|
409 |
+ |
let css = ".x { background: url(\"a;b:c\"); color: red; }";
|
|
410 |
+ |
let by_class = declarations_by_class(css);
|
|
411 |
+ |
let x = by_class.get("x").expect("x is named");
|
|
412 |
+ |
assert_eq!(
|
|
413 |
+ |
*x,
|
|
414 |
+ |
["background".to_string(), "color".to_string()]
|
|
415 |
+ |
.into_iter()
|
|
416 |
+ |
.collect::<BTreeSet<_>>()
|
|
417 |
+ |
);
|
|
418 |
+ |
}
|
|
419 |
+ |
|
|
420 |
+ |
#[test]
|
|
421 |
+ |
fn the_generated_sheet_sets_fill_on_a_badge_and_not_its_shape() {
|
|
422 |
+ |
// The fact goingson's stylesheet states in prose next to its own
|
|
423 |
+ |
// `.badge`: "Fill, edge and text colour come from the generated .badge
|
|
424 |
+ |
// in layout.css... Do not add background, border or box-shadow here."
|
|
425 |
+ |
// A property-grain reader is what turns that comment into a check.
|
|
426 |
+ |
let by_class = declarations_by_class(&crate::stylesheet(&Emit::default()));
|
|
427 |
+ |
let badge = by_class.get("badge").expect("the sheet defines .badge");
|
|
428 |
+ |
// Token::Badge is Depth::Flat, so a badge carries no bevel and no
|
|
429 |
+ |
// fill: what the generated sheet gives it is the text colour, and
|
|
430 |
+ |
// everything about its shape is the app's.
|
|
431 |
+ |
assert!(badge.contains("color"), "got {badge:?}");
|
|
432 |
+ |
assert!(
|
|
433 |
+ |
!badge.contains("padding"),
|
|
434 |
+ |
"shape is the app's, got {badge:?}"
|
|
435 |
+ |
);
|
|
436 |
+ |
}
|
|
437 |
+ |
|
| 310 |
438 |
|
#[test]
|
| 311 |
439 |
|
fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
|
| 312 |
440 |
|
let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
|