Skip to main content

plumb_core/rules/z/
scale_conformance.rs

1//! `z/scale-conformance` — flag `z-index` values that aren't in
2//! `z_index.scale`.
3
4use 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
11/// The single property this rule inspects.
12const Z_INDEX: &str = "z-index";
13
14/// Flags `z-index` values that aren't in `z_index.scale`.
15#[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            // The early `scale.is_empty()` guard above ensures
54            // `nearest_z` always returns `Some`. The `?` keeps the
55            // rule total even if a future refactor relaxes the outer
56            // guard.
57            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
94/// Find the nearest z-index in the scale.
95///
96/// Ties: toward lower absolute value, then toward the value closer to zero.
97///
98/// Returns `None` only when `scale` is empty. Encoding the empty-scale
99/// case in the return type keeps the helper total: callers do not
100/// have to repeat an `is_empty()` precondition to avoid a panic, and
101/// the rule stays sound if a future caller forgets the outer guard.
102fn 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        // 0 is equidistant from -10 and 10. With equal absolute value,
146        // the tie-break picks the higher signed value (10), matching
147        // the existing rule contract.
148        assert_eq!(nearest_z(0, &scale), Some(10));
149    }
150}