Skip to main content

plumb_core/rules/edge/
near_alignment.rs

1//! `edge/near-alignment` — flag sibling edges that almost-but-not-quite
2//! line up.
3//!
4//! ## Heuristic
5//!
6//! For each parent group of siblings (with rects), the rule processes
7//! the four edge axes independently — `left`, `right`, `top`, `bottom`
8//! — and runs a greedy 1-D clustering pass on each:
9//!
10//! 1. Sort the parent group's edge values.
11//! 2. Walk the sorted list; an edge joins the active cluster when it
12//!    is within `alignment.tolerance_px` of the cluster's lowest
13//!    member, otherwise it opens a new cluster.
14//! 3. For each cluster of ≥ 2 members, compute the integer mean
15//!    (truncated; `sum / len`).
16//! 4. Any member whose distance from the centroid is **strictly
17//!    positive** AND **at most `tolerance_px`** fires a violation.
18//!    Pixel-perfect alignments (delta == 0) are deliberately silent.
19//!
20//! Each rule pass emits at most one violation per (node, axis) pair;
21//! a node with several near-aligned edges may be flagged once per axis.
22
23use indexmap::IndexMap;
24
25use crate::config::Config;
26use crate::report::{Confidence, Fix, FixKind, Rect, Severity, Violation, ViolationSink};
27use crate::rules::Rule;
28use crate::snapshot::{SnapshotCtx, SnapshotNode};
29
30/// One of the four edge axes the rule inspects.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum Axis {
33    Left,
34    Right,
35    Top,
36    Bottom,
37}
38
39impl Axis {
40    /// All four axes, in the order the rule processes them.
41    const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom];
42
43    /// Lowercase identifier used in violation messages and metadata.
44    const fn name(self) -> &'static str {
45        match self {
46            Self::Left => "left",
47            Self::Right => "right",
48            Self::Top => "top",
49            Self::Bottom => "bottom",
50        }
51    }
52
53    /// Edge value for a given rect, in CSS pixels.
54    fn edge(self, rect: Rect) -> i32 {
55        match self {
56            Self::Left => rect.x,
57            Self::Right => rect.x.saturating_add_unsigned(rect.width),
58            Self::Top => rect.y,
59            Self::Bottom => rect.y.saturating_add_unsigned(rect.height),
60        }
61    }
62}
63
64/// Flags element edges that almost-but-not-quite line up with sibling
65/// edges.
66#[derive(Debug, Clone, Copy)]
67pub struct NearAlignment;
68
69impl Rule for NearAlignment {
70    fn id(&self) -> &'static str {
71        "edge/near-alignment"
72    }
73
74    fn default_severity(&self) -> Severity {
75        Severity::Info
76    }
77
78    fn summary(&self) -> &'static str {
79        "Flags element edges that almost-but-not-quite line up with sibling edges."
80    }
81
82    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
83        let tolerance = config.alignment.tolerance_px;
84        if tolerance == 0 {
85            // No tolerance configured — every miss is "perfect or
86            // off"; the rule has nothing to say.
87            return;
88        }
89
90        let mut groups: IndexMap<u64, Vec<EdgeEntry<'_>>> = IndexMap::new();
91        for node in ctx.nodes() {
92            let Some(parent) = node.parent else { continue };
93            let Some(rect) = ctx.rect_for(node.dom_order) else {
94                continue;
95            };
96            groups
97                .entry(parent)
98                .or_default()
99                .push(EdgeEntry { node, rect });
100        }
101
102        for siblings in groups.values() {
103            if siblings.len() < 2 {
104                continue;
105            }
106            for axis in Axis::ALL {
107                emit_for_axis(
108                    self.id(),
109                    self.default_severity(),
110                    ctx,
111                    axis,
112                    tolerance,
113                    siblings,
114                    sink,
115                );
116            }
117        }
118    }
119}
120
121/// One sibling, paired with its rect for cheap geometry math.
122#[derive(Debug, Clone, Copy)]
123struct EdgeEntry<'a> {
124    node: &'a SnapshotNode,
125    rect: Rect,
126}
127
128/// Cluster siblings on a single edge axis and emit violations.
129fn emit_for_axis(
130    rule_id: &str,
131    severity: Severity,
132    ctx: &SnapshotCtx<'_>,
133    axis: Axis,
134    tolerance: u32,
135    siblings: &[EdgeEntry<'_>],
136    sink: &mut ViolationSink<'_>,
137) {
138    // Pair every sibling with its edge value, then sort by edge.
139    let mut entries: Vec<(EdgeEntry<'_>, i32)> = siblings
140        .iter()
141        .map(|entry| (*entry, axis.edge(entry.rect)))
142        .collect();
143    entries.sort_by_key(|(_, edge)| *edge);
144
145    let tolerance_i32 = i32::try_from(tolerance).unwrap_or(i32::MAX);
146
147    let mut idx = 0;
148    while idx < entries.len() {
149        // Open a new cluster anchored at `entries[idx]`.
150        let cluster_start_edge = entries[idx].1;
151        let mut end = idx + 1;
152        while end < entries.len() && entries[end].1 - cluster_start_edge <= tolerance_i32 {
153            end += 1;
154        }
155        let cluster = &entries[idx..end];
156        if cluster.len() >= 2 {
157            // Centroid = integer mean (`sum / len`, truncating). Use
158            // i64 to avoid overflow with many large coordinates;
159            // cluster size is bounded by the sibling count so the
160            // cast back to i32 is safe.
161            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
162            let sum: i64 = cluster.iter().map(|(_, e)| i64::from(*e)).sum();
163            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
164            let centroid: i32 = (sum / cluster.len() as i64) as i32;
165            for (entry, edge) in cluster {
166                let delta = (edge - centroid).abs();
167                let delta_u32 = u32::try_from(delta).unwrap_or(0);
168                // delta <= tolerance by the clustering invariant above
169                // (every cluster member sits within `tolerance` of the
170                // anchor, so the mean's distance to each member is too).
171                if delta_u32 == 0 || delta_u32 > tolerance {
172                    continue;
173                }
174                emit_violation(
175                    rule_id,
176                    severity,
177                    ctx,
178                    axis,
179                    entry,
180                    *edge,
181                    centroid,
182                    delta_u32,
183                    cluster.len(),
184                    tolerance,
185                    sink,
186                );
187            }
188        }
189        idx = end;
190    }
191}
192
193// Builds a single violation from values the caller already has on hand;
194// grouping these into a struct would duplicate the loop locals without
195// hiding any real complexity. (Argument count stays under the
196// `too-many-arguments-threshold` of 12 set in `clippy.toml`.)
197fn emit_violation(
198    rule_id: &str,
199    severity: Severity,
200    ctx: &SnapshotCtx<'_>,
201    axis: Axis,
202    entry: &EdgeEntry<'_>,
203    edge: i32,
204    centroid: i32,
205    delta: u32,
206    cluster_size: usize,
207    tolerance: u32,
208    sink: &mut ViolationSink<'_>,
209) {
210    let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
211    metadata.insert("axis".to_owned(), axis.name().into());
212    metadata.insert("edge_px".to_owned(), edge.into());
213    metadata.insert("cluster_centroid_px".to_owned(), centroid.into());
214    metadata.insert("delta_px".to_owned(), delta.into());
215    metadata.insert("cluster_size".to_owned(), cluster_size.into());
216    metadata.insert("tolerance_px".to_owned(), tolerance.into());
217
218    sink.push(Violation {
219        rule_id: rule_id.to_owned(),
220        severity,
221        message: format!(
222            "`{selector}` {axis} edge is {edge}px; {cluster_size} sibling(s) cluster at {centroid}px ({delta}px drift, tolerance {tolerance}px).",
223            selector = entry.node.selector,
224            axis = axis.name(),
225        ),
226        selector: entry.node.selector.clone(),
227        viewport: ctx.snapshot().viewport.clone(),
228        rect: Some(entry.rect),
229        dom_order: entry.node.dom_order,
230        fix: Some(Fix {
231            kind: FixKind::Description {
232                text: format!(
233                    "Snap the {axis} edge to {centroid}px to match the sibling cluster.",
234                    axis = axis.name(),
235                ),
236            },
237            description: format!(
238                "Align `{selector}`'s {axis} edge with its {cluster_size}-member cluster ({centroid}px).",
239                selector = entry.node.selector,
240                axis = axis.name(),
241            ),
242            confidence: Confidence::Low,
243        }),
244        doc_url: "https://plumb.aramhammoudeh.com/rules/edge-near-alignment".to_owned(),
245        metadata,
246    });
247}
248
249#[cfg(test)]
250mod tests {
251    use super::Axis;
252    use crate::report::Rect;
253
254    fn rect(x: i32, y: i32, w: u32, h: u32) -> Rect {
255        Rect {
256            x,
257            y,
258            width: w,
259            height: h,
260        }
261    }
262
263    #[test]
264    fn axis_edges_are_correct() {
265        let r = rect(10, 20, 30, 40);
266        assert_eq!(Axis::Left.edge(r), 10);
267        assert_eq!(Axis::Right.edge(r), 40);
268        assert_eq!(Axis::Top.edge(r), 20);
269        assert_eq!(Axis::Bottom.edge(r), 60);
270    }
271
272    #[test]
273    fn axis_names_are_lowercase() {
274        for (axis, name) in [
275            (Axis::Left, "left"),
276            (Axis::Right, "right"),
277            (Axis::Top, "top"),
278            (Axis::Bottom, "bottom"),
279        ] {
280            assert_eq!(axis.name(), name);
281        }
282    }
283
284    #[test]
285    fn axis_all_lists_every_variant() {
286        // Sanity: ALL covers the four named axes exactly.
287        let names: Vec<&'static str> = Axis::ALL.iter().map(|a| a.name()).collect();
288        assert_eq!(names, vec!["left", "right", "top", "bottom"]);
289    }
290}