-
-
Notifications
You must be signed in to change notification settings - Fork 501
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
9 changed files
with
405 additions
and
254 deletions.
There are no files selected for viewing
318 changes: 318 additions & 0 deletions
318
crates/oxc_linter/src/rules/jest/prefer_lowercase_title/mod.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,318 @@ | ||
use oxc_ast::{ast::Argument, AstKind}; | ||
use oxc_diagnostics::OxcDiagnostic; | ||
use oxc_macros::declare_oxc_lint; | ||
use oxc_span::{CompactStr, Span}; | ||
|
||
#[cfg(test)] | ||
mod tests; | ||
|
||
use crate::{ | ||
context::LintContext, | ||
rule::Rule, | ||
utils::{ | ||
parse_jest_fn_call, JestFnKind, JestGeneralFnKind, ParsedJestFnCallNew, PossibleJestNode, | ||
}, | ||
}; | ||
|
||
fn prefer_lowercase_title_diagnostic(title: &str, span: Span) -> OxcDiagnostic { | ||
OxcDiagnostic::warn("Enforce lowercase test names") | ||
.with_help(format!("`{title:?}`s should begin with lowercase")) | ||
.with_label(span) | ||
} | ||
|
||
#[derive(Debug, Default, Clone)] | ||
pub struct PreferLowercaseTitleConfig { | ||
allowed_prefixes: Vec<CompactStr>, | ||
ignore: Vec<CompactStr>, | ||
ignore_top_level_describe: bool, | ||
lowercase_first_character_only: bool, | ||
} | ||
|
||
impl std::ops::Deref for PreferLowercaseTitle { | ||
type Target = PreferLowercaseTitleConfig; | ||
|
||
fn deref(&self) -> &Self::Target { | ||
&self.0 | ||
} | ||
} | ||
|
||
#[derive(Debug, Default, Clone)] | ||
pub struct PreferLowercaseTitle(Box<PreferLowercaseTitleConfig>); | ||
|
||
declare_oxc_lint!( | ||
/// ### What it does | ||
/// | ||
/// Enforce `it`, `test`, `describe`, and `bench` to have descriptions that begin with a | ||
/// lowercase letter. This provides more readable test failures. This rule is not | ||
/// enabled by default. | ||
/// | ||
/// ### Example | ||
/// | ||
/// ```javascript | ||
/// // invalid | ||
/// it('Adds 1 + 2 to equal 3', () => { | ||
/// expect(sum(1, 2)).toBe(3); | ||
/// }); | ||
/// | ||
/// // valid | ||
/// it('adds 1 + 2 to equal 3', () => { | ||
/// expect(sum(1, 2)).toBe(3); | ||
/// }); | ||
/// ``` | ||
/// | ||
/// ## Options | ||
/// ```json | ||
/// { | ||
/// "jest/prefer-lowercase-title": [ | ||
/// "error", | ||
/// { | ||
/// "ignore": ["describe", "test"] | ||
/// } | ||
/// ] | ||
/// } | ||
/// ``` | ||
/// | ||
/// ### `ignore` | ||
/// | ||
/// This array option controls which Jest or Vitest functions are checked by this rule. There | ||
/// are four possible values: | ||
/// - `"describe"` | ||
/// - `"test"` | ||
/// - `"it"` | ||
/// - `"bench"` | ||
/// | ||
/// By default, none of these options are enabled (the equivalent of | ||
/// `{ "ignore": [] }`). | ||
/// | ||
/// Example of **correct** code for the `{ "ignore": ["describe"] }` option: | ||
/// ```js | ||
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignore": ["describe"] }] */ | ||
/// describe('Uppercase description'); | ||
/// ``` | ||
/// | ||
/// Example of **correct** code for the `{ "ignore": ["test"] }` option: | ||
/// | ||
/// ```js | ||
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignore": ["test"] }] */ | ||
/// test('Uppercase description'); | ||
/// ``` | ||
/// | ||
/// Example of **correct** code for the `{ "ignore": ["it"] }` option: | ||
/// ```js | ||
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignore": ["it"] }] */ | ||
/// it('Uppercase description'); | ||
/// ``` | ||
/// | ||
/// ### `allowedPrefixes` | ||
/// This array option allows specifying prefixes, which contain capitals that titles | ||
/// can start with. This can be useful when writing tests for API endpoints, where | ||
/// you'd like to prefix with the HTTP method. | ||
/// By default, nothing is allowed (the equivalent of `{ "allowedPrefixes": [] }`). | ||
/// | ||
/// Example of **correct** code for the `{ "allowedPrefixes": ["GET"] }` option: | ||
/// ```js | ||
/// /* eslint jest/prefer-lowercase-title: ["error", { "allowedPrefixes": ["GET"] }] */ | ||
/// describe('GET /live'); | ||
/// ``` | ||
/// | ||
/// ### `ignoreTopLevelDescribe` | ||
/// This option can be set to allow only the top-level `describe` blocks to have a | ||
/// title starting with an upper-case letter. | ||
/// Example of **correct** code for the `{ "ignoreTopLevelDescribe": true }` option: | ||
/// | ||
/// ```js | ||
/// /* eslint jest/prefer-lowercase-title: ["error", { "ignoreTopLevelDescribe": true }] */ | ||
/// describe('MyClass', () => { | ||
/// describe('#myMethod', () => { | ||
/// it('does things', () => { | ||
/// // | ||
/// }); | ||
/// }); | ||
/// }); | ||
/// ``` | ||
/// | ||
/// ### `lowercaseFirstCharacterOnly` | ||
/// This option can be set to only validate that the first character of a test name is lowercased. | ||
/// | ||
/// Example of **correct** code for the `{ "lowercaseFirstCharacterOnly": true }` option: | ||
/// | ||
/// ```js | ||
/// /* eslint vitest/prefer-lowercase-title: ["error", { "lowercaseFirstCharacterOnly": true }] */ | ||
/// describe('myClass', () => { | ||
/// describe('myMethod', () => { | ||
/// it('does things', () => { | ||
/// // | ||
/// }); | ||
/// }); | ||
/// }); | ||
/// ``` | ||
/// | ||
/// Example of **incorrect** code for the `{ "lowercaseFirstCharacterOnly": true }` option: | ||
/// | ||
/// ```js | ||
/// /* eslint vitest/prefer-lowercase-title: ["error", { "lowercaseFirstCharacterOnly": true }] */ | ||
/// describe('MyClass', () => { | ||
/// describe('MyMethod', () => { | ||
/// it('does things', () => { | ||
/// // | ||
/// }); | ||
/// }); | ||
/// }); | ||
/// ``` | ||
PreferLowercaseTitle, | ||
jest, | ||
style, | ||
fix | ||
); | ||
|
||
impl Rule for PreferLowercaseTitle { | ||
fn from_configuration(value: serde_json::Value) -> Self { | ||
let obj = value.get(0); | ||
let ignore_top_level_describe = obj | ||
.and_then(|config| config.get("ignoreTopLevelDescribe")) | ||
.and_then(serde_json::Value::as_bool) | ||
.unwrap_or(false); | ||
let lowercase_first_character_only = obj | ||
.and_then(|config| config.get("lowercaseFirstCharacterOnly")) | ||
.and_then(serde_json::Value::as_bool) | ||
.unwrap_or(true); | ||
let ignore = obj | ||
.and_then(|config| config.get("ignore")) | ||
.and_then(serde_json::Value::as_array) | ||
.map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect()) | ||
.unwrap_or_default(); | ||
let allowed_prefixes = obj | ||
.and_then(|config| config.get("allowedPrefixes")) | ||
.and_then(serde_json::Value::as_array) | ||
.map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect()) | ||
.unwrap_or_default(); | ||
|
||
Self(Box::new(PreferLowercaseTitleConfig { | ||
allowed_prefixes, | ||
ignore, | ||
ignore_top_level_describe, | ||
lowercase_first_character_only, | ||
})) | ||
} | ||
|
||
fn run_on_jest_node<'a, 'c>( | ||
&self, | ||
possible_jest_node: &PossibleJestNode<'a, 'c>, | ||
ctx: &'c LintContext<'a>, | ||
) { | ||
let node = possible_jest_node.node; | ||
let AstKind::CallExpression(call_expr) = node.kind() else { | ||
return; | ||
}; | ||
let Some(ParsedJestFnCallNew::GeneralJest(jest_fn_call)) = | ||
parse_jest_fn_call(call_expr, possible_jest_node, ctx) | ||
else { | ||
return; | ||
}; | ||
|
||
let scopes = ctx.scopes(); | ||
|
||
let ignores = Self::populate_ignores(&self.ignore); | ||
|
||
if ignores.contains(&jest_fn_call.name.as_ref()) { | ||
return; | ||
} | ||
|
||
if matches!(jest_fn_call.kind, JestFnKind::General(JestGeneralFnKind::Describe)) { | ||
if self.ignore_top_level_describe && scopes.get_flags(node.scope_id()).is_top() { | ||
return; | ||
} | ||
} else if !matches!( | ||
jest_fn_call.kind, | ||
JestFnKind::General(JestGeneralFnKind::Test | JestGeneralFnKind::Bench) | ||
) { | ||
return; | ||
} | ||
|
||
let Some(arg) = call_expr.arguments.first() else { | ||
return; | ||
}; | ||
|
||
if let Argument::StringLiteral(string_expr) = arg { | ||
self.lint_string(ctx, string_expr.value.as_str(), string_expr.span); | ||
} else if let Argument::TemplateLiteral(template_expr) = arg { | ||
let Some(template_string) = template_expr.quasi() else { | ||
return; | ||
}; | ||
self.lint_string(ctx, template_string.as_str(), template_expr.span); | ||
} | ||
} | ||
} | ||
|
||
impl PreferLowercaseTitle { | ||
fn lint_string<'a>(&self, ctx: &LintContext<'a>, literal: &'a str, span: Span) { | ||
if literal.is_empty() | ||
|| self.allowed_prefixes.iter().any(|name| literal.starts_with(name.as_str())) | ||
{ | ||
return; | ||
} | ||
|
||
if self.lowercase_first_character_only { | ||
let Some(first_char) = literal.chars().next() else { | ||
return; | ||
}; | ||
|
||
let lower = first_char.to_ascii_lowercase(); | ||
if first_char == lower { | ||
return; | ||
} | ||
} else { | ||
for n in 0..literal.chars().count() { | ||
let Some(next_char) = literal.chars().nth(n) else { | ||
return; | ||
}; | ||
|
||
let next_lower = next_char.to_ascii_lowercase(); | ||
|
||
if next_char != next_lower { | ||
break; | ||
} | ||
} | ||
} | ||
|
||
let replacement = if self.lowercase_first_character_only { | ||
cow_utils::CowUtils::cow_to_ascii_lowercase(&literal.chars().as_str()[0..1]) | ||
} else { | ||
cow_utils::CowUtils::cow_to_ascii_lowercase(literal) | ||
}; | ||
|
||
#[allow(clippy::cast_possible_truncation)] | ||
let replacement_len = replacement.len() as u32; | ||
|
||
ctx.diagnostic_with_fix(prefer_lowercase_title_diagnostic(literal, span), |fixer| { | ||
fixer.replace(Span::sized(span.start + 1, replacement_len), replacement) | ||
}); | ||
} | ||
|
||
fn populate_ignores(ignore: &[CompactStr]) -> Vec<&str> { | ||
let mut ignores: Vec<&str> = vec![]; | ||
let test_case_name = ["fit", "it", "xit", "test", "xtest"]; | ||
let describe_alias = ["describe", "fdescribe", "xdescribe"]; | ||
let test_name = "test"; | ||
let it_name = "it"; | ||
let bench_name = "bench"; | ||
|
||
if ignore.iter().any(|alias| alias == "describe") { | ||
ignores.extend(describe_alias.iter()); | ||
} | ||
|
||
if ignore.iter().any(|alias| alias == bench_name) { | ||
ignores.push(bench_name); | ||
} | ||
|
||
if ignore.iter().any(|alias| alias == test_name) { | ||
ignores.extend(test_case_name.iter().filter(|alias| alias.ends_with(test_name))); | ||
} | ||
|
||
if ignore.iter().any(|alias| alias == it_name) { | ||
ignores.extend(test_case_name.iter().filter(|alias| alias.ends_with(it_name))); | ||
} | ||
|
||
ignores | ||
} | ||
} |
Oops, something went wrong.