Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): add vitest/prefer-lowercase-title rule #8152

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
cfcb46d
gen prefer lowercase title
taearls Dec 19, 2024
c80725c
populate some of the rule logic
taearls Dec 19, 2024
c2ceb26
add more logic
taearls Dec 19, 2024
3cf34b6
add missing import
taearls Dec 19, 2024
231c2ef
fix import
taearls Dec 19, 2024
53758bc
run formatter
taearls Dec 19, 2024
039d872
use run on jest node
taearls Dec 19, 2024
3e10a10
fix typo
taearls Dec 19, 2024
01681cd
run rule
taearls Dec 19, 2024
521693e
rename file
taearls Dec 19, 2024
be4bc3d
wip
taearls Dec 22, 2024
1e42f86
Merge branch 'main' into tyler/vitest-prefer-lowercase-title
taearls Dec 22, 2024
55abaaf
get closer to fix
taearls Dec 22, 2024
babaeee
run just fix
taearls Dec 22, 2024
c17aa4c
enable fail tests
taearls Dec 24, 2024
3c341fb
add debug to cargo toml
taearls Dec 26, 2024
e5acf73
Merge branch 'main' into tyler/vitest-prefer-lowercase-title
taearls Dec 26, 2024
3f77885
explicitly get first char
taearls Dec 26, 2024
29231ea
get tests working
taearls Dec 26, 2024
defbbd0
get all tests passing
taearls Dec 26, 2024
a748638
remove unused imporst
taearls Dec 26, 2024
5d39ced
code cleanup
taearls Dec 26, 2024
fc82ed0
code cleanup
taearls Dec 26, 2024
52dafc2
remove dbg calls
taearls Dec 26, 2024
dcb5e63
refactor
taearls Dec 26, 2024
0a920e5
remove unused import
taearls Dec 26, 2024
976f5ea
add ignores support
taearls Dec 26, 2024
24c9e9d
fix lint warning
taearls Dec 26, 2024
c81bf79
undo cargo toml changes, remove added line
taearls Dec 27, 2024
4d87a38
revert cargo toml change
taearls Dec 27, 2024
e927b0a
migrate rule
taearls Dec 27, 2024
716c741
migrate tests
taearls Dec 27, 2024
f3f9135
code cleanup
taearls Dec 27, 2024
4edc087
rename fn variant to bench
taearls Dec 27, 2024
ebe80cf
update documentation
taearls Dec 27, 2024
b637088
formatting
taearls Dec 27, 2024
99b7481
clean up formatting
taearls Dec 27, 2024
37e2564
add bench to ignores
taearls Dec 27, 2024
1f8c369
formatting
taearls Dec 27, 2024
146d6fb
remove unneeded snapshots
taearls Dec 28, 2024
6fb1084
remove vitest util, update var name to possible_jest_node
taearls Dec 28, 2024
f0a1459
formatting
taearls Dec 28, 2024
f480c4a
add prefer-lowercase-title rule to VITEST_COMPATIBLE_JEST_RULES
taearls Dec 28, 2024
16216ed
update snapshot
taearls Dec 28, 2024
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
317 changes: 317 additions & 0 deletions crates/oxc_linter/src/rules/jest/prefer_lowercase_title/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
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,
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
}
}
Loading
Loading