Skip to main content

code_baseline/rules/ast/
no_outline_none.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::{collect_class_attributes, parse_file};
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5/// Flags `outline-none` or `outline-0` in className attributes when there is
6/// no companion `focus-visible:` ring class in the same attribute.
7pub struct NoOutlineNoneRule {
8    id: String,
9    severity: Severity,
10    message: String,
11    suggest: Option<String>,
12    glob: Option<String>,
13}
14
15impl NoOutlineNoneRule {
16    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
17        Ok(Self {
18            id: config.id.clone(),
19            severity: config.severity,
20            message: config.message.clone(),
21            suggest: config.suggest.clone(),
22            glob: config.glob.clone(),
23        })
24    }
25}
26
27impl Rule for NoOutlineNoneRule {
28    fn id(&self) -> &str {
29        &self.id
30    }
31
32    fn severity(&self) -> Severity {
33        self.severity
34    }
35
36    fn file_glob(&self) -> Option<&str> {
37        self.glob.as_deref()
38    }
39
40    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
41        let mut violations = Vec::new();
42        let tree = match parse_file(ctx.file_path, ctx.content) {
43            Some(t) => t,
44            None => return violations,
45        };
46        let source = ctx.content.as_bytes();
47        let attrs = collect_class_attributes(&tree, source);
48
49        for fragments in &attrs {
50            // Collect all tokens across all fragments in this attribute
51            let mut all_tokens: Vec<&str> = Vec::new();
52            for frag in fragments {
53                for token in frag.value.split_whitespace() {
54                    all_tokens.push(token);
55                }
56            }
57
58            let has_outline_remove = all_tokens
59                .iter()
60                .any(|t| *t == "outline-none" || *t == "outline-0");
61            if !has_outline_remove {
62                continue;
63            }
64
65            let has_focus_visible_ring = all_tokens.iter().any(|t| {
66                t.starts_with("focus-visible:ring") || t.starts_with("focus-visible:outline")
67            });
68            if has_focus_visible_ring {
69                continue;
70            }
71
72            // Find the fragment containing the offending token for line/col
73            for frag in fragments {
74                for token in frag.value.split_whitespace() {
75                    if token == "outline-none" || token == "outline-0" {
76                        let col_offset = frag.value.find(token).unwrap_or(0);
77                        let line = frag.line;
78                        violations.push(Violation {
79                            rule_id: self.id.clone(),
80                            severity: self.severity,
81                            file: ctx.file_path.to_path_buf(),
82                            line: Some(line + 1),
83                            column: Some(frag.col + col_offset + 1),
84                            message: self.message.clone(),
85                            suggest: self.suggest.clone(),
86                            source_line: ctx.content.lines().nth(line).map(String::from),
87                            fix: None,
88                        });
89                        break;
90                    }
91                }
92            }
93        }
94
95        violations
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::path::Path;
103
104    fn make_rule() -> NoOutlineNoneRule {
105        NoOutlineNoneRule::new(&RuleConfig {
106            id: "no-outline-none".into(),
107            severity: Severity::Warning,
108            message: "outline-none removes the focus indicator".into(),
109            suggest: Some("Use focus-visible:outline-none with a custom focus ring instead".into()),
110            glob: Some("**/*.{tsx,jsx}".into()),
111            ..Default::default()
112        })
113        .unwrap()
114    }
115
116    fn check(rule: &NoOutlineNoneRule, content: &str) -> Vec<Violation> {
117        let ctx = ScanContext {
118            file_path: Path::new("test.tsx"),
119            content,
120        };
121        rule.check_file(&ctx)
122    }
123
124    #[test]
125    fn outline_none_without_ring_flags() {
126        let rule = make_rule();
127        let violations = check(&rule, r#"function App() { return <div className="outline-none p-4" />; }"#);
128        assert_eq!(violations.len(), 1);
129        assert_eq!(violations[0].rule_id, "no-outline-none");
130    }
131
132    #[test]
133    fn outline_none_with_focus_visible_ring_no_violation() {
134        let rule = make_rule();
135        let violations = check(
136            &rule,
137            r#"function App() { return <div className="outline-none focus-visible:ring-2" />; }"#,
138        );
139        assert!(violations.is_empty());
140    }
141
142    #[test]
143    fn outline_none_with_focus_visible_outline_no_violation() {
144        let rule = make_rule();
145        let violations = check(
146            &rule,
147            r#"function App() { return <div className="outline-none focus-visible:outline-2" />; }"#,
148        );
149        assert!(violations.is_empty());
150    }
151
152    #[test]
153    fn outline_0_without_ring_flags() {
154        let rule = make_rule();
155        let violations = check(&rule, r#"function App() { return <input className="outline-0 bg-white" />; }"#);
156        assert_eq!(violations.len(), 1);
157    }
158
159    #[test]
160    fn inside_cn_call() {
161        let rule = make_rule();
162        let violations = check(
163            &rule,
164            r#"function App() { return <div className={cn("outline-none", "p-4")} />; }"#,
165        );
166        assert_eq!(violations.len(), 1);
167    }
168
169    #[test]
170    fn inside_cn_call_with_ring() {
171        let rule = make_rule();
172        let violations = check(
173            &rule,
174            r#"function App() { return <div className={cn("outline-none", "focus-visible:ring-2")} />; }"#,
175        );
176        assert!(violations.is_empty());
177    }
178
179    #[test]
180    fn non_tsx_skipped() {
181        let rule = make_rule();
182        let ctx = ScanContext {
183            file_path: Path::new("test.rs"),
184            content: "fn main() {}",
185        };
186        assert!(rule.check_file(&ctx).is_empty());
187    }
188
189    #[test]
190    fn no_outline_classes_no_violation() {
191        let rule = make_rule();
192        let violations = check(&rule, r#"function App() { return <div className="bg-white p-4" />; }"#);
193        assert!(violations.is_empty());
194    }
195}