Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3790933

Browse files
authored
feat(linter): add vitest/prefer-lowercase-title rule (#8152)
This pull request implements the [vitest/prefer-lowercase-title](https://github.com/vitest-dev/eslint-plugin-vitest/blob/main/docs/rules/prefer-lowercase-title.md) rule. Since there was an existing jest rule with this title, I followed the existing pattern in [no-unused-vars](https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/rules/eslint/no_unused_vars/mod.rs) to group the jest and vitest rules together in a shared module. I used the existing `jest/prefer-lowercase-title` documentation as a base and modified it where it seemed appropriate. I added a `jest` and `vitest` snapshot suffix for each respective test suite. One item I wasn't 100% about is adding `bench` to the jest test names. Without this change, the vitest test suite fails because of [this check](https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/utils/jest/parse_jest_fn.rs#L108) which validates that we're only parsing valid jest functions from a detected jest file. The unit tests that are sourced from the vitest plugin are all read by the linting host as jest-like files, so adding `bench` as a "valid" jest method allows us to lint a unit test using this keyword. This seemed to me like the least invasive solution to accommodate the new rule without breaking any existing code, but I'm certainly open to alternatives.
1 parent 1de6f85 commit 3790933

File tree

9 files changed

+405
-254
lines changed

9 files changed

+405
-254
lines changed
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
use oxc_ast::{ast::Argument, AstKind};
2+
use oxc_diagnostics::OxcDiagnostic;
3+
use oxc_macros::declare_oxc_lint;
4+
use oxc_span::{CompactStr, Span};
5+
6+
#[cfg(test)]
7+
mod tests;
8+
9+
use crate::{
10+
context::LintContext,
11+
rule::Rule,
12+
utils::{
13+
parse_jest_fn_call, JestFnKind, JestGeneralFnKind, ParsedJestFnCallNew, PossibleJestNode,
14+
},
15+
};
16+
17+
fn prefer_lowercase_title_diagnostic(title: &str, span: Span) -> OxcDiagnostic {
18+
OxcDiagnostic::warn("Enforce lowercase test names")
19+
.with_help(format!("`{title:?}`s should begin with lowercase"))
20+
.with_label(span)
21+
}
22+
23+
#[derive(Debug, Default, Clone)]
24+
pub struct PreferLowercaseTitleConfig {
25+
allowed_prefixes: Vec<CompactStr>,
26+
ignore: Vec<CompactStr>,
27+
ignore_top_level_describe: bool,
28+
lowercase_first_character_only: bool,
29+
}
30+
31+
impl std::ops::Deref for PreferLowercaseTitle {
32+
type Target = PreferLowercaseTitleConfig;
33+
34+
fn deref(&self) -> &Self::Target {
35+
&self.0
36+
}
37+
}
38+
39+
#[derive(Debug, Default, Clone)]
40+
pub struct PreferLowercaseTitle(Box<PreferLowercaseTitleConfig>);
41+
42+
declare_oxc_lint!(
43+
/// ### What it does
44+
///
45+
/// Enforce `it`, `test`, `describe`, and `bench` to have descriptions that begin with a
46+
/// lowercase letter. This provides more readable test failures. This rule is not
47+
/// enabled by default.
48+
///
49+
/// ### Example
50+
///
51+
/// ```javascript
52+
/// // invalid
53+
/// it('Adds 1 + 2 to equal 3', () => {
54+
/// expect(sum(1, 2)).toBe(3);
55+
/// });
56+
///
57+
/// // valid
58+
/// it('adds 1 + 2 to equal 3', () => {
59+
/// expect(sum(1, 2)).toBe(3);
60+
/// });
61+
/// ```
62+
///
63+
/// ## Options
64+
/// ```json
65+
/// {
66+
/// "jest/prefer-lowercase-title": [
67+
/// "error",
68+
/// {
69+
/// "ignore": ["describe", "test"]
70+
/// }
71+
/// ]
72+
/// }
73+
/// ```
74+
///
75+
/// ### `ignore`
76+
///
77+
/// This array option controls which Jest or Vitest functions are checked by this rule. There
78+
/// are four possible values:
79+
/// - `"describe"`
80+
/// - `"test"`
81+
/// - `"it"`
82+
/// - `"bench"`
83+
///
84+
/// By default, none of these options are enabled (the equivalent of
85+
/// `{ "ignore": [] }`).
86+
///
87+
/// Example of **correct** code for the `{ "ignore": ["describe"] }` option:
88+
/// ```js
89+
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignore": ["describe"] }] */
90+
/// describe('Uppercase description');
91+
/// ```
92+
///
93+
/// Example of **correct** code for the `{ "ignore": ["test"] }` option:
94+
///
95+
/// ```js
96+
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignore": ["test"] }] */
97+
/// test('Uppercase description');
98+
/// ```
99+
///
100+
/// Example of **correct** code for the `{ "ignore": ["it"] }` option:
101+
/// ```js
102+
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignore": ["it"] }] */
103+
/// it('Uppercase description');
104+
/// ```
105+
///
106+
/// ### `allowedPrefixes`
107+
/// This array option allows specifying prefixes, which contain capitals that titles
108+
/// can start with. This can be useful when writing tests for API endpoints, where
109+
/// you'd like to prefix with the HTTP method.
110+
/// By default, nothing is allowed (the equivalent of `{ "allowedPrefixes": [] }`).
111+
///
112+
/// Example of **correct** code for the `{ "allowedPrefixes": ["GET"] }` option:
113+
/// ```js
114+
/// /* eslint jest/prefer-lowercase-title: ["error", { "allowedPrefixes": ["GET"] }] */
115+
/// describe('GET /live');
116+
/// ```
117+
///
118+
/// ### `ignoreTopLevelDescribe`
119+
/// This option can be set to allow only the top-level `describe` blocks to have a
120+
/// title starting with an upper-case letter.
121+
/// Example of **correct** code for the `{ "ignoreTopLevelDescribe": true }` option:
122+
///
123+
/// ```js
124+
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignoreTopLevelDescribe": true }] */
125+
/// describe('MyClass', () => {
126+
/// describe('#myMethod', () => {
127+
/// it('does things', () => {
128+
/// //
129+
/// });
130+
/// });
131+
/// });
132+
/// ```
133+
///
134+
/// ### `lowercaseFirstCharacterOnly`
135+
/// This option can be set to only validate that the first character of a test name is lowercased.
136+
///
137+
/// Example of **correct** code for the `{ "lowercaseFirstCharacterOnly": true }` option:
138+
///
139+
/// ```js
140+
/// /* eslint vitest/prefer-lowercase-title: ["error", { "lowercaseFirstCharacterOnly": true }] */
141+
/// describe('myClass', () => {
142+
/// describe('myMethod', () => {
143+
/// it('does things', () => {
144+
/// //
145+
/// });
146+
/// });
147+
/// });
148+
/// ```
149+
///
150+
/// Example of **incorrect** code for the `{ "lowercaseFirstCharacterOnly": true }` option:
151+
///
152+
/// ```js
153+
/// /* eslint vitest/prefer-lowercase-title: ["error", { "lowercaseFirstCharacterOnly": true }] */
154+
/// describe('MyClass', () => {
155+
/// describe('MyMethod', () => {
156+
/// it('does things', () => {
157+
/// //
158+
/// });
159+
/// });
160+
/// });
161+
/// ```
162+
PreferLowercaseTitle,
163+
jest,
164+
style,
165+
fix
166+
);
167+
168+
impl Rule for PreferLowercaseTitle {
169+
fn from_configuration(value: serde_json::Value) -> Self {
170+
let obj = value.get(0);
171+
let ignore_top_level_describe = obj
172+
.and_then(|config| config.get("ignoreTopLevelDescribe"))
173+
.and_then(serde_json::Value::as_bool)
174+
.unwrap_or(false);
175+
let lowercase_first_character_only = obj
176+
.and_then(|config| config.get("lowercaseFirstCharacterOnly"))
177+
.and_then(serde_json::Value::as_bool)
178+
.unwrap_or(true);
179+
let ignore = obj
180+
.and_then(|config| config.get("ignore"))
181+
.and_then(serde_json::Value::as_array)
182+
.map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect())
183+
.unwrap_or_default();
184+
let allowed_prefixes = obj
185+
.and_then(|config| config.get("allowedPrefixes"))
186+
.and_then(serde_json::Value::as_array)
187+
.map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect())
188+
.unwrap_or_default();
189+
190+
Self(Box::new(PreferLowercaseTitleConfig {
191+
allowed_prefixes,
192+
ignore,
193+
ignore_top_level_describe,
194+
lowercase_first_character_only,
195+
}))
196+
}
197+
198+
fn run_on_jest_node<'a, 'c>(
199+
&self,
200+
possible_jest_node: &PossibleJestNode<'a, 'c>,
201+
ctx: &'c LintContext<'a>,
202+
) {
203+
let node = possible_jest_node.node;
204+
let AstKind::CallExpression(call_expr) = node.kind() else {
205+
return;
206+
};
207+
let Some(ParsedJestFnCallNew::GeneralJest(jest_fn_call)) =
208+
parse_jest_fn_call(call_expr, possible_jest_node, ctx)
209+
else {
210+
return;
211+
};
212+
213+
let scopes = ctx.scopes();
214+
215+
let ignores = Self::populate_ignores(&self.ignore);
216+
217+
if ignores.contains(&jest_fn_call.name.as_ref()) {
218+
return;
219+
}
220+
221+
if matches!(jest_fn_call.kind, JestFnKind::General(JestGeneralFnKind::Describe)) {
222+
if self.ignore_top_level_describe && scopes.get_flags(node.scope_id()).is_top() {
223+
return;
224+
}
225+
} else if !matches!(
226+
jest_fn_call.kind,
227+
JestFnKind::General(JestGeneralFnKind::Test | JestGeneralFnKind::Bench)
228+
) {
229+
return;
230+
}
231+
232+
let Some(arg) = call_expr.arguments.first() else {
233+
return;
234+
};
235+
236+
if let Argument::StringLiteral(string_expr) = arg {
237+
self.lint_string(ctx, string_expr.value.as_str(), string_expr.span);
238+
} else if let Argument::TemplateLiteral(template_expr) = arg {
239+
let Some(template_string) = template_expr.quasi() else {
240+
return;
241+
};
242+
self.lint_string(ctx, template_string.as_str(), template_expr.span);
243+
}
244+
}
245+
}
246+
247+
impl PreferLowercaseTitle {
248+
fn lint_string<'a>(&self, ctx: &LintContext<'a>, literal: &'a str, span: Span) {
249+
if literal.is_empty()
250+
|| self.allowed_prefixes.iter().any(|name| literal.starts_with(name.as_str()))
251+
{
252+
return;
253+
}
254+
255+
if self.lowercase_first_character_only {
256+
let Some(first_char) = literal.chars().next() else {
257+
return;
258+
};
259+
260+
let lower = first_char.to_ascii_lowercase();
261+
if first_char == lower {
262+
return;
263+
}
264+
} else {
265+
for n in 0..literal.chars().count() {
266+
let Some(next_char) = literal.chars().nth(n) else {
267+
return;
268+
};
269+
270+
let next_lower = next_char.to_ascii_lowercase();
271+
272+
if next_char != next_lower {
273+
break;
274+
}
275+
}
276+
}
277+
278+
let replacement = if self.lowercase_first_character_only {
279+
cow_utils::CowUtils::cow_to_ascii_lowercase(&literal.chars().as_str()[0..1])
280+
} else {
281+
cow_utils::CowUtils::cow_to_ascii_lowercase(literal)
282+
};
283+
284+
#[allow(clippy::cast_possible_truncation)]
285+
let replacement_len = replacement.len() as u32;
286+
287+
ctx.diagnostic_with_fix(prefer_lowercase_title_diagnostic(literal, span), |fixer| {
288+
fixer.replace(Span::sized(span.start + 1, replacement_len), replacement)
289+
});
290+
}
291+
292+
fn populate_ignores(ignore: &[CompactStr]) -> Vec<&str> {
293+
let mut ignores: Vec<&str> = vec![];
294+
let test_case_name = ["fit", "it", "xit", "test", "xtest"];
295+
let describe_alias = ["describe", "fdescribe", "xdescribe"];
296+
let test_name = "test";
297+
let it_name = "it";
298+
let bench_name = "bench";
299+
300+
if ignore.iter().any(|alias| alias == "describe") {
301+
ignores.extend(describe_alias.iter());
302+
}
303+
304+
if ignore.iter().any(|alias| alias == bench_name) {
305+
ignores.push(bench_name);
306+
}
307+
308+
if ignore.iter().any(|alias| alias == test_name) {
309+
ignores.extend(test_case_name.iter().filter(|alias| alias.ends_with(test_name)));
310+
}
311+
312+
if ignore.iter().any(|alias| alias == it_name) {
313+
ignores.extend(test_case_name.iter().filter(|alias| alias.ends_with(it_name)));
314+
}
315+
316+
ignores
317+
}
318+
}

0 commit comments

Comments
 (0)