1use crate::config::{Config, IgnoreRule};
8use crate::report::{ViewportKey, Violation, ViolationSink};
9use crate::rules::{Rule, register_builtin};
10use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
11use rayon::prelude::*;
12
13#[derive(Debug, Clone, PartialEq)]
25pub struct RunReport {
26 pub reported: Vec<Violation>,
29 pub ignored: Vec<Violation>,
34}
35
36impl RunReport {
37 #[must_use]
39 pub fn empty() -> Self {
40 Self {
41 reported: Vec::new(),
42 ignored: Vec::new(),
43 }
44 }
45
46 #[must_use]
49 pub fn total(&self) -> usize {
50 self.reported.len() + self.ignored.len()
51 }
52}
53
54#[must_use]
64pub fn run(snapshot: &PlumbSnapshot, config: &Config) -> Vec<Violation> {
65 run_many([snapshot], config)
66}
67
68#[must_use]
86pub fn run_many<'a, I>(snapshots: I, config: &Config) -> Vec<Violation>
87where
88 I: IntoIterator<Item = &'a PlumbSnapshot>,
89{
90 run_report(snapshots, config).reported
91}
92
93#[must_use]
103pub fn run_report<'a, I>(snapshots: I, config: &Config) -> RunReport
104where
105 I: IntoIterator<Item = &'a PlumbSnapshot>,
106{
107 let rules = register_builtin();
108 let mut buffer: Vec<Violation> = snapshots
109 .into_iter()
110 .flat_map(|snapshot| run_rules(snapshot, config, &rules))
111 .collect();
112
113 buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
116 buffer.dedup();
117
118 apply_ignores(buffer, &config.ignore)
119}
120
121#[must_use]
134pub fn apply_ignores(violations: Vec<Violation>, rules: &[IgnoreRule]) -> RunReport {
135 if rules.is_empty() {
136 return RunReport {
137 reported: violations,
138 ignored: Vec::new(),
139 };
140 }
141
142 let mut reported = Vec::with_capacity(violations.len());
143 let mut suppressed = Vec::new();
144
145 for violation in violations {
146 if ignore_matches(&violation, rules) {
147 suppressed.push(violation);
148 } else {
149 reported.push(violation);
150 }
151 }
152
153 RunReport {
154 reported,
155 ignored: suppressed,
156 }
157}
158
159fn ignore_matches(violation: &Violation, rules: &[IgnoreRule]) -> bool {
163 rules.iter().any(|rule| {
164 if rule.selector != violation.selector {
165 return false;
166 }
167 match &rule.rule_id {
168 Some(id) => id == &violation.rule_id,
169 None => true,
170 }
171 })
172}
173
174fn run_rules(snapshot: &PlumbSnapshot, config: &Config, rules: &[Box<dyn Rule>]) -> Vec<Violation> {
175 let ctx = if config.viewports.is_empty() {
176 SnapshotCtx::new(snapshot)
177 } else {
178 SnapshotCtx::with_viewports(
179 snapshot,
180 config.viewports.keys().cloned().map(ViewportKey::new),
181 )
182 };
183 let mut buffer: Vec<Violation> = rules
184 .par_iter()
185 .filter(|rule| {
186 config.rules.get(rule.id()).is_none_or(|over| over.enabled)
190 })
191 .flat_map(|rule| {
192 let mut local = Vec::new();
193 let mut sink = ViolationSink::new(&mut local);
194 rule.check(&ctx, config, &mut sink);
195 local
196 })
197 .collect();
198
199 buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
201
202 buffer.dedup();
205
206 buffer
207}
208
209#[cfg(test)]
210mod tests {
211 use crate::config::{Config, IgnoreRule};
212 use crate::report::{Severity, ViewportKey, Violation, ViolationSink};
213 use crate::rules::Rule;
214 use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
215 use indexmap::IndexMap;
216
217 use super::{apply_ignores, run_report, run_rules};
218
219 #[derive(Debug, Clone, Copy)]
220 struct Emission {
221 selector: &'static str,
222 dom_order: u64,
223 }
224
225 #[derive(Debug)]
226 struct OutOfOrderRule {
227 id: &'static str,
228 emissions: &'static [Emission],
229 }
230
231 impl Rule for OutOfOrderRule {
232 fn id(&self) -> &'static str {
233 self.id
234 }
235
236 fn default_severity(&self) -> Severity {
237 Severity::Warning
238 }
239
240 fn summary(&self) -> &'static str {
241 "Test-only rule that emits fixed violations."
242 }
243
244 fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
245 for emission in self.emissions {
246 sink.push(test_violation(
247 self.id(),
248 emission.selector,
249 ctx.snapshot().viewport.clone(),
250 emission.dom_order,
251 ));
252 }
253 }
254 }
255
256 fn test_violation(
257 rule_id: &str,
258 selector: &str,
259 viewport: ViewportKey,
260 dom_order: u64,
261 ) -> Violation {
262 Violation {
263 rule_id: rule_id.to_owned(),
264 severity: Severity::Warning,
265 message: "test violation".to_owned(),
266 selector: selector.to_owned(),
267 viewport,
268 rect: None,
269 dom_order,
270 fix: None,
271 doc_url: "https://plumb.aramhammoudeh.com/rules/test-only".to_owned(),
272 metadata: IndexMap::new(),
273 }
274 }
275
276 #[test]
277 fn run_rules_sorts_parallel_rule_output() {
278 const ALPHA_EMISSIONS: &[Emission] = &[
279 Emission {
280 selector: "html > zed",
281 dom_order: 9,
282 },
283 Emission {
284 selector: "html > alpha",
285 dom_order: 1,
286 },
287 ];
288 const ZED_EMISSIONS: &[Emission] = &[
289 Emission {
290 selector: "html > body",
291 dom_order: 2,
292 },
293 Emission {
294 selector: "html",
295 dom_order: 0,
296 },
297 ];
298
299 let snapshot = PlumbSnapshot::canned();
300 let config = Config::default();
301 let rules: Vec<Box<dyn Rule>> = vec![
302 Box::new(OutOfOrderRule {
303 id: "z/rule",
304 emissions: ZED_EMISSIONS,
305 }),
306 Box::new(OutOfOrderRule {
307 id: "a/rule",
308 emissions: ALPHA_EMISSIONS,
309 }),
310 ];
311
312 let first = run_rules(&snapshot, &config, &rules);
313 let second = run_rules(&snapshot, &config, &rules);
314
315 assert_eq!(first, second);
316 assert_eq!(
317 first.iter().map(Violation::sort_key).collect::<Vec<_>>(),
318 vec![
319 ("a/rule", "desktop", "html > alpha", 1),
320 ("a/rule", "desktop", "html > zed", 9),
321 ("z/rule", "desktop", "html", 0),
322 ("z/rule", "desktop", "html > body", 2),
323 ],
324 );
325 }
326
327 fn fixture_violation(rule_id: &str, selector: &str, dom_order: u64) -> Violation {
328 Violation {
329 rule_id: rule_id.to_owned(),
330 severity: Severity::Warning,
331 message: "test".to_owned(),
332 selector: selector.to_owned(),
333 viewport: ViewportKey::new("desktop"),
334 rect: None,
335 dom_order,
336 fix: None,
337 doc_url: format!(
338 "https://plumb.aramhammoudeh.com/rules/{}",
339 rule_id.replace('/', "-")
340 ),
341 metadata: IndexMap::new(),
342 }
343 }
344
345 #[test]
346 fn apply_ignores_passthrough_when_empty() {
347 let v = vec![
348 fixture_violation("spacing/grid-conformance", "html > body", 2),
349 fixture_violation("color/palette-conformance", "main", 5),
350 ];
351 let report = apply_ignores(v.clone(), &[]);
352 assert_eq!(report.reported, v);
353 assert!(report.ignored.is_empty());
354 }
355
356 #[test]
357 fn apply_ignores_selector_only_match_suppresses_all_rules() {
358 let v = vec![
359 fixture_violation("spacing/grid-conformance", "html > body", 2),
360 fixture_violation("color/palette-conformance", "html > body", 2),
361 fixture_violation("spacing/grid-conformance", "main", 5),
362 ];
363 let ignores = vec![IgnoreRule {
364 selector: "html > body".to_owned(),
365 rule_id: None,
366 reason: "test".to_owned(),
367 }];
368 let report = apply_ignores(v, &ignores);
369 assert_eq!(report.reported.len(), 1);
370 assert_eq!(report.reported[0].selector, "main");
371 assert_eq!(report.ignored.len(), 2);
372 }
373
374 #[test]
375 fn apply_ignores_selector_plus_rule_id_filters_one_rule_only() {
376 let v = vec![
377 fixture_violation("spacing/grid-conformance", "html > body", 2),
378 fixture_violation("color/palette-conformance", "html > body", 2),
379 ];
380 let ignores = vec![IgnoreRule {
381 selector: "html > body".to_owned(),
382 rule_id: Some("spacing/grid-conformance".to_owned()),
383 reason: "test".to_owned(),
384 }];
385 let report = apply_ignores(v, &ignores);
386 assert_eq!(report.reported.len(), 1);
387 assert_eq!(report.reported[0].rule_id, "color/palette-conformance");
388 assert_eq!(report.ignored.len(), 1);
389 assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
390 }
391
392 #[test]
393 fn apply_ignores_selector_mismatch_does_not_filter() {
394 let v = vec![fixture_violation(
395 "spacing/grid-conformance",
396 "html > body",
397 2,
398 )];
399 let ignores = vec![IgnoreRule {
400 selector: "html > body > div".to_owned(),
401 rule_id: None,
402 reason: "test".to_owned(),
403 }];
404 let report = apply_ignores(v.clone(), &ignores);
405 assert_eq!(report.reported, v);
406 assert!(report.ignored.is_empty());
407 }
408
409 #[test]
410 fn apply_ignores_is_deterministic_across_runs() {
411 let v = vec![
412 fixture_violation("a/rule", "html > body", 1),
413 fixture_violation("a/rule", "html > body", 2),
414 fixture_violation("b/rule", "main", 3),
415 ];
416 let ignores = vec![IgnoreRule {
417 selector: "html > body".to_owned(),
418 rule_id: None,
419 reason: "x".to_owned(),
420 }];
421 let first = apply_ignores(v.clone(), &ignores);
422 let second = apply_ignores(v, &ignores);
423 assert_eq!(first, second);
424 }
425
426 #[test]
427 fn run_report_applies_ignores_against_real_engine_output() {
428 let snapshot = PlumbSnapshot::canned();
429 let mut config = Config::default();
433 config.ignore.push(IgnoreRule {
434 selector: "html > body".to_owned(),
435 rule_id: Some("spacing/grid-conformance".to_owned()),
436 reason: "canned snapshot exemption".to_owned(),
437 });
438 let report = run_report([&snapshot], &config);
439 assert!(report.reported.is_empty());
440 assert_eq!(report.ignored.len(), 1);
441 assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
442 assert_eq!(report.ignored[0].selector, "html > body");
443 }
444}