diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 1bd2453bb2467..56e650eb547d8 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -449,6 +449,7 @@ mod promise { mod vitest { pub mod no_import_node_test; + pub mod prefer_to_be_truthy; } oxc_macros::declare_all_lint_rules! { @@ -854,4 +855,5 @@ oxc_macros::declare_all_lint_rules! { promise::no_new_statics, promise::param_names, vitest::no_import_node_test, + vitest::prefer_to_be_truthy, } diff --git a/crates/oxc_linter/src/rules/jest/consistent_test_it.rs b/crates/oxc_linter/src/rules/jest/consistent_test_it.rs index 4bf490c9c8065..e97a3581dc1d6 100644 --- a/crates/oxc_linter/src/rules/jest/consistent_test_it.rs +++ b/crates/oxc_linter/src/rules/jest/consistent_test_it.rs @@ -219,7 +219,7 @@ impl ConsistentTestIt { let AstKind::CallExpression(call_expr) = node.kind() else { return; }; - let Some(ParsedJestFnCallNew::GeneralJestFnCall(jest_fn_call)) = + let Some(ParsedJestFnCallNew::GeneralJest(jest_fn_call)) = parse_jest_fn_call(call_expr, possible_jest_node, ctx) else { return; diff --git a/crates/oxc_linter/src/rules/jest/no_disabled_tests.rs b/crates/oxc_linter/src/rules/jest/no_disabled_tests.rs index 46612cd416386..20a9c63e835d4 100644 --- a/crates/oxc_linter/src/rules/jest/no_disabled_tests.rs +++ b/crates/oxc_linter/src/rules/jest/no_disabled_tests.rs @@ -104,7 +104,7 @@ fn run<'a>(possible_jest_node: &PossibleJestNode<'a, '_>, ctx: &LintContext<'a>) let ParsedGeneralJestFnCall { kind, members, name, .. } = jest_fn_call; // `test('foo')` let kind = match kind { - JestFnKind::Expect | JestFnKind::Unknown => return, + JestFnKind::Expect | JestFnKind::ExpectTypeOf | JestFnKind::Unknown => return, JestFnKind::General(kind) => kind, }; if matches!(kind, JestGeneralFnKind::Test) diff --git a/crates/oxc_linter/src/rules/jest/no_duplicate_hooks.rs b/crates/oxc_linter/src/rules/jest/no_duplicate_hooks.rs index 0e2ea3e57fa2f..64a3084033049 100644 --- a/crates/oxc_linter/src/rules/jest/no_duplicate_hooks.rs +++ b/crates/oxc_linter/src/rules/jest/no_duplicate_hooks.rs @@ -127,7 +127,7 @@ impl NoDuplicateHooks { let AstKind::CallExpression(call_expr) = node.kind() else { return; }; - let Some(ParsedJestFnCallNew::GeneralJestFnCall(jest_fn_call)) = + let Some(ParsedJestFnCallNew::GeneralJest(jest_fn_call)) = parse_jest_fn_call(call_expr, possible_jest_node, ctx) else { return; diff --git a/crates/oxc_linter/src/rules/jest/prefer_hooks_in_order.rs b/crates/oxc_linter/src/rules/jest/prefer_hooks_in_order.rs index d91946f233b1d..28001b8f72945 100644 --- a/crates/oxc_linter/src/rules/jest/prefer_hooks_in_order.rs +++ b/crates/oxc_linter/src/rules/jest/prefer_hooks_in_order.rs @@ -166,7 +166,7 @@ impl PreferHooksInOrder { call_expr: &'a CallExpression<'_>, ctx: &LintContext<'a>, ) { - let Some(ParsedJestFnCallNew::GeneralJestFnCall(jest_fn_call)) = + let Some(ParsedJestFnCallNew::GeneralJest(jest_fn_call)) = parse_jest_fn_call(call_expr, possible_jest_node, ctx) else { *previous_hook_index = -1; diff --git a/crates/oxc_linter/src/rules/jest/prefer_lowercase_title.rs b/crates/oxc_linter/src/rules/jest/prefer_lowercase_title.rs index bf18595235b67..ebaeb00ea4616 100644 --- a/crates/oxc_linter/src/rules/jest/prefer_lowercase_title.rs +++ b/crates/oxc_linter/src/rules/jest/prefer_lowercase_title.rs @@ -174,7 +174,7 @@ impl PreferLowercaseTitle { let AstKind::CallExpression(call_expr) = node.kind() else { return; }; - let Some(ParsedJestFnCallNew::GeneralJestFnCall(jest_fn_call)) = + let Some(ParsedJestFnCallNew::GeneralJest(jest_fn_call)) = parse_jest_fn_call(call_expr, possible_jest_node, ctx) else { return; diff --git a/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs b/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs index 612aaacd9bb6e..a80c2b6cc1d91 100644 --- a/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs +++ b/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs @@ -144,7 +144,7 @@ impl RequireTopLevelDescribe { return; }; - let Some(ParsedJestFnCallNew::GeneralJestFnCall(ParsedGeneralJestFnCall { kind, .. })) = + let Some(ParsedJestFnCallNew::GeneralJest(ParsedGeneralJestFnCall { kind, .. })) = parse_jest_fn_call(call_expr, possible_jest_node, ctx) else { return; diff --git a/crates/oxc_linter/src/rules/vitest/prefer_to_be_truthy.rs b/crates/oxc_linter/src/rules/vitest/prefer_to_be_truthy.rs new file mode 100644 index 0000000000000..c97dd29ef684b --- /dev/null +++ b/crates/oxc_linter/src/rules/vitest/prefer_to_be_truthy.rs @@ -0,0 +1,160 @@ +use oxc_ast::{ + ast::{Argument, Expression}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{ + collect_possible_jest_call_node, is_equality_matcher, + parse_expect_and_typeof_vitest_fn_call, PossibleJestNode, + }, +}; + +fn use_to_be_truthy(span0: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Use `toBeTruthy` instead.").with_label(span0) +} + +#[derive(Debug, Default, Clone)] +pub struct PreferToBeTruthy; + +declare_oxc_lint!( + /// ### What it does + /// + /// This rule warns when `toBe(true)` is used with `expect` or `expectTypeOf`. With `--fix`, it will be replaced with `toBeTruthy()`. + /// + /// ### Examples + /// + /// ```javascript + /// // bad + /// expect(foo).toBe(true) + /// expectTypeOf(foo).toBe(true) + /// + /// // good + /// expect(foo).toBeTruthy() + /// expectTypeOf(foo).toBeTruthy() + /// ``` + PreferToBeTruthy, + style, + fix +); + +impl Rule for PreferToBeTruthy { + fn run_once(&self, ctx: &LintContext) { + for possible_vitest_node in &collect_possible_jest_call_node(ctx) { + Self::run(possible_vitest_node, ctx); + } + } +} + +impl PreferToBeTruthy { + fn run<'a>(possible_vitest_node: &PossibleJestNode<'a, '_>, ctx: &LintContext<'a>) { + let node = possible_vitest_node.node; + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + let Some(vitest_expect_fn_call) = + parse_expect_and_typeof_vitest_fn_call(call_expr, possible_vitest_node, ctx) + else { + return; + }; + let Some(matcher) = vitest_expect_fn_call.matcher() else { + return; + }; + + if !is_equality_matcher(matcher) || vitest_expect_fn_call.args.len() == 0 { + return; + } + + let Some(arg_expr) = vitest_expect_fn_call.args.first().and_then(Argument::as_expression) + else { + return; + }; + + if let Expression::BooleanLiteral(arg) = arg_expr.get_inner_expression() { + if arg.value { + let span = Span::new(matcher.span.start, call_expr.span.end); + + let is_cmp_mem_expr = match matcher.parent { + Some(Expression::ComputedMemberExpression(_)) => true, + Some( + Expression::StaticMemberExpression(_) + | Expression::PrivateFieldExpression(_), + ) => false, + _ => return, + }; + + ctx.diagnostic_with_fix(use_to_be_truthy(span), |fixer| { + let new_matcher = + if is_cmp_mem_expr { "[\"toBeTruthy\"]()" } else { "toBeTruthy()" }; + + fixer.replace(span, new_matcher) + }); + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "[].push(true)", + r#"expect("something");"#, + "expect(true).toBeTrue();", + "expect(false).toBeTrue();", + "expect(fal,se).toBeFalse();", + "expect(true).toBeFalse();", + "expect(value).toEqual();", + "expect(value).not.toBeTrue();", + "expect(value).not.toEqual();", + "expect(value).toBe(undefined);", + "expect(value).not.toBe(undefined);", + "expect(true).toBe(false)", + "expect(value).toBe();", + "expect(true).toMatchSnapshot();", + r#"expect("a string").toMatchSnapshot(true);"#, + r#"expect("a string").not.toMatchSnapshot();"#, + "expect(something).toEqual('a string');", + "expect(true).toBe", + "expectTypeOf(true).toBe()", + ]; + + let fail = vec![ + "expect(false).toBe(true);", + "expectTypeOf(false).toBe(true);", + "expect(wasSuccessful).toEqual(true);", + "expect(fs.existsSync('/path/to/file')).toStrictEqual(true);", + r#"expect("a string").not.toBe(true);"#, + r#"expect("a string").not.toEqual(true);"#, + r#"expectTypeOf("a string").not.toStrictEqual(true);"#, + ]; + + let fix = vec![ + ("expect(false).toBe(true);", "expect(false).toBeTruthy();", None), + ("expectTypeOf(false).toBe(true);", "expectTypeOf(false).toBeTruthy();", None), + ("expect(wasSuccessful).toEqual(true);", "expect(wasSuccessful).toBeTruthy();", None), + ( + "expect(fs.existsSync('/path/to/file')).toStrictEqual(true);", + "expect(fs.existsSync('/path/to/file')).toBeTruthy();", + None, + ), + (r#"expect("a string").not.toBe(true);"#, r#"expect("a string").not.toBeTruthy();"#, None), + ( + r#"expect("a string").not.toEqual(true);"#, + r#"expect("a string").not.toBeTruthy();"#, + None, + ), + ( + r#"expectTypeOf("a string").not.toStrictEqual(true);"#, + r#"expectTypeOf("a string").not.toBeTruthy();"#, + None, + ), + ]; + Tester::new(PreferToBeTruthy::NAME, pass, fail).expect_fix(fix).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/prefer_to_be_truthy.snap b/crates/oxc_linter/src/snapshots/prefer_to_be_truthy.snap new file mode 100644 index 0000000000000..04f6dd93ee708 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/prefer_to_be_truthy.snap @@ -0,0 +1,51 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:15] + 1 │ expect(false).toBe(true); + · ────────── + ╰──── + help: Replace `toBe(true)` with `toBeTruthy()`. + + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:21] + 1 │ expectTypeOf(false).toBe(true); + · ────────── + ╰──── + help: Replace `toBe(true)` with `toBeTruthy()`. + + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:23] + 1 │ expect(wasSuccessful).toEqual(true); + · ───────────── + ╰──── + help: Replace `toEqual(true)` with `toBeTruthy()`. + + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:40] + 1 │ expect(fs.existsSync('/path/to/file')).toStrictEqual(true); + · ─────────────────── + ╰──── + help: Replace `toStrictEqual(true)` with `toBeTruthy()`. + + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:24] + 1 │ expect("a string").not.toBe(true); + · ────────── + ╰──── + help: Replace `toBe(true)` with `toBeTruthy()`. + + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:24] + 1 │ expect("a string").not.toEqual(true); + · ───────────── + ╰──── + help: Replace `toEqual(true)` with `toBeTruthy()`. + + ⚠ eslint-plugin-vitest(prefer-to-be-truthy): Use `toBeTruthy` instead. + ╭─[prefer_to_be_truthy.tsx:1:30] + 1 │ expectTypeOf("a string").not.toStrictEqual(true); + · ─────────────────── + ╰──── + help: Replace `toStrictEqual(true)` with `toBeTruthy()`. diff --git a/crates/oxc_linter/src/utils/jest.rs b/crates/oxc_linter/src/utils/jest.rs index 10d33bbfc27b8..1e7add7bcb176 100644 --- a/crates/oxc_linter/src/utils/jest.rs +++ b/crates/oxc_linter/src/utils/jest.rs @@ -26,6 +26,7 @@ pub const JEST_METHOD_NAMES: phf::Set<&'static str> = phf_set![ "beforeEach", "describe", "expect", + "expectTypeOf", "fdescribe", "fit", "it", @@ -40,6 +41,7 @@ pub const JEST_METHOD_NAMES: phf::Set<&'static str> = phf_set![ #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum JestFnKind { Expect, + ExpectTypeOf, General(JestGeneralFnKind), Unknown, } @@ -48,6 +50,7 @@ impl JestFnKind { pub fn from(name: &str) -> Self { match name { "expect" => Self::Expect, + "expectTypeOf" => Self::ExpectTypeOf, "jest" => Self::General(JestGeneralFnKind::Jest), "describe" | "fdescribe" | "xdescribe" => Self::General(JestGeneralFnKind::Describe), "fit" | "it" | "test" | "xit" | "xtest" => Self::General(JestGeneralFnKind::Test), @@ -113,7 +116,7 @@ pub fn parse_general_jest_fn_call<'a>( ) -> Option> { let jest_fn_call = parse_jest_fn_call(call_expr, possible_jest_node, ctx)?; - if let ParsedJestFnCallNew::GeneralJestFnCall(jest_fn_call) = jest_fn_call { + if let ParsedJestFnCallNew::GeneralJest(jest_fn_call) = jest_fn_call { return Some(jest_fn_call); } None @@ -126,7 +129,7 @@ pub fn parse_expect_jest_fn_call<'a>( ) -> Option> { let jest_fn_call = parse_jest_fn_call(call_expr, possible_jest_node, ctx)?; - if let ParsedJestFnCallNew::ExpectFnCall(jest_fn_call) = jest_fn_call { + if let ParsedJestFnCallNew::Expect(jest_fn_call) = jest_fn_call { return Some(jest_fn_call); } None diff --git a/crates/oxc_linter/src/utils/jest/parse_jest_fn.rs b/crates/oxc_linter/src/utils/jest/parse_jest_fn.rs index 24b6a31a0b079..b23a9489f51ed 100644 --- a/crates/oxc_linter/src/utils/jest/parse_jest_fn.rs +++ b/crates/oxc_linter/src/utils/jest/parse_jest_fn.rs @@ -24,6 +24,7 @@ pub fn parse_jest_fn_call<'a>( let node = possible_jest_node.node; let callee = &call_expr.callee; // If bailed out, we're not jest function + let resolved = resolve_to_jest_fn(call_expr, original)?; let params = NodeChainParams { @@ -67,7 +68,7 @@ pub fn parse_jest_fn_call<'a>( members.push(member); } - if matches!(kind, JestFnKind::Expect) { + if matches!(kind, JestFnKind::Expect | JestFnKind::ExpectTypeOf) { let options = ExpectFnCallOptions { call_expr, members, @@ -77,7 +78,7 @@ pub fn parse_jest_fn_call<'a>( node, ctx, }; - return parse_jest_expect_fn_call(options); + return parse_jest_expect_fn_call(options, matches!(kind, JestFnKind::ExpectTypeOf)); } // Ensure that we're at the "top" of the function call chain otherwise when @@ -104,7 +105,7 @@ pub fn parse_jest_fn_call<'a>( return None; } - return Some(ParsedJestFnCall::GeneralJestFnCall(ParsedGeneralJestFnCall { + return Some(ParsedJestFnCall::GeneralJest(ParsedGeneralJestFnCall { kind, members, name: Cow::Borrowed(name), @@ -117,6 +118,7 @@ pub fn parse_jest_fn_call<'a>( fn parse_jest_expect_fn_call<'a>( options: ExpectFnCallOptions<'a, '_>, + is_type_of: bool, ) -> Option> { let ExpectFnCallOptions { call_expr, members, name, local, head, node, ctx } = options; let (modifiers, matcher, mut expect_error) = match find_modifiers_and_matcher(&members) { @@ -137,7 +139,7 @@ fn parse_jest_expect_fn_call<'a>( } } - return Some(ParsedJestFnCall::ExpectFnCall(ParsedExpectFnCall { + let parsed_expect_fn = ParsedExpectFnCall { kind: JestFnKind::Expect, head, members, @@ -147,7 +149,13 @@ fn parse_jest_expect_fn_call<'a>( matcher_index: matcher, modifier_indices: modifiers, expect_error, - })); + }; + + return Some(if is_type_of { + ParsedJestFnCall::ExpectTypeOf(parsed_expect_fn) + } else { + ParsedJestFnCall::Expect(parsed_expect_fn) + }); } type ModifiersAndMatcherIndex = (Vec, Option); @@ -242,7 +250,7 @@ fn parse_jest_jest_fn_call<'a>( return None; } - return Some(ParsedJestFnCall::GeneralJestFnCall(ParsedGeneralJestFnCall { + return Some(ParsedJestFnCall::GeneralJest(ParsedGeneralJestFnCall { kind: JestFnKind::General(JestGeneralFnKind::Jest), members, name: Cow::Borrowed(name), @@ -307,15 +315,16 @@ fn resolve_first_ident<'a>(expr: &'a Expression<'a>) -> Option<&'a IdentifierRef } pub enum ParsedJestFnCall<'a> { - GeneralJestFnCall(ParsedGeneralJestFnCall<'a>), - ExpectFnCall(ParsedExpectFnCall<'a>), + GeneralJest(ParsedGeneralJestFnCall<'a>), + Expect(ParsedExpectFnCall<'a>), + ExpectTypeOf(ParsedExpectFnCall<'a>), } impl<'a> ParsedJestFnCall<'a> { pub fn kind(&self) -> JestFnKind { match self { - Self::GeneralJestFnCall(call) => call.kind, - Self::ExpectFnCall(call) => call.kind, + Self::GeneralJest(call) => call.kind, + Self::Expect(call) | Self::ExpectTypeOf(call) => call.kind, } } } @@ -328,6 +337,7 @@ pub struct ParsedGeneralJestFnCall<'a> { pub local: Cow<'a, str>, } +#[derive(Debug)] pub struct ParsedExpectFnCall<'a> { pub kind: JestFnKind, pub members: Vec>, diff --git a/crates/oxc_linter/src/utils/mod.rs b/crates/oxc_linter/src/utils/mod.rs index 924567436eb44..7e65a155456f0 100644 --- a/crates/oxc_linter/src/utils/mod.rs +++ b/crates/oxc_linter/src/utils/mod.rs @@ -5,9 +5,10 @@ mod react; mod react_perf; mod tree_shaking; mod unicorn; +mod vitest; pub use self::{ - jest::*, jsdoc::*, nextjs::*, react::*, react_perf::*, tree_shaking::*, unicorn::*, + jest::*, jsdoc::*, nextjs::*, react::*, react_perf::*, tree_shaking::*, unicorn::*, vitest::*, }; /// Check if the Jest rule is adapted to Vitest. diff --git a/crates/oxc_linter/src/utils/vitest.rs b/crates/oxc_linter/src/utils/vitest.rs new file mode 100644 index 0000000000000..9ef6c4fd56695 --- /dev/null +++ b/crates/oxc_linter/src/utils/vitest.rs @@ -0,0 +1,18 @@ +use crate::LintContext; +use oxc_ast::ast::CallExpression; + +use super::{parse_jest_fn_call, ParsedExpectFnCall, ParsedJestFnCallNew, PossibleJestNode}; + +pub fn parse_expect_and_typeof_vitest_fn_call<'a>( + call_expr: &'a CallExpression<'a>, + possible_jest_node: &PossibleJestNode<'a, '_>, + ctx: &LintContext<'a>, +) -> Option> { + let jest_fn_call = parse_jest_fn_call(call_expr, possible_jest_node, ctx)?; + + match jest_fn_call { + ParsedJestFnCallNew::Expect(jest_fn_call) + | ParsedJestFnCallNew::ExpectTypeOf(jest_fn_call) => Some(jest_fn_call), + ParsedJestFnCallNew::GeneralJest(_) => None, + } +}