Skip to content

Commit 1603f78

Browse files
authored
feat(js_analyze): implement noJsxLeakedDollar (#9911)
1 parent 946b50e commit 1603f78

15 files changed

Lines changed: 495 additions & 0 deletions

File tree

.changeset/rude-crabs-mix.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noJsxLeakedDollar`](https://biomejs.dev/linter/rules/no-jsx-leaked-dollar), which flags text nodes with a trailing `$` if the next sibling node is a JSX expression. This could be an unintentional mistake, resulting in a '$' being rendered as text in the output.
6+
7+
**Invalid**:
8+
9+
```jsx
10+
function MyComponent({ user }) {
11+
return <div>Hello ${user.name}</div>;
12+
}
13+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/domain_selector.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/linter_options_check.rs

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use crate::JsRuleAction;
2+
use biome_analyze::{
3+
Ast, FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext,
4+
declare_lint_rule,
5+
};
6+
use biome_console::markup;
7+
use biome_diagnostics::Severity;
8+
use biome_js_factory::make;
9+
use biome_js_syntax::{AnyJsxChild, JsSyntaxKind, JsSyntaxToken, JsxChildList, JsxText};
10+
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, TextRange, TextSize};
11+
use biome_rule_options::no_jsx_leaked_dollar::NoJsxLeakedDollarOptions;
12+
13+
declare_lint_rule! {
14+
/// Flags text nodes with a trailing `$` before a JSX expression.
15+
///
16+
/// This can happen when refactoring from a template literal to JSX and forgetting
17+
/// to remove the dollar sign. This results in an unintentional `$` being rendered
18+
/// as text in the output.
19+
///
20+
/// ```jsx
21+
/// function MyComponent({ user }) {
22+
/// return `Hello ${user.name}`;
23+
/// }
24+
/// ```
25+
///
26+
/// When refactored to JSX, it might look like this:
27+
///
28+
/// ```jsx,ignore
29+
/// function MyComponent({ user }) {
30+
/// return <>Hello ${user.name}</>;
31+
/// }
32+
/// ```
33+
///
34+
/// However, the `$` before `{user.name}` is unnecessary and will be rendered as text in the output.
35+
///
36+
/// ## Examples
37+
///
38+
/// ### Invalid
39+
///
40+
/// ```jsx,expect_diagnostic
41+
/// function MyComponent({ user }) {
42+
/// return <div>Hello ${user.name}</div>;
43+
/// }
44+
/// ```
45+
///
46+
/// ```jsx,expect_diagnostic
47+
/// function MyComponent({ user }) {
48+
/// return <div>${user.name} is your name</div>;
49+
/// }
50+
/// ```
51+
///
52+
/// ### Valid
53+
///
54+
/// ```jsx
55+
/// function MyComponent({ user }) {
56+
/// return <div>Hello {user.name}</div>;
57+
/// }
58+
/// ```
59+
///
60+
/// ```jsx
61+
/// // A lone `$` before a single expression is treated as intentional (e.g. a price).
62+
/// function MyComponent({ price }) {
63+
/// return <div>${price}</div>;
64+
/// }
65+
/// ```
66+
///
67+
pub NoJsxLeakedDollar {
68+
version: "next",
69+
name: "noJsxLeakedDollar",
70+
language: "jsx",
71+
recommended: false,
72+
fix_kind: FixKind::Unsafe,
73+
severity: Severity::Warning,
74+
domains: &[RuleDomain::React],
75+
sources: &[RuleSource::EslintReactJsx("no-leaked-dollar").same(), RuleSource::EslintReactXyz("jsx-no-leaked-dollar").same()],
76+
}
77+
}
78+
79+
impl Rule for NoJsxLeakedDollar {
80+
type Query = Ast<JsxText>;
81+
type State = TextRange;
82+
type Signals = Option<Self::State>;
83+
type Options = NoJsxLeakedDollarOptions;
84+
85+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
86+
let node = ctx.query();
87+
let value_token = node.value_token().ok()?;
88+
let text = value_token.text();
89+
90+
// Check if the text ends with `$`
91+
if !text.ends_with('$') {
92+
return None;
93+
}
94+
95+
// Check if the next sibling is a JsxExpressionChild
96+
let next_sibling = node.syntax().next_sibling()?;
97+
if next_sibling.kind() != JsSyntaxKind::JSX_EXPRESSION_CHILD {
98+
return None;
99+
}
100+
101+
// Exception: if the text is exactly "$" and the parent has only 2 children,
102+
// it looks like an intentional dollar sign (e.g. `<div>${price}</div>`).
103+
if text == "$"
104+
&& let Some(parent) = node.syntax().parent()
105+
&& let Some(parent) = JsxChildList::cast(parent)
106+
&& parent.len() == 2
107+
{
108+
return None;
109+
}
110+
111+
// Return the range of the trailing `$` character
112+
let end = value_token.text_range().end();
113+
let start = end - TextSize::from(1u32);
114+
Some(TextRange::new(start, end))
115+
}
116+
117+
fn diagnostic(_: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
118+
Some(
119+
RuleDiagnostic::new(
120+
rule_category!(),
121+
state,
122+
markup! {
123+
"Possible unintentional "<Emphasis>"'$'"</Emphasis>" before a JSX expression."
124+
},
125+
)
126+
.note(markup! {
127+
"This "<Emphasis>"'$'"</Emphasis>" will be rendered as text. Remove the "<Emphasis>"'$'"</Emphasis>" from the text node or add a suppression if it is intentional."
128+
}),
129+
)
130+
}
131+
132+
fn action(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<JsRuleAction> {
133+
let node = ctx.query();
134+
let value_token = node.value_token().ok()?;
135+
let text = value_token.text();
136+
137+
// Remove the trailing `$`
138+
let new_text = text[..text.len() - 1].to_string();
139+
140+
let new_token = JsSyntaxToken::new_detached(JsSyntaxKind::JSX_TEXT, &new_text, [], []);
141+
let new_jsx_text = AnyJsxChild::JsxText(make::jsx_text(new_token));
142+
let mut mutation = ctx.root().begin();
143+
mutation.replace_node(AnyJsxChild::from(node.clone()), new_jsx_text);
144+
Some(JsRuleAction::new(
145+
ctx.metadata().action_category(ctx.category(), ctx.group()),
146+
ctx.metadata().applicability(),
147+
markup! { "Remove dollar sign." }.to_owned(),
148+
mutation,
149+
))
150+
}
151+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* should generate diagnostics */
2+
const Invalid1 = () => <>Hello ${user.name}</>
3+
const Invalid2 = () => <>Hello $${user.name}</>
4+
const Invalid3 = (props) => {
5+
return <div>Hello ${props.name}</div>;
6+
};
7+
8+
const Invalid4 = (props) => {
9+
return <div>${props.name} is your name</div>;
10+
};
11+
12+
const Invalid5 = (props) => {
13+
return <div>Hello ${props.name} is your name</div>;
14+
};
15+
16+
function Invalid6({ count, total }) {
17+
return <div>Progress: ${count} / ${total}</div>;
18+
}

0 commit comments

Comments
 (0)