code_baseline/rules/ast/
no_outline_none.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::{collect_class_attributes, parse_file};
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5pub 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 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 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}