Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/oxc_linter/src/generated/rule_runner_impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3982,6 +3982,12 @@ impl RuleRunner for crate::rules::vitest::consistent_test_filename::ConsistentTe
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnce;
}

impl RuleRunner for crate::rules::vitest::consistent_vitest_vi::ConsistentVitestVi {
const NODE_TYPES: Option<&AstTypesBitset> =
Some(&AstTypesBitset::from_types(&[AstType::CallExpression, AstType::ImportDeclaration]));
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run;
}

impl RuleRunner for crate::rules::vitest::no_conditional_tests::NoConditionalTests {
const NODE_TYPES: Option<&AstTypesBitset> = None;
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode;
Expand Down
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ pub(crate) mod promise {

pub(crate) mod vitest {
pub mod consistent_test_filename;
pub mod consistent_vitest_vi;
pub mod no_conditional_tests;
pub mod no_import_node_test;
pub mod prefer_called_times;
Expand Down Expand Up @@ -1322,6 +1323,7 @@ oxc_macros::declare_all_lint_rules! {
unicorn::text_encoding_identifier_case,
unicorn::throw_new_error,
vitest::consistent_test_filename,
vitest::consistent_vitest_vi,
vitest::no_conditional_tests,
vitest::no_import_node_test,
vitest::prefer_called_times,
Expand Down
266 changes: 266 additions & 0 deletions crates/oxc_linter/src/rules/vitest/consistent_vitest_vi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;

use oxc_ast::{AstKind, ast::ImportDeclarationSpecifier};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};

use crate::{
AstNode,
context::LintContext,
rule::{DefaultRuleConfig, Rule},
utils::{JestFnKind, JestGeneralFnKind, PossibleJestNode, parse_general_jest_fn_call},
};

fn consistent_vitest_vi_diagnostic(span: Span, fn_value: &VitestFnName) -> OxcDiagnostic {
OxcDiagnostic::warn("The vitest function accessor used is not allowed")
.with_help(format!(
"Prefer using `{}` instead of `{}`.",
fn_value.as_str(),
fn_value.not().as_str()
))
.with_label(span)
}

#[derive(Debug, Default, Clone, Deserialize)]
pub struct ConsistentVitestVi(Box<ConsistentVitestConfig>);

impl std::ops::Deref for ConsistentVitestVi {
type Target = ConsistentVitestConfig;

fn deref(&self) -> &Self::Target {
&self.0
}
}

#[derive(Debug, Clone, PartialEq, Eq, Default, JsonSchema, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VitestFnName {
#[default]
Vi,
Vitest,
}

impl VitestFnName {
fn not(&self) -> Self {
match self {
VitestFnName::Vi => VitestFnName::Vitest,
VitestFnName::Vitest => VitestFnName::Vi,
}
}

fn as_str(&self) -> &'static str {
match self {
VitestFnName::Vi => "vi",
VitestFnName::Vitest => "vitest",
}
}
}

#[derive(Debug, Default, Clone, JsonSchema, Deserialize)]
#[serde(rename_all = "lowercase")]
pub struct ConsistentVitestConfig {
/// Decides whether to prefer vitest function accessor
#[serde(rename = "fn", default)]
function: VitestFnName,
}

declare_oxc_lint!(
/// ### What it does
///
/// This rule triggers an error when an unexpected vitest accessor is used.
///
/// ### Why is this bad?
///
/// Not having a consistent vitest accessor can lead to confusion
/// when `vi` and `vitest` are used interchangeably.
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```js
/// vitest.mock('./src/calculator.ts', { spy: true })
///
/// vi.stubEnv('NODE_ENV', 'production')
/// ```
///
/// Examples of **correct** code for this rule:
/// ```js
/// vi.mock('./src/calculator.ts', { spy: true })
///
/// vi.stubEnv('NODE_ENV', 'production')
/// ```
ConsistentVitestVi,
vitest,
style,
fix,
config = ConsistentVitestConfig,
);

impl Rule for ConsistentVitestVi {
fn from_configuration(value: serde_json::Value) -> Result<Self, serde_json::Error> {
Ok(serde_json::from_value::<DefaultRuleConfig<Self>>(value)
.unwrap_or_default()
.into_inner())
}

fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
match node.kind() {
AstKind::ImportDeclaration(import) => {
if import.source.value != "vitest" {
return;
}

let opposite = self.function.not();
let Some(vitest_import) = import
.specifiers
.as_ref()
.and_then(|specs| specs.iter().find(|spec| spec.name() == opposite.as_str()))
else {
return;
};

ctx.diagnostic_with_fix(
consistent_vitest_vi_diagnostic(vitest_import.span(), &self.function),
|fixer| {
let mut specifiers_without_opposite_accessor: Vec<Cow<str>> = import
.specifiers
.as_ref()
.map(|specs| {
specs
.iter()
.filter(|spec| spec.name() != opposite.as_str())
.map(ImportDeclarationSpecifier::name)
.collect()
})
.unwrap_or_default();

if specifiers_without_opposite_accessor.is_empty() {
fixer.replace(vitest_import.local().span, self.function.as_str())
} else {
if !specifiers_without_opposite_accessor
.iter()
.any(|s| s.as_ref() == self.function.as_str())
{
specifiers_without_opposite_accessor
.push(self.function.as_str().into());
}

let import_text = specifiers_without_opposite_accessor.join(", ");

let Some(first_specifier) =
import.specifiers.as_ref().and_then(|specs| specs.first())
else {
return fixer.noop();
};

let Some(last_specifier) =
import.specifiers.as_ref().and_then(|specs| specs.last())
else {
return fixer.noop();
};

let specifiers_span = Span::new(
first_specifier.local().span.start,
last_specifier.local().span.end,
);

fixer.replace(specifiers_span, import_text)
}
},
);
}
AstKind::CallExpression(call_expr) => {
let Some(vitest_fn) = parse_general_jest_fn_call(
call_expr,
&PossibleJestNode { node, original: None },
ctx,
) else {
return;
};

if vitest_fn.kind != JestFnKind::General(JestGeneralFnKind::Vitest) {
return;
}

if vitest_fn.name == self.function.not().as_str() {
let Some(member_expression) = call_expr.callee.as_member_expression() else {
return;
};

ctx.diagnostic_with_fix(
consistent_vitest_vi_diagnostic(
member_expression.object().span(),
&self.function,
),
|fixer| {
fixer.replace(member_expression.object().span(), self.function.as_str())
},
);
}
}
_ => {}
}
}
}

#[test]
fn test() {
use crate::tester::Tester;

let pass = vec![
(r#"import { expect, it } from "vitest";"#, None),
(r#"import { vi } from "vitest";"#, None),
(r#"import { vitest } from "vitest";"#, Some(serde_json::json!([{ "fn": "vitest" }]))),
(
r#"import { vi } from "vitest";
vi.stubEnv("NODE_ENV", "production");"#,
None,
),
(r#"vi.stubEnv("NODE_ENV", "production");"#, None),
];

let fail = vec![
(r#"import { vitest } from "vitest";"#, None),
(r#"import { expect, vi, vitest } from "vitest";"#, None),
(
r#"import { vitest } from "vitest";
vitest.stubEnv("NODE_ENV", "production");"#,
None,
),
(
r#"vi.stubEnv("NODE_ENV", "production");
vi.clearAllMocks();"#,
Some(serde_json::json!([{ "fn": "vitest" }])),
),
];

let fix = vec![
(r#"import { vitest } from "vitest";"#, r#"import { vi } from "vitest";"#, None), // WORKING
(
r#"import { expect, vi, vitest } from "vitest";"#,
r#"import { expect, vi } from "vitest";"#,
None,
),
(
r#"import { vitest } from "vitest";
vitest.stubEnv("NODE_ENV", "production");"#,
r#"import { vi } from "vitest";
vi.stubEnv("NODE_ENV", "production");"#,
None,
),
(
r#"vi.stubEnv("NODE_ENV", "production");
vi.clearAllMocks();"#,
r#"vitest.stubEnv("NODE_ENV", "production");
vitest.clearAllMocks();"#,
Some(serde_json::json!([{ "fn": "vitest" }])),
),
];
Tester::new(ConsistentVitestVi::NAME, ConsistentVitestVi::PLUGIN, pass, fail)
.expect_fix(fix)
.with_vitest_plugin(true)
.test_and_snapshot();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint-plugin-vitest(consistent-vitest-vi): The vitest function accessor used is not allowed
╭─[consistent_vitest_vi.tsx:1:10]
1 │ import { vitest } from "vitest";
· ──────
╰────
help: Prefer using `vi` instead of `vitest`.

⚠ eslint-plugin-vitest(consistent-vitest-vi): The vitest function accessor used is not allowed
╭─[consistent_vitest_vi.tsx:1:22]
1 │ import { expect, vi, vitest } from "vitest";
· ──────
╰────
help: Prefer using `vi` instead of `vitest`.

⚠ eslint-plugin-vitest(consistent-vitest-vi): The vitest function accessor used is not allowed
╭─[consistent_vitest_vi.tsx:1:10]
1 │ import { vitest } from "vitest";
· ──────
2 │ vitest.stubEnv("NODE_ENV", "production");
╰────
help: Prefer using `vi` instead of `vitest`.

⚠ eslint-plugin-vitest(consistent-vitest-vi): The vitest function accessor used is not allowed
╭─[consistent_vitest_vi.tsx:2:4]
1 │ import { vitest } from "vitest";
2 │ vitest.stubEnv("NODE_ENV", "production");
· ──────
╰────
help: Prefer using `vi` instead of `vitest`.

⚠ eslint-plugin-vitest(consistent-vitest-vi): The vitest function accessor used is not allowed
╭─[consistent_vitest_vi.tsx:1:1]
1 │ vi.stubEnv("NODE_ENV", "production");
· ──
2 │ vi.clearAllMocks();
╰────
help: Prefer using `vitest` instead of `vi`.

⚠ eslint-plugin-vitest(consistent-vitest-vi): The vitest function accessor used is not allowed
╭─[consistent_vitest_vi.tsx:2:4]
1 │ vi.stubEnv("NODE_ENV", "production");
2 │ vi.clearAllMocks();
· ──
╰────
help: Prefer using `vitest` instead of `vi`.
2 changes: 1 addition & 1 deletion crates/oxc_linter/src/utils/jest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ impl JestFnKind {
match name {
"expect" => Self::Expect,
"expectTypeOf" => Self::ExpectTypeOf,
"vi" => Self::General(JestGeneralFnKind::Vitest),
"vi" | "vitest" => Self::General(JestGeneralFnKind::Vitest),
"bench" => Self::General(JestGeneralFnKind::Bench),
"jest" => Self::General(JestGeneralFnKind::Jest),
"describe" | "fdescribe" | "xdescribe" => Self::General(JestGeneralFnKind::Describe),
Expand Down
2 changes: 1 addition & 1 deletion crates/oxc_linter/src/utils/jest/parse_jest_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ fn parse_jest_jest_fn_call<'a>(
) -> Option<ParsedJestFnCall<'a>> {
let lowercase_name = name.cow_to_ascii_lowercase();

if !(lowercase_name == "jest" || lowercase_name == "vi") {
if !(lowercase_name == "jest" || lowercase_name == "vi" || lowercase_name == "vitest") {
return None;
}

Expand Down
Loading