From 0c64499e0962da49cc6863d52ca0700a1927e56c Mon Sep 17 00:00:00 2001 From: Said Atrahouch Date: Wed, 7 Jan 2026 06:34:44 +0100 Subject: [PATCH 1/5] fix(linter/eslint-plugin-vitest): Detect if the arguments have a trailing command and not panic while fix it --- .../src/rules/vitest/prefer_called_once.rs | 36 ++++++++++++++++++- .../snapshots/vitest_prefer_called_once.snap | 8 +++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs index e5d0d4aff013c..45a0467878e10 100644 --- a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs @@ -112,11 +112,14 @@ impl PreferCalledOnce { ctx.diagnostic_with_fix( prefer_called_once_diagnostic(matcher_and_args_span, new_matcher_name.as_ref()), |fixer| { + let argument_without_parenthesis_span = + get_inside_parenthesis_span(called_times_value.span, ctx); + let multi_fix = fixer.for_multifix(); let mut fixes = multi_fix.new_fix_with_capacity(2); fixes.push(fixer.replace(matcher_to_be_fixed.span, new_matcher_name)); - fixes.push(fixer.delete(&called_times_value.span)); + fixes.push(fixer.delete(&argument_without_parenthesis_span)); fixes.with_message("Replace API with preferOnce instead of Times") }, @@ -125,6 +128,28 @@ impl PreferCalledOnce { } } +fn is_open_parenthesis(source_text: &str) -> bool { + source_text.starts_with('(') +} + +fn is_close_parenthesis(source_text: &str) -> bool { + source_text.ends_with(')') +} + +fn get_inside_parenthesis_span(argument_span: Span, ctx: &LintContext<'_>) -> Span { + let mut inside_parentehsis_span = Span::new(argument_span.start, argument_span.end); + + while !is_open_parenthesis(ctx.source_range(inside_parentehsis_span.expand_left(1))) { + inside_parentehsis_span = inside_parentehsis_span.expand_left(1); + } + + while !is_close_parenthesis(ctx.source_range(inside_parentehsis_span.expand_right(1))) { + inside_parentehsis_span = inside_parentehsis_span.expand_right(1); + } + + inside_parentehsis_span +} + #[test] fn test() { use crate::tester::Tester; @@ -153,6 +178,9 @@ fn test() { "expect(fn).resolves.toBeCalledTimes(1);", "expect(fn).resolves.toHaveBeenCalledTimes(1);", "expect(fn).resolves.toHaveBeenCalledTimes(/*comment*/1);", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes( + 1, + );", ]; let fix = vec![ @@ -165,6 +193,12 @@ fn test() { "expect(fn).resolves.toHaveBeenCalledTimes(1);", "expect(fn).resolves.toHaveBeenCalledOnce();", ), + ( + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes( + 1, + );", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + ), ]; Tester::new(PreferCalledOnce::NAME, PreferCalledOnce::PLUGIN, pass, fail) .expect_fix(fix) diff --git a/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap b/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap index fb6015d5bc45d..78a9680d61509 100644 --- a/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap +++ b/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap @@ -49,3 +49,11 @@ source: crates/oxc_linter/src/tester.rs · ─────────────────────────────────── ╰──── help: Prefer `toHaveBeenCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:53] + 1 │ ╭─▶ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes( + 2 │ │ 1, + 3 │ ╰─▶ ); + ╰──── + help: Prefer `toHaveBeenCalledOnce()`. From 678d684b76ea805da231ebc4607324bea7b68866 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Tue, 6 Jan 2026 23:43:52 -0700 Subject: [PATCH 2/5] Apply suggestion from @connorshea Signed-off-by: Connor Shea --- .../src/rules/vitest/prefer_called_once.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs index 45a0467878e10..63a84df28aa4e 100644 --- a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs @@ -137,17 +137,17 @@ fn is_close_parenthesis(source_text: &str) -> bool { } fn get_inside_parenthesis_span(argument_span: Span, ctx: &LintContext<'_>) -> Span { - let mut inside_parentehsis_span = Span::new(argument_span.start, argument_span.end); + let mut inside_parenthesis_span = Span::new(argument_span.start, argument_span.end); - while !is_open_parenthesis(ctx.source_range(inside_parentehsis_span.expand_left(1))) { - inside_parentehsis_span = inside_parentehsis_span.expand_left(1); + while !is_open_parenthesis(ctx.source_range(inside_parenthesis_span.expand_left(1))) { + inside_parenthesis_span = inside_parenthesis_span.expand_left(1); } - while !is_close_parenthesis(ctx.source_range(inside_parentehsis_span.expand_right(1))) { - inside_parentehsis_span = inside_parentehsis_span.expand_right(1); + while !is_close_parenthesis(ctx.source_range(inside_parenthesis_span.expand_right(1))) { + inside_parenthesis_span = inside_parenthesis_span.expand_right(1); } - inside_parentehsis_span + inside_parenthesis_span } #[test] From 26dc2052448a4652a36fb779963da3ea4fc9df3c Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Tue, 6 Jan 2026 23:56:13 -0700 Subject: [PATCH 3/5] Add a few more tests, including some that fail due to comments. --- .../rules/jest/prefer_to_have_been_called.rs | 27 +++++++++++++++++++ .../jest/prefer_to_have_been_called_times.rs | 15 +++++++++++ .../src/rules/vitest/prefer_called_once.rs | 19 +++++++++++++ 3 files changed, 61 insertions(+) diff --git a/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs b/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs index c358a86966949..dc537074a87fc 100644 --- a/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs +++ b/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called.rs @@ -169,6 +169,13 @@ fn test() { "expect(method).not.toHaveBeenCalled();", None, ), + ( + "expect(method).toHaveBeenCalledTimes( + 0, + );", + "expect(method).not.toHaveBeenCalled();", + None, + ), ( "expect(method).not.toHaveBeenCalledTimes(0);", "expect(method).toHaveBeenCalled();", @@ -189,6 +196,26 @@ fn test() { "expect(method).rejects.toHaveBeenCalled();", None, ), + ( + "expect(method).rejects.not.toHaveBeenCalledTimes(0,);", + "expect(method).rejects.toHaveBeenCalled();", + None, + ), + ( + "expect(method).rejects.not.toHaveBeenCalledTimes( + 0, + );", + "expect(method).rejects.toHaveBeenCalled();", + None, + ), + ( + "expect(method).rejects.not.toHaveBeenCalledTimes( + /* call this zero times (because I said so) */ + 0, + );", + "expect(method).rejects.toHaveBeenCalled();", + None, + ), ( "expect(method).toBeCalledTimes(0 as number);", "expect(method).not.toHaveBeenCalled();", diff --git a/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called_times.rs b/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called_times.rs index 5d8ff4b486fa1..15953b7ed6319 100644 --- a/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called_times.rs +++ b/crates/oxc_linter/src/rules/jest/prefer_to_have_been_called_times.rs @@ -214,6 +214,21 @@ fn test() { "expect(method).toHaveBeenCalledTimes(1);", None, ), + ( + "expect(method.mock.calls).toHaveLength( + 1, + );", + "expect(method).toHaveBeenCalledTimes(1);", + None, + ), + ( + "expect(method.mock.calls).toHaveLength( + /* number of calls (one) */ + 1, + );", + "expect(method).toHaveBeenCalledTimes(1);", + None, + ), ( "expect(method.mock.calls).resolves.toHaveLength(x);", "expect(method).resolves.toHaveBeenCalledTimes(x);", diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs index 63a84df28aa4e..d7ccbefc4290a 100644 --- a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs @@ -199,6 +199,25 @@ fn test() { );", "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", ), + ( + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1,);", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + ), + ( + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(/* comment (because why not) */1,);", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + ), + ( + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1/* comment (because why not) */,);", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + ), + ( + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes( + /* I only want to call this function 1 (ONE) time, please. */ + 1, + );", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + ), ]; Tester::new(PreferCalledOnce::NAME, PreferCalledOnce::PLUGIN, pass, fail) .expect_fix(fix) From 953f2bd16f8f7afce244f77efce85dfcb14421b9 Mon Sep 17 00:00:00 2001 From: Said Atrahouch Date: Wed, 7 Jan 2026 12:56:29 +0100 Subject: [PATCH 4/5] fix(linter/eslint-plugin-vitest): Now detecting the comma span only, avoiding remove the comments --- .../src/rules/vitest/prefer_called_once.rs | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs index d7ccbefc4290a..b631c84fa8931 100644 --- a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs @@ -113,41 +113,46 @@ impl PreferCalledOnce { prefer_called_once_diagnostic(matcher_and_args_span, new_matcher_name.as_ref()), |fixer| { let argument_without_parenthesis_span = - get_inside_parenthesis_span(called_times_value.span, ctx); + get_comma_span(call_expr.span, called_times_value.span, ctx); + + let number_of_fixes = + if argument_without_parenthesis_span.is_some() { 3 } else { 2 }; let multi_fix = fixer.for_multifix(); - let mut fixes = multi_fix.new_fix_with_capacity(2); + let mut fixes = multi_fix.new_fix_with_capacity(number_of_fixes); fixes.push(fixer.replace(matcher_to_be_fixed.span, new_matcher_name)); - fixes.push(fixer.delete(&argument_without_parenthesis_span)); + fixes.push(fixer.delete(&called_times_value.span)); + + if let Some(comma_span) = argument_without_parenthesis_span { + fixes.push(fixer.delete(&comma_span)); + } - fixes.with_message("Replace API with preferOnce instead of Times") + fixes.with_message("Replace API with prefer Once instead of Times") }, ); } } } -fn is_open_parenthesis(source_text: &str) -> bool { - source_text.starts_with('(') -} - -fn is_close_parenthesis(source_text: &str) -> bool { - source_text.ends_with(')') +fn has_comma(source_text: &str) -> bool { + source_text.ends_with(',') } -fn get_inside_parenthesis_span(argument_span: Span, ctx: &LintContext<'_>) -> Span { - let mut inside_parenthesis_span = Span::new(argument_span.start, argument_span.end); +fn get_comma_span(call_expr: Span, argument_span: Span, ctx: &LintContext<'_>) -> Option { + let mut offset: u32 = 0; - while !is_open_parenthesis(ctx.source_range(inside_parenthesis_span.expand_left(1))) { - inside_parenthesis_span = inside_parenthesis_span.expand_left(1); + while call_expr.end != argument_span.end + offset + && !has_comma(ctx.source_range(argument_span.expand_right(offset))) + { + offset += 1; } - while !is_close_parenthesis(ctx.source_range(inside_parenthesis_span.expand_right(1))) { - inside_parenthesis_span = inside_parenthesis_span.expand_right(1); + if call_expr.end == argument_span.end + offset { + return None; } - inside_parenthesis_span + Some(Span::new(argument_span.start + offset, argument_span.end + offset)) } #[test] @@ -195,9 +200,11 @@ fn test() { ), ( "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes( - 1, +1, + );", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce( + );", - "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", ), ( "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1,);", @@ -205,18 +212,21 @@ fn test() { ), ( "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(/* comment (because why not) */1,);", - "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce(/* comment (because why not) */);", ), ( "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1/* comment (because why not) */,);", - "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce(/* comment (because why not) */);", ), ( "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes( /* I only want to call this function 1 (ONE) time, please. */ - 1, +1, + );", + "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce( + /* I only want to call this function 1 (ONE) time, please. */ + );", - "expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();", ), ]; Tester::new(PreferCalledOnce::NAME, PreferCalledOnce::PLUGIN, pass, fail) From c3b9bdbe93d734be729b28d1565181e67dd1a1b1 Mon Sep 17 00:00:00 2001 From: Cameron Clark Date: Wed, 7 Jan 2026 13:57:04 +0000 Subject: [PATCH 5/5] fix more edge cases --- .../src/rules/vitest/prefer_called_once.rs | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs index b631c84fa8931..a37901af8ddc5 100644 --- a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs @@ -112,8 +112,13 @@ impl PreferCalledOnce { ctx.diagnostic_with_fix( prefer_called_once_diagnostic(matcher_and_args_span, new_matcher_name.as_ref()), |fixer| { - let argument_without_parenthesis_span = - get_comma_span(call_expr.span, called_times_value.span, ctx); + let argument_without_parenthesis_span = ctx + .find_next_token_within( + called_times_value.span.end, + call_expr.span.end, + ",", + ) + .map(|i| Span::sized(called_times_value.span.end + i, 1)); let number_of_fixes = if argument_without_parenthesis_span.is_some() { 3 } else { 2 }; @@ -135,26 +140,6 @@ impl PreferCalledOnce { } } -fn has_comma(source_text: &str) -> bool { - source_text.ends_with(',') -} - -fn get_comma_span(call_expr: Span, argument_span: Span, ctx: &LintContext<'_>) -> Option { - let mut offset: u32 = 0; - - while call_expr.end != argument_span.end + offset - && !has_comma(ctx.source_range(argument_span.expand_right(offset))) - { - offset += 1; - } - - if call_expr.end == argument_span.end + offset { - return None; - } - - Some(Span::new(argument_span.start + offset, argument_span.end + offset)) -} - #[test] fn test() { use crate::tester::Tester; @@ -228,6 +213,14 @@ fn test() { );", ), + ( + "expect(fn).resolves.toHaveBeenCalledTimes(/*comment,*/1,);", + "expect(fn).resolves.toHaveBeenCalledOnce(/*comment,*/);", + ), + ( + "expect(fn).resolves.toHaveBeenCalledTimes(/*comment,*/1/*comment,*/,);", + "expect(fn).resolves.toHaveBeenCalledOnce(/*comment,*//*comment,*/);", + ), ]; Tester::new(PreferCalledOnce::NAME, PreferCalledOnce::PLUGIN, pass, fail) .expect_fix(fix)