use std::path::Path;
use oxc_ast::ast::{
BindingPatternKind, CallExpression, Expression, JSXAttributeItem, JSXAttributeName,
JSXAttributeValue, JSXOpeningElement,
};
use oxc_ast_visit::{walk, Visit};
use oxc_span::Span;
use crate::{
rules::{Issue, RuleContext, Severity},
utils::offset_to_line_col,
};
pub struct NoArrayIndexKey;
impl super::Rule for NoArrayIndexKey {
fn name(&self) -> &str {
"no_array_index_key"
}
fn run(&self, ctx: &RuleContext<'_>) -> Vec<Issue> {
let mut visitor = ArrayIndexKeyVisitor {
issues: Vec::new(),
source_text: ctx.source_text,
file_path: ctx.file_path,
index_params: Vec::new(),
};
visitor.visit_program(ctx.program);
visitor.issues
}
}
struct ArrayIndexKeyVisitor<'a> {
issues: Vec<Issue>,
source_text: &'a str,
file_path: &'a Path,
index_params: Vec<String>,
}
impl<'a> Visit<'a> for ArrayIndexKeyVisitor<'_> {
fn visit_call_expression(&mut self, expr: &CallExpression<'a>) {
if let Some(index_name) = extract_map_index_param(expr) {
self.index_params.push(index_name);
walk::walk_call_expression(self, expr);
self.index_params.pop();
} else {
walk::walk_call_expression(self, expr);
}
}
fn visit_jsx_opening_element(&mut self, elem: &JSXOpeningElement<'a>) {
if !self.index_params.is_empty() {
for attr_item in &elem.attributes {
if let JSXAttributeItem::Attribute(attr) = attr_item {
if !is_key_prop(&attr.name) {
continue;
}
let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value
else {
continue;
};
let Some(expr) = container.expression.as_expression() else {
continue;
};
if let Some(span) = self.find_index_ref(expr) {
self.emit(span);
}
}
}
}
walk::walk_jsx_opening_element(self, elem);
}
}
impl ArrayIndexKeyVisitor<'_> {
fn find_index_ref(&self, expr: &Expression<'_>) -> Option<Span> {
match expr {
Expression::Identifier(id) => {
if self.index_params.iter().any(|p| p == id.name.as_str()) {
Some(id.span)
} else {
None
}
}
Expression::CallExpression(call) => {
if let Expression::StaticMemberExpression(member) = &call.callee {
return self.find_index_ref(&member.object);
}
None
}
Expression::TemplateLiteral(tpl) => {
for inner_expr in tpl.expressions.iter() {
if let Some(span) = self.find_index_ref(inner_expr) {
return Some(span);
}
}
None
}
Expression::ParenthesizedExpression(paren) => {
self.find_index_ref(&paren.expression)
}
_ => None,
}
}
fn emit(&mut self, span: Span) {
let (line, col) = offset_to_line_col(self.source_text, span.start);
self.issues.push(Issue {
rule: "no_array_index_key".to_string(),
message: "Array index used as 'key' prop causes incorrect reconciliation when items \
are added, removed, or reordered. Use a stable unique ID from the data instead \
(e.g. `key={item.id}`)."
.to_string(),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::Warning,
});
}
}
fn extract_map_index_param(expr: &CallExpression<'_>) -> Option<String> {
let is_map = matches!(
&expr.callee,
Expression::StaticMemberExpression(m) if m.property.name.as_str() == "map"
);
if !is_map {
return None;
}
let callback_arg = expr.arguments.first()?;
let callback_expr = callback_arg.as_expression()?;
let params = match callback_expr {
Expression::ArrowFunctionExpression(arrow) => &arrow.params,
Expression::FunctionExpression(func) => &func.params,
_ => return None,
};
let second = params.items.get(1)?;
if let BindingPatternKind::BindingIdentifier(id) = &second.pattern.kind {
Some(id.name.to_string())
} else {
None
}
}
fn is_key_prop(name: &JSXAttributeName<'_>) -> bool {
matches!(name, JSXAttributeName::Identifier(id) if id.name.as_str() == "key")
}