Java: rework "Inconsistent whitespace indentation in text block" inspection (IDEA-353100)

GitOrigin-RevId: f2b3042e93e5b9e94fc1b2ee1d7341dd9224d480
This commit is contained in:
Bas Leijdekkers
2025-04-14 12:51:05 +02:00
committed by intellij-monorepo-bot
parent e90a4de2d9
commit 9838188e46
18 changed files with 107 additions and 199 deletions

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInspection;
import com.intellij.application.options.CodeStyle;
@@ -14,18 +14,12 @@ import com.intellij.psi.JavaElementVisitor;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementVisitor;
import com.intellij.psi.PsiLiteralExpression;
import com.intellij.psi.codeStyle.CodeStyleManager;
import com.intellij.psi.util.PsiLiteralUtil;
import com.intellij.refactoring.util.CommonRefactoringUtil;
import com.intellij.util.SmartList;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static com.intellij.util.ObjectUtils.tryCast;
public final class InconsistentTextBlockIndentInspection extends AbstractBaseJavaLocalInspectionTool {
@Override
public @NotNull Set<@NotNull JavaFeature> requiredFeatures() {
@@ -38,161 +32,76 @@ public final class InconsistentTextBlockIndentInspection extends AbstractBaseJav
@Override
public void visitLiteralExpression(@NotNull PsiLiteralExpression expression) {
String[] lines = PsiLiteralUtil.getTextBlockLines(expression);
if (lines == null) return;
int tabSize = CodeStyle.getSettings(expression.getProject()).getTabSize(JavaFileType.INSTANCE);
if (lines == null || lines.length == 1) return;
int indentLength = PsiLiteralUtil.getTextBlockIndent(expression);
int indexToReport = -1;
int offset = 0;
String indent = null;
for (String line : lines) {
if (line.length() >= indentLength) {
if (indent == null) {
indent = line.substring(0, indentLength);
}
else {
int mismatched = mismatch(indent, line.substring(0, indentLength));
if (mismatched >= 0) {
indexToReport = offset + mismatched;
break;
}
}
}
offset += line.length() + 1; // length plus newline
}
if (indexToReport < 0) return;
var styleSettings = CodeStyle.getSettings(expression.getProject());
int tabSize = styleSettings.getTabSize(JavaFileType.INSTANCE);
if (tabSize == 1) return;
int start = expression.getText().indexOf('\n');
if (start == -1) return;
start++;
MixedIndentModel indentModel = MixedIndentModel.create(lines);
if (indentModel == null) return;
int indexToReport = indentModel.myInconsistencyIdx;
List<LocalQuickFix> fixes = new SmartList<>(new MakeIndentConsistentFix(IndentType.SPACES),
new MakeIndentConsistentFix(IndentType.TABS),
new MakeIndentConsistentFix(tabSize, IndentType.SPACES));
if (indentModel.canReplaceWithTabs(tabSize)) {
fixes.add(new MakeIndentConsistentFix(tabSize, IndentType.TABS));
}
boolean useTabCharacter = styleSettings.getIndentOptions(JavaFileType.INSTANCE).USE_TAB_CHARACTER;
holder.registerProblem(expression,
new TextRange(start + indexToReport, start + indexToReport + 1),
JavaBundle.message("inspection.inconsistent.text.block.indent.message"),
fixes.toArray(LocalQuickFix.EMPTY_ARRAY));
new MakeIndentConsistentFix(useTabCharacter));
}
};
}
private enum IndentType {
SPACES,
TABS
/**
* Finds and returns the index of the first char mismatch between two Strings, otherwise return -1 if no mismatch is found.
* The index will be in the range of 0 (inclusive) up to the length (inclusive) of the smaller String
*
* @param a the first String to be tested for a mismatch
* @param b the second String to be tested for a mismatch
* @return the index of the first char mismatch between the two String, otherwise {@code -1}.
*/
private static int mismatch(@NotNull String a, @NotNull String b) {
int length = Math.min(a.length(), b.length());
for (int i = 0; i < length; i++) {
if (a.charAt(i) != b.charAt(i)) return i;
}
return -1;
}
private static class MakeIndentConsistentFix extends PsiUpdateModCommandQuickFix {
private final int myTabSize;
private final @NotNull IndentType myDesiredIndentType;
private final boolean myUseTabs;
private MakeIndentConsistentFix(int tabSize, @NotNull IndentType indentType) {
myTabSize = tabSize;
myDesiredIndentType = indentType;
}
private MakeIndentConsistentFix(@NotNull IndentType indentType) {
this(1, indentType);
private MakeIndentConsistentFix(boolean useTabs) {
myUseTabs = useTabs;
}
@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;
return myUseTabs
? JavaBundle.message("inspection.inconsistent.text.block.indent.tabs")
: JavaBundle.message("inspection.inconsistent.text.block.indent.spaces");
}
@Override
protected void applyFix(@NotNull Project project, @NotNull PsiElement element, @NotNull ModPsiUpdater updater) {
PsiLiteralExpression literalExpression = tryCast(element, 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;
String newTextBlock = indentModel.indentWith(myDesiredIndentType, myTabSize);
if (newTextBlock == null) return;
TrailingWhitespacesInTextBlockInspection.replaceTextBlock(literalExpression, "\"\"\"\n" + newTextBlock + "\"\"\"");
}
}
private static class MixedIndentModel {
private final String[] myLines;
private final int[] mySpaces;
private final int[] myTabs;
private final int myInconsistencyIdx;
private MixedIndentModel(String[] lines, int[] spaces, int[] tabs, int inconsistencyIdx) {
myLines = lines;
mySpaces = spaces;
myTabs = tabs;
myInconsistencyIdx = inconsistencyIdx;
}
private boolean canReplaceWithTabs(int tabSize) {
return Arrays.stream(mySpaces).allMatch(nSpaces -> nSpaces == -1 || nSpaces % tabSize == 0);
}
private @Nullable String indentWith(@NotNull IndentType indentType, int tabSize) {
StringBuilder indented = new StringBuilder();
for (int i = 0; i < myLines.length; i++) {
if (i != 0) indented.append('\n');
String line = myLines[i];
int nSpaces = mySpaces[i];
if (nSpaces == -1) {
indented.append(line);
continue;
}
int nTabs = myTabs[i];
String indent = createIndent(nSpaces, nTabs, indentType, tabSize);
if (indent == null) return null;
indented.append(indent);
indented.append(line, nSpaces + nTabs, line.length());
}
return indented.toString();
}
private static @Nullable String createIndent(int nSpaces, int nTabs, @NotNull IndentType indentType, int tabSize) {
if (indentType == IndentType.SPACES) return " ".repeat(nSpaces + nTabs * tabSize);
if (nSpaces % tabSize != 0) return null;
return "\t".repeat(nTabs + nSpaces / tabSize);
}
private static @Nullable MixedIndentModel create(String[] lines) {
int indent = PsiLiteralUtil.getTextBlockIndent(lines, true, false);
if (indent <= 0) return null;
int[] spaces = new int[lines.length];
int[] tabs = new int[lines.length];
Character expectedIndentChar = null;
int inconsistencyIdx = -1;
int pos = 0;
for (int i = 0; i < lines.length; i++) {
if (i != 0) pos++;
String line = lines[i];
boolean isContentPart = !line.isBlank() || i == lines.length - 1;
if (!isContentPart) {
spaces[i] = -1;
tabs[i] = -1;
pos += line.length();
continue;
}
if (expectedIndentChar == null) expectedIndentChar = line.charAt(0);
for (int j = 0; j < line.length(); j++) {
char c = line.charAt(j);
if (c != ' ' && c != '\t') break;
if (j < indent) inconsistencyIdx = getInconsistencyIndex(inconsistencyIdx, c, pos, j, expectedIndentChar);
if (c == ' ') {
spaces[i]++;
}
else {
tabs[i]++;
}
}
pos += line.length();
}
return inconsistencyIdx == -1 ? null : new MixedIndentModel(lines, spaces, tabs, inconsistencyIdx);
}
private static int getInconsistencyIndex(int inconsistencyIdx, char c, int pos, int idx, @NotNull Character expectedIndentChar) {
if (inconsistencyIdx != -1) return inconsistencyIdx;
if (expectedIndentChar == c) return -1;
return pos + idx;
CodeStyleManager.getInstance(project).reformat(element);
}
}
}

View File

@@ -1,24 +1,24 @@
<html>
<body>
Reports text blocks that are indented using both spaces and tabs.
Such cases produce unexpected results since spaces and tabs are treated equally by the text block processing.
Reports text blocks that are indented using both space and tab characters.
This can produce unexpected results because spaces and tabs are treated equally by javac's text block processing.
<p>In the following example, spaces and tabs are visualized as <code>·</code> and <code></code> respectively,
and a tab is equal to 4 spaces in the editor.</p>
and a tab is equal to 2 spaces in the editor.</p>
<p><b>Example:</b></p>
<pre><code>
String colors = """
········red
green
········blue""";
····red
green
····blue""";
</code></pre>
<p>After printing such a string, the result will be:</p>
<p>When printing such a string, the result will be:</p>
<pre><code>
······red
··red
green
······blue
··blue
</code></pre>
<p>After the compiler removes an equal amount of spaces or tabs from the beginning of each line,
<p>After the compiler removes an equal number of whitespace characters from the beginning of each line,
some lines remain with leading spaces.</p>
<!-- tooltip end -->
<p><small>New in 2021.1</small></p>

View File

@@ -1,11 +1,11 @@
// "Replace tabs with spaces (1 tab = 4 spaces)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {
String colors = """
red
green
blue""";
red
green
blue""";
}
}

View File

@@ -1,8 +0,0 @@
// "Replace tabs with spaces (1 tab = 4 spaces)" "true"
class Foo {
void test() {
String a = """
""";
}
}

View File

@@ -1,10 +1,10 @@
// "Replace spaces with tabs (4 spaces = 1 tab)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {
String colors = """
red
green
blue""";
red
green
blue""";
}
}

View File

@@ -1,10 +1,10 @@
// "Replace spaces with tabs (4 spaces = 1 tab)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {
String colors = """
red
green
blue""";
red
green
blue""";
}
}

View File

@@ -1,20 +1,20 @@
// "Replace spaces with tabs (1 space = 1 tab)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {
String colors = """
red
green
blue
""";
red
green
blue
""";

View File

@@ -1,4 +1,4 @@
// "Replace spaces with tabs (1 space = 1 tab)" "false"
// "Indent text block with spaces only" "false"
class Foo {
void test() {

View File

@@ -1,4 +1,4 @@
// "Replace tabs with spaces (1 tab = 4 spaces)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {

View File

@@ -0,0 +1,9 @@
// "Indent text block with spaces only" "false"
class Foo {
void test() {
String s = """
<caret>foo
bar""";
}
}

View File

@@ -1,4 +1,4 @@
// "Replace tabs with spaces (1 tab = 4 spaces)" "true"
// "Indent text block with spaces only" "false"
class Foo {
void test() {

View File

@@ -1,4 +1,4 @@
// "Replace spaces with tabs (4 spaces = 1 tab)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {

View File

@@ -1,4 +1,4 @@
// "Replace spaces with tabs (1 space = 1 tab)" "false"
// "Indent text block with spaces only" "false"
class Foo {
void test() {

View File

@@ -1,4 +1,4 @@
// "Replace spaces with tabs (4 spaces = 1 tab)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {

View File

@@ -1,4 +1,4 @@
// "Replace spaces with tabs (1 space = 1 tab)" "false"
// "Indent text block with spaces only" "false"
class Foo {
void test() {

View File

@@ -1,10 +1,10 @@
// "Replace spaces with tabs (4 spaces = 1 tab)" "false"
// "Indent text block with spaces only" "false"
class Foo {
void test() {
String colors = """
red
<caret> green
<caret> green
blue""";
}
}

View File

@@ -1,4 +1,4 @@
// "Replace spaces with tabs (1 space = 1 tab)" "true"
// "Indent text block with spaces only" "true"
class Foo {
void test() {

View File

@@ -780,11 +780,9 @@ inspection.implicit.to.explicit.class.backward.migration.fix.name=Convert implic
inspection.explicit.to.implicit.class.migration.name=Explicit class declaration can be converted into implicitly declared class
inspection.explicit.to.implicit.class.migration.fix.name=Convert into implicitly declared class
inspection.inconsistent.text.block.indent.name=Inconsistent whitespace indentation in text block
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.inconsistent.text.block.indent.message=Text block indent consists of mixed tabs and spaces
inspection.inconsistent.text.block.indent.spaces=Indent text block with spaces only
inspection.inconsistent.text.block.indent.tabs=Indent text block with tabs only
inspection.suspicious.return.byte.input.stream.name = Suspicious byte value returned from 'InputStream.read()'
inspection.suspicious.return.byte.input.stream.convert.to.unsigned = Convert to an unsigned byte
inspection.trailing.whitespaces.in.text.block.name=Trailing whitespace in text block