Skip to content

Commit 96c4d77

Browse files
committed
feat(linter/unicorn): implement no-array-callback-reference 2
1 parent be58d6d commit 96c4d77

File tree

4 files changed

+471
-0
lines changed

4 files changed

+471
-0
lines changed

crates/oxc_linter/src/generated/rule_runner_impls.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2334,6 +2334,11 @@ impl RuleRunner for crate::rules::unicorn::no_anonymous_default_export::NoAnonym
23342334
const NODE_TYPES: Option<&AstTypesBitset> = None;
23352335
}
23362336

2337+
impl RuleRunner for crate::rules::unicorn::no_array_callback_reference::NoArrayCallbackReference {
2338+
const NODE_TYPES: Option<&AstTypesBitset> =
2339+
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));
2340+
}
2341+
23372342
impl RuleRunner for crate::rules::unicorn::no_array_for_each::NoArrayForEach {
23382343
const NODE_TYPES: Option<&AstTypesBitset> =
23392344
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ pub(crate) mod unicorn {
400400
pub mod no_abusive_eslint_disable;
401401
pub mod no_accessor_recursion;
402402
pub mod no_anonymous_default_export;
403+
pub mod no_array_callback_reference;
403404
pub mod no_array_for_each;
404405
pub mod no_array_method_this_argument;
405406
pub mod no_array_reduce;
@@ -1125,6 +1126,7 @@ oxc_macros::declare_all_lint_rules! {
11251126
unicorn::explicit_length_check,
11261127
unicorn::filename_case,
11271128
unicorn::new_for_builtins,
1129+
unicorn::no_array_callback_reference,
11281130
unicorn::no_array_sort,
11291131
unicorn::no_array_reverse,
11301132
unicorn::no_instanceof_builtins,
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
use oxc_ast::{
2+
AstKind,
3+
ast::{Expression, match_member_expression},
4+
};
5+
use oxc_diagnostics::OxcDiagnostic;
6+
use oxc_macros::declare_oxc_lint;
7+
use oxc_span::{GetSpan, Span};
8+
9+
use crate::{AstNode, ast_util::is_method_call, context::LintContext, rule::Rule};
10+
11+
fn no_array_callback_reference_diagnostic(span: Span) -> OxcDiagnostic {
12+
OxcDiagnostic::warn("Avoid passing a function reference directly to iterator methods")
13+
.with_help(
14+
"Wrap the function in an arrow function to explicitly pass only the element argument",
15+
)
16+
.with_label(span)
17+
}
18+
19+
#[derive(Debug, Default, Clone)]
20+
pub struct NoArrayCallbackReference;
21+
22+
// See <https://github.com/oxc-project/oxc/issues/6050> for documentation details.
23+
declare_oxc_lint!(
24+
/// ### What it does
25+
///
26+
/// Prevents passing a function reference directly to iterator methods
27+
///
28+
/// ### Why is this bad?
29+
///
30+
/// Passing functions to iterator methods can cause issues when the function is changed
31+
/// without realizing that the iterator passes 2 more parameters to it (index and array).
32+
/// This can lead to unexpected behavior when the function signature changes.
33+
///
34+
/// ### Examples
35+
///
36+
/// Examples of **incorrect** code for this rule:
37+
/// ```js
38+
/// const foo = array.map(callback);
39+
/// array.forEach(callback);
40+
/// const result = array.filter(lib.method);
41+
/// ```
42+
///
43+
/// Examples of **correct** code for this rule:
44+
/// ```js
45+
/// const foo = array.map(element => callback(element));
46+
/// array.forEach(element => { callback(element); });
47+
/// const result = array.filter(element => lib.method(element));
48+
///
49+
/// // Built-in functions are allowed
50+
/// const foo = array.map(String);
51+
/// const bar = array.filter(Boolean);
52+
/// ```
53+
NoArrayCallbackReference,
54+
unicorn,
55+
pedantic,
56+
pending
57+
);
58+
59+
impl Rule for NoArrayCallbackReference {
60+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
61+
let AstKind::CallExpression(call_expr) = node.kind() else { return };
62+
63+
let is_relevant_method = is_method_call(
64+
call_expr,
65+
None,
66+
Some(&[
67+
"every",
68+
"filter",
69+
"find",
70+
"findLast",
71+
"findIndex",
72+
"findLastIndex",
73+
"flatMap",
74+
"forEach",
75+
"map",
76+
"some",
77+
]),
78+
Some(1),
79+
Some(2),
80+
) || is_method_call(
81+
call_expr,
82+
None,
83+
Some(&["reduce", "reduceRight"]),
84+
Some(1),
85+
Some(2),
86+
);
87+
88+
if !is_relevant_method {
89+
return;
90+
}
91+
92+
// Check if this is from an ignored library/object or uses computed/dynamic property access
93+
if let Some(member_expr) = call_expr.callee.get_member_expr() {
94+
// Skip computed member expressions like foo['map'] or foo[map]
95+
if member_expr.is_computed() {
96+
return;
97+
}
98+
99+
let object = member_expr.object();
100+
if is_ignored_object(object) {
101+
return;
102+
}
103+
104+
// Skip if object is a member expression (e.g., types.map, oidc.Client.find)
105+
// These are likely not array methods but methods on namespaces/classes
106+
if object.as_member_expression().is_some() {
107+
return;
108+
}
109+
}
110+
111+
// Get the first argument (the callback)
112+
let Some(first_arg) = call_expr.arguments.first() else { return };
113+
114+
let Some(callback_expr) = first_arg.as_expression() else { return };
115+
116+
if !should_wrap_callback(callback_expr) {
117+
return;
118+
}
119+
120+
ctx.diagnostic(no_array_callback_reference_diagnostic(callback_expr.span()));
121+
}
122+
}
123+
124+
fn should_wrap_callback(expr: &Expression) -> bool {
125+
match expr {
126+
Expression::Identifier(ident) if is_allowed_builtin(&ident.name) => false,
127+
Expression::ConditionalExpression(cond_expr) => {
128+
should_wrap_callback(&cond_expr.consequent)
129+
|| should_wrap_callback(&cond_expr.alternate)
130+
}
131+
Expression::CallExpression(call_expr) => {
132+
if let Some(member_expr) = call_expr.callee.get_member_expr()
133+
&& let Some(prop_name) = member_expr.static_property_name()
134+
&& prop_name == "bind"
135+
{
136+
return false;
137+
}
138+
139+
true
140+
}
141+
Expression::SequenceExpression(seq_expr) => {
142+
seq_expr.expressions.last().is_none_or(|e| should_wrap_callback(e))
143+
}
144+
Expression::ComputedMemberExpression(_)
145+
| Expression::StaticMemberExpression(_)
146+
| Expression::PrivateFieldExpression(_)
147+
| Expression::Identifier(_)
148+
| Expression::YieldExpression(_)
149+
| Expression::AssignmentExpression(_)
150+
| Expression::LogicalExpression(_)
151+
| Expression::BinaryExpression(_)
152+
| Expression::UnaryExpression(_)
153+
| Expression::UpdateExpression(_)
154+
| Expression::NewExpression(_) => true,
155+
156+
// These can't be callbacks, don't need to wrap
157+
_ => false,
158+
}
159+
}
160+
161+
fn is_allowed_builtin(name: &str) -> bool {
162+
matches!(
163+
name,
164+
"String"
165+
| "Number"
166+
| "Boolean"
167+
| "Symbol"
168+
| "BigInt"
169+
| "RegExp"
170+
| "Date"
171+
| "Array"
172+
| "Object"
173+
| "Map"
174+
| "Set"
175+
| "WeakMap"
176+
| "WeakSet"
177+
| "Promise"
178+
| "Error"
179+
| "AggregateError"
180+
| "EvalError"
181+
| "RangeError"
182+
| "ReferenceError"
183+
| "SyntaxError"
184+
| "TypeError"
185+
| "URIError"
186+
| "Int8Array"
187+
| "Uint8Array"
188+
| "Uint8ClampedArray"
189+
| "Int16Array"
190+
| "Uint16Array"
191+
| "Int32Array"
192+
| "Uint32Array"
193+
| "Float32Array"
194+
| "Float64Array"
195+
| "BigInt64Array"
196+
| "BigUint64Array"
197+
| "DataView"
198+
| "ArrayBuffer"
199+
| "SharedArrayBuffer"
200+
)
201+
}
202+
203+
fn is_ignored_object(expr: &Expression) -> bool {
204+
match expr {
205+
Expression::Identifier(ident) => {
206+
matches!(
207+
ident.name.as_str(),
208+
"Promise"
209+
| "lodash"
210+
| "underscore"
211+
| "_"
212+
| "React"
213+
| "Vue"
214+
| "Async"
215+
| "async"
216+
| "$"
217+
| "jQuery"
218+
| "Children"
219+
| "types" // MobX State Tree and similar type libraries
220+
)
221+
}
222+
// Check for call expressions like $(this) or jQuery(...)
223+
Expression::CallExpression(call_expr) => {
224+
if let Expression::Identifier(ident) = call_expr.callee.without_parentheses() {
225+
matches!(ident.name.as_str(), "$" | "jQuery")
226+
} else {
227+
false
228+
}
229+
}
230+
match_member_expression!(Expression) => {
231+
let member_expr = expr.to_member_expression();
232+
if let Expression::Identifier(obj_ident) = member_expr.object()
233+
&& obj_ident.name == "React"
234+
&& let Some(prop_name) = member_expr.static_property_name()
235+
&& prop_name == "Children"
236+
{
237+
return true;
238+
}
239+
240+
false
241+
}
242+
_ => false,
243+
}
244+
}
245+
246+
#[test]
247+
fn test() {
248+
use crate::tester::Tester;
249+
250+
let pass = vec![
251+
"foo.find(Boolean)",
252+
"foo.map(String)",
253+
"foo.map(Number)",
254+
"foo.map(BigInt)",
255+
"foo.map(Boolean)",
256+
"foo.map(Symbol)",
257+
"new foo.map(fn);",
258+
"map(fn);",
259+
"foo['map'](fn);",
260+
"foo[map](fn);",
261+
"foo.notListedMethod(fn);",
262+
"foo.map();",
263+
"foo.map(fn, extraArgument1, extraArgument2);",
264+
"foo.map(...argumentsArray)",
265+
"Promise.map(fn)",
266+
"Promise.forEach(fn)",
267+
"lodash.map(fn)",
268+
"underscore.map(fn)",
269+
"_.map(fn)",
270+
"Async.map(list, fn)",
271+
"async.map(list, fn)",
272+
"React.Children.forEach(children, fn)",
273+
"Children.forEach(children, fn)",
274+
"Vue.filter(name, fn)",
275+
"$(this).find(tooltip)",
276+
"$.map(realArray, function(value, index) {});",
277+
"$(this).filter(tooltip)",
278+
"jQuery(this).find(tooltip)",
279+
"jQuery.map(realArray, function(value, index) {});",
280+
"jQuery(this).filter(tooltip)",
281+
"foo.map(() => {})",
282+
"foo.map(function() {})",
283+
"foo.map(function bar() {})",
284+
"foo.map(function (a) {}.bind(bar))",
285+
"async function foo() {
286+
const clientId = 20
287+
const client = await oidc.Client.find(clientId)
288+
}",
289+
"const results = collection
290+
.find({
291+
$and: [cursorQuery, params.query]
292+
}, {
293+
projection: params.projection
294+
})
295+
.sort($sort)
296+
.limit(params.limit + 1)
297+
.toArray()",
298+
"const EventsStore = types.model('EventsStore', {
299+
events: types.optional(types.map(Event), {}),
300+
})",
301+
"foo.map(_ ? () => {} : _ ? () => {} : () => {})",
302+
"foo.reduce(_ ? () => {} : _ ? () => {} : () => {})",
303+
"foo.every(_ ? Boolean : _ ? Boolean : Boolean)",
304+
"foo.map(_ ? String : _ ? Number : Boolean)",
305+
];
306+
307+
let fail = vec![
308+
"bar.map(fn)",
309+
"bar.reduce(fn)",
310+
"foo.map(lib.fn)",
311+
"foo.reduce(lib.fn)",
312+
"foo.map(
313+
_
314+
? String // This one should be ignored
315+
: callback
316+
);",
317+
"foo.forEach(
318+
_
319+
? callbackA
320+
: _
321+
? callbackB
322+
: callbackC
323+
);",
324+
"async function * foo () {
325+
foo.map((0, bar));
326+
foo.map(yield bar);
327+
foo.map(yield* bar);
328+
foo.map(() => bar);
329+
foo.map(bar &&= baz);
330+
foo.map(bar || baz);
331+
foo.map(bar + bar);
332+
foo.map(+ bar);
333+
foo.map(++ bar);
334+
foo.map(new Function(''));
335+
}",
336+
];
337+
338+
Tester::new(NoArrayCallbackReference::NAME, NoArrayCallbackReference::PLUGIN, pass, fail)
339+
.test_and_snapshot();
340+
}

0 commit comments

Comments
 (0)