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 if rect.width == 0 || rect.height == 0 {
100 continue;
101 }
102 if !crate::rules::util::is_layout_relevant(node) {
103 continue;
104 }
105 groups
106 .entry(parent)
107 .or_default()
108 .push(EdgeEntry { node, rect });
109 }
110
111 for siblings in groups.values() {
112 if siblings.len() < 2 {
113 continue;
114 }
115 for axis in Axis::ALL {
116 emit_for_axis(
117 self.id(),
118 self.default_severity(),
119 ctx,
120 axis,
121 tolerance,
122 siblings,
123 sink,
124 );
125 }
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy)]
132struct EdgeEntry<'a> {
133 node: &'a SnapshotNode,
134 rect: Rect,
135}
136
137fn emit_for_axis(
139 rule_id: &str,
140 severity: Severity,
141 ctx: &SnapshotCtx<'_>,
142 axis: Axis,
143 tolerance: u32,
144 siblings: &[EdgeEntry<'_>],
145 sink: &mut ViolationSink<'_>,
146) {
147 let mut entries: Vec<(EdgeEntry<'_>, i32)> = siblings
149 .iter()
150 .map(|entry| (*entry, axis.edge(entry.rect)))
151 .collect();
152 entries.sort_by_key(|(_, edge)| *edge);
153
154 let tolerance_i32 = i32::try_from(tolerance).unwrap_or(i32::MAX);
155
156 let mut idx = 0;
157 while idx < entries.len() {
158 let cluster_start_edge = entries[idx].1;
160 let mut end = idx + 1;
161 while end < entries.len() && entries[end].1 - cluster_start_edge <= tolerance_i32 {
162 end += 1;
163 }
164 let cluster = &entries[idx..end];
165 if cluster.len() >= 2 {
166 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
171 let sum: i64 = cluster.iter().map(|(_, e)| i64::from(*e)).sum();
172 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
173 let centroid: i32 = (sum / cluster.len() as i64) as i32;
174 for (entry, edge) in cluster {
175 let delta = (edge - centroid).abs();
176 let delta_u32 = u32::try_from(delta).unwrap_or(0);
177 if delta_u32 == 0 || delta_u32 > tolerance {
181 continue;
182 }
183 emit_violation(
184 rule_id,
185 severity,
186 ctx,
187 axis,
188 entry,
189 *edge,
190 centroid,
191 delta_u32,
192 cluster.len(),
193 tolerance,
194 sink,
195 );
196 }
197 }
198 idx = end;
199 }
200}
201
202fn emit_violation(
207 rule_id: &str,
208 severity: Severity,
209 ctx: &SnapshotCtx<'_>,
210 axis: Axis,
211 entry: &EdgeEntry<'_>,
212 edge: i32,
213 centroid: i32,
214 delta: u32,
215 cluster_size: usize,
216 tolerance: u32,
217 sink: &mut ViolationSink<'_>,
218) {
219 let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
220 metadata.insert("axis".to_owned(), axis.name().into());
221 metadata.insert("edge_px".to_owned(), edge.into());
222 metadata.insert("cluster_centroid_px".to_owned(), centroid.into());
223 metadata.insert("delta_px".to_owned(), delta.into());
224 metadata.insert("cluster_size".to_owned(), cluster_size.into());
225 metadata.insert("tolerance_px".to_owned(), tolerance.into());
226
227 sink.push(Violation {
228 rule_id: rule_id.to_owned(),
229 severity,
230 message: format!(
231 "`{selector}` {axis} edge is {edge}px; {cluster_size} sibling(s) cluster at {centroid}px ({delta}px drift, tolerance {tolerance}px).",
232 selector = entry.node.selector,
233 axis = axis.name(),
234 ),
235 selector: entry.node.selector.clone(),
236 viewport: ctx.snapshot().viewport.clone(),
237 rect: Some(entry.rect),
238 dom_order: entry.node.dom_order,
239 fix: Some(Fix {
240 kind: FixKind::Description {
241 text: format!(
242 "Snap the {axis} edge to {centroid}px to match the sibling cluster.",
243 axis = axis.name(),
244 ),
245 },
246 description: format!(
247 "Align `{selector}`'s {axis} edge with its {cluster_size}-member cluster ({centroid}px).",
248 selector = entry.node.selector,
249 axis = axis.name(),
250 ),
251 confidence: Confidence::Low,
252 }),
253 doc_url: "https://plumb.aramhammoudeh.com/rules/edge-near-alignment".to_owned(),
254 metadata,
255 });
256}
257
258#[cfg(test)]
259mod tests {
260 use super::Axis;
261 use crate::report::Rect;
262
263 fn rect(x: i32, y: i32, w: u32, h: u32) -> Rect {
264 Rect {
265 x,
266 y,
267 width: w,
268 height: h,
269 }
270 }
271
272 #[test]
273 fn axis_edges_are_correct() {
274 let r = rect(10, 20, 30, 40);
275 assert_eq!(Axis::Left.edge(r), 10);
276 assert_eq!(Axis::Right.edge(r), 40);
277 assert_eq!(Axis::Top.edge(r), 20);
278 assert_eq!(Axis::Bottom.edge(r), 60);
279 }
280
281 #[test]
282 fn axis_names_are_lowercase() {
283 for (axis, name) in [
284 (Axis::Left, "left"),
285 (Axis::Right, "right"),
286 (Axis::Top, "top"),
287 (Axis::Bottom, "bottom"),
288 ] {
289 assert_eq!(axis.name(), name);
290 }
291 }
292
293 #[test]
294 fn axis_all_lists_every_variant() {
295 let names: Vec<&'static str> = Axis::ALL.iter().map(|a| a.name()).collect();
297 assert_eq!(names, vec!["left", "right", "top", "bottom"]);
298 }
299}