use crate::config::{Config, IgnoreRule};
use crate::report::{ViewportKey, Violation, ViolationSink};
use crate::rules::{Rule, register_builtin};
use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
use rayon::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub struct RunReport {
pub reported: Vec<Violation>,
pub ignored: Vec<Violation>,
}
impl RunReport {
#[must_use]
pub fn empty() -> Self {
Self {
reported: Vec::new(),
ignored: Vec::new(),
}
}
#[must_use]
pub fn total(&self) -> usize {
self.reported.len() + self.ignored.len()
}
}
#[must_use]
pub fn run(snapshot: &PlumbSnapshot, config: &Config) -> Vec<Violation> {
run_many([snapshot], config)
}
#[must_use]
pub fn run_many<'a, I>(snapshots: I, config: &Config) -> Vec<Violation>
where
I: IntoIterator<Item = &'a PlumbSnapshot>,
{
run_report(snapshots, config).reported
}
#[must_use]
pub fn run_report<'a, I>(snapshots: I, config: &Config) -> RunReport
where
I: IntoIterator<Item = &'a PlumbSnapshot>,
{
let rules = register_builtin();
let mut buffer: Vec<Violation> = snapshots
.into_iter()
.flat_map(|snapshot| run_rules(snapshot, config, &rules))
.collect();
buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
buffer.dedup();
apply_ignores(buffer, &config.ignore)
}
#[must_use]
pub fn apply_ignores(violations: Vec<Violation>, rules: &[IgnoreRule]) -> RunReport {
if rules.is_empty() {
return RunReport {
reported: violations,
ignored: Vec::new(),
};
}
let mut reported = Vec::with_capacity(violations.len());
let mut suppressed = Vec::new();
for violation in violations {
if ignore_matches(&violation, rules) {
suppressed.push(violation);
} else {
reported.push(violation);
}
}
RunReport {
reported,
ignored: suppressed,
}
}
fn ignore_matches(violation: &Violation, rules: &[IgnoreRule]) -> bool {
rules.iter().any(|rule| {
if rule.selector != violation.selector {
return false;
}
match &rule.rule_id {
Some(id) => id == &violation.rule_id,
None => true,
}
})
}
fn run_rules(snapshot: &PlumbSnapshot, config: &Config, rules: &[Box<dyn Rule>]) -> Vec<Violation> {
let ctx = if config.viewports.is_empty() {
SnapshotCtx::new(snapshot)
} else {
SnapshotCtx::with_viewports(
snapshot,
config.viewports.keys().cloned().map(ViewportKey::new),
)
};
let mut buffer: Vec<Violation> = rules
.par_iter()
.filter(|rule| {
config.rules.get(rule.id()).is_none_or(|over| over.enabled)
})
.flat_map(|rule| {
let mut local = Vec::new();
let mut sink = ViolationSink::new(&mut local);
rule.check(&ctx, config, &mut sink);
if let Some(override_severity) =
config.rules.get(rule.id()).and_then(|over| over.severity)
{
for violation in &mut local {
violation.severity = override_severity;
}
}
local
})
.collect();
buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
buffer.dedup();
buffer
}
#[cfg(test)]
mod tests {
use crate::config::{Config, IgnoreRule};
use crate::report::{Severity, ViewportKey, Violation, ViolationSink};
use crate::rules::Rule;
use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
use indexmap::IndexMap;
use super::{apply_ignores, run_report, run_rules};
#[derive(Debug, Clone, Copy)]
struct Emission {
selector: &'static str,
dom_order: u64,
}
#[derive(Debug)]
struct OutOfOrderRule {
id: &'static str,
emissions: &'static [Emission],
}
impl Rule for OutOfOrderRule {
fn id(&self) -> &'static str {
self.id
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn summary(&self) -> &'static str {
"Test-only rule that emits fixed violations."
}
fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
for emission in self.emissions {
sink.push(test_violation(
self.id(),
emission.selector,
ctx.snapshot().viewport.clone(),
emission.dom_order,
));
}
}
}
fn test_violation(
rule_id: &str,
selector: &str,
viewport: ViewportKey,
dom_order: u64,
) -> Violation {
Violation {
rule_id: rule_id.to_owned(),
severity: Severity::Warning,
message: "test violation".to_owned(),
selector: selector.to_owned(),
viewport,
rect: None,
dom_order,
fix: None,
doc_url: "https://plumb.aramhammoudeh.com/rules/test-only".to_owned(),
metadata: IndexMap::new(),
}
}
#[test]
fn run_rules_sorts_parallel_rule_output() {
const ALPHA_EMISSIONS: &[Emission] = &[
Emission {
selector: "html > zed",
dom_order: 9,
},
Emission {
selector: "html > alpha",
dom_order: 1,
},
];
const ZED_EMISSIONS: &[Emission] = &[
Emission {
selector: "html > body",
dom_order: 2,
},
Emission {
selector: "html",
dom_order: 0,
},
];
let snapshot = PlumbSnapshot::canned();
let config = Config::default();
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(OutOfOrderRule {
id: "z/rule",
emissions: ZED_EMISSIONS,
}),
Box::new(OutOfOrderRule {
id: "a/rule",
emissions: ALPHA_EMISSIONS,
}),
];
let first = run_rules(&snapshot, &config, &rules);
let second = run_rules(&snapshot, &config, &rules);
assert_eq!(first, second);
assert_eq!(
first.iter().map(Violation::sort_key).collect::<Vec<_>>(),
vec![
("a/rule", "desktop", "html > alpha", 1),
("a/rule", "desktop", "html > zed", 9),
("z/rule", "desktop", "html", 0),
("z/rule", "desktop", "html > body", 2),
],
);
}
fn fixture_violation(rule_id: &str, selector: &str, dom_order: u64) -> Violation {
Violation {
rule_id: rule_id.to_owned(),
severity: Severity::Warning,
message: "test".to_owned(),
selector: selector.to_owned(),
viewport: ViewportKey::new("desktop"),
rect: None,
dom_order,
fix: None,
doc_url: format!(
"https://plumb.aramhammoudeh.com/rules/{}",
rule_id.replace('/', "-")
),
metadata: IndexMap::new(),
}
}
#[test]
fn apply_ignores_passthrough_when_empty() {
let v = vec![
fixture_violation("spacing/grid-conformance", "html > body", 2),
fixture_violation("color/palette-conformance", "main", 5),
];
let report = apply_ignores(v.clone(), &[]);
assert_eq!(report.reported, v);
assert!(report.ignored.is_empty());
}
#[test]
fn apply_ignores_selector_only_match_suppresses_all_rules() {
let v = vec![
fixture_violation("spacing/grid-conformance", "html > body", 2),
fixture_violation("color/palette-conformance", "html > body", 2),
fixture_violation("spacing/grid-conformance", "main", 5),
];
let ignores = vec![IgnoreRule {
selector: "html > body".to_owned(),
rule_id: None,
reason: "test".to_owned(),
}];
let report = apply_ignores(v, &ignores);
assert_eq!(report.reported.len(), 1);
assert_eq!(report.reported[0].selector, "main");
assert_eq!(report.ignored.len(), 2);
}
#[test]
fn apply_ignores_selector_plus_rule_id_filters_one_rule_only() {
let v = vec![
fixture_violation("spacing/grid-conformance", "html > body", 2),
fixture_violation("color/palette-conformance", "html > body", 2),
];
let ignores = vec![IgnoreRule {
selector: "html > body".to_owned(),
rule_id: Some("spacing/grid-conformance".to_owned()),
reason: "test".to_owned(),
}];
let report = apply_ignores(v, &ignores);
assert_eq!(report.reported.len(), 1);
assert_eq!(report.reported[0].rule_id, "color/palette-conformance");
assert_eq!(report.ignored.len(), 1);
assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
}
#[test]
fn apply_ignores_selector_mismatch_does_not_filter() {
let v = vec![fixture_violation(
"spacing/grid-conformance",
"html > body",
2,
)];
let ignores = vec![IgnoreRule {
selector: "html > body > div".to_owned(),
rule_id: None,
reason: "test".to_owned(),
}];
let report = apply_ignores(v.clone(), &ignores);
assert_eq!(report.reported, v);
assert!(report.ignored.is_empty());
}
#[test]
fn apply_ignores_is_deterministic_across_runs() {
let v = vec![
fixture_violation("a/rule", "html > body", 1),
fixture_violation("a/rule", "html > body", 2),
fixture_violation("b/rule", "main", 3),
];
let ignores = vec![IgnoreRule {
selector: "html > body".to_owned(),
rule_id: None,
reason: "x".to_owned(),
}];
let first = apply_ignores(v.clone(), &ignores);
let second = apply_ignores(v, &ignores);
assert_eq!(first, second);
}
#[test]
fn run_report_applies_ignores_against_real_engine_output() {
let snapshot = PlumbSnapshot::canned();
let mut config = Config::default();
config.ignore.push(IgnoreRule {
selector: "html > body".to_owned(),
rule_id: Some("spacing/grid-conformance".to_owned()),
reason: "canned snapshot exemption".to_owned(),
});
let report = run_report([&snapshot], &config);
assert!(report.reported.is_empty());
assert_eq!(report.ignored.len(), 1);
assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
assert_eq!(report.ignored[0].selector, "html > body");
}
}