-
-
Notifications
You must be signed in to change notification settings - Fork 539
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(js/noUselessEscapeInString): add lint rule (#5229)
- Loading branch information
Showing
15 changed files
with
556 additions
and
116 deletions.
There are no files selected for viewing
133 changes: 77 additions & 56 deletions
133
crates/biome_configuration/src/analyzer/linter/rules.rs
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
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
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
203 changes: 203 additions & 0 deletions
203
crates/biome_js_analyze/src/lint/nursery/no_useless_escape_in_string.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,203 @@ | ||
use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, context::RuleContext, declare_lint_rule}; | ||
use biome_console::markup; | ||
use biome_diagnostics::Severity; | ||
use biome_js_syntax::{ | ||
AnyJsTemplateElement, JsLiteralMemberName, JsStringLiteralExpression, JsSyntaxKind, | ||
JsSyntaxToken, JsTemplateExpression, | ||
}; | ||
use biome_rowan::{BatchMutationExt, TextRange, declare_node_union}; | ||
|
||
use crate::JsRuleAction; | ||
|
||
declare_lint_rule! { | ||
/// Disallow unnecessary escapes in string literals. | ||
/// | ||
/// Escaping non-special characters in string literals doesn't have any effect. | ||
/// Hence, they may confuse a reader. | ||
/// | ||
/// ## Examples | ||
/// | ||
/// ### Invalid | ||
/// | ||
/// ```js,expect_diagnostic | ||
/// const s = "\a"; | ||
/// ``` | ||
/// | ||
/// ```js,expect_diagnostic | ||
/// const o = { | ||
/// "\a": 0, | ||
/// }; | ||
/// ``` | ||
/// | ||
/// ```js,expect_diagnostic | ||
/// const s = `${0}\a`; | ||
/// ``` | ||
/// | ||
/// ### Valid | ||
/// | ||
/// ```js | ||
/// const s = "\n"; | ||
/// ``` | ||
/// | ||
/// Tagged string template are ignored: | ||
/// | ||
/// ```js | ||
/// const s = tagged`\a`; | ||
/// ``` | ||
/// | ||
/// JSX strings are ignored: | ||
/// | ||
/// ```jsx | ||
/// <div attr="str\a"/>; | ||
/// ``` | ||
/// | ||
pub NoUselessEscapeInString { | ||
version: "next", | ||
name: "noUselessEscapeInString", | ||
language: "js", | ||
recommended: true, | ||
severity: Severity::Warning, | ||
fix_kind: FixKind::Safe, | ||
} | ||
} | ||
|
||
impl Rule for NoUselessEscapeInString { | ||
type Query = Ast<AnyString>; | ||
type State = (JsSyntaxToken, usize); | ||
type Signals = Option<Self::State>; | ||
type Options = (); | ||
|
||
fn run(ctx: &RuleContext<Self>) -> Self::Signals { | ||
let node = ctx.query(); | ||
match node { | ||
AnyString::JsStringLiteralExpression(literal) => { | ||
let tokrn = literal.value_token().ok()?; | ||
let text = tokrn.text(); | ||
next_useless_escape(text, text.bytes().next()?).map(|index| (tokrn, index)) | ||
} | ||
AnyString::JsTemplateExpression(template) => { | ||
if template.tag().is_some() { | ||
return None; | ||
} | ||
for element in template.elements() { | ||
match element { | ||
AnyJsTemplateElement::JsTemplateChunkElement(chunk) => { | ||
let Ok(chunk) = chunk.template_chunk_token() else { | ||
continue; | ||
}; | ||
if let Some(index) = next_useless_escape(chunk.text(), b'`') { | ||
return Some((chunk, index)); | ||
} | ||
} | ||
AnyJsTemplateElement::JsTemplateElement(_) => {} | ||
} | ||
} | ||
None | ||
} | ||
AnyString::JsLiteralMemberName(member_name) => { | ||
let Ok(token) = member_name.value() else { | ||
return None; | ||
}; | ||
if token.kind() == JsSyntaxKind::JS_STRING_LITERAL { | ||
let text = token.text_trimmed(); | ||
next_useless_escape(text, text.bytes().next()?).map(|index| (token, index)) | ||
} else { | ||
None | ||
} | ||
} | ||
} | ||
} | ||
|
||
fn diagnostic(_: &RuleContext<Self>, (token, index): &Self::State) -> Option<RuleDiagnostic> { | ||
let escape_start = token | ||
.text_trimmed_range() | ||
.start() | ||
.checked_add((*index as u32 + 1).into())?; | ||
let escaped_char = token.text()[(1 + index)..].chars().next()?; | ||
Some( | ||
RuleDiagnostic::new( | ||
rule_category!(), | ||
TextRange::at(escape_start, (escaped_char.len_utf8() as u32).into()), | ||
"The character doesn't need to be escaped.", | ||
) | ||
.note("Only quotes that enclose the string and special characters need to be escaped."), | ||
) | ||
} | ||
|
||
fn action(ctx: &RuleContext<Self>, (token, index): &Self::State) -> Option<JsRuleAction> { | ||
let mut new_text = token.text_trimmed().to_string(); | ||
new_text.remove(*index); | ||
let new_token = JsSyntaxToken::new_detached(token.kind(), &new_text, [], []); | ||
let mut mutation = ctx.root().begin(); | ||
mutation.replace_token_transfer_trivia(token.clone(), new_token); | ||
Some(JsRuleAction::new( | ||
ctx.metadata().action_category(ctx.category(), ctx.group()), | ||
ctx.metadata().applicability(), | ||
markup! { "Unescape the character." }.to_owned(), | ||
mutation, | ||
)) | ||
} | ||
} | ||
|
||
declare_node_union! { | ||
/// Any string literal excluding JsxString. | ||
pub AnyString = JsStringLiteralExpression | JsTemplateExpression | JsLiteralMemberName | ||
} | ||
|
||
/// Returns the index in `str` of the first useless escape. | ||
fn next_useless_escape(str: &str, quote: u8) -> Option<usize> { | ||
let mut it = str.bytes().enumerate(); | ||
while let Some((i, c)) = it.next() { | ||
if c == b'\\' { | ||
if let Some((_, c)) = it.next() { | ||
match c { | ||
// Meaningful escaped character | ||
b'^' | ||
| b'\r' | ||
| b'\n' | ||
| b'0'..=b'7' | ||
| b'\\' | ||
| b'b' | ||
| b'f' | ||
| b'n' | ||
| b'r' | ||
| b't' | ||
| b'u' | ||
| b'v' | ||
| b'x' => {} | ||
// Preserve escaping of Unicode characters U+2028 and U+2029 | ||
0xE2 => { | ||
if !(matches!(it.next(), Some((_, 0x80))) | ||
&& matches!(it.next(), Some((_, 0xA8 | 0xA9)))) | ||
{ | ||
return Some(i); | ||
} | ||
} | ||
_ => { | ||
// The quote can be escaped | ||
if c != quote { | ||
return Some(i); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
None | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
|
||
#[test] | ||
fn test_next_useless_escape() { | ||
assert_eq!(next_useless_escape(r"\n", b'"'), None); | ||
assert_eq!(next_useless_escape(r"\'", b'"'), Some(0)); | ||
|
||
assert_eq!(next_useless_escape("\\\u{2027}", b'"'), Some(0)); | ||
assert_eq!(next_useless_escape("\\\u{2028}", b'"'), None); | ||
assert_eq!(next_useless_escape("\\\u{2029}", b'"'), None); | ||
assert_eq!(next_useless_escape("\\\u{2030}", b'"'), Some(0)); | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.