Skip to content

Commit 096ac7b

Browse files
committed
refactor(linter): clean up jsx-a11y/anchor-is-valid (#4831)
1 parent 8827659 commit 096ac7b

File tree

4 files changed

+160
-115
lines changed

4 files changed

+160
-115
lines changed

crates/oxc_ast/src/ast_impl/jsx.rs

+60
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ impl<'a> fmt::Display for JSXNamespacedName<'a> {
2424
}
2525
}
2626

27+
impl<'a> JSXElementName<'a> {
28+
pub fn as_identifier(&self) -> Option<&JSXIdentifier<'a>> {
29+
match self {
30+
Self::Identifier(id) => Some(id.as_ref()),
31+
_ => None,
32+
}
33+
}
34+
}
35+
2736
impl<'a> JSXMemberExpression<'a> {
2837
pub fn get_object_identifier(&self) -> &JSXIdentifier {
2938
let mut member_expr = self;
@@ -91,11 +100,62 @@ impl<'a> JSXAttribute<'a> {
91100
matches!(&self.name, JSXAttributeName::Identifier(ident) if ident.name == name)
92101
}
93102

103+
pub fn is_identifier_ignore_case(&self, name: &str) -> bool {
104+
matches!(&self.name, JSXAttributeName::Identifier(ident) if ident.name.eq_ignore_ascii_case(name))
105+
}
106+
94107
pub fn is_key(&self) -> bool {
95108
self.is_identifier("key")
96109
}
97110
}
98111

112+
impl<'a> JSXAttributeName<'a> {
113+
pub fn as_identifier(&self) -> Option<&JSXIdentifier<'a>> {
114+
match self {
115+
Self::Identifier(ident) => Some(ident.as_ref()),
116+
Self::NamespacedName(_) => None,
117+
}
118+
}
119+
pub fn get_identifier(&self) -> &JSXIdentifier<'a> {
120+
match self {
121+
Self::Identifier(ident) => ident.as_ref(),
122+
Self::NamespacedName(namespaced) => &namespaced.property,
123+
}
124+
}
125+
}
126+
impl<'a> JSXAttributeValue<'a> {
127+
pub fn as_string_literal(&self) -> Option<&StringLiteral<'a>> {
128+
match self {
129+
Self::StringLiteral(lit) => Some(lit.as_ref()),
130+
_ => None,
131+
}
132+
}
133+
}
134+
135+
impl<'a> JSXAttributeItem<'a> {
136+
/// Get the contained [`JSXAttribute`] if it is an attribute item, otherwise
137+
/// returns [`None`].
138+
///
139+
/// This is the inverse of [`JSXAttributeItem::as_spread`].
140+
pub fn as_attribute(&self) -> Option<&JSXAttribute<'a>> {
141+
match self {
142+
Self::Attribute(attr) => Some(attr),
143+
Self::SpreadAttribute(_) => None,
144+
}
145+
}
146+
147+
/// Get the contained [`JSXSpreadAttribute`] if it is a spread attribute item,
148+
/// otherwise returns [`None`].
149+
///
150+
/// This is the inverse of [`JSXAttributeItem::as_attribute`].
151+
pub fn as_spread(&self) -> Option<&JSXSpreadAttribute<'a>> {
152+
match self {
153+
Self::Attribute(_) => None,
154+
Self::SpreadAttribute(spread) => Some(spread),
155+
}
156+
}
157+
}
158+
99159
impl<'a> JSXChild<'a> {
100160
pub const fn is_expression_container(&self) -> bool {
101161
matches!(self, Self::ExpressionContainer(_))

crates/oxc_linter/src/rules/jsx_a11y/anchor_is_valid.rs

+86-69
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
use std::ops::Deref;
2+
13
use oxc_ast::{
24
ast::{JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXExpression},
35
AstKind,
46
};
57
use oxc_diagnostics::OxcDiagnostic;
68
use oxc_macros::declare_oxc_lint;
7-
use oxc_span::Span;
9+
use oxc_span::{CompactStr, Span};
10+
use serde_json::Value;
811

912
use crate::{
1013
context::LintContext,
@@ -35,8 +38,17 @@ fn cant_be_anchor(span0: Span) -> OxcDiagnostic {
3538
pub struct AnchorIsValid(Box<AnchorIsValidConfig>);
3639

3740
#[derive(Debug, Default, Clone)]
38-
struct AnchorIsValidConfig {
39-
valid_hrefs: Vec<String>,
41+
pub struct AnchorIsValidConfig {
42+
/// Unique and sorted list of valid hrefs
43+
valid_hrefs: Vec<CompactStr>,
44+
}
45+
46+
impl Deref for AnchorIsValid {
47+
type Target = AnchorIsValidConfig;
48+
49+
fn deref(&self) -> &Self::Target {
50+
&self.0
51+
}
4052
}
4153

4254
declare_oxc_lint!(
@@ -109,11 +121,10 @@ declare_oxc_lint!(
109121

110122
impl Rule for AnchorIsValid {
111123
fn from_configuration(value: serde_json::Value) -> Self {
112-
let valid_hrefs =
113-
value.get("validHrefs").and_then(|v| v.as_array()).map_or_else(Vec::new, |array| {
114-
array.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<String>>()
115-
});
116-
Self(Box::new(AnchorIsValidConfig { valid_hrefs }))
124+
let Some(valid_hrefs) = value.get("validHrefs").and_then(Value::as_array) else {
125+
return Self::default();
126+
};
127+
Self(Box::new(valid_hrefs.iter().collect()))
117128
}
118129

119130
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
@@ -124,76 +135,82 @@ impl Rule for AnchorIsValid {
124135
let Some(name) = &get_element_type(ctx, &jsx_el.opening_element) else {
125136
return;
126137
};
127-
if name == "a" {
128-
if let Option::Some(herf_attr) =
129-
has_jsx_prop_ignore_case(&jsx_el.opening_element, "href")
130-
{
131-
// Check if the 'a' element has a correct href attribute
132-
match herf_attr {
133-
JSXAttributeItem::Attribute(attr) => match &attr.value {
134-
Some(value) => {
135-
let is_empty = check_value_is_empty(value, &self.0.valid_hrefs);
136-
if is_empty {
137-
if has_jsx_prop_ignore_case(&jsx_el.opening_element, "onclick")
138-
.is_some()
139-
{
140-
ctx.diagnostic(cant_be_anchor(ident.span));
141-
return;
142-
}
143-
ctx.diagnostic(incorrect_href(ident.span));
144-
return;
145-
}
146-
}
147-
None => {
148-
ctx.diagnostic(incorrect_href(ident.span));
149-
return;
150-
}
151-
},
152-
JSXAttributeItem::SpreadAttribute(_) => {
153-
// pass
154-
return;
155-
}
156-
}
138+
if name != "a" {
139+
return;
140+
};
141+
if let Option::Some(href_attr) =
142+
has_jsx_prop_ignore_case(&jsx_el.opening_element, "href")
143+
{
144+
let JSXAttributeItem::Attribute(attr) = href_attr else {
157145
return;
158-
}
159-
// Exclude '<a {...props} />' case
160-
let has_spreed_attr =
161-
jsx_el.opening_element.attributes.iter().any(|attr| match attr {
162-
JSXAttributeItem::SpreadAttribute(_) => true,
163-
JSXAttributeItem::Attribute(_) => false,
164-
});
165-
if has_spreed_attr {
146+
};
147+
148+
// Check if the 'a' element has a correct href attribute
149+
let Some(value) = attr.value.as_ref() else {
150+
ctx.diagnostic(incorrect_href(ident.span));
151+
return;
152+
};
153+
154+
let is_empty = self.check_value_is_empty(value);
155+
if is_empty {
156+
if has_jsx_prop_ignore_case(&jsx_el.opening_element, "onclick").is_some() {
157+
ctx.diagnostic(cant_be_anchor(ident.span));
158+
return;
159+
}
160+
ctx.diagnostic(incorrect_href(ident.span));
166161
return;
167162
}
168-
ctx.diagnostic(missing_href_attribute(ident.span));
163+
return;
169164
}
165+
// Exclude '<a {...props} />' case
166+
let has_spread_attr = jsx_el.opening_element.attributes.iter().any(|attr| match attr {
167+
JSXAttributeItem::SpreadAttribute(_) => true,
168+
JSXAttributeItem::Attribute(_) => false,
169+
});
170+
if has_spread_attr {
171+
return;
172+
}
173+
ctx.diagnostic(missing_href_attribute(ident.span));
170174
}
171175
}
172176
}
173177

174-
fn check_value_is_empty(value: &JSXAttributeValue, valid_hrefs: &[String]) -> bool {
175-
match value {
176-
JSXAttributeValue::Element(_) => false,
177-
JSXAttributeValue::StringLiteral(str_lit) => {
178-
let href_value = str_lit.value.to_string(); // Assuming Atom implements ToString
179-
href_value.is_empty()
180-
|| href_value == "#"
181-
|| href_value == "javascript:void(0)"
182-
|| !valid_hrefs.contains(&href_value)
178+
impl AnchorIsValid {
179+
fn check_value_is_empty(&self, value: &JSXAttributeValue) -> bool {
180+
match value {
181+
JSXAttributeValue::Element(_) => false,
182+
JSXAttributeValue::StringLiteral(str_lit) => self.is_invalid_href(&str_lit.value),
183+
JSXAttributeValue::ExpressionContainer(exp) => match &exp.expression {
184+
JSXExpression::Identifier(ident) => ident.name == "undefined",
185+
JSXExpression::NullLiteral(_) => true,
186+
JSXExpression::StringLiteral(str_lit) => self.is_invalid_href(&str_lit.value),
187+
_ => false,
188+
},
189+
JSXAttributeValue::Fragment(_) => true,
183190
}
184-
JSXAttributeValue::ExpressionContainer(exp) => match &exp.expression {
185-
JSXExpression::Identifier(ident) => ident.name == "undefined",
186-
JSXExpression::NullLiteral(_) => true,
187-
JSXExpression::StringLiteral(str_lit) => {
188-
let href_value = str_lit.value.to_string();
189-
href_value.is_empty()
190-
|| href_value == "#"
191-
|| href_value == "javascript:void(0)"
192-
|| !valid_hrefs.contains(&href_value)
193-
}
194-
_ => false,
195-
},
196-
JSXAttributeValue::Fragment(_) => true,
191+
}
192+
}
193+
194+
impl AnchorIsValidConfig {
195+
fn new(mut valid_hrefs: Vec<CompactStr>) -> Self {
196+
valid_hrefs.sort_unstable();
197+
valid_hrefs.dedup();
198+
Self { valid_hrefs }
199+
}
200+
201+
fn is_invalid_href(&self, href: &str) -> bool {
202+
href.is_empty() || href == "#" || href == "javascript:void(0)" || !self.contains(href)
203+
}
204+
205+
fn contains(&self, href: &str) -> bool {
206+
self.valid_hrefs.binary_search_by(|valid_href| valid_href.as_str().cmp(href)).is_ok()
207+
}
208+
}
209+
210+
impl<'v> FromIterator<&'v Value> for AnchorIsValidConfig {
211+
fn from_iter<T: IntoIterator<Item = &'v Value>>(iter: T) -> Self {
212+
let hrefs = iter.into_iter().filter_map(Value::as_str).map(CompactStr::from).collect();
213+
Self::new(hrefs)
197214
}
198215
}
199216

crates/oxc_linter/src/utils/react.rs

+13-45
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::borrow::Cow;
33
use oxc_ast::{
44
ast::{
55
CallExpression, Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue,
6-
JSXChild, JSXElement, JSXElementName, JSXExpression, JSXOpeningElement, MemberExpression,
6+
JSXChild, JSXElement, JSXExpression, JSXOpeningElement, MemberExpression,
77
},
88
match_member_expression, AstKind,
99
};
@@ -28,40 +28,22 @@ pub fn has_jsx_prop<'a, 'b>(
2828
node: &'b JSXOpeningElement<'a>,
2929
target_prop: &'b str,
3030
) -> Option<&'b JSXAttributeItem<'a>> {
31-
node.attributes.iter().find(|attr| match attr {
32-
JSXAttributeItem::SpreadAttribute(_) => false,
33-
JSXAttributeItem::Attribute(attr) => {
34-
let JSXAttributeName::Identifier(name) = &attr.name else {
35-
return false;
36-
};
37-
38-
name.name == target_prop
39-
}
40-
})
31+
node.attributes
32+
.iter()
33+
.find(|attr| attr.as_attribute().is_some_and(|attr| attr.is_identifier(target_prop)))
4134
}
4235

4336
pub fn has_jsx_prop_ignore_case<'a, 'b>(
4437
node: &'b JSXOpeningElement<'a>,
4538
target_prop: &'b str,
4639
) -> Option<&'b JSXAttributeItem<'a>> {
47-
node.attributes.iter().find(|attr| match attr {
48-
JSXAttributeItem::SpreadAttribute(_) => false,
49-
JSXAttributeItem::Attribute(attr) => {
50-
let JSXAttributeName::Identifier(name) = &attr.name else {
51-
return false;
52-
};
53-
54-
name.name.eq_ignore_ascii_case(target_prop)
55-
}
40+
node.attributes.iter().find(|attr| {
41+
attr.as_attribute().is_some_and(|attr| attr.is_identifier_ignore_case(target_prop))
5642
})
5743
}
5844

5945
pub fn get_prop_value<'a, 'b>(item: &'b JSXAttributeItem<'a>) -> Option<&'b JSXAttributeValue<'a>> {
60-
if let JSXAttributeItem::Attribute(attr) = item {
61-
attr.value.as_ref()
62-
} else {
63-
None
64-
}
46+
item.as_attribute().and_then(|item| item.value.as_ref())
6547
}
6648

6749
pub fn get_jsx_attribute_name<'a>(attr: &JSXAttributeName<'a>) -> Cow<'a, str> {
@@ -74,13 +56,7 @@ pub fn get_jsx_attribute_name<'a>(attr: &JSXAttributeName<'a>) -> Cow<'a, str> {
7456
}
7557

7658
pub fn get_string_literal_prop_value<'a>(item: &'a JSXAttributeItem<'_>) -> Option<&'a str> {
77-
get_prop_value(item).and_then(|v| {
78-
if let JSXAttributeValue::StringLiteral(s) = v {
79-
Some(s.value.as_str())
80-
} else {
81-
None
82-
}
83-
})
59+
get_prop_value(item).and_then(JSXAttributeValue::as_string_literal).map(|s| s.value.as_str())
8460
}
8561

8662
// ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isHiddenFromScreenReader.js
@@ -132,11 +108,8 @@ pub fn is_presentation_role(jsx_opening_el: &JSXOpeningElement) -> bool {
132108
let Some(role) = has_jsx_prop(jsx_opening_el, "role") else {
133109
return false;
134110
};
135-
let Some("presentation" | "none") = get_string_literal_prop_value(role) else {
136-
return false;
137-
};
138111

139-
true
112+
matches!(get_string_literal_prop_value(role), Some("presentation" | "none"))
140113
}
141114

142115
// TODO: Should re-implement
@@ -246,9 +219,7 @@ pub fn get_element_type<'c, 'a>(
246219
context: &'c LintContext<'a>,
247220
element: &JSXOpeningElement<'a>,
248221
) -> Option<Cow<'c, str>> {
249-
let JSXElementName::Identifier(ident) = &element.name else {
250-
return None;
251-
};
222+
let name = element.name.as_identifier()?;
252223

253224
let OxlintSettings { jsx_a11y, .. } = context.settings();
254225

@@ -259,17 +230,14 @@ pub fn get_element_type<'c, 'a>(
259230
has_jsx_prop_ignore_case(element, polymorphic_prop_name_value)
260231
})
261232
.and_then(get_prop_value)
262-
.and_then(|prop_value| match prop_value {
263-
JSXAttributeValue::StringLiteral(str) => Some(str.value.as_str()),
264-
_ => None,
265-
});
233+
.and_then(JSXAttributeValue::as_string_literal)
234+
.map(|s| s.value.as_str());
266235

267-
let raw_type = polymorphic_prop.unwrap_or_else(|| ident.name.as_str());
236+
let raw_type = polymorphic_prop.unwrap_or_else(|| name.name.as_str());
268237
match jsx_a11y.components.get(raw_type) {
269238
Some(component) => Some(Cow::Borrowed(component)),
270239
None => Some(Cow::Borrowed(raw_type)),
271240
}
272-
// Some(String::from(jsx_a11y.components.get(raw_type).map_or(raw_type, |c| c)))
273241
}
274242

275243
pub fn parse_jsx_value(value: &JSXAttributeValue) -> Result<f64, ()> {

crates/oxc_span/src/atom.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ impl<'a> fmt::Display for Atom<'a> {
198198
///
199199
/// Currently implemented as just a wrapper around [`compact_str::CompactString`],
200200
/// but will be reduced in size with a custom implementation later.
201-
#[derive(Clone, Eq)]
201+
#[derive(Clone, Eq, PartialOrd, Ord)]
202202
#[cfg_attr(feature = "serialize", derive(serde::Deserialize))]
203203
pub struct CompactStr(CompactString);
204204

0 commit comments

Comments
 (0)