diff --git a/java/java-impl/src/META-INF/JavaPlugin.xml b/java/java-impl/src/META-INF/JavaPlugin.xml index a9f3c54fbd26..9cd4e70514d7 100644 --- a/java/java-impl/src/META-INF/JavaPlugin.xml +++ b/java/java-impl/src/META-INF/JavaPlugin.xml @@ -1448,6 +1448,12 @@ groupKey="group.names.performance.issues" enabledByDefault="true" level="WARNING" implementationClass="com.intellij.codeInspection.bulkOperation.UseBulkOperationInspection" key="inspection.use.bulk.operation.display.name" bundle="messages.JavaBundle"/> + fixes = new SmartList<>(new MakeIndentConsistentFix(IndentType.SPACES), + new MakeIndentConsistentFix(IndentType.TABS)); + indentModel.findAvailableIndentTypes(desiredIndent, tabSize) + .forEach(type -> fixes.add(new MakeIndentConsistentFix(tabSize, type))); + int start = expression.getText().indexOf('\n'); + if (start == -1) return; + start++; + holder.registerProblem(expression, + new TextRange(start + indexToReport, start + indexToReport + 1), + JavaBundle.message("inspection.inconsistent.text.block.indent.message"), + fixes.toArray(LocalQuickFix.EMPTY_ARRAY)); + } + }; + } + + private enum IndentType { + SPACES, + TABS; + + private static @Nullable IndentType of(char c) { + if (c == ' ') return SPACES; + if (c == '\t') return TABS; + return null; + } + } + + private static class MakeIndentConsistentFix implements LocalQuickFix { + + private final int myTabSize; + private final @NotNull IndentType myDesiredIndentType; + + private MakeIndentConsistentFix(int tabSize, @NotNull IndentType indentType) { + myTabSize = tabSize; + myDesiredIndentType = indentType; + } + + private MakeIndentConsistentFix(@NotNull IndentType indentType) { + this(1, indentType); + } + + @Override + public @IntentionFamilyName @NotNull String getFamilyName() { + String message; + if (myDesiredIndentType == IndentType.TABS) { + message = myTabSize == 1 ? + JavaBundle.message("inspection.inconsistent.text.block.indent.spaces.to.tabs.one.to.one.fix") : + JavaBundle.message("inspection.inconsistent.text.block.indent.spaces.to.tabs.many.to.one.fix", myTabSize); + } + else { + message = myTabSize == 1 ? + JavaBundle.message("inspection.inconsistent.text.block.indent.tabs.to.spaces.one.to.one.fix") : + JavaBundle.message("inspection.inconsistent.text.block.indent.tabs.to.spaces.one.to.many.fix", myTabSize); + } + return message; + } + + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + PsiLiteralExpression literalExpression = tryCast(descriptor.getPsiElement(), PsiLiteralExpression.class); + if (literalExpression == null || !literalExpression.isTextBlock()) return; + if (!CommonRefactoringUtil.checkReadOnlyStatus(project, literalExpression)) return; + String[] lines = PsiLiteralUtil.getTextBlockLines(literalExpression); + if (lines == null) return; + MixedIndentModel indentModel = MixedIndentModel.create(lines); + if (indentModel == null) return; + int desiredIndent = indentModel.findDesiredIndent(myTabSize); + if (desiredIndent == -1) return; + StringBuilder newTextBlock = new StringBuilder(); + newTextBlock.append("\"\"\"\n"); + String indentText = myDesiredIndentType == IndentType.SPACES ? + Strings.repeat(" ", desiredIndent) : Strings.repeat("\t", desiredIndent / myTabSize); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (i != 0) newTextBlock.append('\n'); + if (!isContentPart(lines, i, line)) { + newTextBlock.append(line); + continue; + } + int lineIndent = MixedIndentModel.findLineIndent(line, desiredIndent, myTabSize, myDesiredIndentType); + if (lineIndent == -1) return; + MergingCharSequence newLine = StringUtil.replaceSubSequence(line, 0, lineIndent, indentText); + newTextBlock.append(newLine); + } + newTextBlock.append("\"\"\""); + + PsiElementFactory elementFactory = JavaPsiFacade.getElementFactory(project); + PsiExpression replacement = elementFactory.createExpressionFromText(newTextBlock.toString(), literalExpression); + CodeStyleManager manager = CodeStyleManager.getInstance(project); + manager.performActionWithFormatterDisabled((Runnable)() -> WriteAction.run(() -> literalExpression.replace(replacement))); + } + + @Override + public boolean startInWriteAction() { + return false; + } + } + + private static class MixedIndentModel { + private final String @NotNull [] myLines; + + private MixedIndentModel(String @NotNull [] lines) { + myLines = lines; + } + + private int findDesiredIndent(int tabSize) { + int desiredIndent = Integer.MAX_VALUE; + for (int i = 0; i < myLines.length; i++) { + String line = myLines[i]; + if (!isContentPart(myLines, i, line)) continue; + int lineIndent = 0; + for (int j = 0; j < line.length(); j++) { + char c = line.charAt(j); + if (c == ' ') lineIndent++; + else if (c == '\t') lineIndent += tabSize; + else break; + } + if (lineIndent < desiredIndent) desiredIndent = lineIndent; + } + return desiredIndent; + } + + private int findFirstInconsistentCharIdx(int desiredIndent, int tabSize) { + int pos = 0; + IndentType indentType = null; + for (int i = 0; i < myLines.length; i++) { + if (i != 0) pos++; + String line = myLines[i]; + if (!isContentPart(myLines, i, line)) { + if (!line.isEmpty()) pos += line.length(); + continue; + } + int indentToSee = desiredIndent; + for (int j = 0; j < line.length(); j++) { + if (indentToSee <= 0) break; + char c = line.charAt(j); + if (c == ' ') indentToSee--; + else if (c == '\t') indentToSee -= tabSize; + else return -1; + IndentType curIndentType = Objects.requireNonNull(IndentType.of(c)); + if (indentType == null) { + indentType = curIndentType; + } + else if (indentType != curIndentType) { + return pos + j; + } + } + pos += line.length(); + } + return -1; + } + + private @NotNull List findAvailableIndentTypes(int desiredIndent, int tabSize) { + List indentTypes = new SmartList<>(IndentType.SPACES, IndentType.TABS); + for (int i = 0; i < myLines.length; i++) { + String line = myLines[i]; + if (!isContentPart(myLines, i, line)) continue; + int indentToSee = desiredIndent; + int nSpaces = 0; + for (int j = 0; j < line.length(); j++) { + char c = line.charAt(j); + if (c == ' ') { + indentToSee--; + nSpaces++; + } + else if (c == '\t') { + if (nSpaces % tabSize != 0) { + indentTypes.remove(IndentType.TABS); + } + indentToSee -= tabSize; + nSpaces = 0; + } + if (indentToSee <= 0) break; + } + if (nSpaces % tabSize != 0) indentTypes.remove(IndentType.TABS); + if (indentToSee < 0) indentTypes.remove(IndentType.SPACES); + } + return indentTypes; + } + + private static int findLineIndent(@NotNull String line, int desiredIndent, int tabSize, @NotNull IndentType desiredIndentType) { + int i; + int nSpaces = 0; + for (i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == ' ') { + nSpaces++; + desiredIndent--; + } + else if (c == '\t') { + if (desiredIndentType == IndentType.TABS && nSpaces % tabSize != 0) { + return -1; + } + nSpaces = 0; + desiredIndent -= tabSize; + } + if (desiredIndent <= 0) break; + } + if (desiredIndentType == IndentType.TABS && nSpaces % tabSize != 0) return -1; + return desiredIndent == 0 ? i + 1 : -1; + } + + private static @Nullable MixedIndentModel create(String @NotNull [] lines) { + int indent = PsiLiteralUtil.getTextBlockIndent(lines, true, false); + if (indent <= 0) return null; + IndentType indentType = null; + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (!isContentPart(lines, i, line)) continue; + for (int j = 0; j < indent; j++) { + IndentType curIndentType = IndentType.of(line.charAt(j)); + if (indentType == null) { + indentType = curIndentType; + continue; + } + if (curIndentType != indentType) { + return new MixedIndentModel(lines); + } + } + } + return null; + } + } + + private static boolean isContentPart(String @NotNull [] lines, int i, String line) { + return !line.isBlank() || i == lines.length - 1; + } +} diff --git a/java/java-impl/src/inspectionDescriptions/InconsistentTextBlockIndent.html b/java/java-impl/src/inspectionDescriptions/InconsistentTextBlockIndent.html new file mode 100644 index 000000000000..28ca1b31e0e7 --- /dev/null +++ b/java/java-impl/src/inspectionDescriptions/InconsistentTextBlockIndent.html @@ -0,0 +1,24 @@ + + +Reports text blocks with indent that consists of both spaces and tabs. +Such cases might be misleading since one space is treated as one tab during text block processing. + +

For example (here spaces are visualized as · and tabs as , one tab takes 4 spaces in editor):

+

+String colors = """
+····················red
+␉   ␉   ␉   ␉   ␉   green
+····················blue""";
+
+ +

After printing such string the result would be:

+

+red
+···············green
+blue
+
+ +

This inspection only reports if the configured language level is 15 or higher.

+

New in 2021.1

+ + \ No newline at end of file diff --git a/java/java-psi-api/src/com/intellij/psi/util/PsiLiteralUtil.java b/java/java-psi-api/src/com/intellij/psi/util/PsiLiteralUtil.java index 8d424aa34aa5..73f2be911e6c 100644 --- a/java/java-psi-api/src/com/intellij/psi/util/PsiLiteralUtil.java +++ b/java/java-psi-api/src/com/intellij/psi/util/PsiLiteralUtil.java @@ -391,7 +391,7 @@ public final class PsiLiteralUtil { * @param expression a text block expression * @return the lines of the expression, or null if the expression is not a text block. */ - public static String @Nullable [] getTextBlockLines(PsiLiteralExpression expression) { + public static String @Nullable [] getTextBlockLines(@NotNull PsiLiteralExpression expression) { if (!expression.isTextBlock()) return null; String rawText = expression.getText(); if (rawText.length() < 7 || !rawText.endsWith("\"\"\"")) return null; @@ -412,7 +412,7 @@ public final class PsiLiteralUtil { * @param expression a text block literal expression * @return the indent of the text block counted in characters, where a tab is also counted as 1. */ - public static int getTextBlockIndent(PsiLiteralExpression expression) { + public static int getTextBlockIndent(@NotNull PsiLiteralExpression expression) { String[] lines = getTextBlockLines(expression); if (lines == null) return -1; return getTextBlockIndent(lines); @@ -427,6 +427,7 @@ public final class PsiLiteralUtil { /** * @see #getTextBlockIndent(PsiLiteralExpression) + * Note that this method might change some of the given lines. */ public static int getTextBlockIndent(String @NotNull [] lines, boolean preserveContent, boolean ignoreLastLine) { int prefix = Integer.MAX_VALUE; diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterOneLineBlock.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterOneLineBlock.java new file mode 100644 index 000000000000..e0a41a868f51 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterOneLineBlock.java @@ -0,0 +1,8 @@ +// "Replace tabs with spaces (1 tab = 4 spaces)" "true" + +class Foo { + void test() { + String a = """ + """; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterSimple.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterSimple.java new file mode 100644 index 000000000000..01872c45fd69 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterSimple.java @@ -0,0 +1,10 @@ +// "Replace spaces with tabs (4 spaces = 1 tab)" "true" + +class Foo { + void test() { + String colors = """ + red + green + blue"""; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterWithBlankLines.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterWithBlankLines.java new file mode 100644 index 000000000000..c635507aee69 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/afterWithBlankLines.java @@ -0,0 +1,20 @@ +// "Replace spaces with tabs (1 space = 1 tab)" "true" + +class Foo { + void test() { + String colors = """ + + + red + + + + green + + + + blue + + + """; + diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeBlank.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeBlank.java new file mode 100644 index 000000000000..d579c9c858ab --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeBlank.java @@ -0,0 +1,9 @@ +// "Replace spaces with tabs (1 space = 1 tab)" "false" + +class Foo { + void test() { + String colors = """ + + """; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeDesiredIndentIntersectsTabFromContent.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeDesiredIndentIntersectsTabFromContent.java new file mode 100644 index 000000000000..47b9ee2b9545 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeDesiredIndentIntersectsTabFromContent.java @@ -0,0 +1,11 @@ +// "Replace tabs with spaces (1 tab = 4 spaces)" "false" + +class Foo { + void test() { + String colors = """ + red + green + blue"""; + + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeOneLineBlock.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeOneLineBlock.java new file mode 100644 index 000000000000..3afde9a5f4ef --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeOneLineBlock.java @@ -0,0 +1,8 @@ +// "Replace tabs with spaces (1 tab = 4 spaces)" "true" + +class Foo { + void test() { + String a = """ + """; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeSimple.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeSimple.java new file mode 100644 index 000000000000..af2ec46a62f7 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeSimple.java @@ -0,0 +1,10 @@ +// "Replace spaces with tabs (4 spaces = 1 tab)" "true" + +class Foo { + void test() { + String colors = """ + red + green + blue"""; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeSpacesOnly.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeSpacesOnly.java new file mode 100644 index 000000000000..7d97555f44aa --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeSpacesOnly.java @@ -0,0 +1,11 @@ +// "Replace spaces with tabs (1 space = 1 tab)" "false" + +class Foo { + void test() { + String a = """ + red + blue + green + """; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeTabsOnly.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeTabsOnly.java new file mode 100644 index 000000000000..ae9f7164185c --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeTabsOnly.java @@ -0,0 +1,11 @@ +// "Replace spaces with tabs (1 space = 1 tab)" "false" + +class Foo { + void test() { + String a = """ + red + blue + green + """; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeUnevenNumberOfSpaces.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeUnevenNumberOfSpaces.java new file mode 100644 index 000000000000..672374e51bd1 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeUnevenNumberOfSpaces.java @@ -0,0 +1,10 @@ +// "Replace spaces with tabs (4 spaces = 1 tab)" "false" + +class Foo { + void test() { + String colors = """ + red + green + blue"""; + } +} \ No newline at end of file diff --git a/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeWithBlankLines.java b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeWithBlankLines.java new file mode 100644 index 000000000000..4cd76f3ffa81 --- /dev/null +++ b/java/java-tests/testData/inspection/inconsistentTextBlockIndent/beforeWithBlankLines.java @@ -0,0 +1,20 @@ +// "Replace spaces with tabs (1 space = 1 tab)" "true" + +class Foo { + void test() { + String colors = """ + + + red + + + + green + + + + blue + + + """; + diff --git a/java/java-tests/testSrc/com/intellij/java/codeInspection/InconsistentTextBlockIndentInspectionTest.java b/java/java-tests/testSrc/com/intellij/java/codeInspection/InconsistentTextBlockIndentInspectionTest.java new file mode 100644 index 000000000000..de23bd8b7f59 --- /dev/null +++ b/java/java-tests/testSrc/com/intellij/java/codeInspection/InconsistentTextBlockIndentInspectionTest.java @@ -0,0 +1,19 @@ +// Copyright 2000-2020 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.InconsistentTextBlockIndentInspection; +import com.intellij.codeInspection.LocalInspectionTool; +import org.jetbrains.annotations.NotNull; + +public class InconsistentTextBlockIndentInspectionTest extends LightQuickFixParameterizedTestCase { + @Override + protected LocalInspectionTool @NotNull [] configureLocalInspectionTools() { + return new LocalInspectionTool[]{new InconsistentTextBlockIndentInspection()}; + } + + @Override + protected String getBasePath() { + return "/inspection/inconsistentTextBlockIndent"; + } +} diff --git a/java/openapi/resources/messages/JavaBundle.properties b/java/openapi/resources/messages/JavaBundle.properties index 104c77348a06..439200d8445b 100644 --- a/java/openapi/resources/messages/JavaBundle.properties +++ b/java/openapi/resources/messages/JavaBundle.properties @@ -697,6 +697,12 @@ inspection.text.block.backward.migration.name=Text block can be replaced with re inspection.text.block.migration.message={0} can be replaced with text block inspection.text.block.migration.name=Text block can be used inspection.text.block.migration.suggest.literal.replacement=Apply to single string literals +inspection.inconsistent.text.block.indent.name=Inconsistent white space indentation +inspection.inconsistent.text.block.indent.message=Text block indent consists of tabs and spaces +inspection.inconsistent.text.block.indent.spaces.to.tabs.one.to.one.fix=Replace spaces with tabs (1 space = 1 tab) +inspection.inconsistent.text.block.indent.spaces.to.tabs.many.to.one.fix=Replace spaces with tabs ({0} spaces = 1 tab) +inspection.inconsistent.text.block.indent.tabs.to.spaces.one.to.one.fix=Replace tabs with spaces (1 tab = 1 space) +inspection.inconsistent.text.block.indent.tabs.to.spaces.one.to.many.fix=Replace tabs with spaces (1 tab = {0} spaces) inspection.undeclared.service.usage.message=Usage of service ''{0}'' is not declared in module-info inspection.undeclared.service.usage.name=Usage of service not declared in 'module-info' inspection.unused.assignment.option=Report ++i when may be replaced with (i + 1)