Skip to content

Commit 004ffa0

Browse files
shulaodaDonIsaac
andauthored
feat(linter/vitest): implement prefer-each (#5203)
Related to #4656 --------- Co-authored-by: Don Isaac <[email protected]>
1 parent ce454cf commit 004ffa0

File tree

3 files changed

+405
-0
lines changed

3 files changed

+405
-0
lines changed

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ mod promise {
458458
mod vitest {
459459
pub mod no_conditional_tests;
460460
pub mod no_import_node_test;
461+
pub mod prefer_each;
461462
pub mod prefer_to_be_falsy;
462463
pub mod prefer_to_be_truthy;
463464
pub mod require_local_test_context_for_concurrent_snapshots;
@@ -874,6 +875,7 @@ oxc_macros::declare_all_lint_rules! {
874875
promise::no_return_in_finally,
875876
promise::prefer_await_to_then,
876877
vitest::no_import_node_test,
878+
vitest::prefer_each,
877879
vitest::prefer_to_be_falsy,
878880
vitest::prefer_to_be_truthy,
879881
vitest::no_conditional_tests,
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
use std::collections::HashSet;
2+
3+
use oxc_ast::AstKind;
4+
use oxc_diagnostics::OxcDiagnostic;
5+
use oxc_macros::declare_oxc_lint;
6+
use oxc_semantic::{AstNode, AstNodeId};
7+
use oxc_span::{GetSpan, Span};
8+
9+
use crate::{
10+
context::LintContext,
11+
rule::Rule,
12+
utils::{parse_jest_fn_call, JestFnKind, JestGeneralFnKind, PossibleJestNode},
13+
};
14+
15+
fn use_prefer_each(span: Span, fn_name: &str) -> OxcDiagnostic {
16+
OxcDiagnostic::warn("Enforce using `each` rather than manual loops")
17+
.with_help(format!("Prefer using `{fn_name}.each` rather than a manual loop."))
18+
.with_label(span)
19+
}
20+
21+
#[inline]
22+
fn is_in_test(ctx: &LintContext<'_>, id: AstNodeId) -> bool {
23+
ctx.nodes().iter_parents(id).any(|node| {
24+
let AstKind::CallExpression(ancestor_call_expr) = node.kind() else { return false };
25+
let Some(ancestor_member_expr) = ancestor_call_expr.callee.as_member_expression() else {
26+
return false;
27+
};
28+
let Some(id) = ancestor_member_expr.object().get_identifier_reference() else {
29+
return false;
30+
};
31+
32+
matches!(JestFnKind::from(id.name.as_str()), JestFnKind::General(JestGeneralFnKind::Test))
33+
})
34+
}
35+
36+
#[derive(Debug, Default, Clone)]
37+
pub struct PreferEach;
38+
39+
declare_oxc_lint!(
40+
/// ### What it does
41+
/// This rule enforces using `each` rather than manual loops.
42+
///
43+
/// ### Examples
44+
///
45+
/// Examples of **incorrect** code for this rule:
46+
/// ```js
47+
/// for (const item of items) {
48+
/// describe(item, () => {
49+
/// expect(item).toBe('foo')
50+
/// })
51+
/// }
52+
/// ```
53+
///
54+
/// Examples of **correct** code for this rule:
55+
/// ```js
56+
/// describe.each(items)('item', (item) => {
57+
/// expect(item).toBe('foo')
58+
/// })
59+
/// ```
60+
PreferEach,
61+
style,
62+
);
63+
64+
impl Rule for PreferEach {
65+
fn run_once(&self, ctx: &LintContext<'_>) {
66+
let mut skip = HashSet::<AstNodeId>::new();
67+
ctx.nodes().iter().for_each(|node| {
68+
Self::run(node, ctx, &mut skip);
69+
});
70+
}
71+
}
72+
73+
impl PreferEach {
74+
fn run<'a>(node: &AstNode<'a>, ctx: &LintContext<'a>, skip: &mut HashSet<AstNodeId>) {
75+
let kind = node.kind();
76+
77+
let AstKind::CallExpression(call_expr) = kind else { return };
78+
79+
let Some(vitest_fn_call) =
80+
parse_jest_fn_call(call_expr, &PossibleJestNode { node, original: None }, ctx)
81+
else {
82+
return;
83+
};
84+
85+
if !matches!(
86+
vitest_fn_call.kind(),
87+
JestFnKind::General(
88+
JestGeneralFnKind::Describe | JestGeneralFnKind::Hook | JestGeneralFnKind::Test
89+
)
90+
) {
91+
return;
92+
}
93+
94+
for parent_node in ctx.nodes().iter_parents(node.id()).skip(1) {
95+
match parent_node.kind() {
96+
AstKind::CallExpression(_) => {
97+
return;
98+
}
99+
AstKind::ForStatement(_)
100+
| AstKind::ForInStatement(_)
101+
| AstKind::ForOfStatement(_) => {
102+
if skip.contains(&parent_node.id()) || is_in_test(ctx, parent_node.id()) {
103+
return;
104+
}
105+
106+
let fn_name = if matches!(
107+
vitest_fn_call.kind(),
108+
JestFnKind::General(JestGeneralFnKind::Test)
109+
) {
110+
"it"
111+
} else {
112+
"describe"
113+
};
114+
115+
let span = match parent_node.kind() {
116+
AstKind::ForStatement(statement) => {
117+
Span::new(statement.span.start, statement.body.span().start)
118+
}
119+
AstKind::ForInStatement(statement) => {
120+
Span::new(statement.span.start, statement.body.span().start)
121+
}
122+
AstKind::ForOfStatement(statement) => {
123+
Span::new(statement.span.start, statement.body.span().start)
124+
}
125+
_ => unreachable!(),
126+
};
127+
128+
skip.insert(parent_node.id());
129+
ctx.diagnostic(use_prefer_each(span, fn_name));
130+
131+
break;
132+
}
133+
_ => {}
134+
}
135+
}
136+
}
137+
}
138+
139+
#[test]
140+
fn test() {
141+
use crate::tester::Tester;
142+
143+
let pass = vec![
144+
r#"it("is true", () => { expect(true).toBe(false) });"#,
145+
r#"it.each(getNumbers())("only returns numbers that are greater than seven", number => {
146+
expect(number).toBeGreaterThan(7);
147+
});"#,
148+
r#"it("returns numbers that are greater than five", function () {
149+
for (const number of getNumbers()) {
150+
expect(number).toBeGreaterThan(5);
151+
}
152+
});"#,
153+
r#"it("returns things that are less than ten", function () {
154+
for (const thing in things) {
155+
expect(thing).toBeLessThan(10);
156+
}
157+
});"#,
158+
r#"it("only returns numbers that are greater than seven", function () {
159+
const numbers = getNumbers();
160+
161+
for (let i = 0; i < numbers.length; i++) {
162+
expect(numbers[i]).toBeGreaterThan(7);
163+
}
164+
});"#,
165+
];
166+
167+
let fail = vec![
168+
" for (const [input, expected] of data) {
169+
it(`results in ${expected}`, () => {
170+
expect(fn(input)).toBe(expected)
171+
});
172+
}",
173+
" for (const [input, expected] of data) {
174+
describe(`when the input is ${input}`, () => {
175+
it(`results in ${expected}`, () => {
176+
expect(fn(input)).toBe(expected)
177+
});
178+
});
179+
}",
180+
"for (const [input, expected] of data) {
181+
describe(`when the input is ${input}`, () => {
182+
it(`results in ${expected}`, () => {
183+
expect(fn(input)).toBe(expected)
184+
});
185+
});
186+
}
187+
188+
for (const [input, expected] of data) {
189+
it.skip(`results in ${expected}`, () => {
190+
expect(fn(input)).toBe(expected)
191+
});
192+
}",
193+
"for (const [input, expected] of data) {
194+
it.skip(`results in ${expected}`, () => {
195+
expect(fn(input)).toBe(expected)
196+
});
197+
}",
198+
"it('is true', () => {
199+
expect(true).toBe(false);
200+
});
201+
202+
for (const [input, expected] of data) {
203+
it.skip(`results in ${expected}`, () => {
204+
expect(fn(input)).toBe(expected)
205+
});
206+
}",
207+
" for (const [input, expected] of data) {
208+
it.skip(`results in ${expected}`, () => {
209+
expect(fn(input)).toBe(expected)
210+
});
211+
}
212+
213+
it('is true', () => {
214+
expect(true).toBe(false);
215+
});",
216+
" it('is true', () => {
217+
expect(true).toBe(false);
218+
});
219+
220+
for (const [input, expected] of data) {
221+
it.skip(`results in ${expected}`, () => {
222+
expect(fn(input)).toBe(expected)
223+
});
224+
}
225+
226+
it('is true', () => {
227+
expect(true).toBe(false);
228+
});",
229+
"for (const [input, expected] of data) {
230+
it(`results in ${expected}`, () => {
231+
expect(fn(input)).toBe(expected)
232+
});
233+
234+
it(`results in ${expected}`, () => {
235+
expect(fn(input)).toBe(expected)
236+
});
237+
}",
238+
"for (const [input, expected] of data) {
239+
it(`results in ${expected}`, () => {
240+
expect(fn(input)).toBe(expected)
241+
});
242+
}
243+
244+
for (const [input, expected] of data) {
245+
it(`results in ${expected}`, () => {
246+
expect(fn(input)).toBe(expected)
247+
});
248+
}",
249+
"for (const [input, expected] of data) {
250+
beforeEach(() => setupSomething(input));
251+
252+
test(`results in ${expected}`, () => {
253+
expect(doSomething()).toBe(expected)
254+
});
255+
}",
256+
r#"
257+
for (const [input, expected] of data) {
258+
it("only returns numbers that are greater than seven", function () {
259+
const numbers = getNumbers(input);
260+
261+
for (let i = 0; i < numbers.length; i++) {
262+
expect(numbers[i]).toBeGreaterThan(7);
263+
}
264+
});
265+
}
266+
"#,
267+
r#"
268+
for (const [input, expected] of data) {
269+
beforeEach(() => setupSomething(input));
270+
271+
it("only returns numbers that are greater than seven", function () {
272+
const numbers = getNumbers();
273+
274+
for (let i = 0; i < numbers.length; i++) {
275+
expect(numbers[i]).toBeGreaterThan(7);
276+
}
277+
});
278+
}
279+
"#,
280+
];
281+
282+
Tester::new(PreferEach::NAME, pass, fail).test_and_snapshot();
283+
}

0 commit comments

Comments
 (0)