Skip to main content

code_baseline/rules/ast/
no_click_handler.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5/// Shared logic for flagging non-interactive elements with onClick but no role.
6fn 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
120/// Flags `<div>` elements with `onClick` but no `role` attribute.
121pub 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
156/// Flags `<span>` elements with `onClick` but no `role` attribute.
157pub 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}