Skip to content

Commit 0a9a735

Browse files
committed
fix(linter/no-loop-func): allow safe let closures (#22811)
Oxlint’s `eslint/no-loop-func` was reporting safe closures that ESLint allows, including callbacks capturing `let` bindings from containing `for` loop headers and outer `let` values only written before the loop. This updates the `let` safety check to match ESLint more closely and adds a regression test for the reported false positives. fixes #22804
1 parent 1599f11 commit 0a9a735

1 file changed

Lines changed: 151 additions & 10 deletions

File tree

crates/oxc_linter/src/rules/eslint/no_loop_func.rs

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use oxc_ast::{
1010
use oxc_ast_visit::{Visit, walk};
1111
use oxc_diagnostics::OxcDiagnostic;
1212
use oxc_macros::declare_oxc_lint;
13-
use oxc_semantic::{AstNode, NodeId, SymbolId};
13+
use oxc_semantic::{AstNode, NodeId, ScopeId, SymbolId};
1414
use oxc_span::{GetSpan, Span};
1515
use oxc_syntax::{scope::ScopeFlags, symbol::SymbolFlags};
1616

@@ -326,11 +326,11 @@ impl NoLoopFunc {
326326
}
327327

328328
/// Check if a let-declared variable is unsafe in a loop
329-
fn is_let_unsafe_in_loop(
329+
fn is_let_unsafe_in_loop<'a>(
330330
symbol_id: SymbolId,
331-
symbol_decl_node: &AstNode,
332-
loop_node: &AstNode,
333-
ctx: &LintContext,
331+
symbol_decl_node: &AstNode<'a>,
332+
loop_node: &AstNode<'a>,
333+
ctx: &LintContext<'a>,
334334
) -> bool {
335335
let loop_body_span = Self::get_loop_body_span(loop_node);
336336
let decl_span = symbol_decl_node.span();
@@ -340,23 +340,136 @@ impl NoLoopFunc {
340340
return false;
341341
}
342342

343-
// For `for` loops, check if let is declared in the loop header (init expression)
344-
// `for (let i = 0; ...)` creates fresh bindings per iteration - safe
345-
if Self::is_in_for_loop_header(symbol_decl_node, loop_node) {
343+
// For `for` loops, check if let is declared in this loop header or in a containing
344+
// loop header. `for (let i = 0; ...)` creates fresh bindings per iteration, and that
345+
// binding remains safe for functions in nested loop bodies within the same iteration.
346+
if Self::is_in_containing_for_loop_header(symbol_decl_node, loop_node, ctx) {
346347
return false;
347348
}
348349

349-
// If declared outside the loop body, check for modifications
350350
let scoping = ctx.scoping();
351+
let border = Self::get_top_loop_node(loop_node, Some(symbol_decl_node), ctx).span().start;
352+
let symbol_variable_scope_id =
353+
Self::get_enclosing_variable_scope_id(scoping.symbol_scope_id(symbol_id), ctx);
354+
355+
// If declared outside the loop body, writes are safe only when they happen before the
356+
// relevant loop and in the same variable scope as the declaration.
351357
for reference in scoping.get_resolved_references(symbol_id) {
352358
if reference.is_write() {
353-
return true;
359+
let ref_span = ctx.semantic().reference_span(reference);
360+
let ref_variable_scope_id =
361+
Self::get_enclosing_variable_scope_id(reference.scope_id(), ctx);
362+
if ref_span.start >= border || ref_variable_scope_id != symbol_variable_scope_id {
363+
return true;
364+
}
354365
}
355366
}
356367

357368
false
358369
}
359370

371+
/// Get the outermost loop that starts after an excluded node.
372+
fn get_top_loop_node<'a, 'ctx>(
373+
loop_node: &'ctx AstNode<'a>,
374+
excluded_node: Option<&AstNode>,
375+
ctx: &'ctx LintContext<'a>,
376+
) -> &'ctx AstNode<'a> {
377+
let border = excluded_node.map_or(0, |node| node.span().end);
378+
let mut top_loop_node = loop_node;
379+
let mut containing_loop_node = Some(loop_node);
380+
381+
while let Some(current_loop_node) = containing_loop_node {
382+
if current_loop_node.span().start < border {
383+
break;
384+
}
385+
top_loop_node = current_loop_node;
386+
containing_loop_node = Self::get_containing_loop_node(current_loop_node, ctx);
387+
}
388+
389+
top_loop_node
390+
}
391+
392+
/// Gets the containing loop node of a node without crossing function boundaries.
393+
fn get_containing_loop_node<'a, 'ctx>(
394+
node: &'ctx AstNode<'a>,
395+
ctx: &'ctx LintContext<'a>,
396+
) -> Option<&'ctx AstNode<'a>> {
397+
let nodes = ctx.nodes();
398+
let mut current = node;
399+
400+
loop {
401+
let parent = nodes.parent_node(current.id());
402+
match parent.kind() {
403+
AstKind::WhileStatement(_) | AstKind::DoWhileStatement(_) => return Some(parent),
404+
AstKind::ForStatement(stmt) => {
405+
if stmt
406+
.init
407+
.as_ref()
408+
.is_none_or(|init| !init.span().contains_inclusive(current.span()))
409+
{
410+
return Some(parent);
411+
}
412+
}
413+
AstKind::ForInStatement(stmt) => {
414+
if !stmt.right.span().contains_inclusive(current.span()) {
415+
return Some(parent);
416+
}
417+
}
418+
AstKind::ForOfStatement(stmt) => {
419+
if !stmt.right.span().contains_inclusive(current.span()) {
420+
return Some(parent);
421+
}
422+
}
423+
AstKind::Function(_)
424+
| AstKind::ArrowFunctionExpression(_)
425+
| AstKind::Program(_) => {
426+
return None;
427+
}
428+
_ => {}
429+
}
430+
current = parent;
431+
}
432+
}
433+
434+
fn get_enclosing_variable_scope_id(scope_id: ScopeId, ctx: &LintContext) -> ScopeId {
435+
let scoping = ctx.scoping();
436+
let mut current_scope_id = scope_id;
437+
loop {
438+
if scoping.scope_flags(current_scope_id).is_var() {
439+
return current_scope_id;
440+
}
441+
let Some(parent_scope_id) = scoping.scope_parent_id(current_scope_id) else {
442+
return current_scope_id;
443+
};
444+
current_scope_id = parent_scope_id;
445+
}
446+
}
447+
448+
fn is_in_containing_for_loop_header<'a>(
449+
symbol_decl_node: &AstNode<'a>,
450+
loop_node: &AstNode<'a>,
451+
ctx: &LintContext<'a>,
452+
) -> bool {
453+
let nodes = ctx.nodes();
454+
let mut current = loop_node;
455+
456+
loop {
457+
if Self::is_in_for_loop_header(symbol_decl_node, current) {
458+
return true;
459+
}
460+
461+
let parent = nodes.parent_node(current.id());
462+
match parent.kind() {
463+
AstKind::Function(_)
464+
| AstKind::ArrowFunctionExpression(_)
465+
| AstKind::Program(_) => {
466+
return false;
467+
}
468+
_ => current = parent,
469+
}
470+
}
471+
}
472+
360473
/// Check if a variable declaration is in a for loop's header (init expression)
361474
fn is_in_for_loop_header(symbol_decl_node: &AstNode, loop_node: &AstNode) -> bool {
362475
let decl_span = symbol_decl_node.span();
@@ -698,6 +811,34 @@ fn test() {
698811
",
699812
// Function in the for-update slot is not in the loop body.
700813
"for (var i = 0; i < l; i++, (function () { i; })()) { }",
814+
r"
815+
const callbacks = [];
816+
for (let row = 0; row < 5; row++) {
817+
for (let col = 0; col < 5; col++) {
818+
callbacks.push(function () {
819+
return row + col;
820+
});
821+
}
822+
}
823+
824+
let factor = 0;
825+
if (factor < 5) {
826+
factor++;
827+
} else {
828+
for (let index = 0; index < 5; index++) {
829+
const compute = (value, index) => factor * value + index;
830+
}
831+
}
832+
833+
let isOn = false;
834+
if (isOn) {
835+
isOn = true;
836+
}
837+
838+
for (let imageId = 0; imageId < 5; imageId++) {
839+
image.onload = () => (isOn ? 'enabled' : 'disabled');
840+
}
841+
",
701842
];
702843

703844
let fail = vec![

0 commit comments

Comments
 (0)