[java] IDEA-379317 Add StringBuilderRepeatCanBeUsedFix

This is the first part of IDEA-379317 ticket.
Second part - Suggestion to convert sb.append("*".repeat(10)); to sb.repeat("*", 10); - will be provided in separate merge request.

Merge-request: IJ-MR-179573
Merged-by: Marcin Mikosik <marcin.mikosik@jetbrains.com>

GitOrigin-RevId: c52077e6659b3d0108dc6145456fe98da681cfb4
This commit is contained in:
Marcin Mikosik
2025-10-29 16:38:38 +00:00
committed by intellij-monorepo-bot
parent 30b3d1267d
commit a523332df0
27 changed files with 361 additions and 36 deletions
@@ -19,6 +19,7 @@ import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.util.PsiUtil;
import com.siyeh.ig.callMatcher.CallMatcher;
import com.siyeh.ig.psiutils.*;
import com.siyeh.ipp.psiutils.ErrorUtil;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -40,12 +41,14 @@ public final class StringRepeatCanBeUsedInspection extends AbstractBaseJavaLocal
@Override
public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) {
if (!PsiUtil.getLanguageLevel(holder.getFile()).isAtLeast(LanguageLevel.JDK_11)) return PsiElementVisitor.EMPTY_VISITOR;
var languageLevel = PsiUtil.getLanguageLevel(holder.getFile());
if (!languageLevel.isAtLeast(LanguageLevel.JDK_11)) return PsiElementVisitor.EMPTY_VISITOR;
return new JavaElementVisitor() {
@Override
public void visitForStatement(@NotNull PsiForStatement statement) {
PsiMethodCallExpression call = findAppendCall(statement);
if (call == null) return;
if (ErrorUtil.containsDeepError(call)) return;
PsiReferenceExpression qualifier = tryCast(PsiUtil.skipParenthesizedExprDown(call.getMethodExpression().getQualifierExpression()),
PsiReferenceExpression.class);
if (qualifier == null || !ExpressionUtil.isEffectivelyUnqualified(qualifier)) return;
@@ -55,9 +58,19 @@ public final class StringRepeatCanBeUsedInspection extends AbstractBaseJavaLocal
if (var.getType().equals(PsiTypes.longType()) || VariableAccessUtils.variableIsUsed(var, call)) return;
PsiExpression arg = call.getArgumentList().getExpressions()[0];
if (SideEffectChecker.mayHaveSideEffects(arg)) return;
holder.registerProblem(statement.getFirstChild(), JavaBundle.message(
"inspection.message.can.be.replaced.with.string.repeat"),
new StringRepeatCanBeUsedFix(ADD_MATH_MAX));
if (languageLevel.isAtLeast(LanguageLevel.JDK_21)) {
PsiType type = qualifier.getType();
if (type == null) return;
String builderClassName = type.getPresentableText();
holder.registerProblem(statement.getFirstChild(),
JavaBundle.message("inspection.message.can.be.replaced.with.builder.repeat",
builderClassName + ".repeat()"),
new AbstractStringBuilderRepeatCanBeUsedFix(ADD_MATH_MAX, builderClassName));
}
else {
holder.registerProblem(statement.getFirstChild(), JavaBundle.message("inspection.message.can.be.replaced.with.string.repeat"),
new StringRepeatCanBeUsedFix(ADD_MATH_MAX));
}
}
};
}
@@ -70,11 +83,40 @@ public final class StringRepeatCanBeUsedInspection extends AbstractBaseJavaLocal
return call;
}
private static final class StringRepeatCanBeUsedFix extends PsiUpdateModCommandQuickFix {
private final boolean myAddMathMax;
private static final class AbstractStringBuilderRepeatCanBeUsedFix extends RepeatCanBeUsedFix {
private final String builderClassShortName;
private AbstractStringBuilderRepeatCanBeUsedFix(boolean addMathMax, String builderClassShortName) {
super(addMathMax);
this.builderClassShortName = builderClassShortName;
}
@Override
public @Nls(capitalization = Nls.Capitalization.Sentence) @NotNull String getFamilyName() {
return CommonQuickFixBundle.message("fix.replace.with.x", builderClassShortName + ".repeat()");
}
@Override
protected void replaceWithRepeatCall(PsiExpression qualifierExpression,
String repeatedStringExpression,
String countText,
CommentTracker ct,
PsiExpression arg,
PsiForStatement forStatement,
PsiMethodCallExpression appendCall) {
String replacement = qualifierExpression.getText() + ".repeat(" + repeatedStringExpression + ", " + countText + ");";
PsiExpressionStatement result = (PsiExpressionStatement)ct.replaceAndRestoreComments(forStatement, replacement);
if (myAddMathMax) {
PsiMethodCallExpression repeatCall = (PsiMethodCallExpression)result.getExpression();
PsiMethodCallExpression maxCall = (PsiMethodCallExpression)repeatCall.getArgumentList().getExpressions()[1];
simplifyMaxCall(maxCall);
}
}
}
private static final class StringRepeatCanBeUsedFix extends RepeatCanBeUsedFix {
private StringRepeatCanBeUsedFix(boolean addMathMax) {
myAddMathMax = addMathMax;
super(addMathMax);
}
@Override
@@ -82,17 +124,45 @@ public final class StringRepeatCanBeUsedInspection extends AbstractBaseJavaLocal
return CommonQuickFixBundle.message("fix.replace.with.x", "String.repeat()");
}
@Override
protected void replaceWithRepeatCall(PsiExpression qualifierExpression,
String repeatedStringExpression,
String countText,
CommentTracker ct,
PsiExpression arg,
PsiForStatement forStatement,
PsiMethodCallExpression call) {
String replacement = repeatedStringExpression + ".repeat(" + countText + ")";
ct.replace(arg, replacement);
PsiExpressionStatement result = (PsiExpressionStatement)ct.replaceAndRestoreComments(forStatement, call.getParent());
if (myAddMathMax) {
PsiMethodCallExpression appendCall = (PsiMethodCallExpression)result.getExpression();
PsiMethodCallExpression repeatCall = (PsiMethodCallExpression)appendCall.getArgumentList().getExpressions()[0];
PsiMethodCallExpression maxCall = (PsiMethodCallExpression)repeatCall.getArgumentList().getExpressions()[0];
simplifyMaxCall(maxCall);
}
}
}
private static abstract class RepeatCanBeUsedFix extends PsiUpdateModCommandQuickFix {
protected final boolean myAddMathMax;
private RepeatCanBeUsedFix(boolean addMathMax) {
myAddMathMax = addMathMax;
}
@Override
protected void applyFix(@NotNull Project project, @NotNull PsiElement element, @NotNull ModPsiUpdater updater) {
PsiForStatement statement = PsiTreeUtil.getParentOfType(element, PsiForStatement.class);
if (statement == null) return;
CountingLoop loop = CountingLoop.from(statement);
PsiForStatement forStatement = PsiTreeUtil.getParentOfType(element, PsiForStatement.class);
if (forStatement == null) return;
CountingLoop loop = CountingLoop.from(forStatement);
if (loop == null) return;
PsiMethodCallExpression call = findAppendCall(statement);
if (call == null) return;
PsiExpression builder = call.getMethodExpression().getQualifierExpression();
if (builder == null) return;
PsiExpression arg = call.getArgumentList().getExpressions()[0];
PsiMethodCallExpression appendCall = findAppendCall(forStatement);
if (appendCall == null) return;
if (ErrorUtil.containsDeepError(appendCall)) return;
PsiExpression qualifierExpression = appendCall.getMethodExpression().getQualifierExpression();
if (qualifierExpression == null) return;
PsiExpression arg = appendCall.getArgumentList().getExpressions()[0];
PsiExpression from, to;
if (loop.isDescending()) {
from = loop.getBound();
@@ -103,23 +173,28 @@ public final class StringRepeatCanBeUsedInspection extends AbstractBaseJavaLocal
to = loop.getBound();
}
CommentTracker ct = new CommentTracker();
String repeatQualifier = getRepeatQualifier(arg, ct);
String repeatedStringExpression = getRepeatedStringExpression(arg, ct);
String countText = getCountText(from, to, loop.isIncluding(), ct);
if (myAddMathMax) {
countText = CommonClassNames.JAVA_LANG_MATH + ".max(0," + countText + ")";
}
String replacement = repeatQualifier + ".repeat(" + countText + ")";
ct.replace(arg, replacement);
PsiExpressionStatement result = (PsiExpressionStatement)ct.replaceAndRestoreComments(statement, call.getParent());
if (myAddMathMax) {
PsiMethodCallExpression appendCall = (PsiMethodCallExpression)result.getExpression();
PsiMethodCallExpression repeatCall = (PsiMethodCallExpression)appendCall.getArgumentList().getExpressions()[0];
PsiMethodCallExpression maxCall = (PsiMethodCallExpression)repeatCall.getArgumentList().getExpressions()[0];
PsiExpression count = maxCall.getArgumentList().getExpressions()[1];
LongRangeSet range = CommonDataflow.getExpressionRange(count);
if (range != null && !range.isEmpty() && range.min() >= 0) {
maxCall.replace(count);
}
replaceWithRepeatCall(qualifierExpression, repeatedStringExpression, countText, ct, arg, forStatement, appendCall);
}
protected abstract void replaceWithRepeatCall(
PsiExpression qualifierExpression,
String repeatedStringExpression,
String countText,
CommentTracker ct,
PsiExpression arg,
PsiForStatement forStatement,
PsiMethodCallExpression appendCall);
protected void simplifyMaxCall(PsiMethodCallExpression maxCall) {
PsiExpression count = maxCall.getArgumentList().getExpressions()[1];
LongRangeSet range = CommonDataflow.getExpressionRange(count);
if (range != null && !range.isEmpty() && range.min() >= 0) {
maxCall.replace(count);
}
}
@@ -146,15 +221,16 @@ public final class StringRepeatCanBeUsedInspection extends AbstractBaseJavaLocal
return countText;
}
private static @NotNull String getRepeatQualifier(PsiExpression arg, CommentTracker ct) {
if (arg instanceof PsiLiteralExpression literal && !TypeUtils.isJavaLangString(arg.getType())) {
private static @NotNull String getRepeatedStringExpression(PsiExpression arg, CommentTracker ct) {
boolean isStringType = TypeUtils.isJavaLangString(arg.getType());
if (arg instanceof PsiLiteralExpression literal && !isStringType) {
Object value = literal.getValue();
if (value instanceof Character) {
return PsiLiteralUtil.stringForCharLiteral(literal.getText());
}
return StringUtil.wrapWithDoubleQuote(StringUtil.escapeStringCharacters(String.valueOf(value)));
}
if (TypeUtils.isJavaLangString(arg.getType()) && NullabilityUtil.getExpressionNullability(arg, true) == Nullability.NOT_NULL) {
if (isStringType && NullabilityUtil.getExpressionNullability(arg, true) == Nullability.NOT_NULL) {
return ct.text(arg, ParenthesesUtils.METHOD_CALL_PRECEDENCE);
}
return CommonClassNames.JAVA_LANG_STRING + ".valueOf(" + ct.text(arg) + ")";
@@ -1859,10 +1859,10 @@
groupKey="group.names.language.level.specific.issues.and.migration.aids11" enabledByDefault="true" level="WARNING"
implementationClass="com.intellij.codeInspection.ReadWriteStringCanBeUsedInspection"
key="inspection.read.write.string.can.be.used.display.name" bundle="messages.JavaBundle"/>
<localInspection groupPathKey="group.path.names.java.language.level.specific.issues.and.migration.aids" language="JAVA"
<localInspection groupPath="Java" language="JAVA"
shortName="StringRepeatCanBeUsed"
groupBundle="messages.InspectionsBundle"
groupKey="group.names.language.level.specific.issues.and.migration.aids11" enabledByDefault="true" level="WARNING"
groupKey="group.names.language.level.specific.issues.and.migration.aids" enabledByDefault="true" level="WARNING"
implementationClass="com.intellij.codeInspection.StringRepeatCanBeUsedInspection"
key="inspection.string.repeat.can.be.used.display.name" bundle="messages.JavaBundle"/>
<localInspection groupPathKey="group.path.names.java.language.level.specific.issues.and.migration.aids" language="JAVA"
@@ -1,7 +1,8 @@
<html>
<body>
Reports loops that can be replaced with a single <code>String.repeat()</code> method (available since Java 11).
<p><b>Example:</b></p>
Reports loops that can be replaced with a single <code>StringBuilder.repeat()</code> method (available since Java 21) or
<code>String.repeat()</code> method (available since Java 11).
<p><b>Example (Java 11):</b></p>
<pre><code>
void append(StringBuilder sb, int count, Object obj) {
for (int i = 0; i &lt; count; i++) {
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
// comment before for-loop
// comment before append
sb.repeat(" ", 100);
return sb.toString();
}
}
@@ -0,0 +1,8 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String spaces(int a, int b, int c, int d) {
StringBuilder sb = new StringBuilder();
sb.repeat(" ", Math.max(0, c - d - (a - b)));
return sb.toString();
}
}
@@ -0,0 +1,8 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String testRepeat(String s, StringBuilder sb, int digits) {
if ((s.length() < digits) && (sb.length() > 0)) {
sb.repeat("0", digits - s.length());
}
}
}
@@ -0,0 +1,11 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
public int pendingSpaces;
String testRepeat(StringBuilder buffer) {
if (pendingSpaces > 0) {
buffer.repeat(" ", pendingSpaces);
pendingSpaces = 0;
}
}
}
@@ -0,0 +1,8 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String spaces(int c, int d) {
StringBuilder sb = new StringBuilder();
sb.repeat("123456", Math.max(0, c - d));
return sb.toString();
}
}
@@ -0,0 +1,8 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String hundredTimes(String s) {
StringBuilder sb = new StringBuilder();
sb.repeat(String.valueOf(s), 100);
return sb.toString();
}
}
@@ -0,0 +1,11 @@
// "Replace with 'StringBuilder.repeat()'" "true"
import java.util.Objects;
class Test {
String hundredTimes(String s) {
Objects.requireNonNull(s);
StringBuilder sb = new StringBuilder();
sb.repeat(s, 100);
return sb.toString();
}
}
@@ -0,0 +1,8 @@
// "Replace with 'StringBuffer.repeat()'" "true"
class Test {
String hundredSpaces() {
StringBuffer sb = new StringBuffer();
sb.repeat(" ", 100);
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "false"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append('*
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "false"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append(("*
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "false"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append("a" + "*
}
return sb.toString();
}
}
@@ -0,0 +1,12 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
// comment before for-loop
f<caret>or(int i=0; i<100; i++) {
// comment before append
sb.append(" ");
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String spaces(int a, int b, int c, int d) {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=a-b; i<c-d; i++) {
sb.append(' ');
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String testRepeat(String s, StringBuilder sb, int digits) {
if ((s.length() < digits) && (sb.length() > 0)) {
f<caret>or (int i=s.length(); i < digits; i++) {
sb.append('0');
}
}
}
}
@@ -0,0 +1,12 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
public int pendingSpaces;
String testRepeat(StringBuilder buffer) {
if (pendingSpaces > 0) {
fo<caret>r (int sp = 0; sp < pendingSpaces; sp++)
buffer.append(' ');
pendingSpaces = 0;
}
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String spaces(int c, int d) {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=1; i<=c-d; i++) {
sb.append(123_456);
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "true"
class Test {
String hundredTimes(String s) {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append(s);
}
return sb.toString();
}
}
@@ -0,0 +1,13 @@
// "Replace with 'StringBuilder.repeat()'" "true"
import java.util.Objects;
class Test {
String hundredTimes(String s) {
Objects.requireNonNull(s);
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append(s);
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuilder.repeat()'" "false"
class Test {
String hundredNumbers() {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append(Math.random());
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'StringBuffer.repeat()'" "true"
class Test {
String hundredSpaces() {
StringBuffer sb = new StringBuffer();
f<caret>or(int i=0; i<100; i++) {
sb.append(" ");
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'String.repeat()'" "false"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append('*
}
return sb.toString();
}
}
@@ -0,0 +1,10 @@
// "Replace with 'String.repeat()'" "false"
class Test {
String hundredSpaces() {
StringBuilder sb = new StringBuilder();
f<caret>or(int i=0; i<100; i++) {
sb.append("a" + "*
}
return sb.toString();
}
}
@@ -0,0 +1,28 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.java.codeInspection;
import com.intellij.codeInsight.daemon.quickFix.LightQuickFixParameterizedTestCase;
import com.intellij.codeInspection.LocalInspectionTool;
import com.intellij.codeInspection.StringRepeatCanBeUsedInspection;
import com.intellij.testFramework.LightProjectDescriptor;
import org.jetbrains.annotations.NotNull;
import static com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase.JAVA_21;
public class StringBuilderRepeatCanBeUsedInspectionTest extends LightQuickFixParameterizedTestCase {
@Override
protected LocalInspectionTool @NotNull [] configureLocalInspectionTools() {
return new LocalInspectionTool[]{new StringRepeatCanBeUsedInspection()};
}
@NotNull
@Override
protected LightProjectDescriptor getProjectDescriptor() {
return JAVA_21;
}
@Override
protected String getBasePath() {
return "/inspection/stringBuilderRepeat";
}
}
@@ -531,6 +531,7 @@ inspection.message.can.be.replaced.with.files.readstring=Can be replaced with 'F
inspection.message.can.be.replaced.with.optional.of.nullable=Can be replaced with Optional.ofNullable()
inspection.message.can.be.replaced.with.single.expression.in.functional.style=Can be replaced with single expression in functional style
inspection.message.can.be.replaced.with.string.repeat=Can be replaced with 'String.repeat()'
inspection.message.can.be.replaced.with.builder.repeat=Can be replaced with ''{0}''
inspection.message.lambda.parameter.type.is.redundant=Lambda parameter type is redundant
inspection.message.pseudo.functional.style.code=Pseudo functional style code
inspection.message.redundant.default.parameter.value.assignment=Redundant default parameter value assignment
@@ -1410,7 +1411,7 @@ inspection.explicit.array.filling.display.name=Explicit array filling
inspection.java.8.collection.remove.if.display.name=Loop can be replaced with 'Collection.removeIf()'
inspection.java.8.list.replace.all.display.name=Loop can be replaced with 'List.replaceAll()'
inspection.java.8.map.api.display.name=Simplifiable 'Map' operations
inspection.string.repeat.can.be.used.display.name=String.repeat() can be used
inspection.string.repeat.can.be.used.display.name='repeat()' method can be used
inspection.read.write.string.can.be.used.display.name='Files.readString()' or 'Files.writeString()' can be used
inspection.java.9.collection.factory.display.name=Immutable collection creation can be replaced with collection factory call
inspection.explicit.argument.can.be.lambda.display.name=Explicit argument can be lambda