From f674cf223c09f5fc721ddae33e8f88048eb8b497 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Thu, 15 Oct 2026 13:22:19 +0200 Subject: [PATCH 01/11] Add rule metadata --- .../org/sonar/l10n/java/rules/java/S9433.html | 51 +++++++++++++++++++ .../org/sonar/l10n/java/rules/java/S9433.json | 24 +++++++++ .../main/resources/profiles/Sonar_way/S9433 | 0 3 files changed, 75 insertions(+) create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.html create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.json create mode 100644 sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9433 diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.html new file mode 100644 index 00000000000..6f14fce7cb3 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.html @@ -0,0 +1,51 @@ +

An issue is raised when a literal value (numeric, boolean, or other primitive) is converted to a string by concatenating it with an empty string +rather than using explicit conversion methods.

+

Why is this an issue?

+

Converting literals to strings by concatenating them with empty strings is an inefficient and unclear approach.

+

When you write an expression that concatenates an empty string with a non-string value, the language runtime must:

+ +

This creates unnecessary intermediate objects and performs extra operations. More importantly, the intent is unclear - a developer reading an empty +string concatenated with a value must infer that this is a type conversion, rather than seeing an explicit conversion call.

+

Most languages provide explicit, efficient methods for converting primitives to strings:

+ +

These methods directly convert the value to a string without unnecessary intermediate steps, making both the intent and the operation clearer.

+

In Java, these explicit methods include Integer.toString(), Boolean.toString(), and other type-specific methods, or the +generic String.valueOf() method.

+

What is the potential impact?

+

The performance impact of using string operations as an indirect method for type conversion is typically small in individual cases, but can +accumulate in large applications or performance-sensitive code paths. More significantly, this pattern reduces code readability and maintainability by +obscuring the developer’s intent.

+

How to fix it

+

Replace empty string concatenation with explicit type conversion methods. For primitive types, use the wrapper class’s static +toString() method (e.g., Integer.toString(), Long.toString(), Boolean.toString()). Alternatively, +use String.valueOf() which works for all types.

+

Code examples

+

Noncompliant code example

+
+String s = "" + 123;   // Noncompliant
+String b = "" + true;  // Noncompliant
+String f = "" + 3.14f; // Noncompliant
+
+

Compliant solution

+
+String s = Integer.toString(123);
+String b = Boolean.toString(true);
+String f = Float.toString(3.14f);
+
+

Resources

+

Documentation

+ + diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.json new file mode 100644 index 00000000000..b3fabe51098 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9433.json @@ -0,0 +1,24 @@ +{ + "title": "Literals should not be converted to strings using empty string concatenation", + "type": "CODE_SMELL", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5min" + }, + "tags": [ + "confusing", + "performance" + ], + "defaultSeverity": "Major", + "ruleSpecification": "RSPEC-9433", + "sqKey": "S9433", + "scope": "All", + "quickfix": "unknown", + "code": { + "impacts": { + "MAINTAINABILITY": "MEDIUM" + }, + "attribute": "CLEAR" + } +} diff --git a/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9433 b/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9433 new file mode 100644 index 00000000000..e69de29bb2d From 1c6d12240ff43c8b2c2ac348ce1036d608b9d361 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Thu, 15 Oct 2026 13:24:05 +0200 Subject: [PATCH 02/11] Add failing reproducer --- ...ralToStringByConcatenationCheckSample.java | 54 +++++++++++++++++++ .../LiteralToStringByConcatenationCheck.java | 35 ++++++++++++ ...teralToStringByConcatenationCheckTest.java | 45 ++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java create mode 100644 java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java create mode 100644 java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java new file mode 100644 index 00000000000..b62fb33f6da --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java @@ -0,0 +1,54 @@ +/* + * SonarQube Java + * Copyright (C) 2012-2026 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public License + * as published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package checks; + +class LiteralToStringByConcatenationCheckSample { + + private static final String EMPTY = ""; + + String convertLiterals(int value) { + String itemCount = "" + 123; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} +// ^^^^^^^^ + String enabled = "" + true; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} +// ^^^^^^^^^ + String ratio = "" + 3.14f; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} +// ^^^^^^^^^^ + String initial = "" + 'A'; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} +// ^^^^^^^^^ + String timeout = 42L + ""; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} +// ^^^^^^^^ + String rate = ("") + (2.5d); // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} +// ^^^^^^^^^^^^^^ + return itemCount + enabled + ratio + initial + timeout + rate; + } + + String explicitOrOrdinaryConcatenation(int value) { + String explicitInteger = Integer.toString(123); + String explicitBoolean = Boolean.toString(true); + String explicitGeneric = String.valueOf(3.14f); + String prefixed = "items=" + 123; + String variable = "" + value; + String absent = "" + null; + String text = "" + "ready"; + String constant = EMPTY + 123; + String computed = "" + (1 + 2); + return explicitInteger + explicitBoolean + explicitGeneric + prefixed + variable + absent + text + constant + computed; + } +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java new file mode 100644 index 00000000000..af9703f2429 --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -0,0 +1,35 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import java.util.List; +import org.sonar.check.Rule; +import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; +import org.sonar.plugins.java.api.tree.Tree; + +@Rule(key = "S9433") +public class LiteralToStringByConcatenationCheck extends IssuableSubscriptionVisitor { + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.PLUS); + } + + @Override + public void visitNode(Tree tree) { + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java new file mode 100644 index 00000000000..d08a91d9056 --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java @@ -0,0 +1,45 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; + +class LiteralToStringByConcatenationCheckTest { + + private static final String SAMPLE = "checks/LiteralToStringByConcatenationCheckSample.java"; + private static final LiteralToStringByConcatenationCheck CHECK = new LiteralToStringByConcatenationCheck(); + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(SAMPLE)) + .withCheck(CHECK) + .verifyIssues(); + } + + @Test + void test_without_semantic() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(SAMPLE)) + .withCheck(CHECK) + .withoutSemantic() + .verifyIssues(); + } +} From df471ca5be61a2f980bd6177c228c6545a42c3c8 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Thu, 15 Oct 2026 13:25:06 +0200 Subject: [PATCH 03/11] SONARJAVA-7155: Implemented rule S9433 Literals should not be converted to strings using empty string concatenation Detects primitive literals converted to strings by direct concatenation with an empty string literal and recommends an explicit conversion. --- .../LiteralToStringByConcatenationCheck.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java index af9703f2429..b71ec06e162 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -18,12 +18,17 @@ import java.util.List; import org.sonar.check.Rule; +import org.sonar.java.model.ExpressionUtils; import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; +import org.sonar.plugins.java.api.tree.BinaryExpressionTree; +import org.sonar.plugins.java.api.tree.ExpressionTree; import org.sonar.plugins.java.api.tree.Tree; @Rule(key = "S9433") public class LiteralToStringByConcatenationCheck extends IssuableSubscriptionVisitor { + private static final String MESSAGE = "Use an explicit conversion instead of concatenating this literal with an empty string."; + @Override public List nodesToVisit() { return List.of(Tree.Kind.PLUS); @@ -31,5 +36,26 @@ public List nodesToVisit() { @Override public void visitNode(Tree tree) { + BinaryExpressionTree binaryExpression = (BinaryExpressionTree) tree; + ExpressionTree leftOperand = ExpressionUtils.skipParentheses(binaryExpression.leftOperand()); + ExpressionTree rightOperand = ExpressionUtils.skipParentheses(binaryExpression.rightOperand()); + if ((isEmptyStringLiteral(leftOperand) && isPrimitiveLiteral(rightOperand)) + || (isPrimitiveLiteral(leftOperand) && isEmptyStringLiteral(rightOperand))) { + reportIssue(binaryExpression, MESSAGE); + } + } + + private static boolean isEmptyStringLiteral(ExpressionTree tree) { + return tree.is(Tree.Kind.STRING_LITERAL) && tree.asConstant(String.class).filter(String::isEmpty).isPresent(); + } + + private static boolean isPrimitiveLiteral(ExpressionTree tree) { + return tree.is( + Tree.Kind.BOOLEAN_LITERAL, + Tree.Kind.CHAR_LITERAL, + Tree.Kind.INT_LITERAL, + Tree.Kind.LONG_LITERAL, + Tree.Kind.FLOAT_LITERAL, + Tree.Kind.DOUBLE_LITERAL); } } From 6cb495176e2a540484e6de6b0de1f6ac49e8b50e Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Thu, 15 Oct 2026 14:33:19 +0200 Subject: [PATCH 04/11] SONARJAVA-7155 Fix sample locations, skip constant contexts, drop LGPL header Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ralToStringByConcatenationCheckSample.java | 51 +++++++++++-------- .../LiteralToStringByConcatenationCheck.java | 34 ++++++++++++- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java index b62fb33f6da..3a98f0e9fe9 100644 --- a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java @@ -1,27 +1,36 @@ -/* - * SonarQube Java - * Copyright (C) 2012-2026 SonarSource SA - * mailto:contact AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public License - * as published by the Free Software Foundation; either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ package checks; class LiteralToStringByConcatenationCheckSample { private static final String EMPTY = ""; + static final String CONSTANT = "" + 42; + static final String PARENTHESIZED_CONSTANT = ("" + 42); + final String instanceField = "" + 42; // Noncompliant + static String staticField = "" + 42; // Noncompliant + static final Runnable LAMBDA = () -> System.out.println("" + 42); // Noncompliant + + interface Constants { + String INTERFACE_CONSTANT = "" + 42; + } + + @SuppressWarnings("" + 1) + void annotated() { + } + + String switchOnString(String input) { + switch (input) { + case "" + 42: + return "" + 1; // Noncompliant + default: + return input; + } + } + + void loopVariable() { + for (String s = "" + 0; s.isEmpty(); ) { // Noncompliant + break; + } + } String convertLiterals(int value) { String itemCount = "" + 123; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} @@ -31,11 +40,11 @@ String convertLiterals(int value) { String ratio = "" + 3.14f; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} // ^^^^^^^^^^ String initial = "" + 'A'; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} -// ^^^^^^^^^ +// ^^^^^^^^ String timeout = 42L + ""; // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} // ^^^^^^^^ String rate = ("") + (2.5d); // Noncompliant {{Use an explicit conversion instead of concatenating this literal with an empty string.}} -// ^^^^^^^^^^^^^^ +// ^^^^^^^^^^^^^ return itemCount + enabled + ratio + initial + timeout + rate; } diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java index b71ec06e162..7b9e9f4a75e 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -19,10 +19,13 @@ import java.util.List; import org.sonar.check.Rule; import org.sonar.java.model.ExpressionUtils; +import org.sonar.java.model.ModifiersUtils; import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; import org.sonar.plugins.java.api.tree.BinaryExpressionTree; import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.Modifier; import org.sonar.plugins.java.api.tree.Tree; +import org.sonar.plugins.java.api.tree.VariableTree; @Rule(key = "S9433") public class LiteralToStringByConcatenationCheck extends IssuableSubscriptionVisitor { @@ -39,12 +42,39 @@ public void visitNode(Tree tree) { BinaryExpressionTree binaryExpression = (BinaryExpressionTree) tree; ExpressionTree leftOperand = ExpressionUtils.skipParentheses(binaryExpression.leftOperand()); ExpressionTree rightOperand = ExpressionUtils.skipParentheses(binaryExpression.rightOperand()); - if ((isEmptyStringLiteral(leftOperand) && isPrimitiveLiteral(rightOperand)) - || (isPrimitiveLiteral(leftOperand) && isEmptyStringLiteral(rightOperand))) { + boolean isLiteralConversion = (isEmptyStringLiteral(leftOperand) && isPrimitiveLiteral(rightOperand)) + || (isPrimitiveLiteral(leftOperand) && isEmptyStringLiteral(rightOperand)); + if (isLiteralConversion && !isInConstantContext(binaryExpression)) { reportIssue(binaryExpression, MESSAGE); } } + /** + * Returns true when the expression is used where a compile-time constant is expected or meaningful: + * annotation arguments, case labels, and initializers of constant fields (static final fields, or interface fields). + * In these contexts, replacing the concatenation with an explicit conversion would not compile or would change semantics. + */ + private static boolean isInConstantContext(Tree tree) { + Tree parent = tree.parent(); + while (parent != null && !parent.is(Tree.Kind.BLOCK, Tree.Kind.LAMBDA_EXPRESSION, Tree.Kind.METHOD, Tree.Kind.CONSTRUCTOR) + && !Tree.Kind.CLASS_KINDS.contains(parent.kind())) { + if (parent.is(Tree.Kind.ANNOTATION, Tree.Kind.CASE_LABEL)) { + return true; + } + if (parent.is(Tree.Kind.VARIABLE)) { + return isConstantField((VariableTree) parent); + } + parent = parent.parent(); + } + return false; + } + + private static boolean isConstantField(VariableTree variable) { + Tree owner = variable.parent(); + return Tree.Kind.CLASS_KINDS.contains(owner.kind()) + && (owner.is(Tree.Kind.INTERFACE, Tree.Kind.ANNOTATION_TYPE) || ModifiersUtils.hasAll(variable.modifiers(), Modifier.STATIC, Modifier.FINAL)); + } + private static boolean isEmptyStringLiteral(ExpressionTree tree) { return tree.is(Tree.Kind.STRING_LITERAL) && tree.asConstant(String.class).filter(String::isEmpty).isPresent(); } From 1d7274b176e6f9c41c1d01aa859d5386c62c13ac Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Thu, 15 Oct 2026 15:02:12 +0200 Subject: [PATCH 05/11] SONARJAVA-7155 Restrict constant context to constant expressions and annotation defaults Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ralToStringByConcatenationCheckSample.java | 33 ++++++++++++++ .../LiteralToStringByConcatenationCheck.java | 44 ++++++++++++++----- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java index 3a98f0e9fe9..cc2835b868f 100644 --- a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java @@ -1,5 +1,7 @@ package checks; +import java.util.List; + class LiteralToStringByConcatenationCheckSample { private static final String EMPTY = ""; @@ -8,15 +10,46 @@ class LiteralToStringByConcatenationCheckSample { final String instanceField = "" + 42; // Noncompliant static String staticField = "" + 42; // Noncompliant static final Runnable LAMBDA = () -> System.out.println("" + 42); // Noncompliant + static final List LIST = List.of("" + 1); // Noncompliant + static final String FORMATTED = String.format("%s", "" + 42); // Noncompliant + static final String[] ARRAY = {"" + 1}; // Noncompliant + static final String CONDITIONAL = true ? "" + 1 : "x"; + static final String NESTED = "a" + ("" + 1); + static final int SELECTOR = 1; + static final String SWITCHED = switch (SELECTOR) { + case 1 -> "" + 1; // Noncompliant + default -> ""; + }; interface Constants { String INTERFACE_CONSTANT = "" + 42; } + @interface WithDefault { + String value() default "" + 1; + String[] values() default {"" + 1}; + } + @SuppressWarnings("" + 1) void annotated() { } + @SuppressWarnings(value = "" + 1) + void named() { + } + + @SuppressWarnings({"" + 1}) + void arrayAnnotation() { + } + + @SuppressWarnings(value = {"" + 1, ("" + 2)}) + void namedArrayAnnotation() { + } + + void assignmentInArguments(String s) { + System.out.println(s = "" + 1); // Noncompliant + } + String switchOnString(String input) { switch (input) { case "" + 42: diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java index 7b9e9f4a75e..fb451c3659e 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -23,6 +23,7 @@ import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; import org.sonar.plugins.java.api.tree.BinaryExpressionTree; import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.MethodTree; import org.sonar.plugins.java.api.tree.Modifier; import org.sonar.plugins.java.api.tree.Tree; import org.sonar.plugins.java.api.tree.VariableTree; @@ -50,23 +51,42 @@ public void visitNode(Tree tree) { } /** - * Returns true when the expression is used where a compile-time constant is expected or meaningful: - * annotation arguments, case labels, and initializers of constant fields (static final fields, or interface fields). - * In these contexts, replacing the concatenation with an explicit conversion would not compile or would change semantics. + * Returns true when the expression is part of a constant expression used where a compile-time constant is expected + * or meaningful: annotation arguments, annotation element default values, case labels, and initializers of constant + * fields (static final fields, or interface fields). + * Only parentheses, string concatenations and conditional expressions are traversed to find the enclosing context, + * so that concatenations nested in method invocations or other non-constant expressions are still reported. */ private static boolean isInConstantContext(Tree tree) { + Tree expression = tree; Tree parent = tree.parent(); - while (parent != null && !parent.is(Tree.Kind.BLOCK, Tree.Kind.LAMBDA_EXPRESSION, Tree.Kind.METHOD, Tree.Kind.CONSTRUCTOR) - && !Tree.Kind.CLASS_KINDS.contains(parent.kind())) { - if (parent.is(Tree.Kind.ANNOTATION, Tree.Kind.CASE_LABEL)) { - return true; - } - if (parent.is(Tree.Kind.VARIABLE)) { - return isConstantField((VariableTree) parent); - } + while (parent.is(Tree.Kind.PARENTHESIZED_EXPRESSION, Tree.Kind.PLUS, Tree.Kind.CONDITIONAL_EXPRESSION)) { + expression = parent; parent = parent.parent(); } - return false; + if (parent.is(Tree.Kind.CASE_LABEL)) { + return true; + } + if (parent.is(Tree.Kind.VARIABLE)) { + return isConstantField((VariableTree) parent); + } + return isAnnotationValue(expression); + } + + private static boolean isAnnotationValue(Tree expression) { + Tree value = expression; + Tree parent = expression.parent(); + if (parent.is(Tree.Kind.LIST) && parent.parent().is(Tree.Kind.NEW_ARRAY)) { + value = parent.parent(); + parent = value.parent(); + } + if (parent.is(Tree.Kind.METHOD)) { + return value == ((MethodTree) parent).defaultValue(); + } + if (parent.is(Tree.Kind.ASSIGNMENT)) { + parent = parent.parent(); + } + return parent.is(Tree.Kind.ARGUMENTS) && parent.parent().is(Tree.Kind.ANNOTATION); } private static boolean isConstantField(VariableTree variable) { From 8aca0fe597f76ac474da7269a9b1cf7d79db0a89 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Fri, 16 Oct 2026 10:05:53 +0200 Subject: [PATCH 06/11] SONARJAVA-7155 Extract LITERAL_KINDS to Tree.Kind and test longer concatenations Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ralToStringByConcatenationCheckSample.java | 13 +++++++++++- .../LiteralToStringByConcatenationCheck.java | 9 ++------ .../org/sonar/plugins/java/api/tree/Tree.java | 21 +++++++++++++++++++ .../sonar/plugins/java/api/tree/TreeTest.java | 8 +++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java index cc2835b868f..fbdde9799e4 100644 --- a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java @@ -81,6 +81,15 @@ String convertLiterals(int value) { return itemCount + enabled + ratio + initial + timeout + rate; } + String longerConcatenations() { + String leading = "" + 3 + 4; // Noncompliant +// ^^^^^^ + String trailing = 3 + "" + 4; // Noncompliant +// ^^^^^^ + String computedFirst = 3 + 4 + ""; + return leading + trailing + computedFirst; + } + String explicitOrOrdinaryConcatenation(int value) { String explicitInteger = Integer.toString(123); String explicitBoolean = Boolean.toString(true); @@ -89,8 +98,10 @@ String explicitOrOrdinaryConcatenation(int value) { String variable = "" + value; String absent = "" + null; String text = "" + "ready"; + String block = "" + """ + ready"""; String constant = EMPTY + 123; String computed = "" + (1 + 2); - return explicitInteger + explicitBoolean + explicitGeneric + prefixed + variable + absent + text + constant + computed; + return explicitInteger + explicitBoolean + explicitGeneric + prefixed + variable + absent + text + block + constant + computed; } } diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java index fb451c3659e..42772fd5619 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -100,12 +100,7 @@ private static boolean isEmptyStringLiteral(ExpressionTree tree) { } private static boolean isPrimitiveLiteral(ExpressionTree tree) { - return tree.is( - Tree.Kind.BOOLEAN_LITERAL, - Tree.Kind.CHAR_LITERAL, - Tree.Kind.INT_LITERAL, - Tree.Kind.LONG_LITERAL, - Tree.Kind.FLOAT_LITERAL, - Tree.Kind.DOUBLE_LITERAL); + return Tree.Kind.LITERAL_KINDS.contains(tree.kind()) + && !tree.is(Tree.Kind.STRING_LITERAL, Tree.Kind.TEXT_BLOCK, Tree.Kind.NULL_LITERAL); } } diff --git a/java-frontend/src/main/java/org/sonar/plugins/java/api/tree/Tree.java b/java-frontend/src/main/java/org/sonar/plugins/java/api/tree/Tree.java index a82959ba821..53f6df55f46 100644 --- a/java-frontend/src/main/java/org/sonar/plugins/java/api/tree/Tree.java +++ b/java-frontend/src/main/java/org/sonar/plugins/java/api/tree/Tree.java @@ -818,6 +818,27 @@ enum Kind implements GrammarRuleKey { OR_ASSIGNMENT ); + /** + * The {@link Kind}s of all literals: {@link Kind#INT_LITERAL}, {@link Kind#LONG_LITERAL}, {@link Kind#FLOAT_LITERAL}, + * {@link Kind#DOUBLE_LITERAL}, {@link Kind#BOOLEAN_LITERAL}, {@link Kind#CHAR_LITERAL}, {@link Kind#STRING_LITERAL}, + * {@link Kind#TEXT_BLOCK} and {@link Kind#NULL_LITERAL}. All of these are backed by {@link LiteralTree}. + * + *

Use this as the single source of truth when a visitor or predicate must handle every literal, instead of + * hand-listing the kinds. In particular, it can be returned directly from {@code nodesToVisit()} of a subscription visitor that + * needs to visit all literals.

+ */ + public static final List LITERAL_KINDS = List.of( + INT_LITERAL, + LONG_LITERAL, + FLOAT_LITERAL, + DOUBLE_LITERAL, + BOOLEAN_LITERAL, + CHAR_LITERAL, + STRING_LITERAL, + TEXT_BLOCK, + NULL_LITERAL + ); + final Class associatedInterface; Kind(Class associatedInterface) { diff --git a/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java b/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java index ed0808a7837..3f7ddc18ded 100644 --- a/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java +++ b/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java @@ -44,4 +44,12 @@ void assignment_kinds_contains_exactly_all_kinds_backed_by_assignment_expression assertThat(Tree.Kind.ASSIGNMENT_KINDS).containsExactlyInAnyOrder(expected); } + @Test + void literal_kinds_contains_exactly_all_kinds_backed_by_literal_tree() { + Tree.Kind[] expected = Arrays.stream(Tree.Kind.values()) + .filter(kind -> kind.getAssociatedInterface() == LiteralTree.class) + .toArray(Tree.Kind[]::new); + assertThat(Tree.Kind.LITERAL_KINDS).containsExactlyInAnyOrder(expected); + } + } From 7501e648270107cc53c128d3e55693549f67236d Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Fri, 16 Oct 2026 10:34:58 +0200 Subject: [PATCH 07/11] Add constant concatenation regression cases --- ...ralToStringByConcatenationCheckSample.java | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java index fbdde9799e4..8b7ba1d49fb 100644 --- a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java @@ -5,16 +5,48 @@ class LiteralToStringByConcatenationCheckSample { private static final String EMPTY = ""; - static final String CONSTANT = "" + 42; - static final String PARENTHESIZED_CONSTANT = ("" + 42); + static final String CONSTANT = "" + 42; // Noncompliant {{Replace this concatenation with the string literal "42".}} +// ^^^^^^^ + static final String PARENTHESIZED_CONSTANT = ("" + 42); // Noncompliant {{Replace this concatenation with the string literal "42".}} +// ^^^^^^^ final String instanceField = "" + 42; // Noncompliant static String staticField = "" + 42; // Noncompliant static final Runnable LAMBDA = () -> System.out.println("" + 42); // Noncompliant static final List LIST = List.of("" + 1); // Noncompliant static final String FORMATTED = String.format("%s", "" + 42); // Noncompliant static final String[] ARRAY = {"" + 1}; // Noncompliant - static final String CONDITIONAL = true ? "" + 1 : "x"; - static final String NESTED = "a" + ("" + 1); + static final String CONDITIONAL = true ? "" + 1 : "x"; // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ + static final String NESTED = "a" + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "a1".}} +// ^^^^^^^^^^^^^^ + static final String REPEATED = ("" + 1) + ("" + 2); // Noncompliant {{Replace this concatenation with the string literal "12".}} +// ^^^^^^^^^^^^^^^^^^^ + static final String LONGER = "" + 3 + 4; // Noncompliant {{Replace this concatenation with the string literal "34".}} +// ^^^^^^^^^^^ + static final String TRAILING = 3 + "" + 4; // Noncompliant {{Replace this concatenation with the string literal "34".}} +// ^^^^^^^^^^ + static final String ARITHMETIC = (1 + 2) + ("" + 4); // Noncompliant {{Replace this concatenation with the string literal "34".}} +// ^^^^^^^^^^^^^^^^^^ + static final String BOOLEAN = "" + true; // Noncompliant {{Replace this concatenation with the string literal "true".}} +// ^^^^^^^^^ + static final String CHARACTER = "" + 'A'; // Noncompliant {{Replace this concatenation with the string literal "A".}} +// ^^^^^^^^ + static final String FLOAT = "" + 3.14f; // Noncompliant {{Replace this concatenation with the string literal "3.14".}} +// ^^^^^^^^^^ + static final String DOUBLE = "" + 2.5d; // Noncompliant {{Replace this concatenation with the string literal "2.5".}} +// ^^^^^^^^^ + static final String LONG = "" + 42L; // Noncompliant {{Replace this concatenation with the string literal "42".}} +// ^^^^^^^^ + static final String HEX = "" + 0xff; // Noncompliant {{Replace this concatenation with the string literal "255".}} +// ^^^^^^^^^ + static final String ESCAPED = "\"\\\n" + ("" + '\t'); // Noncompliant {{Replace this concatenation with the string literal "\"\\\n\t".}} +// ^^^^^^^^^^^^^^^^^^^^^ + static final String CONTROL = "" + '\0'; // Noncompliant {{Replace this concatenation with the string literal "\u0000".}} +// ^^^^^^^^^ + static final String DYNAMIC = System.getProperty("prefix") + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ + static final String ORDINARY = "a" + "b"; + static final String LITERAL = "a1"; static final int SELECTOR = 1; static final String SWITCHED = switch (SELECTOR) { case 1 -> "" + 1; // Noncompliant @@ -22,27 +54,36 @@ class LiteralToStringByConcatenationCheckSample { }; interface Constants { - String INTERFACE_CONSTANT = "" + 42; + String INTERFACE_CONSTANT = "" + 42; // Noncompliant {{Replace this concatenation with the string literal "42".}} +// ^^^^^^^ } @interface WithDefault { - String value() default "" + 1; - String[] values() default {"" + 1}; + String value() default "" + 1; // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ + String[] values() default {"" + 1}; // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ } - @SuppressWarnings("" + 1) + @SuppressWarnings("" + 1) // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ void annotated() { } - @SuppressWarnings(value = "" + 1) + @SuppressWarnings(value = "" + 1) // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ void named() { } - @SuppressWarnings({"" + 1}) + @SuppressWarnings({"" + 1}) // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ void arrayAnnotation() { } - @SuppressWarnings(value = {"" + 1, ("" + 2)}) + @SuppressWarnings(value = {"" + 1, // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ + ("" + 2)}) // Noncompliant {{Replace this concatenation with the string literal "2".}} +// ^^^^^^ void namedArrayAnnotation() { } @@ -52,7 +93,8 @@ void assignmentInArguments(String s) { String switchOnString(String input) { switch (input) { - case "" + 42: + case "" + 42: // Noncompliant {{Replace this concatenation with the string literal "42".}} +// ^^^^^^^ return "" + 1; // Noncompliant default: return input; From 39cb422ed8c1d8e4e60c84b82fd230c55c8a5a70 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Fri, 16 Oct 2026 10:39:29 +0200 Subject: [PATCH 08/11] Report constant concatenations with literal replacements --- ...ralToStringByConcatenationCheckSample.java | 39 +++++--- .../LiteralToStringByConcatenationCheck.java | 99 ++++++++++++++++++- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java index 8b7ba1d49fb..579e9a5b031 100644 --- a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckSample.java @@ -8,7 +8,7 @@ class LiteralToStringByConcatenationCheckSample { static final String CONSTANT = "" + 42; // Noncompliant {{Replace this concatenation with the string literal "42".}} // ^^^^^^^ static final String PARENTHESIZED_CONSTANT = ("" + 42); // Noncompliant {{Replace this concatenation with the string literal "42".}} -// ^^^^^^^ +// ^^^^^^^ final String instanceField = "" + 42; // Noncompliant static String staticField = "" + 42; // Noncompliant static final Runnable LAMBDA = () -> System.out.println("" + 42); // Noncompliant @@ -16,37 +16,46 @@ class LiteralToStringByConcatenationCheckSample { static final String FORMATTED = String.format("%s", "" + 42); // Noncompliant static final String[] ARRAY = {"" + 1}; // Noncompliant static final String CONDITIONAL = true ? "" + 1 : "x"; // Noncompliant {{Replace this concatenation with the string literal "1".}} -// ^^^^^^ +// ^^^^^^ static final String NESTED = "a" + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "a1".}} -// ^^^^^^^^^^^^^^ +// ^^^^^^^^^^^^^^ static final String REPEATED = ("" + 1) + ("" + 2); // Noncompliant {{Replace this concatenation with the string literal "12".}} -// ^^^^^^^^^^^^^^^^^^^ +// ^^^^^^^^^^^^^^^^^^^ static final String LONGER = "" + 3 + 4; // Noncompliant {{Replace this concatenation with the string literal "34".}} -// ^^^^^^^^^^^ +// ^^^^^^^^^^ static final String TRAILING = 3 + "" + 4; // Noncompliant {{Replace this concatenation with the string literal "34".}} -// ^^^^^^^^^^ +// ^^^^^^^^^^ static final String ARITHMETIC = (1 + 2) + ("" + 4); // Noncompliant {{Replace this concatenation with the string literal "34".}} -// ^^^^^^^^^^^^^^^^^^ +// ^^^^^^^^^^^^^^^^^^ static final String BOOLEAN = "" + true; // Noncompliant {{Replace this concatenation with the string literal "true".}} -// ^^^^^^^^^ +// ^^^^^^^^^ static final String CHARACTER = "" + 'A'; // Noncompliant {{Replace this concatenation with the string literal "A".}} -// ^^^^^^^^ +// ^^^^^^^^ static final String FLOAT = "" + 3.14f; // Noncompliant {{Replace this concatenation with the string literal "3.14".}} -// ^^^^^^^^^^ +// ^^^^^^^^^^ static final String DOUBLE = "" + 2.5d; // Noncompliant {{Replace this concatenation with the string literal "2.5".}} -// ^^^^^^^^^ +// ^^^^^^^^^ static final String LONG = "" + 42L; // Noncompliant {{Replace this concatenation with the string literal "42".}} -// ^^^^^^^^ +// ^^^^^^^^ static final String HEX = "" + 0xff; // Noncompliant {{Replace this concatenation with the string literal "255".}} -// ^^^^^^^^^ +// ^^^^^^^^^ static final String ESCAPED = "\"\\\n" + ("" + '\t'); // Noncompliant {{Replace this concatenation with the string literal "\"\\\n\t".}} -// ^^^^^^^^^^^^^^^^^^^^^ +// ^^^^^^^^^^^^^^^^^^^^^^ static final String CONTROL = "" + '\0'; // Noncompliant {{Replace this concatenation with the string literal "\u0000".}} // ^^^^^^^^^ static final String DYNAMIC = System.getProperty("prefix") + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "1".}} -// ^^^^^^ +// ^^^^^^ static final String ORDINARY = "a" + "b"; static final String LITERAL = "a1"; + static final String NON_CONSTANT = ("" + 1) + (1 / 0); // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ + static final String UNICODE = "" + '\u0041'; // Noncompliant {{Replace this concatenation with a string literal.}} +// ^^^^^^^^^^^^^ + static final String OTHER_ESCAPES = "" + '\r' + '\b' + '\f'; // Noncompliant {{Replace this concatenation with the string literal "\r\b\f".}} +// ^^^^^^^^^^^^^^^^^^^^^^^ + static final String WITH_TEXT_BLOCK = """ + text""" + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "1".}} +// ^^^^^^ static final int SELECTOR = 1; static final String SWITCHED = switch (SELECTOR) { case 1 -> "" + 1; // Noncompliant diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java index 42772fd5619..c1785d07856 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -17,12 +17,16 @@ package org.sonar.java.checks; import java.util.List; +import java.util.Locale; +import java.util.Optional; import org.sonar.check.Rule; import org.sonar.java.model.ExpressionUtils; +import org.sonar.java.model.LiteralUtils; import org.sonar.java.model.ModifiersUtils; import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; import org.sonar.plugins.java.api.tree.BinaryExpressionTree; import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.LiteralTree; import org.sonar.plugins.java.api.tree.MethodTree; import org.sonar.plugins.java.api.tree.Modifier; import org.sonar.plugins.java.api.tree.Tree; @@ -41,13 +45,102 @@ public List nodesToVisit() { @Override public void visitNode(Tree tree) { BinaryExpressionTree binaryExpression = (BinaryExpressionTree) tree; + if (!isLiteralConversion(binaryExpression)) { + return; + } + if (isInConstantContext(binaryExpression)) { + ExpressionTree concatenation = enclosingConstantConcatenation(binaryExpression); + if (firstLiteralConversion(concatenation).filter(first -> first == binaryExpression).isPresent()) { + String replacement = constantValue(concatenation) + .map(value -> "the string literal \"" + escapeString(value.toString()) + "\"") + .orElse("a string literal"); + reportIssue(concatenation, "Replace this concatenation with " + replacement + "."); + } + } else { + reportIssue(binaryExpression, MESSAGE); + } + } + + private static boolean isLiteralConversion(BinaryExpressionTree binaryExpression) { ExpressionTree leftOperand = ExpressionUtils.skipParentheses(binaryExpression.leftOperand()); ExpressionTree rightOperand = ExpressionUtils.skipParentheses(binaryExpression.rightOperand()); - boolean isLiteralConversion = (isEmptyStringLiteral(leftOperand) && isPrimitiveLiteral(rightOperand)) + return (isEmptyStringLiteral(leftOperand) && isPrimitiveLiteral(rightOperand)) || (isPrimitiveLiteral(leftOperand) && isEmptyStringLiteral(rightOperand)); - if (isLiteralConversion && !isInConstantContext(binaryExpression)) { - reportIssue(binaryExpression, MESSAGE); + } + + private static ExpressionTree enclosingConstantConcatenation(ExpressionTree tree) { + ExpressionTree expression = tree; + Tree parent = expression.parent(); + while (parent.is(Tree.Kind.PARENTHESIZED_EXPRESSION, Tree.Kind.PLUS) + && constantValue((ExpressionTree) parent).filter(String.class::isInstance).isPresent()) { + expression = (ExpressionTree) parent; + parent = expression.parent(); + } + return ExpressionUtils.skipParentheses(expression); + } + + private static Optional firstLiteralConversion(ExpressionTree tree) { + ExpressionTree expression = ExpressionUtils.skipParentheses(tree); + if (!(expression instanceof BinaryExpressionTree binaryExpression) || !expression.is(Tree.Kind.PLUS)) { + return Optional.empty(); + } + if (isLiteralConversion(binaryExpression)) { + return Optional.of(binaryExpression); + } + return firstLiteralConversion(binaryExpression.leftOperand()) + .or(() -> firstLiteralConversion(binaryExpression.rightOperand())); + } + + private static Optional constantValue(ExpressionTree tree) { + try { + return resolveConstantValue(ExpressionUtils.skipParentheses(tree)); + } catch (IllegalArgumentException | ArithmeticException exception) { + return Optional.empty(); + } + } + + private static Optional resolveConstantValue(ExpressionTree expression) { + if (expression.is(Tree.Kind.PLUS)) { + BinaryExpressionTree binaryExpression = (BinaryExpressionTree) expression; + return constantValue(binaryExpression.leftOperand()).flatMap(left -> + constantValue(binaryExpression.rightOperand()).flatMap(right -> + left instanceof String || right instanceof String + ? Optional.of(left.toString() + right) + : expression.asConstant())); + } + if (expression instanceof LiteralTree literal) { + return literalValue(literal); + } + return expression.asConstant(); + } + + private static Optional literalValue(LiteralTree literal) { + return switch (literal.kind()) { + case STRING_LITERAL -> Optional.of(LiteralUtils.trimQuotes(literal.value()).translateEscapes()); + case CHAR_LITERAL -> Optional.of(LiteralUtils.trimQuotes(literal.value()).translateEscapes().charAt(0)); + case FLOAT_LITERAL -> Optional.of(Float.parseFloat(literal.value().replace("_", ""))); + case DOUBLE_LITERAL -> Optional.of(Double.parseDouble(literal.value().replace("_", ""))); + case TEXT_BLOCK -> Optional.empty(); + default -> literal.asConstant(); + }; + } + + private static String escapeString(String value) { + StringBuilder escaped = new StringBuilder(); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + escaped.append(switch (character) { + case '\\' -> "\\\\"; + case '"' -> "\\\""; + case '\n' -> "\\n"; + case '\r' -> "\\r"; + case '\t' -> "\\t"; + case '\b' -> "\\b"; + case '\f' -> "\\f"; + default -> Character.isISOControl(character) ? String.format(Locale.ROOT, "\\u%04x", (int) character) : Character.toString(character); + }); } + return escaped.toString(); } /** From 543e38e6238ec85b5db7227edd98a480abd5e082 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Fri, 16 Oct 2026 11:03:43 +0200 Subject: [PATCH 09/11] Add character constant regression cases --- ...ngByConcatenationCheckConstantsSample.java | 24 +++++++++++++++++++ ...teralToStringByConcatenationCheckTest.java | 18 ++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckConstantsSample.java diff --git a/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckConstantsSample.java b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckConstantsSample.java new file mode 100644 index 00000000000..686f9fd138f --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/LiteralToStringByConcatenationCheckConstantsSample.java @@ -0,0 +1,24 @@ +package checks; + +class LiteralToStringByConcatenationCheckConstantsSample { + + static final char LETTER = 'a'; + static final int NUMBER = 97; + + static final String LEADING = LETTER + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "a1".}} +// ^^^^^^^^^^^^^^^^^ + static final String TRAILING = "" + 1 + LETTER; // Noncompliant {{Replace this concatenation with the string literal "1a".}} +// ^^^^^^^^^^^^^^^ + static final String MEMBER = "" + 1 + Constants.LETTER; // Noncompliant {{Replace this concatenation with the string literal "1b".}} +// ^^^^^^^^^^^^^^^^^^^^^^^^^ + static final String MINIMUM = "" + 1 + Character.MIN_VALUE; // Noncompliant {{Replace this concatenation with the string literal "1\u0000".}} +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + static final String NUMERIC = NUMBER + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "971".}} +// ^^^^^^^^^^^^^^^^^ + static final String ARITHMETIC = (LETTER + 1) + ("" + 1); // Noncompliant {{Replace this concatenation with the string literal "981".}} +// ^^^^^^^^^^^^^^^^^^^^^^^ + + interface Constants { + char LETTER = 'b'; + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java index d08a91d9056..4fa94e3af72 100644 --- a/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java +++ b/java-checks/src/test/java/org/sonar/java/checks/LiteralToStringByConcatenationCheckTest.java @@ -24,6 +24,7 @@ class LiteralToStringByConcatenationCheckTest { private static final String SAMPLE = "checks/LiteralToStringByConcatenationCheckSample.java"; + private static final String CONSTANTS_SAMPLE = "checks/LiteralToStringByConcatenationCheckConstantsSample.java"; private static final LiteralToStringByConcatenationCheck CHECK = new LiteralToStringByConcatenationCheck(); @Test @@ -42,4 +43,21 @@ void test_without_semantic() { .withoutSemantic() .verifyIssues(); } + + @Test + void character_constants() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(CONSTANTS_SAMPLE)) + .withCheck(CHECK) + .verifyIssues(); + } + + @Test + void character_constants_without_semantic() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(CONSTANTS_SAMPLE)) + .withCheck(CHECK) + .withoutSemantic() + .verifyIssues(); + } } From 6c4c53a3461f7c87a98ce6eeeedec708f4b59b66 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Fri, 16 Oct 2026 11:04:31 +0200 Subject: [PATCH 10/11] Fix literal-kind assertion argument order --- .../test/java/org/sonar/plugins/java/api/tree/TreeTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java b/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java index 3f7ddc18ded..633242343e5 100644 --- a/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java +++ b/java-frontend/src/test/java/org/sonar/plugins/java/api/tree/TreeTest.java @@ -46,10 +46,10 @@ void assignment_kinds_contains_exactly_all_kinds_backed_by_assignment_expression @Test void literal_kinds_contains_exactly_all_kinds_backed_by_literal_tree() { - Tree.Kind[] expected = Arrays.stream(Tree.Kind.values()) + Tree.Kind[] actual = Arrays.stream(Tree.Kind.values()) .filter(kind -> kind.getAssociatedInterface() == LiteralTree.class) .toArray(Tree.Kind[]::new); - assertThat(Tree.Kind.LITERAL_KINDS).containsExactlyInAnyOrder(expected); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Tree.Kind.LITERAL_KINDS); } } From 677f480f233bfee777f7a9cfd3cbf929a2d492f6 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Fri, 16 Oct 2026 11:04:32 +0200 Subject: [PATCH 11/11] Preserve character constants in literal replacements --- .../java/checks/LiteralToStringByConcatenationCheck.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java index c1785d07856..1b45ef97bf8 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/LiteralToStringByConcatenationCheck.java @@ -111,7 +111,12 @@ private static Optional resolveConstantValue(ExpressionTree expression) if (expression instanceof LiteralTree literal) { return literalValue(literal); } - return expression.asConstant(); + Optional value = expression.asConstant(); + if (expression.symbolType().is("char")) { + return value.filter(Integer.class::isInstance) + .map(codeUnit -> (char) ((Integer) codeUnit).intValue()); + } + return value; } private static Optional literalValue(LiteralTree literal) {