Skip to main content

plumb_core/rules/sibling/
padding_consistency.rs

1//! `sibling/padding-consistency` — flag sibling elements with
2//! inconsistent padding.
3
4use indexmap::IndexMap;
5
6use crate::config::Config;
7use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
8use crate::rules::Rule;
9use crate::rules::util::parse_px;
10use crate::snapshot::SnapshotCtx;
11
12/// The padding longhands checked for consistency.
13const PADDING_PROPERTIES: &[&str] = &[
14    "padding-top",
15    "padding-right",
16    "padding-bottom",
17    "padding-left",
18];
19
20/// Padding this far from the sibling median (in CSS pixels) triggers a
21/// violation.
22const PADDING_DEVIATION_PX: u32 = 4;
23
24/// Flags sibling elements with inconsistent padding.
25#[derive(Debug, Clone, Copy)]
26pub struct PaddingConsistency;
27
28impl Rule for PaddingConsistency {
29    fn id(&self) -> &'static str {
30        "sibling/padding-consistency"
31    }
32
33    fn default_severity(&self) -> Severity {
34        Severity::Info
35    }
36
37    fn summary(&self) -> &'static str {
38        "Flags sibling elements with inconsistent padding."
39    }
40
41    fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
42        // Group nodes by parent dom_order.
43        let mut groups: IndexMap<u64, Vec<usize>> = IndexMap::new();
44        for (idx, node) in ctx.snapshot().nodes.iter().enumerate() {
45            let Some(parent) = node.parent else { continue };
46            groups.entry(parent).or_default().push(idx);
47        }
48
49        let nodes = &ctx.snapshot().nodes;
50
51        for siblings in groups.values() {
52            if siblings.len() < 2 {
53                continue;
54            }
55
56            for prop in PADDING_PROPERTIES {
57                // Collect (index, parsed px value) for siblings that have
58                // the property and it parses.
59                let parsed: Vec<(usize, f64)> = siblings
60                    .iter()
61                    .filter_map(|&idx| {
62                        let raw = nodes[idx].computed_styles.get(*prop)?;
63                        let val = parse_px(raw)?;
64                        Some((idx, val))
65                    })
66                    .collect();
67
68                if parsed.len() < 2 {
69                    continue;
70                }
71
72                let median = median_f64(&parsed.iter().map(|(_, v)| *v).collect::<Vec<_>>());
73
74                for &(idx, val) in &parsed {
75                    let dev = (val - median).abs();
76                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
77                    let dev_u32 = dev.round() as u32;
78                    if dev_u32 <= PADDING_DEVIATION_PX {
79                        continue;
80                    }
81
82                    let node = &nodes[idx];
83                    let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
84                    metadata.insert(
85                        "property".to_owned(),
86                        serde_json::Value::String((*prop).to_owned()),
87                    );
88                    metadata.insert(
89                        "rendered_padding_px".to_owned(),
90                        serde_json::Value::from(val),
91                    );
92                    metadata.insert(
93                        "sibling_median_px".to_owned(),
94                        serde_json::Value::from(median),
95                    );
96                    metadata.insert("deviation_px".to_owned(), serde_json::Value::from(dev_u32));
97
98                    sink.push(Violation {
99                        rule_id: self.id().to_owned(),
100                        severity: self.default_severity(),
101                        message: format!(
102                            "`{selector}` has {prop} {val}px; sibling median is {median}px ({dev_u32}px drift).",
103                            selector = node.selector,
104                        ),
105                        selector: node.selector.clone(),
106                        viewport: ctx.snapshot().viewport.clone(),
107                        rect: ctx.rect_for(node.dom_order),
108                        dom_order: node.dom_order,
109                        fix: Some(Fix {
110                            kind: FixKind::Description {
111                                text: format!(
112                                    "Match sibling {prop} ({median}px) to keep padding consistent. Drift: {dev_u32}px.",
113                                ),
114                            },
115                            description: format!(
116                                "Bring `{selector}` {prop} in line with its siblings ({median}px).",
117                                selector = node.selector,
118                            ),
119                            confidence: Confidence::Low,
120                        }),
121                        doc_url: "https://plumb.aramhammoudeh.com/rules/sibling-padding-consistency"
122                            .to_owned(),
123                        metadata,
124                    });
125                }
126            }
127        }
128    }
129}
130
131/// Median of a slice of f64 values.
132///
133/// For even counts, the lower of the two middle values wins (same
134/// deterministic tie-break as `sibling/height-consistency`).
135///
136/// Uses [`f64::total_cmp`] for the sort key — `partial_cmp` returns
137/// `None` on `NaN`, which would force a fallback comparator and risk
138/// nondeterministic ordering. `total_cmp` defines a total order over
139/// all `f64` bit patterns (including `NaN`s), which keeps the median
140/// reproducible regardless of the input distribution.
141fn median_f64(values: &[f64]) -> f64 {
142    let mut sorted: Vec<f64> = values.to_vec();
143    sorted.sort_by(f64::total_cmp);
144    let mid = sorted.len() / 2;
145    if sorted.len().is_multiple_of(2) {
146        sorted[mid - 1]
147    } else {
148        sorted[mid]
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::median_f64;
155
156    #[test]
157    fn median_odd_count_picks_middle() {
158        assert!((median_f64(&[1.0, 5.0, 3.0]) - 3.0).abs() < f64::EPSILON);
159    }
160
161    #[test]
162    fn median_even_count_picks_lower_middle() {
163        // Sorted: [1.0, 3.0, 5.0, 7.0]. Lower of the two middle values
164        // wins, which is 3.0.
165        assert!((median_f64(&[1.0, 7.0, 3.0, 5.0]) - 3.0).abs() < f64::EPSILON);
166    }
167
168    #[test]
169    fn median_with_nan_is_total_and_does_not_panic() {
170        // `f64::total_cmp` defines a total order over NaN bit patterns,
171        // so the sort is well-defined and deterministic. Without
172        // `total_cmp`, `partial_cmp` would return `None` and the
173        // fallback comparator would risk a nondeterministic median.
174        // total_cmp orders NaN after positive infinity, so sorted
175        // looks like [1.0, 2.0, 3.0, NaN]; the lower middle is 2.0.
176        let values = [1.0_f64, f64::NAN, 3.0, 2.0];
177        let result = median_f64(&values);
178        assert!((result - 2.0).abs() < f64::EPSILON);
179    }
180}