-
-
Notifications
You must be signed in to change notification settings - Fork 503
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(biome_js_analyzer):
noSubstr
(#3125)
- Loading branch information
Showing
13 changed files
with
832 additions
and
62 deletions.
There are no files selected for viewing
8 changes: 8 additions & 0 deletions
8
crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
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
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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,206 @@ | ||
use biome_analyze::{ | ||
context::RuleContext, declare_rule, ActionCategory, Ast, FixKind, Rule, RuleDiagnostic, | ||
RuleSource, | ||
}; | ||
use biome_console::markup; | ||
use biome_js_factory::make; | ||
use biome_js_syntax::{ | ||
AnyJsName, JsCallArguments, JsExpressionStatement, JsSyntaxToken, JsVariableStatement, | ||
}; | ||
use biome_rowan::{declare_node_union, AstSeparatedList, BatchMutationExt, TextRange, TokenText}; | ||
|
||
use crate::JsRuleAction; | ||
|
||
declare_rule! { | ||
/// Enforce the use of `String.slice()` over `String.substr()` and `String.substring()`. | ||
/// | ||
/// `String.slice()` is preferred over `String.substr()` and `String.substring()` because it is a more popular option with clearer behavior, | ||
/// and it has a consistent counterpart in arrays. | ||
/// | ||
/// Note that `String.substr`, `String.substring` and `String.slice` are not identical when arguments are passed. | ||
/// For detailed differences, refer to the MDN documentation: | ||
/// - [The difference between substring() and substr()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring#the_difference_between_substring_and_substr) | ||
/// - [Differences between substring() and slice()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring#differences_between_substring_and_slice) | ||
/// | ||
/// ## Examples | ||
/// | ||
/// ### Invalid | ||
/// | ||
/// ```js,expect_diagnostic | ||
/// foo.substr(); | ||
/// ``` | ||
/// | ||
/// ```js,expect_diagnostic | ||
/// foo.substring(); | ||
/// ``` | ||
/// | ||
/// ### Valid | ||
/// | ||
/// ```js | ||
/// foo.slice(beginIndex, endIndex); | ||
/// ``` | ||
/// | ||
pub NoSubstr { | ||
version: "next", | ||
name: "noSubstr", | ||
language: "js", | ||
recommended: false, | ||
sources: &[RuleSource::EslintUnicorn("prefer-string-slice")], | ||
fix_kind: FixKind::Unsafe, | ||
} | ||
} | ||
|
||
impl Rule for NoSubstr { | ||
type Query = Ast<AnyJsStatement>; | ||
type State = NoSubstrState; | ||
type Signals = Option<Self::State>; | ||
type Options = (); | ||
|
||
fn run(ctx: &RuleContext<Self>) -> Self::Signals { | ||
let node = ctx.query(); | ||
let value_token = node.value_token()?; | ||
|
||
if matches!(value_token.text_trimmed(), "substr" | "substring") { | ||
Some(NoSubstrState { value_token }) | ||
} else { | ||
None | ||
} | ||
} | ||
|
||
fn diagnostic(_: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> { | ||
let diagnostic_message = markup! { | ||
"Avoid using "{state.member_name().text()}" and consider using slice instead." | ||
} | ||
.to_owned(); | ||
let note_message = markup! { | ||
<Emphasis>"slice"</Emphasis>" is more commonly used and has a less surprising behavior." | ||
} | ||
.to_owned(); | ||
let mdn_link = | ||
markup! { | ||
"See "<Hyperlink href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring#the_difference_between_substring_and_substr">"MDN web docs"</Hyperlink>" for more details." | ||
} | ||
.to_owned(); | ||
Some( | ||
RuleDiagnostic::new(rule_category!(), state.span(), diagnostic_message) | ||
.note(note_message) | ||
.note(mdn_link), | ||
) | ||
} | ||
|
||
fn action(ctx: &RuleContext<Self>, _: &Self::State) -> Option<JsRuleAction> { | ||
let node = ctx.query(); | ||
let arguments = node.arguments()?; | ||
let args = arguments.args(); | ||
|
||
if !args.is_empty() { | ||
// If the function has arguments, we cannot replace it with slice() as it has different behavior. | ||
// - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring#differences_between_substring_and_slice | ||
// - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substr#description | ||
return None; | ||
} | ||
|
||
let mut mutation = ctx.root().begin(); | ||
let replaced_function = make::js_name(make::ident("slice")); | ||
mutation.replace_element(node.member()?.into(), replaced_function.into()); | ||
|
||
Some(JsRuleAction::new( | ||
ActionCategory::QuickFix, | ||
ctx.metadata().applicability(), | ||
markup! { "Use "<Emphasis>".slice()"</Emphasis>" instead." }.to_owned(), | ||
mutation, | ||
)) | ||
} | ||
} | ||
|
||
#[derive(Debug, Clone)] | ||
pub struct NoSubstrState { | ||
value_token: JsSyntaxToken, | ||
} | ||
|
||
impl NoSubstrState { | ||
fn member_name(&self) -> TokenText { | ||
self.value_token.token_text_trimmed() | ||
} | ||
|
||
fn span(&self) -> TextRange { | ||
self.value_token.text_range() | ||
} | ||
} | ||
|
||
// Helper union type to handle both JsExpressionStatement and JsVariableStatement. | ||
// To handle arguments, we need to know the type of the statement. | ||
declare_node_union! { | ||
pub AnyJsStatement = JsExpressionStatement | JsVariableStatement | ||
} | ||
|
||
impl AnyJsStatement { | ||
pub fn value_token(&self) -> Option<JsSyntaxToken> { | ||
match self { | ||
AnyJsStatement::JsExpressionStatement(node) => { | ||
let callee = node | ||
.expression() | ||
.ok()? | ||
.as_js_call_expression()? | ||
.callee() | ||
.ok()?; | ||
callee | ||
.as_js_static_member_expression()? | ||
.member() | ||
.ok()? | ||
.value_token() | ||
.ok() | ||
} | ||
AnyJsStatement::JsVariableStatement(node) => { | ||
let declaration = node.declaration().ok()?; | ||
let declarators = declaration.declarators(); | ||
declarators.into_iter().find_map(|declarator| { | ||
let init = declarator.ok()?.initializer()?; | ||
init.expression() | ||
.ok()? | ||
.as_js_static_member_expression()? | ||
.member() | ||
.ok()? | ||
.value_token() | ||
.ok() | ||
}) | ||
} | ||
} | ||
} | ||
pub fn member(&self) -> Option<AnyJsName> { | ||
match self { | ||
AnyJsStatement::JsExpressionStatement(node) => { | ||
let callee = node | ||
.expression() | ||
.ok()? | ||
.as_js_call_expression()? | ||
.callee() | ||
.ok()?; | ||
callee.as_js_static_member_expression()?.member().ok() | ||
} | ||
AnyJsStatement::JsVariableStatement(node) => { | ||
let declaration = node.declaration().ok()?; | ||
let declarators = declaration.declarators(); | ||
declarators.into_iter().find_map(|declarator| { | ||
let init = declarator.ok()?.initializer()?; | ||
init.expression() | ||
.ok()? | ||
.as_js_static_member_expression()? | ||
.member() | ||
.ok() | ||
}) | ||
} | ||
} | ||
} | ||
pub fn arguments(&self) -> Option<JsCallArguments> { | ||
match self { | ||
AnyJsStatement::JsExpressionStatement(node) => node | ||
.expression() | ||
.ok()? | ||
.as_js_call_expression()? | ||
.arguments() | ||
.ok(), | ||
_ => None, | ||
} | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
20 changes: 20 additions & 0 deletions
20
crates/biome_js_analyze/tests/specs/nursery/noSubstr/invalid.js
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,20 @@ | ||
const f = foo.substr; | ||
foo.substr() | ||
foo?.substr() | ||
foo.bar?.substring() | ||
foo?.[0]?.substring() | ||
foo.bar.substr?.() | ||
foo.bar?.substring?.() | ||
foo.bar?.baz?.substr() | ||
foo.bar?.baz.substring() | ||
foo.bar.baz?.substr() | ||
"foo".substr() | ||
"foo".substr(bar.length, Math.min(baz, 100)) // "foo".slice(bar.length, bar.length + Math.min(baz, 100)) | ||
"foo".substr(1, "abc".length) // "foo".slice(1, 1 + "abc".length) | ||
"foo".substr("1", 2) | ||
"foo".substring(length, 0) // "foo".slice(0, Math.max(0, length)) | ||
foo.substring(start) // foo.slice(Math.max(0, start)) | ||
foo.substring(start, end) | ||
"foo".substring(1, 3) | ||
// Extra arguments | ||
foo.substring(1, 2, 3) |
Oops, something went wrong.