feat(linter/eslint-plugin-vitest): implement prefer-to-be-truthy (#4755)

Related to #4656
This commit is contained in:
dalaoshu 2024-08-08 22:07:43 +08:00 committed by GitHub
parent b20e3351fb
commit 41f861fd15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 264 additions and 19 deletions

View file

@ -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,
}

View file

@ -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;

View file

@ -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)

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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();
}

View file

@ -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()`.

View file

@ -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<ParsedGeneralJestFnCall<'a>> {
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<ParsedExpectFnCall<'a>> {
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

View file

@ -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<ParsedJestFnCall<'a>> {
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<usize>, Option<usize>);
@ -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<KnownMemberExpressionProperty<'a>>,

View file

@ -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.

View file

@ -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<ParsedExpectFnCall<'a>> {
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,
}
}