react_auditor/rules/performance/
a_has_content.rs1use 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}