Skip to main content

react_auditor/rules/performance/
a_has_content.rs

1use oxc_ast::ast::Program;
2use oxc_ast_visit::Visit;
3use oxc_ast_visit::walk;
4use oxc_semantic::Semantic;
5
6use crate::rules::{Rule, RuleFinding, RuleMeta, Severity};
7
8pub struct AHasContent;
9
10const RULE_META: RuleMeta = RuleMeta {
11    id: "a-has-content",
12    default_severity: Severity::Warning,
13    category: "accessibility",
14    description: "Anchor and button elements should have text content or an aria-label",
15};
16
17impl Rule for AHasContent {
18    fn meta(&self) -> &RuleMeta {
19        &RULE_META
20    }
21
22    fn run(&self, program: &Program, _semantic: &Semantic, source_text: &str) -> Vec<RuleFinding> {
23        let mut collector = AHasContentCollector {
24            findings: Vec::new(),
25            source: source_text,
26        };
27        collector.visit_program(program);
28        collector.findings
29    }
30}
31
32struct AHasContentCollector<'a> {
33    findings: Vec<RuleFinding>,
34    source: &'a str,
35}
36
37impl<'a> Visit<'a> for AHasContentCollector<'a> {
38    fn visit_jsx_element(&mut self, el: &oxc_ast::ast::JSXElement<'a>) {
39        let is_target = matches!(
40            &el.opening_element.name,
41            oxc_ast::ast::JSXElementName::Identifier(id)
42                if matches!(id.name.as_str(), "a" | "button")
43        );
44
45        if is_target {
46            let mut has_aria = false;
47            for attr_item in &el.opening_element.attributes {
48                if let oxc_ast::ast::JSXAttributeItem::Attribute(attr) = attr_item
49                    && let oxc_ast::ast::JSXAttributeName::Identifier(ident) = &attr.name
50                    && matches!(ident.name.as_str(), "aria-label" | "aria-labelledby")
51                {
52                    has_aria = true;
53                    break;
54                }
55            }
56
57            if !has_aria {
58                let has_text_content = el.children.iter().any(|child| match child {
59                    oxc_ast::ast::JSXChild::Text(text) => {
60                        let trimmed = text.value.trim();
61                        !trimmed.is_empty()
62                    }
63                    oxc_ast::ast::JSXChild::Element(_)
64                    | oxc_ast::ast::JSXChild::ExpressionContainer(_) => true,
65                    _ => false,
66                });
67
68                if !has_text_content {
69                    let start = el.opening_element.span.start as usize;
70                    let line = self.source[..start].lines().count().max(1);
71                    let col = start - self.source[..start].rfind('\n').map(|i| i + 1).unwrap_or(0);
72                    let tag = if matches!(&el.opening_element.name,
73                        oxc_ast::ast::JSXElementName::Identifier(id) if id.name.as_str() == "a"
74                    ) {
75                        "link"
76                    } else {
77                        "button"
78                    };
79                    self.findings.push(RuleFinding {
80                        line,
81                        column: col + 1,
82                        message: format!(
83                            "`<{tag}>` element has no text content or `aria-label` attribute"
84                        ),
85                    });
86                }
87            }
88        }
89
90        walk::walk_jsx_element(self, el);
91    }
92}