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)
193 })
194 .flat_map(|rule| {
195 let mut local = Vec::new();
196 let mut sink = ViolationSink::new(&mut local);
197 rule.check(&ctx, config, &mut sink);
198 if let Some(override_severity) =
202 config.rules.get(rule.id()).and_then(|over| over.severity)
203 {
204 for violation in &mut local {
205 violation.severity = override_severity;
206 }
207 }
208 local
209 })
210 .collect();
211
212 buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
214
215 buffer.dedup();
218
219 buffer
220}
221
222#[cfg(test)]
223mod tests {
224 use crate::config::{Config, IgnoreRule};
225 use crate::report::{Severity, ViewportKey, Violation, ViolationSink};
226 use crate::rules::Rule;
227 use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
228 use indexmap::IndexMap;
229
230 use super::{apply_ignores, run_report, run_rules};
231
232 #[derive(Debug, Clone, Copy)]
233 struct Emission {
234 selector: &'static str,
235 dom_order: u64,
236 }
237
238 #[derive(Debug)]
239 struct OutOfOrderRule {
240 id: &'static str,
241 emissions: &'static [Emission],
242 }
243
244 impl Rule for OutOfOrderRule {
245 fn id(&self) -> &'static str {
246 self.id
247 }
248
249 fn default_severity(&self) -> Severity {
250 Severity::Warning
251 }
252
253 fn summary(&self) -> &'static str {
254 "Test-only rule that emits fixed violations."
255 }
256
257 fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
258 for emission in self.emissions {
259 sink.push(test_violation(
260 self.id(),
261 emission.selector,
262 ctx.snapshot().viewport.clone(),
263 emission.dom_order,
264 ));
265 }
266 }
267 }
268
269 fn test_violation(
270 rule_id: &str,
271 selector: &str,
272 viewport: ViewportKey,
273 dom_order: u64,
274 ) -> Violation {
275 Violation {
276 rule_id: rule_id.to_owned(),
277 severity: Severity::Warning,
278 message: "test violation".to_owned(),
279 selector: selector.to_owned(),
280 viewport,
281 rect: None,
282 dom_order,
283 fix: None,
284 doc_url: "https://plumb.aramhammoudeh.com/rules/test-only".to_owned(),
285 metadata: IndexMap::new(),
286 }
287 }
288
289 #[test]
290 fn run_rules_sorts_parallel_rule_output() {
291 const ALPHA_EMISSIONS: &[Emission] = &[
292 Emission {
293 selector: "html > zed",
294 dom_order: 9,
295 },
296 Emission {
297 selector: "html > alpha",
298 dom_order: 1,
299 },
300 ];
301 const ZED_EMISSIONS: &[Emission] = &[
302 Emission {
303 selector: "html > body",
304 dom_order: 2,
305 },
306 Emission {
307 selector: "html",
308 dom_order: 0,
309 },
310 ];
311
312 let snapshot = PlumbSnapshot::canned();
313 let config = Config::default();
314 let rules: Vec<Box<dyn Rule>> = vec![
315 Box::new(OutOfOrderRule {
316 id: "z/rule",
317 emissions: ZED_EMISSIONS,
318 }),
319 Box::new(OutOfOrderRule {
320 id: "a/rule",
321 emissions: ALPHA_EMISSIONS,
322 }),
323 ];
324
325 let first = run_rules(&snapshot, &config, &rules);
326 let second = run_rules(&snapshot, &config, &rules);
327
328 assert_eq!(first, second);
329 assert_eq!(
330 first.iter().map(Violation::sort_key).collect::<Vec<_>>(),
331 vec![
332 ("a/rule", "desktop", "html > alpha", 1),
333 ("a/rule", "desktop", "html > zed", 9),
334 ("z/rule", "desktop", "html", 0),
335 ("z/rule", "desktop", "html > body", 2),
336 ],
337 );
338 }
339
340 fn fixture_violation(rule_id: &str, selector: &str, dom_order: u64) -> Violation {
341 Violation {
342 rule_id: rule_id.to_owned(),
343 severity: Severity::Warning,
344 message: "test".to_owned(),
345 selector: selector.to_owned(),
346 viewport: ViewportKey::new("desktop"),
347 rect: None,
348 dom_order,
349 fix: None,
350 doc_url: format!(
351 "https://plumb.aramhammoudeh.com/rules/{}",
352 rule_id.replace('/', "-")
353 ),
354 metadata: IndexMap::new(),
355 }
356 }
357
358 #[test]
359 fn apply_ignores_passthrough_when_empty() {
360 let v = vec![
361 fixture_violation("spacing/grid-conformance", "html > body", 2),
362 fixture_violation("color/palette-conformance", "main", 5),
363 ];
364 let report = apply_ignores(v.clone(), &[]);
365 assert_eq!(report.reported, v);
366 assert!(report.ignored.is_empty());
367 }
368
369 #[test]
370 fn apply_ignores_selector_only_match_suppresses_all_rules() {
371 let v = vec![
372 fixture_violation("spacing/grid-conformance", "html > body", 2),
373 fixture_violation("color/palette-conformance", "html > body", 2),
374 fixture_violation("spacing/grid-conformance", "main", 5),
375 ];
376 let ignores = vec![IgnoreRule {
377 selector: "html > body".to_owned(),
378 rule_id: None,
379 reason: "test".to_owned(),
380 }];
381 let report = apply_ignores(v, &ignores);
382 assert_eq!(report.reported.len(), 1);
383 assert_eq!(report.reported[0].selector, "main");
384 assert_eq!(report.ignored.len(), 2);
385 }
386
387 #[test]
388 fn apply_ignores_selector_plus_rule_id_filters_one_rule_only() {
389 let v = vec![
390 fixture_violation("spacing/grid-conformance", "html > body", 2),
391 fixture_violation("color/palette-conformance", "html > body", 2),
392 ];
393 let ignores = vec![IgnoreRule {
394 selector: "html > body".to_owned(),
395 rule_id: Some("spacing/grid-conformance".to_owned()),
396 reason: "test".to_owned(),
397 }];
398 let report = apply_ignores(v, &ignores);
399 assert_eq!(report.reported.len(), 1);
400 assert_eq!(report.reported[0].rule_id, "color/palette-conformance");
401 assert_eq!(report.ignored.len(), 1);
402 assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
403 }
404
405 #[test]
406 fn apply_ignores_selector_mismatch_does_not_filter() {
407 let v = vec![fixture_violation(
408 "spacing/grid-conformance",
409 "html > body",
410 2,
411 )];
412 let ignores = vec![IgnoreRule {
413 selector: "html > body > div".to_owned(),
414 rule_id: None,
415 reason: "test".to_owned(),
416 }];
417 let report = apply_ignores(v.clone(), &ignores);
418 assert_eq!(report.reported, v);
419 assert!(report.ignored.is_empty());
420 }
421
422 #[test]
423 fn apply_ignores_is_deterministic_across_runs() {
424 let v = vec![
425 fixture_violation("a/rule", "html > body", 1),
426 fixture_violation("a/rule", "html > body", 2),
427 fixture_violation("b/rule", "main", 3),
428 ];
429 let ignores = vec![IgnoreRule {
430 selector: "html > body".to_owned(),
431 rule_id: None,
432 reason: "x".to_owned(),
433 }];
434 let first = apply_ignores(v.clone(), &ignores);
435 let second = apply_ignores(v, &ignores);
436 assert_eq!(first, second);
437 }
438
439 #[test]
440 fn run_report_applies_ignores_against_real_engine_output() {
441 let snapshot = PlumbSnapshot::canned();
442 let mut config = Config::default();
446 config.ignore.push(IgnoreRule {
447 selector: "html > body".to_owned(),
448 rule_id: Some("spacing/grid-conformance".to_owned()),
449 reason: "canned snapshot exemption".to_owned(),
450 });
451 let report = run_report([&snapshot], &config);
452 assert!(report.reported.is_empty());
453 assert_eq!(report.ignored.len(), 1);
454 assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
455 assert_eq!(report.ignored[0].selector, "html > body");
456 }
457}