Skip to main content

plumb_core/rules/spacing/
grid_conformance.rs

1//! `spacing/grid-conformance` — flag spacing values that aren't on the
2//! configured grid.
3//!
4//! Iterates the physical-longhand spacing properties (margin / padding
5//! per side, plus `gap` / `row-gap` / `column-gap`) and emits one
6//! violation per offending property when the parsed pixel value isn't
7//! a multiple of `config.spacing.base_unit`.
8
9use indexmap::IndexMap;
10
11use crate::config::Config;
12use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
13use crate::rules::Rule;
14use crate::rules::spacing::SPACING_PROPERTIES;
15use crate::rules::util::{nearest_multiple, parse_px};
16use crate::snapshot::SnapshotCtx;
17
18/// Tolerance for the off-grid test. Subpixel rounding from
19/// `getComputedStyle` can leave a residue of ~1e-12; `1e-6` keeps the
20/// rule resilient without admitting honest off-grid values.
21const FRACT_TOLERANCE: f64 = 1e-6;
22
23/// Flags spacing values that aren't multiples of `spacing.base_unit`.
24#[derive(Debug, Clone, Copy)]
25pub struct GridConformance;
26
27impl Rule for GridConformance {
28    fn id(&self) -> &'static str {
29        "spacing/grid-conformance"
30    }
31
32    fn default_severity(&self) -> Severity {
33        Severity::Warning
34    }
35
36    fn summary(&self) -> &'static str {
37        "Flags spacing values that aren't multiples of `spacing.base_unit`."
38    }
39
40    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
41        let base_unit = config.spacing.base_unit;
42        if base_unit == 0 {
43            // Defensive: a zero base_unit makes the check meaningless and
44            // would force a div-by-zero. Skip the rule entirely.
45            return;
46        }
47        let base_unit_f = f64::from(base_unit);
48
49        for node in ctx.nodes() {
50            for prop in SPACING_PROPERTIES {
51                let Some(raw) = node.computed_styles.get(*prop) else {
52                    continue;
53                };
54                let Some(value) = parse_px(raw) else { continue };
55                if (value / base_unit_f).fract().abs() <= FRACT_TOLERANCE {
56                    continue;
57                }
58                let suggested = nearest_multiple(value, base_unit);
59                let to = if suggested == 0 {
60                    "0".to_owned()
61                } else {
62                    format!("{suggested}px")
63                };
64                sink.push(Violation {
65                    rule_id: self.id().to_owned(),
66                    severity: self.default_severity(),
67                    message: format!(
68                        "`{selector}` has off-grid {prop} {raw}; expected a multiple of {base_unit}px.",
69                        selector = node.selector,
70                    ),
71                    selector: node.selector.clone(),
72                    viewport: ctx.snapshot().viewport.clone(),
73                    rect: ctx.rect_for(node.dom_order),
74                    dom_order: node.dom_order,
75                    fix: Some(Fix {
76                        kind: FixKind::CssPropertyReplace {
77                            property: (*prop).to_owned(),
78                            from: raw.clone(),
79                            to: to.clone(),
80                        },
81                        description: format!(
82                            "Snap `{prop}` to the nearest spacing-grid value ({to}).",
83                        ),
84                        confidence: Confidence::Medium,
85                    }),
86                    doc_url: "https://plumb.aramhammoudeh.com/rules/spacing-grid-conformance"
87                        .to_owned(),
88                    metadata: IndexMap::new(),
89                });
90            }
91        }
92    }
93}