1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum Axis {
33 Left,
34 Right,
35 Top,
36 Bottom,
37}
38
39impl Axis {
40 const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom];
42
43 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 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#[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 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#[derive(Debug, Clone, Copy)]
123struct EdgeEntry<'a> {
124 node: &'a SnapshotNode,
125 rect: Rect,
126}
127
128fn 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 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 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 #[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 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
193fn 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 let names: Vec<&'static str> = Axis::ALL.iter().map(|a| a.name()).collect();
288 assert_eq!(names, vec!["left", "right", "top", "bottom"]);
289 }
290}