Skip to content

Commit e617d36

Browse files
fix(lint): noUselessStringConcat now correctly detects leading concatenation operators from operatorLinebreak=before (#7874)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent be79a6b commit e617d36

File tree

4 files changed

+123
-69
lines changed

4 files changed

+123
-69
lines changed

.changeset/quick-apples-count.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [#7230](https://github.com/biomejs/biome/issues/7230): [`noUselessStringConcat`](https://biomejs.dev/linter/rules/no-useless-string-concat/) no longer emits false positives for multi-line strings with leading `+` operators.
6+
7+
Previously, the rule did not check for leading newlines on the `+` operator, emitting false positives if one occurred at the start of a line. \
8+
Notably, formatting with `operatorLinebreak="before"` would move the `+` operators to the start of lines automatically, resulting in spurious errors whenever a multi-line string was used.
9+
10+
Now, the rule correctly detects and ignores multi-line concatenations with leading operators as well, working regardless of the setting of `operatorLinebreak`.
11+
12+
**Example**
13+
```ts
14+
// The following code used to error if the `+` operators were at the start of lines (as opposed to the end).
15+
// Now, the rule correctly recognizes this as a stylistic concatenation and ignores it.
16+
const reallyLongStringThatShouldNotError =
17+
"Lorem ipsum dolor sit amet consectetur adipiscing elit."
18+
+ "Quisque faucibus ex sapien vitae pellentesque sem placerat."
19+
+ "In id cursus mi pretium tellus duis convallis."
20+
+ "Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla";
21+
```

crates/biome_js_analyze/src/lint/complexity/no_useless_string_concat.rs

Lines changed: 90 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ use crate::JsRuleAction;
1717
declare_lint_rule! {
1818
/// Disallow unnecessary concatenation of string or template literals.
1919
///
20-
/// This rule aims to flag the concatenation of 2 literals when they could be combined into a single literal. Literals can be strings or template literals.
21-
/// Concatenation of multiple strings is allowed when the strings are spread over multiple lines in order to prevent exceeding the maximum line width.
20+
/// This rule aims to flag concatenation of string or template literals when they could be combined into a single literal.
21+
/// Notably, this also includes concatenating a string with a number (unlike the derivative ESLint rule).
22+
///
23+
/// Concatenation of multiple strings is allowed for multi-line strings (such as ones used to prevent exceeding the maximum line width).
2224
///
2325
/// ## Examples
2426
///
@@ -29,6 +31,10 @@ declare_lint_rule! {
2931
/// ```
3032
///
3133
/// ```js,expect_diagnostic
34+
/// const foo = "string" + 123;
35+
/// ```
36+
///
37+
/// ```js,expect_diagnostic
3238
/// const a = "a" + "b" + "c";
3339
/// ```
3440
///
@@ -58,10 +64,18 @@ declare_lint_rule! {
5864
/// const a = 'foo' + bar;
5965
/// ```
6066
///
67+
/// Multi-line strings are ignored:
68+
///
6169
/// ```js
62-
/// const a = 'foo' +
70+
/// const multiline = 'foo' + // formatting
6371
/// 'bar'
6472
/// ```
73+
///
74+
/// ```js
75+
/// const alsoMultiline = 'foo'
76+
/// + 'bar'
77+
/// + `baz`
78+
/// ```
6579
pub NoUselessStringConcat {
6680
version: "1.8.0",
6781
name: "noUselessStringConcat",
@@ -84,13 +98,13 @@ impl Rule for NoUselessStringConcat {
8498
let parent_binary_expression = get_parent_binary_expression(node);
8599

86100
// Prevent duplicated error reportings when the parent is a useless concatenation too, i.e.: "a" + "b" + "c"
87-
if parent_binary_expression.is_some()
88-
&& get_useless_concat(&parent_binary_expression?).is_some()
101+
if parent_binary_expression
102+
.is_some_and(|parent_expression| get_concatenation_range(&parent_expression).is_some())
89103
{
90104
return None;
91105
}
92106

93-
get_useless_concat(node)
107+
get_concatenation_range(node)
94108
}
95109

96110
fn diagnostic(_ctx: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
@@ -173,12 +187,35 @@ impl Rule for NoUselessStringConcat {
173187
}
174188
}
175189

176-
fn get_useless_concat(node: &JsBinaryExpression) -> Option<TextRange> {
177-
if is_stylistic_concatenation(node) {
178-
return None;
179-
}
190+
/// Returns if the passed `JsBinaryExpression` is the concatenation of two string literals.
191+
fn is_string_concatenation(binary_expression: &JsBinaryExpression) -> bool {
192+
let left = binary_expression.left().ok();
193+
let right = binary_expression.right().ok();
194+
let has_left_string_expression = is_string_expression(&left)
195+
|| is_binary_expression_with_literal_string(&left)
196+
|| is_parenthesized_concatenation(&left);
197+
let has_right_string_expression = is_string_expression(&right)
198+
|| is_binary_expression_with_literal_string(&right)
199+
|| is_parenthesized_concatenation(&right);
200+
let has_left_numeric_expression = is_numeric_expression(&left) || is_numeric_calculation(&left);
201+
let has_right_numeric_expression =
202+
is_numeric_expression(&right) || is_numeric_calculation(&right);
203+
let has_string_expression = match (
204+
has_left_string_expression,
205+
has_left_numeric_expression,
206+
has_right_string_expression,
207+
has_right_numeric_expression,
208+
) {
209+
(true, _, true, _) => true, // "a" + "b"
210+
(false, true, true, _) => true, // 1 + "a"
211+
(true, _, false, true) => true, // "a" + 1
212+
_ => false,
213+
};
214+
215+
let operator = binary_expression.operator().ok();
216+
let has_plus_operator = matches!(operator, Some(JsBinaryOperator::Plus));
180217

181-
is_concatenation(node)
218+
has_plus_operator && has_string_expression
182219
}
183220

184221
fn is_string_expression(expression: &Option<AnyJsExpression>) -> bool {
@@ -232,59 +269,55 @@ fn is_binary_expression_with_literal_string(expression: &Option<AnyJsExpression>
232269
false
233270
}
234271

235-
fn is_concatenation(binary_expression: &JsBinaryExpression) -> Option<TextRange> {
236-
let left = binary_expression.left().ok();
237-
let right = binary_expression.right().ok();
238-
let has_left_string_expression = is_string_expression(&left)
239-
|| is_binary_expression_with_literal_string(&left)
240-
|| is_parenthesized_concatenation(&left);
241-
let has_right_string_expression = is_string_expression(&right)
242-
|| is_binary_expression_with_literal_string(&right)
243-
|| is_parenthesized_concatenation(&right);
244-
let has_left_numeric_expression = is_numeric_expression(&left) || is_numeric_calculation(&left);
245-
let has_right_numeric_expression =
246-
is_numeric_expression(&right) || is_numeric_calculation(&right);
247-
let operator = binary_expression.operator().ok();
272+
fn is_parenthesized_concatenation(expression: &Option<AnyJsExpression>) -> bool {
273+
if let Some(AnyJsExpression::JsParenthesizedExpression(parenthesized_expression)) = expression {
274+
return is_binary_expression_with_literal_string(
275+
&parenthesized_expression.expression().ok(),
276+
);
277+
}
248278

249-
let has_plus_operator = matches!(operator, Some(JsBinaryOperator::Plus));
250-
let has_string_expression = match (
251-
has_left_string_expression,
252-
has_left_numeric_expression,
253-
has_right_string_expression,
254-
has_right_numeric_expression,
255-
) {
256-
(true, _, true, _) => true, // "a" + "b"
257-
(false, true, true, _) => true, // 1 + "a"
258-
(true, _, false, true) => true, // "a" + 1
259-
_ => false,
260-
};
279+
false
280+
}
261281

262-
if has_plus_operator && has_string_expression {
263-
let range_start = if is_binary_expression_with_literal_string(&left) {
264-
match left {
265-
Some(AnyJsExpression::JsBinaryExpression(left_binary_expression)) => {
266-
extract_concat_range(&left_binary_expression)
267-
}
268-
_ => None,
269-
}
270-
} else {
271-
left.map(|left| left.range().start())
272-
};
273-
let range_end = right.map(|right| right.range().end());
282+
/// Returns the diagnostic range of the passed `JsBinaryExpression`, or `None` if it is either
283+
/// not a string concatenation or stylistically exempt.
284+
fn get_concatenation_range(binary_expression: &JsBinaryExpression) -> Option<TextRange> {
285+
if !is_string_concatenation(binary_expression) {
286+
return None;
287+
}
274288

275-
return match (range_start, range_end) {
276-
(Some(range_start), Some(range_end)) => Some(TextRange::new(range_start, range_end)),
277-
_ => None,
278-
};
289+
if is_stylistic_concatenation(binary_expression) {
290+
return None;
279291
}
280292

281-
None
293+
let left = binary_expression.left().ok();
294+
let right = binary_expression.right().ok();
295+
let range_start = if is_binary_expression_with_literal_string(&left) {
296+
match left {
297+
Some(AnyJsExpression::JsBinaryExpression(left_binary_expression)) => {
298+
extract_concat_range(&left_binary_expression)
299+
}
300+
_ => None,
301+
}
302+
} else {
303+
left.map(|left| left.range().start())
304+
};
305+
let range_end = right.map(|right| right.range().end());
306+
307+
match (range_start, range_end) {
308+
(Some(range_start), Some(range_end)) => Some(TextRange::new(range_start, range_end)),
309+
_ => None,
310+
}
282311
}
283312

284-
/// Returns if the passed `JsBinaryExpression` has a multiline string concatenation
313+
/// Returns if the passed `JsBinaryExpression` is a multiline string concatenation,
314+
/// meaning either the operator or RHS have a leading newline.
315+
/// Assumes the expression is known to be a valid concatenation that will otherwise trigger the rule.
285316
fn is_stylistic_concatenation(binary_expression: &JsBinaryExpression) -> bool {
286-
let operator = binary_expression.operator().ok();
287-
let is_plus_operator = matches!(operator, Some(JsBinaryOperator::Plus));
317+
// TODO: Review desired rule behavior if the first operand is a number
318+
let has_newline_in_operator = binary_expression
319+
.operator_token()
320+
.is_ok_and(|operator| operator.has_leading_newline());
288321
let has_newline_in_right = binary_expression.right().is_ok_and(|right| {
289322
match (
290323
right.as_any_js_literal_expression(),
@@ -305,17 +338,7 @@ fn is_stylistic_concatenation(binary_expression: &JsBinaryExpression) -> bool {
305338
}
306339
});
307340

308-
is_plus_operator && has_newline_in_right
309-
}
310-
311-
fn is_parenthesized_concatenation(expression: &Option<AnyJsExpression>) -> bool {
312-
if let Some(AnyJsExpression::JsParenthesizedExpression(parenthesized_expression)) = expression {
313-
return is_binary_expression_with_literal_string(
314-
&parenthesized_expression.expression().ok(),
315-
);
316-
}
317-
318-
false
341+
has_newline_in_operator || has_newline_in_right
319342
}
320343

321344
fn extract_string_value(expression: &Option<AnyJsExpression>) -> Option<String> {

crates/biome_js_analyze/tests/specs/complexity/noUselessStringConcat/valid.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ const a = foo + 'a' + 'b'
88
const a = foo + `a` + `b`
99
const a = (number + 1) + 'px'
1010
const a = (1 + +2) + `b`
11-
const stylisticConcat = 'foo' +
11+
const stylisticConcat = 'foo' + // formatting
1212
'bar'
1313
const stylisticConcat = `foo` +
1414
'bar' +
1515
`baz`
16+
const stylisticConcatLeading = 'foo' // formatting
17+
+ 'bar'
18+
const stylisticConcatLeading = `foo`
19+
+ 'bar'
20+
+ `baz`

crates/biome_js_analyze/tests/specs/complexity/noUselessStringConcat/valid.js.snap

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ const a = foo + 'a' + 'b'
1414
const a = foo + `a` + `b`
1515
const a = (number + 1) + 'px'
1616
const a = (1 + +2) + `b`
17-
const stylisticConcat = 'foo' +
17+
const stylisticConcat = 'foo' + // formatting
1818
'bar'
1919
const stylisticConcat = `foo` +
2020
'bar' +
2121
`baz`
22+
const stylisticConcatLeading = 'foo' // formatting
23+
+ 'bar'
24+
const stylisticConcatLeading = `foo`
25+
+ 'bar'
26+
+ `baz`
2227
2328
```

0 commit comments

Comments
 (0)