plumb_core/rules/z/
scale_conformance.rs1use indexmap::IndexMap;
5
6use crate::config::Config;
7use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
8use crate::rules::Rule;
9use crate::snapshot::SnapshotCtx;
10
11const Z_INDEX: &str = "z-index";
13
14#[derive(Debug, Clone, Copy)]
16pub struct ScaleConformance;
17
18impl Rule for ScaleConformance {
19 fn id(&self) -> &'static str {
20 "z/scale-conformance"
21 }
22
23 fn default_severity(&self) -> Severity {
24 Severity::Warning
25 }
26
27 fn summary(&self) -> &'static str {
28 "Flags `z-index` values that aren't in `z_index.scale`."
29 }
30
31 fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
32 let scale = &config.z_index.scale;
33 if scale.is_empty() {
34 return;
35 }
36
37 for node in ctx.nodes() {
38 let Some(raw) = node.computed_styles.get(Z_INDEX) else {
39 continue;
40 };
41 let trimmed = raw.trim();
42 if trimmed.eq_ignore_ascii_case("auto") {
43 continue;
44 }
45 let Ok(value) = trimmed.parse::<i32>() else {
46 continue;
47 };
48
49 if scale.contains(&value) {
50 continue;
51 }
52
53 let Some(nearest) = nearest_z(value, scale) else {
58 continue;
59 };
60
61 let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
62 metadata.insert("z_index".to_owned(), serde_json::Value::from(value));
63 metadata.insert("nearest".to_owned(), serde_json::Value::from(nearest));
64
65 sink.push(Violation {
66 rule_id: self.id().to_owned(),
67 severity: self.default_severity(),
68 message: format!(
69 "`{selector}` has off-scale z-index {value}; expected a value from z_index.scale.",
70 selector = node.selector,
71 ),
72 selector: node.selector.clone(),
73 viewport: ctx.snapshot().viewport.clone(),
74 rect: ctx.rect_for(node.dom_order),
75 dom_order: node.dom_order,
76 fix: Some(Fix {
77 kind: FixKind::CssPropertyReplace {
78 property: Z_INDEX.to_owned(),
79 from: raw.clone(),
80 to: nearest.to_string(),
81 },
82 description: format!(
83 "Snap `z-index` to the nearest scale value ({nearest}).",
84 ),
85 confidence: Confidence::Medium,
86 }),
87 doc_url: "https://plumb.aramhammoudeh.com/rules/z-scale-conformance".to_owned(),
88 metadata,
89 });
90 }
91 }
92}
93
94fn nearest_z(value: i32, scale: &[i32]) -> Option<i32> {
103 scale.iter().copied().fold(None, |best, candidate| {
104 let candidate_delta = value.abs_diff(candidate);
105 match best {
106 None => Some(candidate),
107 Some(current) => {
108 let current_delta = value.abs_diff(current);
109 if candidate_delta < current_delta
110 || (candidate_delta == current_delta
111 && candidate.unsigned_abs() < current.unsigned_abs())
112 || (candidate_delta == current_delta
113 && candidate.unsigned_abs() == current.unsigned_abs()
114 && candidate > current)
115 {
116 Some(candidate)
117 } else {
118 Some(current)
119 }
120 }
121 }
122 })
123}
124
125#[cfg(test)]
126mod tests {
127 use super::nearest_z;
128
129 #[test]
130 fn empty_scale_returns_none() {
131 assert_eq!(nearest_z(5, &[]), None);
132 }
133
134 #[test]
135 fn picks_closest_z_in_scale() {
136 let scale = [0, 10, 100];
137 assert_eq!(nearest_z(7, &scale), Some(10));
138 assert_eq!(nearest_z(3, &scale), Some(0));
139 assert_eq!(nearest_z(60, &scale), Some(100));
140 }
141
142 #[test]
143 fn breaks_ties_toward_higher_signed_value_for_equal_abs() {
144 let scale = [-10, 10];
145 assert_eq!(nearest_z(0, &scale), Some(10));
149 }
150}