1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5fn check_click_handler(
7 ctx: &ScanContext,
8 tag_name: &str,
9 id: &str,
10 severity: Severity,
11 message: &str,
12 suggest: &Option<String>,
13) -> Vec<Violation> {
14 let mut violations = Vec::new();
15 let tree = match parse_file(ctx.file_path, ctx.content) {
16 Some(t) => t,
17 None => return violations,
18 };
19 let source = ctx.content.as_bytes();
20 visit(
21 tree.root_node(),
22 source,
23 ctx,
24 tag_name,
25 id,
26 severity,
27 message,
28 suggest,
29 &mut violations,
30 );
31 violations
32}
33
34fn visit(
35 node: tree_sitter::Node,
36 source: &[u8],
37 ctx: &ScanContext,
38 tag_name: &str,
39 id: &str,
40 severity: Severity,
41 message: &str,
42 suggest: &Option<String>,
43 violations: &mut Vec<Violation>,
44) {
45 let kind = node.kind();
46 if kind == "jsx_self_closing_element" || kind == "jsx_opening_element" {
47 if is_tag(&node, source, tag_name)
48 && has_attribute(&node, source, "onClick")
49 && !has_role_attribute(&node, source)
50 {
51 let row = node.start_position().row;
52 violations.push(Violation {
53 rule_id: id.to_string(),
54 severity,
55 file: ctx.file_path.to_path_buf(),
56 line: Some(row + 1),
57 column: Some(node.start_position().column + 1),
58 message: message.to_string(),
59 suggest: suggest.clone(),
60 source_line: ctx.content.lines().nth(row).map(String::from),
61 fix: None,
62 });
63 }
64 }
65
66 for i in 0..node.child_count() {
67 if let Some(child) = node.child(i) {
68 visit(child, source, ctx, tag_name, id, severity, message, suggest, violations);
69 }
70 }
71}
72
73fn is_tag(node: &tree_sitter::Node, source: &[u8], expected: &str) -> bool {
74 for i in 0..node.child_count() {
75 if let Some(child) = node.child(i) {
76 if child.kind() == "identifier" || child.kind() == "member_expression" {
77 if let Ok(name) = child.utf8_text(source) {
78 return name == expected;
79 }
80 }
81 }
82 }
83 false
84}
85
86fn has_attribute(node: &tree_sitter::Node, source: &[u8], attr_name: &str) -> bool {
87 for i in 0..node.child_count() {
88 if let Some(child) = node.child(i) {
89 if child.kind() == "jsx_attribute" {
90 if let Some(name_node) = child.child(0) {
91 if let Ok(name) = name_node.utf8_text(source) {
92 if name == attr_name {
93 return true;
94 }
95 }
96 }
97 }
98 }
99 }
100 false
101}
102
103fn has_role_attribute(node: &tree_sitter::Node, source: &[u8]) -> bool {
104 for i in 0..node.child_count() {
105 if let Some(child) = node.child(i) {
106 if child.kind() == "jsx_attribute" {
107 if let Some(name_node) = child.child(0) {
108 if let Ok(name) = name_node.utf8_text(source) {
109 if name == "role" {
110 return true;
111 }
112 }
113 }
114 }
115 }
116 }
117 false
118}
119
120pub struct NoDivClickHandlerRule {
122 id: String,
123 severity: Severity,
124 message: String,
125 suggest: Option<String>,
126 glob: Option<String>,
127}
128
129impl NoDivClickHandlerRule {
130 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
131 Ok(Self {
132 id: config.id.clone(),
133 severity: config.severity,
134 message: config.message.clone(),
135 suggest: config.suggest.clone(),
136 glob: config.glob.clone(),
137 })
138 }
139}
140
141impl Rule for NoDivClickHandlerRule {
142 fn id(&self) -> &str {
143 &self.id
144 }
145 fn severity(&self) -> Severity {
146 self.severity
147 }
148 fn file_glob(&self) -> Option<&str> {
149 self.glob.as_deref()
150 }
151 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
152 check_click_handler(ctx, "div", &self.id, self.severity, &self.message, &self.suggest)
153 }
154}
155
156pub struct NoSpanClickHandlerRule {
158 id: String,
159 severity: Severity,
160 message: String,
161 suggest: Option<String>,
162 glob: Option<String>,
163}
164
165impl NoSpanClickHandlerRule {
166 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
167 Ok(Self {
168 id: config.id.clone(),
169 severity: config.severity,
170 message: config.message.clone(),
171 suggest: config.suggest.clone(),
172 glob: config.glob.clone(),
173 })
174 }
175}
176
177impl Rule for NoSpanClickHandlerRule {
178 fn id(&self) -> &str {
179 &self.id
180 }
181 fn severity(&self) -> Severity {
182 self.severity
183 }
184 fn file_glob(&self) -> Option<&str> {
185 self.glob.as_deref()
186 }
187 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
188 check_click_handler(ctx, "span", &self.id, self.severity, &self.message, &self.suggest)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::path::Path;
196
197 fn make_div_rule() -> NoDivClickHandlerRule {
198 NoDivClickHandlerRule::new(&RuleConfig {
199 id: "no-div-click-handler".into(),
200 severity: Severity::Error,
201 message: "Non-interactive <div> with onClick".into(),
202 suggest: Some("Use <button> instead".into()),
203 glob: Some("**/*.{tsx,jsx}".into()),
204 ..Default::default()
205 })
206 .unwrap()
207 }
208
209 fn make_span_rule() -> NoSpanClickHandlerRule {
210 NoSpanClickHandlerRule::new(&RuleConfig {
211 id: "no-span-click-handler".into(),
212 severity: Severity::Error,
213 message: "Non-interactive <span> with onClick".into(),
214 suggest: Some("Use <button> instead".into()),
215 glob: Some("**/*.{tsx,jsx}".into()),
216 ..Default::default()
217 })
218 .unwrap()
219 }
220
221 fn check_div(content: &str) -> Vec<Violation> {
222 let rule = make_div_rule();
223 let ctx = ScanContext {
224 file_path: Path::new("test.tsx"),
225 content,
226 };
227 rule.check_file(&ctx)
228 }
229
230 fn check_span(content: &str) -> Vec<Violation> {
231 let rule = make_span_rule();
232 let ctx = ScanContext {
233 file_path: Path::new("test.tsx"),
234 content,
235 };
236 rule.check_file(&ctx)
237 }
238
239 #[test]
240 fn div_with_onclick_no_role_flags() {
241 let v = check_div(r#"function App() { return <div onClick={handleClick}>text</div>; }"#);
242 assert_eq!(v.len(), 1);
243 }
244
245 #[test]
246 fn div_with_onclick_and_role_no_violation() {
247 let v = check_div(
248 r#"function App() { return <div role="button" onClick={handleClick}>text</div>; }"#,
249 );
250 assert!(v.is_empty());
251 }
252
253 #[test]
254 fn div_without_onclick_no_violation() {
255 let v = check_div(r#"function App() { return <div className="card">text</div>; }"#);
256 assert!(v.is_empty());
257 }
258
259 #[test]
260 fn self_closing_div_with_onclick_flags() {
261 let v = check_div(r#"function App() { return <div onClick={fn} />; }"#);
262 assert_eq!(v.len(), 1);
263 }
264
265 #[test]
266 fn span_with_onclick_no_role_flags() {
267 let v = check_span(r#"function App() { return <span onClick={fn}>text</span>; }"#);
268 assert_eq!(v.len(), 1);
269 }
270
271 #[test]
272 fn span_with_onclick_and_role_no_violation() {
273 let v = check_span(
274 r#"function App() { return <span role="link" onClick={fn}>text</span>; }"#,
275 );
276 assert!(v.is_empty());
277 }
278
279 #[test]
280 fn button_not_flagged_by_div_rule() {
281 let v = check_div(r#"function App() { return <button onClick={fn}>text</button>; }"#);
282 assert!(v.is_empty());
283 }
284
285 #[test]
286 fn non_tsx_skipped() {
287 let rule = make_div_rule();
288 let ctx = ScanContext {
289 file_path: Path::new("test.rs"),
290 content: "fn main() {}",
291 };
292 assert!(rule.check_file(&ctx).is_empty());
293 }
294
295 #[test]
296 fn multiline_jsx_div() {
297 let content = r#"function App() {
298 return (
299 <div
300 className="card"
301 onClick={handleClick}
302 >
303 content
304 </div>
305 );
306}"#;
307 let v = check_div(content);
308 assert_eq!(v.len(), 1);
309 }
310}