diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c86aea6ca9..f3f6bc27fd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b Contributed by @Conaclos - Add [nursery/noIrregularWhitespace](https://biomejs.dev/linter/rules/no-irregular-whitespace). Contributed by @michellocana +- Add [nursery/useTrimStartEnd](https://biomejs.dev/linter/rules/use-trim-start-end/). Contributed by @chansuke #### Enhancements diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs index 1c914b1d625e..1605aded2ca2 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs @@ -1488,6 +1488,14 @@ pub(crate) fn migrate_eslint_any_rule( let rule = group.no_substr.get_or_insert(Default::default()); rule.set_level(rule_severity.into()); } + "unicorn/prefer-string-trim-start-end" => { + if !options.include_nursery { + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group.use_trim_start_end.get_or_insert(Default::default()); + rule.set_level(rule_severity.into()); + } "unicorn/require-number-to-fixed-digits-argument" => { if !options.include_nursery { return false; diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index a2859dc56572..8789170e1bd6 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2984,6 +2984,9 @@ pub struct Nursery { #[doc = "Require regex literals to be declared at the top level."] #[serde(skip_serializing_if = "Option::is_none")] pub use_top_level_regex: Option>, + #[doc = "Enforce the use of String.trimStart() and String.trimEnd() over String.trimLeft() and String.trimRight()."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_trim_start_end: Option>, #[doc = "Use valid values for the autocomplete attribute on input elements."] #[serde(skip_serializing_if = "Option::is_none")] pub use_valid_autocomplete: Option>, @@ -3060,6 +3063,7 @@ impl Nursery { "useThrowNewError", "useThrowOnlyError", "useTopLevelRegex", + "useTrimStartEnd", "useValidAutocomplete", ]; const RECOMMENDED_RULES: &'static [&'static str] = &[ @@ -3171,6 +3175,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -3462,11 +3467,16 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -3746,11 +3756,16 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -4007,6 +4022,10 @@ impl Nursery { .use_top_level_regex .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "useTrimStartEnd" => self + .use_trim_start_end + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "useValidAutocomplete" => self .use_valid_autocomplete .as_ref() diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 42c416eb051c..3909ca509cc1 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -173,6 +173,7 @@ define_categories! { "lint/nursery/useThrowNewError": "https://biomejs.dev/linter/rules/use-throw-new-error", "lint/nursery/useThrowOnlyError": "https://biomejs.dev/linter/rules/use-throw-only-error", "lint/nursery/useTopLevelRegex": "https://biomejs.dev/linter/rules/use-top-level-regex", + "lint/nursery/useTrimStartEnd": "https://biomejs.dev/linter/rules/use-trim-start-end", "lint/nursery/useValidAutocomplete": "https://biomejs.dev/linter/rules/use-valid-autocomplete", "lint/performance/noAccumulatingSpread": "https://biomejs.dev/linter/rules/no-accumulating-spread", "lint/performance/noBarrelFile": "https://biomejs.dev/linter/rules/no-barrel-file", diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index b67667ecb6c3..6bd9825bc64f 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -37,6 +37,7 @@ pub mod use_strict_mode; pub mod use_throw_new_error; pub mod use_throw_only_error; pub mod use_top_level_regex; +pub mod use_trim_start_end; pub mod use_valid_autocomplete; declare_lint_group! { @@ -78,6 +79,7 @@ declare_lint_group! { self :: use_throw_new_error :: UseThrowNewError , self :: use_throw_only_error :: UseThrowOnlyError , self :: use_top_level_regex :: UseTopLevelRegex , + self :: use_trim_start_end :: UseTrimStartEnd , self :: use_valid_autocomplete :: UseValidAutocomplete , ] } diff --git a/crates/biome_js_analyze/src/lint/nursery/use_trim_start_end.rs b/crates/biome_js_analyze/src/lint/nursery/use_trim_start_end.rs new file mode 100644 index 000000000000..26d0774228e7 --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/use_trim_start_end.rs @@ -0,0 +1,282 @@ +use biome_analyze::{ + context::RuleContext, declare_lint_rule, ActionCategory, Ast, FixKind, Rule, RuleDiagnostic, + RuleSource, +}; +use biome_console::markup; +use biome_js_factory::make::{self}; +use biome_js_syntax::{ + AnyJsExpression, AnyJsLiteralExpression, AnyJsName, AnyJsTemplateElement, JsCallExpression, + JsComputedMemberExpression, JsLanguage, JsSyntaxKind, JsSyntaxToken, JsTemplateExpression, +}; +use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, SyntaxToken, TextRange}; + +use crate::JsRuleAction; + +declare_lint_rule! { + /// Enforce the use of `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. + /// + /// While `String.trimLeft()` and `String.trimRight()` are aliases for `String.trimStart()` and `String.trimEnd()`, + /// only using the latter pair ensures consistency and is preferable for their direction-independent wording. + /// + /// Note that `String.trimStart()` and `String.trimEnd()` methods do not take any parameters. Any arguments passed to these methods will be ignored. + /// See the MDN documentation for more details: + /// - [String.prototype.trimStart()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart) + /// - [String.prototype.trimEnd()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd) + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```js,expect_diagnostic + /// const foo = bar.trimLeft(); + /// ``` + /// + /// ```js,expect_diagnostic + /// const foo = bar.trimRight(); + /// ``` + /// + /// ### Valid + /// + /// ```js + /// const foo = bar.trimStart(); + /// ``` + /// + /// ```js + /// const foo = bar.trimEnd(); + /// ``` + /// + pub UseTrimStartEnd { + version: "next", + name: "useTrimStartEnd", + language: "js", + recommended: false, + sources: &[RuleSource::EslintUnicorn("prefer-string-trim-start-end")], + fix_kind: FixKind::Safe, + } +} + +#[derive(Debug, Clone)] +pub struct UseTrimStartEndState { + member_name: String, + span: TextRange, +} + +impl Rule for UseTrimStartEnd { + type Query = Ast; + type State = UseTrimStartEndState; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let node = ctx.query(); + let arguments = node.arguments().ok()?; + let args = arguments.args(); + + if !args.is_empty() { + // If arguments are present, it suggests this function call may not be intended for `String.trimStart()` or `String.trimEnd()`, + // as these methods do not accept parameters according to the specification: + // - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart#parameters + // - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd#parameters + return None; + } + + let callee = node.callee().ok()?; + let (member_name, span) = match callee { + AnyJsExpression::JsComputedMemberExpression(callee) => { + let member = callee.member().ok()?; + let value = member.as_static_value()?; + let span = value.range(); + let member_name = value.as_string_constant()?.to_string(); + (member_name, span) + } + AnyJsExpression::JsStaticMemberExpression(callee) => { + let token = callee.member().ok()?.value_token().ok()?; + let span = token.text_range(); + let member_name = token.text_trimmed().to_string(); + (member_name, span) + } + _ => return None, + }; + + Some(UseTrimStartEndState { member_name, span }) + } + + fn diagnostic(_: &RuleContext, state: &Self::State) -> Option { + let suggested_name = generate_suggested_name(&state.member_name)?; + + let diagnostic_message = markup! { + "Use "{suggested_name}" instead of "{state.member_name}"." + } + .to_owned(); + let note_message = { + markup! { + ""{state.member_name}" is an alias for "{suggested_name}"." + } + .to_owned() + }; + + Some( + RuleDiagnostic::new(rule_category!(), state.span, diagnostic_message) + .note(note_message), + ) + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + let node = ctx.query(); + let callee = node.callee().ok()?; + + let is_computed_member = JsComputedMemberExpression::can_cast(callee.syntax().kind()); + let computed_member_expression_opt = if is_computed_member { + callee.as_js_computed_member_expression() + } else { + None + }; + let is_template = if is_computed_member { + if let Ok(computed_member) = computed_member_expression_opt?.member() { + JsTemplateExpression::can_cast(computed_member.syntax().kind()) + } else { + false + } + } else { + false + }; + // Need to keep the original token to replace it with the new token. + // `.as_static_value()` strips the information of tick tokens. + let token = extract_token_from_expression(callee.clone())?; + let replaced_member_name = suggested_name(&token)?; + + let mut elements = vec![]; + let template_elements = AnyJsTemplateElement::from(make::js_template_chunk_element( + make::js_template_chunk(replaced_member_name.as_str()), + )); + elements.push(template_elements); + + let callee_object = match callee { + AnyJsExpression::JsStaticMemberExpression(ref expression) => expression.object().ok(), + AnyJsExpression::JsComputedMemberExpression(ref expression) => expression.object().ok(), + _ => None, + }; + + let transformed_expression = if is_template { + AnyJsExpression::JsTemplateExpression( + make::js_template_expression( + computed_member_expression_opt? + .member() + .ok()? + .as_js_template_expression()? + .l_tick_token() + .ok()?, + make::js_template_element_list(elements), + computed_member_expression_opt? + .member() + .ok()? + .as_js_template_expression()? + .r_tick_token() + .ok()?, + ) + .build(), + ) + } else { + AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsStringLiteralExpression( + make::js_string_literal_expression(JsSyntaxToken::new_detached( + // Need to handle "'text'" and "\"text\"". + // make::js_string_literal() call the function that format the text as below: + // > &format!("\"{text}\"") + JsSyntaxKind::JS_STRING_LITERAL, + &replaced_member_name, + [], + [], + )), + ), + ) + }; + + let call_expression = if is_computed_member { + AnyJsExpression::JsComputedMemberExpression( + make::js_computed_member_expression( + callee_object?, + computed_member_expression_opt?.l_brack_token().ok()?, + transformed_expression, + computed_member_expression_opt?.r_brack_token().ok()?, + ) + .build(), + ) + } else { + AnyJsExpression::JsStaticMemberExpression(make::js_static_member_expression( + callee_object?, + callee + .as_js_static_member_expression()? + .operator_token() + .ok()?, + AnyJsName::JsName(make::js_name(make::ident(&replaced_member_name))), + )) + }; + + let mut mutation = ctx.root().begin(); + mutation.replace_node(callee, call_expression); + + Some(JsRuleAction::new( + ActionCategory::QuickFix, + ctx.metadata().applicability(), + markup! { "Replace "{state.member_name}" with "{replaced_member_name}"." } + .to_owned(), + mutation, + )) + } +} + +fn extract_token_from_expression(callee: AnyJsExpression) -> Option> { + let token = if let AnyJsExpression::JsComputedMemberExpression(expression) = callee { + let member = expression.member().ok()?; + match member { + AnyJsExpression::AnyJsLiteralExpression(literal) => literal.value_token().ok(), + AnyJsExpression::JsTemplateExpression(element) => { + element.elements().into_iter().find_map(|x| { + x.as_js_template_chunk_element() + .and_then(|chunk| chunk.template_chunk_token().ok()) + }) + } + _ => None, + } + } else if let AnyJsExpression::JsStaticMemberExpression(expression) = callee { + expression.member().ok()?.value_token().ok() + } else { + None + }; + token +} + +// Handle "'text'" and "\"text\"" and "text" cases +fn suggested_name(text: &SyntaxToken) -> Option { + let trimmed = text.text_trimmed(); + + let is_single_quoted = trimmed.starts_with('\'') && trimmed.ends_with('\''); + let is_double_quoted = trimmed.starts_with('"') && trimmed.ends_with('"'); + + let unquoted = if is_single_quoted { + trimmed.trim_matches('\'') + } else if is_double_quoted { + trimmed.trim_matches('"') + } else { + trimmed + }; + + generate_suggested_name(unquoted).map(|suggested_name| { + if is_single_quoted { + format!("'{}'", suggested_name) + } else if is_double_quoted { + format!("\"{}\"", suggested_name) + } else { + suggested_name.to_string() + } + }) +} + +fn generate_suggested_name(member_name: &str) -> Option<&str> { + match member_name { + "trimLeft" => Some("trimStart"), + "trimRight" => Some("trimEnd"), + _ => None, + } +} diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index 42faf69b7994..0a23b9b0481f 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -363,6 +363,8 @@ pub type UseThrowOnlyError = ::Options; pub type UseTopLevelRegex = ::Options; +pub type UseTrimStartEnd = + ::Options; pub type UseValidAnchor = ::Options; pub type UseValidAriaProps = diff --git a/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/invalid.js b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/invalid.js new file mode 100644 index 000000000000..d4edefaa8e65 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/invalid.js @@ -0,0 +1,13 @@ +foo.trimLeft() +foo.trimRight() +trimLeft.trimRight() +foo.trimLeft.trimRight() +"foo".trimLeft() +foo + // comment + .trimRight /* comment */ + /* comment */ + () +bar['trimLeft']() +bar["trimLeft"]() +bar[`trimLeft`]() diff --git a/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/invalid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/invalid.js.snap new file mode 100644 index 000000000000..1a3afc2fb5bf --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/invalid.js.snap @@ -0,0 +1,248 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid.js +--- +# Input +```jsx +foo.trimLeft() +foo.trimRight() +trimLeft.trimRight() +foo.trimLeft.trimRight() +"foo".trimLeft() +foo + // comment + .trimRight /* comment */ + /* comment */ + () +bar['trimLeft']() +bar["trimLeft"]() +bar[`trimLeft`]() + +``` + +# Diagnostics +``` +invalid.js:1:5 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimStart instead of trimLeft. + + > 1 │ foo.trimLeft() + │ ^^^^^^^^ + 2 │ foo.trimRight() + 3 │ trimLeft.trimRight() + + i trimLeft is an alias for trimStart. + + i Safe fix: Replace trimLeft with trimStart. + + 1 │ - foo.trimLeft() + 1 │ + foo.trimStart() + 2 2 │ foo.trimRight() + 3 3 │ trimLeft.trimRight() + + +``` + +``` +invalid.js:2:5 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimEnd instead of trimRight. + + 1 │ foo.trimLeft() + > 2 │ foo.trimRight() + │ ^^^^^^^^^ + 3 │ trimLeft.trimRight() + 4 │ foo.trimLeft.trimRight() + + i trimRight is an alias for trimEnd. + + i Safe fix: Replace trimRight with trimEnd. + + 1 1 │ foo.trimLeft() + 2 │ - foo.trimRight() + 2 │ + foo.trimEnd() + 3 3 │ trimLeft.trimRight() + 4 4 │ foo.trimLeft.trimRight() + + +``` + +``` +invalid.js:3:10 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimEnd instead of trimRight. + + 1 │ foo.trimLeft() + 2 │ foo.trimRight() + > 3 │ trimLeft.trimRight() + │ ^^^^^^^^^ + 4 │ foo.trimLeft.trimRight() + 5 │ "foo".trimLeft() + + i trimRight is an alias for trimEnd. + + i Safe fix: Replace trimRight with trimEnd. + + 1 1 │ foo.trimLeft() + 2 2 │ foo.trimRight() + 3 │ - trimLeft.trimRight() + 3 │ + trimLeft.trimEnd() + 4 4 │ foo.trimLeft.trimRight() + 5 5 │ "foo".trimLeft() + + +``` + +``` +invalid.js:4:14 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimEnd instead of trimRight. + + 2 │ foo.trimRight() + 3 │ trimLeft.trimRight() + > 4 │ foo.trimLeft.trimRight() + │ ^^^^^^^^^ + 5 │ "foo".trimLeft() + 6 │ foo + + i trimRight is an alias for trimEnd. + + i Safe fix: Replace trimRight with trimEnd. + + 2 2 │ foo.trimRight() + 3 3 │ trimLeft.trimRight() + 4 │ - foo.trimLeft.trimRight() + 4 │ + foo.trimLeft.trimEnd() + 5 5 │ "foo".trimLeft() + 6 6 │ foo + + +``` + +``` +invalid.js:5:7 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimStart instead of trimLeft. + + 3 │ trimLeft.trimRight() + 4 │ foo.trimLeft.trimRight() + > 5 │ "foo".trimLeft() + │ ^^^^^^^^ + 6 │ foo + 7 │ // comment + + i trimLeft is an alias for trimStart. + + i Safe fix: Replace trimLeft with trimStart. + + 3 3 │ trimLeft.trimRight() + 4 4 │ foo.trimLeft.trimRight() + 5 │ - "foo".trimLeft() + 5 │ + "foo".trimStart() + 6 6 │ foo + 7 7 │ // comment + + +``` + +``` +invalid.js:8:3 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimEnd instead of trimRight. + + 6 │ foo + 7 │ // comment + > 8 │ .trimRight /* comment */ + │ ^^^^^^^^^^^^^^^^^^^^^^^ + 9 │ /* comment */ + 10 │ () + + i trimRight is an alias for trimEnd. + + i Safe fix: Replace trimRight with trimEnd. + + 6 6 │ foo + 7 7 │ // comment + 8 │ - → .trimRight·/*·comment·*/ + 8 │ + → .trimEnd·/*·comment·*/ + 9 9 │ /* comment */ + 10 10 │ () + + +``` + +``` +invalid.js:11:5 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimStart instead of trimLeft. + + 9 │ /* comment */ + 10 │ () + > 11 │ bar['trimLeft']() + │ ^^^^^^^^^^ + 12 │ bar["trimLeft"]() + 13 │ bar[`trimLeft`]() + + i trimLeft is an alias for trimStart. + + i Safe fix: Replace trimLeft with 'trimStart'. + + 9 9 │ /* comment */ + 10 10 │ () + 11 │ - bar['trimLeft']() + 11 │ + bar['trimStart']() + 12 12 │ bar["trimLeft"]() + 13 13 │ bar[`trimLeft`]() + + +``` + +``` +invalid.js:12:5 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimStart instead of trimLeft. + + 10 │ () + 11 │ bar['trimLeft']() + > 12 │ bar["trimLeft"]() + │ ^^^^^^^^^^ + 13 │ bar[`trimLeft`]() + 14 │ + + i trimLeft is an alias for trimStart. + + i Safe fix: Replace trimLeft with "trimStart". + + 10 10 │ () + 11 11 │ bar['trimLeft']() + 12 │ - bar["trimLeft"]() + 12 │ + bar["trimStart"]() + 13 13 │ bar[`trimLeft`]() + 14 14 │ + + +``` + +``` +invalid.js:13:6 lint/nursery/useTrimStartEnd FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Use trimStart instead of trimLeft. + + 11 │ bar['trimLeft']() + 12 │ bar["trimLeft"]() + > 13 │ bar[`trimLeft`]() + │ ^^^^^^^^ + 14 │ + + i trimLeft is an alias for trimStart. + + i Safe fix: Replace trimLeft with trimStart. + + 11 11 │ bar['trimLeft']() + 12 12 │ bar["trimLeft"]() + 13 │ - bar[`trimLeft`]() + 13 │ + bar[`trimStart`]() + 14 14 │ + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js new file mode 100644 index 000000000000..0ee8a8cb289e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js @@ -0,0 +1,28 @@ +const foo = bar.trimStart(); +const foo = bar.trimEnd(); +bar.trimStart?.() +foo.trimStart(), +foo.trimStart?.(), +foo.trimEnd(), +// Not `CallExpression` +new foo.trimLeft();, +// Not `MemberExpression` +trimLeft();, +// `callee.property` is not a `Identifier` +foo[\'trimLeft\']();', +// Computed +foo[trimLeft]();, +// Not `trimLeft`/`trimRight` +foo.bar();, +// More argument(s) +foo.trimLeft(extra);, +foo.trimLeft(...argumentsArray), +// `trimLeft` is in argument +foo.bar(trimLeft), +foo.bar(foo.trimLeft), +// `trimLeft` is in `MemberExpression.object` +trimLeft.foo(), +foo.trimLeft.bar(), +bar['trimStart']() +bar["trimStart"]() +bar[`trimStart`]() diff --git a/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js.snap index c003376f1784..7bebf5c3c195 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/useTrimStartEnd/valid.js.snap @@ -7,6 +7,30 @@ expression: valid.js const foo = bar.trimStart(); const foo = bar.trimEnd(); bar.trimStart?.() +foo.trimStart(), +foo.trimStart?.(), +foo.trimEnd(), +// Not `CallExpression` +new foo.trimLeft();, +// Not `MemberExpression` +trimLeft();, +// `callee.property` is not a `Identifier` +foo[\'trimLeft\']();', +// Computed +foo[trimLeft]();, +// Not `trimLeft`/`trimRight` +foo.bar();, +// More argument(s) +foo.trimLeft(extra);, +foo.trimLeft(...argumentsArray), +// `trimLeft` is in argument +foo.bar(trimLeft), +foo.bar(foo.trimLeft), +// `trimLeft` is in `MemberExpression.object` +trimLeft.foo(), +foo.trimLeft.bar(), bar['trimStart']() +bar["trimStart"]() +bar[`trimStart`]() ``` diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 7c2374257e9b..188225911559 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1273,6 +1273,10 @@ export interface Nursery { * Require regex literals to be declared at the top level. */ useTopLevelRegex?: RuleConfiguration_for_Null; + /** + * Enforce the use of String.trimStart() and String.trimEnd() over String.trimLeft() and String.trimRight(). + */ + useTrimStartEnd?: RuleFixConfiguration_for_Null; /** * Use valid values for the autocomplete attribute on input elements. */ @@ -2567,6 +2571,7 @@ export type Category = | "lint/nursery/useThrowNewError" | "lint/nursery/useThrowOnlyError" | "lint/nursery/useTopLevelRegex" + | "lint/nursery/useTrimStartEnd" | "lint/nursery/useValidAutocomplete" | "lint/performance/noAccumulatingSpread" | "lint/performance/noBarrelFile" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index edfb0ae35681..33d5c469cc63 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -2147,6 +2147,13 @@ { "type": "null" } ] }, + "useTrimStartEnd": { + "description": "Enforce the use of String.trimStart() and String.trimEnd() over String.trimLeft() and String.trimRight().", + "anyOf": [ + { "$ref": "#/definitions/RuleFixConfiguration" }, + { "type": "null" } + ] + }, "useValidAutocomplete": { "description": "Use valid values for the autocomplete attribute on input elements.", "anyOf": [