[java] IDEA-216276 Add ShowDuplicateElementsFix that shows popup with a list of element duplicates and use it for duplicate-method error

#IDEA-216276 Fixed
Merge-request: IJ-MR-171237
Merged-by: Marcin Mikosik <marcin.mikosik@jetbrains.com>

GitOrigin-RevId: 5aab229888a361d3d4f653708b7f42d832c44645
This commit is contained in:
Marcin Mikosik
2025-09-11 08:47:24 +00:00
committed by intellij-monorepo-bot
parent 5e4b906e52
commit c4272dc602
28 changed files with 264 additions and 30 deletions
@@ -127,6 +127,8 @@ public abstract class QuickFixFactory {
public abstract @NotNull IntentionAction createNavigateToDuplicateElementFix(@NotNull NavigatablePsiElement element);
public abstract @NotNull IntentionAction createShowDuplicateElementsFix(@NotNull List<@NotNull ? extends NavigatablePsiElement> elements);
public abstract @NotNull IntentionAction createConvertToStringLiteralAction();
/**
@@ -209,6 +209,11 @@ reuse.variable.declaration.text=Reuse previous variable ''{0}''
navigate.variable.declaration.family=Navigate to variable declaration
navigate.variable.declaration.text=Navigate to previous declared variable ''{0}''
navigate.duplicate.element.text=Navigate to duplicate {0}
show.duplicate.elements.family=Show duplicates
show.duplicate.elements.text=Show ''{0}'' duplicates
show.duplicate.elements.popup.title=Choose Duplicate
show.duplicate.elements.navigate.family=Navigate to duplicate
show.duplicate.elements.navigate.text=Line #{0}
safe.delete.family=Safe delete
safe.delete.text=Safe delete ''{0}''
setup.jdk.location.family=Setup JDK Location
@@ -338,11 +338,11 @@ public final class DefaultJavaErrorFixProvider extends AbstractJavaErrorFixProvi
error.context().method() instanceof SyntheticElement ?
null : myFactory.createSameErasureButDifferentMethodsFix(error.context().method(), error.context().superMethod()));
fixes(METHOD_DUPLICATE, (error, sink) -> {
error.context().methods().stream()
.filter(m -> !m.equals(error.psi()))
.filter(m -> !(m instanceof SyntheticElement)) // filters out synthetic methods, such as Enum#values()
.findFirst()
.ifPresent(m -> sink.accept(myFactory.createNavigateToDuplicateElementFix(m)));
// filters out synthetic methods, such as Enum#values()
var duplicates = ContainerUtil.filter(error.context().methods(), m -> !(m instanceof SyntheticElement));
if (duplicates.size() > 1) {
sink.accept(myFactory.createShowDuplicateElementsFix(duplicates));
}
});
}
@@ -27,6 +27,10 @@ public class NavigateToDuplicateElementFix extends PsiBasedModCommandAction<Navi
@Override
protected @NotNull ModCommand perform(@NotNull ActionContext context, @NotNull NavigatablePsiElement element) {
return createSelectCommand(element);
}
static @NotNull ModCommand createSelectCommand(@NotNull NavigatablePsiElement element) {
if (element instanceof PsiNameIdentifierOwner owner) {
PsiElement identifier = owner.getNameIdentifier();
if (identifier != null) {
@@ -0,0 +1,119 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.daemon.impl.quickfix;
import com.intellij.codeInsight.daemon.QuickFixBundle;
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo;
import com.intellij.codeInspection.util.IntentionFamilyName;
import com.intellij.modcommand.*;
import com.intellij.openapi.util.text.HtmlBuilder;
import com.intellij.openapi.util.text.HtmlChunk;
import com.intellij.psi.NavigatablePsiElement;
import com.intellij.psi.PsiMethod;
import com.intellij.psi.SmartPointerManager;
import com.intellij.psi.SmartPsiElementPointer;
import com.intellij.psi.util.JavaElementKind;
import com.intellij.util.containers.ContainerUtil;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.List;
import java.util.Objects;
/**
* Action that displays a popup with a list of duplicate elements (classes, methods, fields, etc.) passed to constructor.
* Code for the currently selected element in the list is displayed in the preview and also highlighted in the editor.
* Selecting an element from the list closes the popup and navigates to the selected element.
*/
@NotNullByDefault
public class ShowDuplicateElementsFix extends PsiBasedModCommandAction<NavigatablePsiElement> {
private final List<SmartPsiElementPointer<NavigatablePsiElement>> myNavigatablePsiElements;
public ShowDuplicateElementsFix(List<? extends NavigatablePsiElement> duplicates) {
super(NavigatablePsiElement.class);
myNavigatablePsiElements = ContainerUtil.map(duplicates, dup -> SmartPointerManager.createPointer(dup));
}
@Override
public String getFamilyName() {
return QuickFixBundle.message("show.duplicate.elements.family");
}
@Override
protected Presentation getPresentation(ActionContext context, NavigatablePsiElement section) {
var name = QuickFixBundle.message("show.duplicate.elements.text", elementName(getDuplicatePsiElements()));
return Presentation.of(name);
}
@Override
protected ModCommand perform(ActionContext context, NavigatablePsiElement element) {
var title = QuickFixBundle.message("show.duplicate.elements.popup.title");
var navigateActions = ContainerUtil.map(getDuplicatePsiElements(), d -> navigateAction(d));
return ModCommand.chooseAction(title, navigateActions);
}
private static String elementName(List<NavigatablePsiElement> elements) {
if (!elements.isEmpty()) {
NavigatablePsiElement element = elements.get(0);
if (element instanceof PsiMethod method) {
return method.getName() + "()";
}
return Objects.requireNonNullElse(element.getName(), "");
}
return "";
}
@Override
protected IntentionPreviewInfo generatePreview(ActionContext context, NavigatablePsiElement element) {
var builder = new HtmlBuilder();
var elements = getDuplicatePsiElements();
for (int i = 0; i < elements.size(); i++) {
if (i != 0) {
builder.append(HtmlChunk.br());
}
var current = elements.get(i);
builder.append(IntentionPreviewInfo.navigatePreviewHtmlChunk(current.getContainingFile(), current.getTextOffset()));
}
return new IntentionPreviewInfo.Html(builder.toFragment());
}
private List<NavigatablePsiElement> getDuplicatePsiElements() {
return ContainerUtil.mapNotNull(myNavigatablePsiElements, SmartPsiElementPointer::getElement);
}
private static ModCommandAction navigateAction(NavigatablePsiElement navigatablePsiElement) {
return new NavigateToAction(navigatablePsiElement);
}
private static class NavigateToAction extends PsiBasedModCommandAction<NavigatablePsiElement> {
@IntentionFamilyName
private final String myFamilyName;
private NavigateToAction(NavigatablePsiElement navigatablePsiElement) {
super(navigatablePsiElement);
myFamilyName =
QuickFixBundle.message("show.duplicate.elements.navigate.family", JavaElementKind.fromElement(navigatablePsiElement).object());
}
@Override
public String getFamilyName() {
return myFamilyName;
}
@Override
protected Presentation getPresentation(ActionContext context, NavigatablePsiElement element) {
int lineNumber = element.getContainingFile().getFileDocument().getLineNumber(element.getTextOffset());
var title = QuickFixBundle.message("show.duplicate.elements.navigate.text", (lineNumber + 1));
return Presentation.of(title).withHighlighting(element.getTextRange());
}
@Override
protected IntentionPreviewInfo generatePreview(ActionContext context, NavigatablePsiElement element) {
return IntentionPreviewInfo.snippet(element);
}
@Override
protected ModCommand perform(ActionContext context, NavigatablePsiElement element) {
return NavigateToDuplicateElementFix.createSelectCommand(element);
}
}
}
@@ -246,6 +246,11 @@ public final class QuickFixFactoryImpl extends QuickFixFactory {
return new NavigateToDuplicateElementFix(element).asIntention();
}
@Override
public @NotNull IntentionAction createShowDuplicateElementsFix(@NotNull List<@NotNull ? extends NavigatablePsiElement> elements) {
return new ShowDuplicateElementsFix(elements).asIntention();
}
@Override
public @NotNull IntentionAction createConvertToStringLiteralAction() {
return new ConvertToStringLiteralAction().asIntention();
@@ -1,4 +1,4 @@
// "Navigate to duplicate method" "true"
// "Show 'foo()' duplicates|->Line #5" "true"
public class MyClass {
public void foo() {}
@@ -1,4 +1,4 @@
// "Navigate to duplicate method" "true"
// "Show 'foo()' duplicates|->Line #4" "true"
public class MyClass {
public void <selection><caret>foo</selection>() {}
@@ -1,4 +1,4 @@
// "Navigate to duplicate method" "true"
// "Show 'foo()' duplicates|->Line #5" "true"
public class MyClass {
public void foo<caret>() {}
@@ -1,4 +1,4 @@
// "Navigate to duplicate method" "true"
// "Show 'foo()' duplicates|->Line #5" "true"
public class MyClass {
public <T> void foo<caret>(T bar) {}
@@ -1,4 +1,4 @@
// "Navigate to duplicate method" "true"
// "Show 'foo()' duplicates|->Line #4" "true"
public class MyClass {
public void foo() {}
@@ -1,11 +0,0 @@
// 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.codeInsight.daemon.impl.quickfix;
import com.intellij.codeInsight.daemon.quickFix.LightQuickFixParameterizedTestCase;
public class NavigateToDuplicateMethodFixTest extends LightQuickFixParameterizedTestCase {
@Override
protected String getBasePath() {
return "/codeInsight/daemonCodeAnalyzer/quickFix/navigateToDuplicateMethodFix";
}
}
@@ -0,0 +1,28 @@
// 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.codeInsight.daemon.impl.quickfix;
import com.intellij.codeInsight.daemon.quickFix.LightQuickFixParameterizedTestCase;
import com.intellij.codeInsight.intention.IntentionAction;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.ui.ChooserInterceptor;
import com.intellij.ui.UiInterceptors;
import com.intellij.util.IncorrectOperationException;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class ShowDuplicateMethodsFixTest extends LightQuickFixParameterizedTestCase {
@Override
protected String getBasePath() {
return "/codeInsight/daemonCodeAnalyzer/quickFix/showDuplicateMethodsFix";
}
@Override
protected boolean shouldBeAvailableAfterExecution() {
return true;
}
}
@@ -294,7 +294,22 @@ class IntentionPreviewDiffResultTest : LightPlatformCodeInsightFixture4TestCase(
).formatResult()
assertEquals("", result)
}
@Test
fun testSnippet() {
val text = """
public void method() {
int a = 1;
}
""".trimIndent()
val result = IntentionPreviewDiffResult.fromSnippet(IntentionPreviewInfo.Snippet(JavaFileType.INSTANCE, text, 5)).formatResult()
assertEquals("""
5 : public void method() {
6 : int a = 1;
7 : }
""".trimIndent(), result)
}
/**
* Returns a textual representation of diffs from created and modified text, how they will look
* in the intention preview window. Each diff chunk is separated via a horizontal dashed line.
@@ -82,6 +82,17 @@ a:com.intellij.codeInsight.completion.CompletionService
- highlightMessage():java.lang.String
- highlightReference(com.intellij.lang.annotation.AnnotationBuilder):com.intellij.lang.annotation.AnnotationBuilder
- highlightSeverity():com.intellij.lang.annotation.HighlightSeverity
com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
- *s:snippet(com.intellij.psi.NavigatablePsiElement):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$Snippet
*f:com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$Snippet
- java.lang.Record
- com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
- <init>(com.intellij.openapi.fileTypes.FileType,java.lang.String,I):V
- f:equals(java.lang.Object):Z
- fileType():com.intellij.openapi.fileTypes.FileType
- f:hashCode():I
- startLine():I
- text():java.lang.String
f:com.intellij.codeInsight.intention.preview.IntentionPreviewUtils
- *s:getModCommandPreview(com.intellij.modcommand.ModCommand,com.intellij.modcommand.ActionContext):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
com.intellij.codeInsight.lookup.LookupEx
+1
View File
@@ -128,6 +128,7 @@ com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
- s:moveToDirectory(com.intellij.openapi.vfs.VirtualFile,com.intellij.openapi.vfs.VirtualFile):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
- s:navigate(com.intellij.psi.NavigatablePsiElement):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
- s:navigate(com.intellij.psi.PsiFile,I):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$Html
- s:navigatePreviewHtmlChunk(com.intellij.psi.PsiFile,I):com.intellij.openapi.util.text.HtmlChunk
- s:rename(com.intellij.openapi.vfs.VirtualFile,java.lang.String):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
- s:rename(com.intellij.psi.PsiFile,java.lang.String):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
com.intellij.codeInsight.lookup.Lookup
@@ -8,6 +8,7 @@ import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.fileTypes.FileType;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.HtmlBuilder;
import com.intellij.openapi.util.text.HtmlChunk;
import com.intellij.openapi.vfs.VfsUtilCore;
@@ -450,6 +451,10 @@ public interface IntentionPreviewInfo {
* @return a presentation describing that the action will navigate to the specified target element
*/
static @NotNull Html navigate(@NotNull PsiFile file, int offset) {
return new Html(navigatePreviewHtmlChunk(file, offset).wrapWith("p"));
}
static @NotNull HtmlChunk navigatePreviewHtmlChunk(@NotNull PsiFile file, int offset) {
Icon icon = file.getIcon(0);
Document document = file.getFileDocument();
HtmlBuilder builder = new HtmlBuilder();
@@ -461,7 +466,7 @@ public interface IntentionPreviewInfo {
builder.append(AnalysisBundle.message("html.preview.navigate.line"))
.append(String.valueOf(lineNumber+1));
}
return new Html(builder.wrapWith("p"));
return builder.toFragment();
}
/**
@@ -497,4 +502,35 @@ public interface IntentionPreviewInfo {
.toFragment();
return new Html(content);
}
/**
* Preview of a code snippet. It will be rendered using the color scheme configured for specified {@code fileType}
* with line numbers.
*
* @param fileType file type of code snippet
* @param text snippet text
* @param startLine line number of the first line of the snippet
*/
@ApiStatus.Experimental
record Snippet(@NotNull FileType fileType, @NotNull String text, int startLine) implements IntentionPreviewInfo {
}
/**
* Creates snippet preview containing text of {@code element}.
*
* @param element element which text will be used as a snippet
* @return snippet preview for given element
*/
@ApiStatus.Experimental
static @NotNull Snippet snippet(@NotNull NavigatablePsiElement element) {
var textRange = element.getTextRange();
var document = element.getContainingFile().getFileDocument();
int startOffset = textRange.getStartOffset();
int startLine = document.getLineNumber(startOffset);
int startOffsetOfFirstLine = document.getLineStartOffset(startLine);
int endOffset = textRange.getEndOffset();
var fileType = element.getContainingFile().getFileType();
var text = document.getText(TextRange.create(startOffsetOfFirstLine, endOffset)).stripIndent();
return new Snippet(fileType, text, startLine);
}
}
@@ -4175,6 +4175,7 @@ f:com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- equals(java.lang.Object):Z
- sf:fromCustomDiff(com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$CustomDiff):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- sf:fromMultiDiff(com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$MultiFileDiff):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- sf:fromSnippet(com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$Snippet):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- f:getDiffs():java.util.List
- f:getNewText():java.lang.String
- hashCode():I
@@ -4184,6 +4185,7 @@ f:com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult$Com
- bs:create$default(com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult$Companion,com.intellij.openapi.fileTypes.FileType,java.lang.String,java.lang.String,com.intellij.diff.comparison.ComparisonPolicy,Z,java.lang.String,I,java.lang.Object):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- f:fromCustomDiff(com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$CustomDiff):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- f:fromMultiDiff(com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$MultiFileDiff):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
- f:fromSnippet(com.intellij.codeInsight.intention.preview.IntentionPreviewInfo$Snippet):com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult
f:com.intellij.codeInsight.intention.impl.preview.IntentionPreviewDiffResult$DiffInfo
- <init>(com.intellij.openapi.fileTypes.FileType,java.lang.String,I,I,java.util.List):V
- f:component1():com.intellij.openapi.fileTypes.FileType
@@ -277,6 +277,7 @@ private class CommandCompletionDocumentationTarget(
private fun postprocess(info: IntentionPreviewInfo) = when (info) {
is IntentionPreviewInfo.CustomDiff -> IntentionPreviewDiffResult.fromCustomDiff(info)
is IntentionPreviewInfo.MultiFileDiff -> IntentionPreviewDiffResult.fromMultiDiff(info)
is IntentionPreviewInfo.Snippet -> IntentionPreviewDiffResult.fromSnippet(info)
else -> info
}
}
@@ -21,6 +21,15 @@ data class IntentionPreviewDiffResult(val diffs: List<DiffInfo>, @TestOnly val n
data class Fragment(val type: HighlightingType, val start: Int, val end: Int)
/**
* Preview texts with specified fragment highlighting.
*
* @param fileType type of file from which fileText is taken
* @param fileText preview text
* @param startLine line in the file where fileText starts
* @param length fileText's line count
* @param fragments list fragments to be highlighted in preview
*/
data class DiffInfo(
val fileType: FileType,
val fileText: String,
@@ -178,5 +187,11 @@ data class IntentionPreviewDiffResult(val diffs: List<DiffInfo>, @TestOnly val n
val text = diffs.joinToString("\n----------\n") { diff -> diff.newText }
return IntentionPreviewDiffResult(diffInfos, text)
}
@JvmStatic
fun fromSnippet(info: IntentionPreviewInfo.Snippet): IntentionPreviewDiffResult {
val diffInfo = DiffInfo(info.fileType, info.text, info.startLine, info.text.lines().size, listOf())
return IntentionPreviewDiffResult(listOf(diffInfo), info.text)
}
}
}
@@ -305,6 +305,7 @@ class IntentionPreviewPopupUpdateProcessor internal constructor(
private fun postprocess(info: IntentionPreviewInfo) = when (info) {
is IntentionPreviewInfo.CustomDiff -> IntentionPreviewDiffResult.fromCustomDiff(info)
is IntentionPreviewInfo.MultiFileDiff -> IntentionPreviewDiffResult.fromMultiDiff(info)
is IntentionPreviewInfo.Snippet -> IntentionPreviewDiffResult.fromSnippet(info)
else -> info
}