fix(linter/eslint/prefer-template): report separate nested concatenations (#27764)

Oxlint skipped concatenations nested inside an unrelated `+`, such as `a + (b ? "|" + c : "")` or a concatenation in a callback body. ESLint reports those inner chains independently.

Only group directly connected additions through parentheses, and keep string classification consistent at TypeScript expression boundaries.

Addresses report 10 (`eslint/prefer-template`) in #27746.
This commit is contained in:
camc314 committed 2026-10-16 09:14:57 +00:00
1 parent 4770423f7b
commit ddea86b023
2 files changed
+135 -12

No files matched your search

@@ -6,7 +6,9 @@ use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use crate::{AstNode, context::LintContext, fixer::RuleFixer, rule::Rule};
use crate::{
AstNode, ast_util::outermost_paren_parent, context::LintContext, fixer::RuleFixer, rule::Rule,
};
fn prefer_template_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("Unexpected string concatenation.")
@@ -56,11 +58,9 @@ impl Rule for PreferTemplate {
if !matches!(expr.operator, BinaryOperator::Addition) {
return;
}
// check is the outermost binary expression
let not_outermost = ctx.nodes().ancestor_kinds(node.id()).any(
|v| matches!(v, AstKind::BinaryExpression(e) if e.operator == BinaryOperator::Addition),
);
if not_outermost {
if outermost_paren_parent(node, ctx).is_some_and(
|parent| matches!(parent.kind(), AstKind::BinaryExpression(e) if e.operator == BinaryOperator::Addition),
) {
return;
}
if check_should_report(expr) {
@@ -369,8 +369,8 @@ fn check_should_report(expr: &BinaryExpression) -> bool {
return false;
}
let left = expr.left.get_inner_expression();
let right = expr.right.get_inner_expression();
let left = expr.left.without_parentheses();
let right = expr.right.without_parentheses();
let left_is_string = left.is_string_literal();
let right_is_string = right.is_string_literal();
@@ -391,8 +391,8 @@ fn all_none_string_literal(expr: &Expression) -> bool {
if let Expression::BinaryExpression(binary) = expr
&& binary.operator == BinaryOperator::Addition
{
return all_none_string_literal(binary.left.get_inner_expression())
&& all_none_string_literal(binary.right.get_inner_expression());
return all_none_string_literal(binary.left.without_parentheses())
&& all_none_string_literal(binary.right.without_parentheses());
}
!expr.is_string_literal()
@@ -402,8 +402,8 @@ fn any_none_string_literal(expr: &Expression) -> bool {
if let Expression::BinaryExpression(binary) = expr
&& binary.operator == BinaryOperator::Addition
{
return any_none_string_literal(binary.left.get_inner_expression())
|| any_none_string_literal(binary.right.get_inner_expression());
return any_none_string_literal(binary.left.without_parentheses())
|| any_none_string_literal(binary.right.without_parentheses());
}
!expr.is_string_literal()
@@ -424,6 +424,9 @@ fn test() {
r#"var foo = `foo` +
`bar` +
"hoge";"#,
r#"a + ("x" as string)"#,
r#"a + ("x" satisfies string)"#,
r#"a + "x"!"#,
];
let fail = vec![
@@ -496,6 +499,17 @@ fn test() {
r#"var foo = "Hello " + "world " + "another " + test"#,
r#"'Hello ' + '"world" ' + test"#,
r#""Hello " + "'world' " + test"#,
r#"export function f(a, b, c) { return a + (b ? "|" + c : ""); }"#,
r#"export const g = (x) => x + [1].map(function (y) { return "v" + y; }).length;"#,
r#"a + ((b ? (("|" + c)) : ""))"#,
r#"a + (("|" + c) * d)"#,
r#"a + fn("|" + c)"#,
r#"a + (() => "|" + c)()"#,
r#"a + `${"|" + c}`"#,
r#"(a + "x" as string) + c"#,
r#"(a + "x" satisfies string) + c"#,
r#"(a + "x")! + c"#,
r#"((a + "x") as string) + "y""#,
];
let fix = vec![
@@ -671,6 +685,24 @@ fn test() {
),
(r#"'Hello ' + '"world" ' + test"#, r#"`Hello ` + `"world" ${ test}`"#, None),
(r#""Hello " + "'world' " + test"#, "`Hello ` + `'world' ${ test}`", None),
(
r#"export function f(a, b, c) { return a + (b ? "|" + c : ""); }"#,
r#"export function f(a, b, c) { return a + (b ? `|${ c}` : ""); }"#,
None,
),
(
r#"export const g = (x) => x + [1].map(function (y) { return "v" + y; }).length;"#,
r"export const g = (x) => x + [1].map(function (y) { return `v${ y}`; }).length;",
None,
),
(r#"a + ((b ? (("|" + c)) : ""))"#, r#"a + ((b ? ((`|${ c}`)) : ""))"#, None),
(r#"a + (("|" + c) * d)"#, "a + ((`|${ c}`) * d)", None),
(r#"a + fn("|" + c)"#, "a + fn(`|${ c}`)", None),
(r#"a + (() => "|" + c)()"#, "a + (() => `|${ c}`)()", None),
(r#"a + (("|" + c))"#, "`${a }|${ c}`", None),
(r#"(a + "x" as string) + c"#, "(`${a }x` as string) + c", None),
(r#"(a + "x" satisfies string) + c"#, "(`${a }x` satisfies string) + c", None),
(r#"(a + "x")! + c"#, "(`${a }x`)! + c", None),
];
Tester::new(PreferTemplate::NAME, PreferTemplate::PLUGIN, pass, fail)
@@ -478,3 +478,94 @@ source: crates/oxc_linter/src/tester.rs
· ────────────────────────────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:46]
1 │ export function f(a, b, c) { return a + (b ? "|" + c : ""); }
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:59]
1 │ export const g = (x) => x + [1].map(function (y) { return "v" + y; }).length;
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:13]
1 │ a + ((b ? (("|" + c)) : ""))
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:7]
1 │ a + (("|" + c) * d)
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:8]
1 │ a + fn("|" + c)
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:12]
1 │ a + (() => "|" + c)()
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:1]
1 │ a + `${"|" + c}`
· ────────────────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:8]
1 │ a + `${"|" + c}`
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:2]
1 │ (a + "x" as string) + c
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:2]
1 │ (a + "x" satisfies string) + c
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:2]
1 │ (a + "x")! + c
· ───────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:1]
1 │ ((a + "x") as string) + "y"
· ───────────────────────────
╰────
help: Use template literals instead of string concatenation.
⚠ eslint(prefer-template): Unexpected string concatenation.
╭─[prefer_template.tsx:1:3]
1 │ ((a + "x") as string) + "y"
· ───────
╰────
help: Use template literals instead of string concatenation.