diff --git a/java/java-impl-inspections/src/com/intellij/codeInspection/OverwrittenKeyInspection.java b/java/java-impl-inspections/src/com/intellij/codeInspection/OverwrittenKeyInspection.java index cb1f1c587c75..f564db01adff 100644 --- a/java/java-impl-inspections/src/com/intellij/codeInspection/OverwrittenKeyInspection.java +++ b/java/java-impl-inspections/src/com/intellij/codeInspection/OverwrittenKeyInspection.java @@ -1,13 +1,12 @@ -// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2023 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.codeInsight.ExpressionUtil; import com.intellij.codeInsight.PsiEquivalenceUtil; import com.intellij.codeInsight.daemon.impl.analysis.HighlightControlFlowUtil; -import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo; import com.intellij.codeInspection.util.InspectionMessage; -import com.intellij.ide.util.PsiNavigationSupport; import com.intellij.java.JavaBundle; +import com.intellij.modcommand.ModCommand; import com.intellij.openapi.project.Project; import com.intellij.psi.*; import com.intellij.psi.util.PsiTreeUtil; @@ -229,7 +228,7 @@ public class OverwrittenKeyInspection extends AbstractBaseJavaLocalInspectionToo } } - private static class NavigateToDuplicateFix implements LocalQuickFix { + private static class NavigateToDuplicateFix extends ModCommandQuickFix { private final SmartPsiElementPointer myPointer; NavigateToDuplicateFix(PsiExpression arg) { @@ -244,30 +243,15 @@ public class OverwrittenKeyInspection extends AbstractBaseJavaLocalInspectionToo } @Override - public boolean startInWriteAction() { - return false; + public @NotNull ModCommand perform(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + PsiExpression element = myPointer.getElement(); + if (element == null) return ModCommands.nop(); + return ModCommands.select(element); } @Override public boolean availableInBatchMode() { return false; } - - @Override - public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { - PsiExpression element = myPointer.getElement(); - if (element == null) return; - PsiFile file = element.getContainingFile(); - if (file == null) return; - int offset = element.getTextRange().getStartOffset(); - PsiNavigationSupport.getInstance().createNavigatable(project, file.getVirtualFile(), offset).navigate(true); - } - - @Override - public @NotNull IntentionPreviewInfo generatePreview(@NotNull Project project, @NotNull ProblemDescriptor previewDescriptor) { - NavigatablePsiElement element = tryCast(myPointer.getElement(), NavigatablePsiElement.class); - if (element == null) return IntentionPreviewInfo.EMPTY; - return IntentionPreviewInfo.navigate(element); - } } } diff --git a/java/java-impl-inspections/src/com/intellij/codeInspection/SimplifyStreamApiCallChainsInspection.java b/java/java-impl-inspections/src/com/intellij/codeInspection/SimplifyStreamApiCallChainsInspection.java index 992ece73a9ac..04a9f1c5c074 100644 --- a/java/java-impl-inspections/src/com/intellij/codeInspection/SimplifyStreamApiCallChainsInspection.java +++ b/java/java-impl-inspections/src/com/intellij/codeInspection/SimplifyStreamApiCallChainsInspection.java @@ -4,7 +4,6 @@ package com.intellij.codeInspection; import com.intellij.codeInsight.ExpressionUtil; import com.intellij.codeInsight.Nullability; import com.intellij.codeInsight.NullableNotNullManager; -import com.intellij.codeInsight.intention.FileModifier; import com.intellij.codeInsight.intention.impl.StreamRefactoringUtil; import com.intellij.codeInspection.dataFlow.DfaUtil; import com.intellij.codeInspection.dataFlow.NullabilityUtil; @@ -12,6 +11,7 @@ import com.intellij.codeInspection.redundantCast.RemoveRedundantCastUtil; import com.intellij.codeInspection.util.InspectionMessage; import com.intellij.codeInspection.util.IntentionName; import com.intellij.java.JavaBundle; +import com.intellij.modcommand.ModCommand; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.TextRange; @@ -260,7 +260,7 @@ public class SimplifyStreamApiCallChainsInspection extends AbstractBaseJavaLocal return new TextRange(startOffset, endOffset).shiftRight(-expression.getTextOffset()); } - interface CallChainFix extends FileModifier { + interface CallChainFix { @IntentionName String getName(); void applyFix(@NotNull Project project, PsiElement element); } @@ -283,7 +283,7 @@ public class SimplifyStreamApiCallChainsInspection extends AbstractBaseJavaLocal PsiElement simplify(PsiMethodCallExpression element); } - private static class SimplifyCallChainFix implements LocalQuickFix { + private static class SimplifyCallChainFix extends ModCommandQuickFix { private final CallChainFix myFix; SimplifyCallChainFix(CallChainFix fix) { @@ -303,14 +303,8 @@ public class SimplifyStreamApiCallChainsInspection extends AbstractBaseJavaLocal } @Override - public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { - myFix.applyFix(project, descriptor.getStartElement()); - } - - @Override - public @Nullable FileModifier getFileModifierForPreview(@NotNull PsiFile target) { - CallChainFix newFix = (CallChainFix)myFix.getFileModifierForPreview(target); - return newFix == myFix ? this : new SimplifyCallChainFix(newFix); + public @NotNull ModCommand perform(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + return ModCommands.psiUpdate(descriptor.getStartElement(), e -> myFix.applyFix(project, e)); } } @@ -1680,12 +1674,6 @@ public class SimplifyStreamApiCallChainsInspection extends AbstractBaseJavaLocal myValuePointer = SmartPointerManager.createPointer(value); } - @Override - public @Nullable FileModifier getFileModifierForPreview(@NotNull PsiFile target) { - PsiExpression expression = myValuePointer.getElement(); - return expression == null ? null : new AnyMatchContainsFix(expression); - } - @Override public String getName() { return CommonQuickFixBundle.message("fix.replace.with.x", "List.contains()"); diff --git a/java/java-tests/testData/inspection/streamApiCallChains/afterMapEntrySet.java b/java/java-tests/testData/inspection/streamApiCallChains/afterMapEntrySet.java index 8f31dc0d444b..78f821870dc8 100644 --- a/java/java-tests/testData/inspection/streamApiCallChains/afterMapEntrySet.java +++ b/java/java-tests/testData/inspection/streamApiCallChains/afterMapEntrySet.java @@ -4,7 +4,7 @@ import java.util.*; class Test { public boolean testAnyMatch(Map map) { - map.keySet().stream().forEach(System.out::println); + map.keySet().stream().filter(x -> !x.isEmpty()).forEach(System.out::println); map.values().stream().forEach(System.out::println); /*3*/ /*4*/ diff --git a/java/java-tests/testData/inspection/streamApiCallChains/beforeMapEntrySet.java b/java/java-tests/testData/inspection/streamApiCallChains/beforeMapEntrySet.java index b0143d268755..b2fdfa58d07b 100644 --- a/java/java-tests/testData/inspection/streamApiCallChains/beforeMapEntrySet.java +++ b/java/java-tests/testData/inspection/streamApiCallChains/beforeMapEntrySet.java @@ -4,7 +4,7 @@ import java.util.*; class Test { public boolean testAnyMatch(Map map) { - map.entrySet().stream().map(Map.Entry::getKey).forEach(System.out::println); + map.entrySet().stream().map(Map.Entry::getKey).filter(x -> !x.isEmpty()).forEach(System.out::println); map.entrySet().stream().map(Map.Entry::getValue).forEach(System.out::println); map/*1*/.entrySet()/*2*/.stream()./*3*/map(e -> /*4*/e.getKey())/*5*/.forEach(System.out::println); map.entrySet().stream().map(e -> { diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModCommand.java b/platform/analysis-api/src/com/intellij/modcommand/ModCommand.java new file mode 100644 index 000000000000..6f8ee4b8b040 --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModCommand.java @@ -0,0 +1,108 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.ReadonlyStatusHandler; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.util.concurrency.annotations.RequiresEdt; +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * A transparent command, which modifies the project/workspace state (writes file, changes setting, moves editor caret, etc.), + * or produces a user interaction (displays question, launches browser, etc.). + *

+ * All inheritors are records, so the whole state is declarative and readable. + */ +public sealed interface ModCommand permits ModCompositeCommand, ModLegacyBridge, ModNavigate, ModNothing, ModUpdatePsiFile { + /** + * Executes the command + * + * @param project current project + * @return execution status + */ + @RequiresEdt + @NotNull ModStatus execute(@NotNull Project project); + + /** + * @return true if the command does nothing + */ + default boolean isEmpty() { + return false; + } + + /** + * Performs preparatory step, if necessary. In particular unlocks necessary files for writing + * + * @return status of execution + */ + @RequiresEdt + default @NotNull ModStatus prepare() { + Set files = modifiedFiles(); + if (files.isEmpty()) return ModStatus.SUCCESS; + Project project = ContainerUtil.getFirstItem(files).getProject(); + VirtualFile[] vFiles = ContainerUtil.map2Array(files, VirtualFile.class, PsiFile::getVirtualFile); + return ReadonlyStatusHandler.ensureFilesWritable(project, vFiles) ? ModStatus.SUCCESS : ModStatus.CANCEL; + } + + /** + * @return set of files that are potentially modified by this command + */ + default @NotNull Set<@NotNull PsiFile> modifiedFiles() { + return Set.of(); + } + + /** + * A helper method to implement {@link #andThen(ModCommand)}. Should not be called directly. + * + * @param next command to be executed right after current + * @return merged command that executes both this and next actions; null if merge is not possible. + * Here, {@link ModCompositeCommand} is not returned + * @see #andThen(ModCommand) + */ + default @Nullable ModCommand tryMerge(@NotNull ModCommand next) { + return null; + } + + /** + * @param next command to be executed right after current + * @return the composite command that executes both current and the next command + */ + default @NotNull ModCommand andThen(@NotNull ModCommand next) { + if (isEmpty()) return next; + if (next.isEmpty()) return this; + ModCommand merged = tryMerge(next); + if (merged != null) { + return merged; + } + List commands = new ArrayList<>(unpack()); + ModCommand last = Objects.requireNonNull(ContainerUtil.getLastItem(commands)); + List nextCommands = next.unpack(); + for (int i = 0; i < nextCommands.size(); i++) { + ModCommand command = nextCommands.get(i); + merged = last.tryMerge(command); + if (merged != null) { + last = merged; + } else { + commands.set(commands.size() - 1, last); + commands.addAll(nextCommands.subList(i, nextCommands.size())); + break; + } + } + return commands.size() == 1 ? commands.get(0) : new ModCompositeCommand(commands); + } + + /** + * @return list of individual commands this command consists of + */ + default @NotNull List<@NotNull ModCommand> unpack() { + return List.of(this); + } +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModCompositeCommand.java b/platform/analysis-api/src/com/intellij/modcommand/ModCompositeCommand.java new file mode 100644 index 000000000000..2e840636175d --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModCompositeCommand.java @@ -0,0 +1,53 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A composite command that contains leaf commands inside + * + * @param commands list of commands; must not contain composite commands + */ +public record ModCompositeCommand(@NotNull List<@NotNull ModCommand> commands) implements ModCommand { + public ModCompositeCommand { + commands = List.copyOf(commands); + for (ModCommand command : commands) { + if (command instanceof ModCompositeCommand) { + throw new IllegalArgumentException("Nested composite command"); + } + } + } + + @Override + public @NotNull ModStatus execute(@NotNull Project project) { + for (ModCommand command : commands) { + ModStatus status = command.execute(project); + if (status != ModStatus.SUCCESS) { + return status; + } + } + return ModStatus.SUCCESS; + } + + @Override + public boolean isEmpty() { + return ContainerUtil.all(commands, ModCommand::isEmpty); + } + + @Override + public @NotNull Set<@NotNull PsiFile> modifiedFiles() { + return commands.stream().flatMap(c -> c.modifiedFiles().stream()).collect(Collectors.toSet()); + } + + @Override + public @NotNull List<@NotNull ModCommand> unpack() { + return commands; + } +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModLegacyBridge.java b/platform/analysis-api/src/com/intellij/modcommand/ModLegacyBridge.java new file mode 100644 index 000000000000..b141212106ea --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModLegacyBridge.java @@ -0,0 +1,18 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +/** + * Opaque command that just executes a supplied runnable. Used to support legacy API, + * should not be used in client code + * @param runnable runnable to execute + */ +public record ModLegacyBridge(@NotNull Runnable runnable) implements ModCommand { + @Override + public @NotNull ModStatus execute(@NotNull Project project) { + runnable.run(); + return ModStatus.SUCCESS; + } +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModNavigate.java b/platform/analysis-api/src/com/intellij/modcommand/ModNavigate.java new file mode 100644 index 000000000000..b9d09e2737cc --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModNavigate.java @@ -0,0 +1,36 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileEditor; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.TextEditor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +/** + * A command that updates the selection and/or caret position in the editor + * + * @param file file that should be opened in the editor. The command aborts if the selected editor contains another file + * @param selectionStart selection start; -1 if selection should not be changed + * @param selectionEnd selection end; -1 if selection should not be changed + * @param caret caret position; -1 if caret position should not be changed + */ +public record ModNavigate(@NotNull VirtualFile file, int selectionStart, int selectionEnd, int caret) implements ModCommand { + @Override + public @NotNull ModStatus execute(@NotNull Project project) { + FileEditor fileEditor = FileEditorManager.getInstance(project).getSelectedEditor(file); + if (fileEditor instanceof TextEditor textEditor) { + Editor editor = textEditor.getEditor(); + if (selectionStart != -1 && selectionEnd != -1) { + editor.getSelectionModel().setSelection(selectionStart, selectionEnd); + } + if (caret != -1) { + editor.getCaretModel().moveToOffset(caret); + } + return ModStatus.SUCCESS; + } + return ModStatus.ABORT; + } +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModNothing.java b/platform/analysis-api/src/com/intellij/modcommand/ModNothing.java new file mode 100644 index 000000000000..34f0ad7d2983 --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModNothing.java @@ -0,0 +1,22 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +/** + * A command that does nothing + */ +public record ModNothing() implements ModCommand { + public static final ModNothing NOTHING = new ModNothing(); + + @Override + public @NotNull ModStatus execute(@NotNull Project project) { + return ModStatus.SUCCESS; + } + + @Override + public boolean isEmpty() { + return true; + } +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModStatus.java b/platform/analysis-api/src/com/intellij/modcommand/ModStatus.java new file mode 100644 index 000000000000..9c2c75ee7809 --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModStatus.java @@ -0,0 +1,21 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +/** + * Status of the execution of {@link ModCommand} + */ +public enum ModStatus { + /** + * Operation completed successfully + */ + SUCCESS, + /** + * Operation aborted due to intermittent state change, no changes are introduced, + * recreating the command and performing the operation again may be helpful + */ + ABORT, + /** + * Operation cancelled by user via UI + */ + CANCEL +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModUpdatePsiFile.java b/platform/analysis-api/src/com/intellij/modcommand/ModUpdatePsiFile.java new file mode 100644 index 000000000000..d0442301b123 --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/ModUpdatePsiFile.java @@ -0,0 +1,51 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.modcommand; + +import com.intellij.codeInsight.intention.preview.IntentionPreviewUtils; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Set; + +/** + * A command that updates the content of a given {@link PsiFile} + * + * @param file file to update + * @param oldText old text (expected). The command aborts if the old text doesn't match + * @param newText new text + */ +public record ModUpdatePsiFile(@NotNull PsiFile file, @NotNull String oldText, @NotNull String newText) implements ModCommand { + @Override + public @NotNull ModStatus execute(@NotNull Project project) { + return IntentionPreviewUtils.writeAndCompute(() -> { + if (!file.textMatches(oldText)) return ModStatus.ABORT; + Document document = file.getViewProvider().getDocument(); + document.replaceString(0, document.getTextLength(), newText); + PsiDocumentManager.getInstance(project).commitDocument(document); + return ModStatus.SUCCESS; + }); + } + + @Override + public boolean isEmpty() { + return oldText.equals(newText); + } + + @Override + public @NotNull Set<@NotNull PsiFile> modifiedFiles() { + return Set.of(file); + } + + @Override + public @Nullable ModCommand tryMerge(@NotNull ModCommand next) { + if (next instanceof ModUpdatePsiFile update && file.isEquivalentTo(update.file) && + newText.equals(update.oldText)) { + return new ModUpdatePsiFile(file, oldText, update.newText); + } + return null; + } +} diff --git a/platform/analysis-api/src/com/intellij/modcommand/package-info.java b/platform/analysis-api/src/com/intellij/modcommand/package-info.java new file mode 100644 index 000000000000..6257bb3dfd90 --- /dev/null +++ b/platform/analysis-api/src/com/intellij/modcommand/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@ApiStatus.Experimental +package com.intellij.modcommand; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/platform/lang-api/src/com/intellij/codeInspection/ModCommandQuickFix.java b/platform/lang-api/src/com/intellij/codeInspection/ModCommandQuickFix.java new file mode 100644 index 000000000000..616a08ad520a --- /dev/null +++ b/platform/lang-api/src/com/intellij/codeInspection/ModCommandQuickFix.java @@ -0,0 +1,103 @@ +// Copyright 2000-2023 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.codeInsight.intention.FileModifier; +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo; +import com.intellij.modcommand.*; +import com.intellij.model.SideEffectGuard; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.util.concurrency.AppExecutorUtil; +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.ExecutionException; + +/** + * A novel experimental kind of quick-fix that creates a command instead of performing actual modification. + * Default preview for this fix is based on the command returned from {@link #perform(Project, ProblemDescriptor)} + * @see ModCommand + */ +@ApiStatus.Experimental +public abstract class ModCommandQuickFix implements LocalQuickFix { + /** + * A method that computes the final action of the quick-fix and returns it. Executed in a background read-action. + * + * @param project {@link Project} + * @param descriptor problem reported by the tool which provided this quick fix action + * @return a command to be applied to finally execute the fix. + * @see ModCommands for useful utility methods to construct the commands + */ + @NotNull + public abstract ModCommand perform(@NotNull Project project, @NotNull ProblemDescriptor descriptor); + + @Override + public final boolean startInWriteAction() { + return false; + } + + @Override + public final @Nullable PsiElement getElementToMakeWritable(@NotNull PsiFile currentFile) { + return null; + } + + @Override + public final @Nullable FileModifier getFileModifierForPreview(@NotNull PsiFile target) { + return null; + } + + @Override + public final void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + ModCommand command = getCommand(project, descriptor); + if (command.prepare() != ModStatus.SUCCESS) return; + command.execute(project); + } + + private @NotNull ModCommand getCommand(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + ModCommand command; + try { + command = ReadAction.nonBlocking(() -> SideEffectGuard.computeWithoutSideEffects(() -> perform(project, descriptor))) + .submit(AppExecutorUtil.getAppExecutorService()).get(); + } + catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + if (ContainerUtil.exists(command.unpack(), c -> c instanceof ModLegacyBridge)) { + throw new AssertionError("Wrong fix implementation: " + getClass().getName()); + } + return command; + } + + @Override + public @NotNull IntentionPreviewInfo generatePreview(@NotNull Project project, @NotNull ProblemDescriptor previewDescriptor) { + IntentionPreviewInfo info = null; + IntentionPreviewInfo info2 = IntentionPreviewInfo.EMPTY; + for (ModCommand command : getCommand(project, previewDescriptor).unpack()) { + if (command instanceof ModUpdatePsiFile modFile) { + if (info != null) { + return IntentionPreviewInfo.EMPTY; + } + PsiFile file = previewDescriptor.getPsiElement().getContainingFile(); + if (file == modFile.file()) { + modFile.execute(project); + info = IntentionPreviewInfo.DIFF; + } else { + info = new IntentionPreviewInfo.CustomDiff(modFile.file().getFileType(), modFile.file().getName(), modFile.oldText(), + modFile.newText()); + } + } + else if (command instanceof ModNavigate navigate && navigate.caret() != -1) { + PsiFile file = PsiManager.getInstance(project).findFile(navigate.file()); + if (file != null) { + info2 = IntentionPreviewInfo.navigate(file, navigate.caret()); + } + } + } + return info == null ? info2 : info; + } +} diff --git a/platform/lang-api/src/com/intellij/codeInspection/ModCommands.java b/platform/lang-api/src/com/intellij/codeInspection/ModCommands.java new file mode 100644 index 000000000000..3d188c6a836d --- /dev/null +++ b/platform/lang-api/src/com/intellij/codeInspection/ModCommands.java @@ -0,0 +1,148 @@ +// Copyright 2000-2023 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.modcommand.ModCommand; +import com.intellij.modcommand.ModNavigate; +import com.intellij.modcommand.ModNothing; +import com.intellij.modcommand.ModUpdatePsiFile; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.RangeMarker; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.TextEditor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.*; +import com.intellij.psi.impl.source.PostprocessReformattingAspect; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Utility methods to create commands + * + * @see ModCommand + */ +@ApiStatus.Experimental +public final class ModCommands { + /** + * @return a command that does nothing + */ + public static @NotNull ModCommand nop() { + return ModNothing.NOTHING; + } + + /** + * @param target element to select + * @return a command that selects given element in the editor, assuming that it's opened in the editor + */ + public static @NotNull ModCommand select(@NotNull PsiElement target) { + VirtualFile file = target.getContainingFile().getVirtualFile(); + TextRange range = target.getTextRange(); + return new ModNavigate(file, range.getStartOffset(), range.getEndOffset(), range.getStartOffset()); + } + + /** + * @param orig PsiElement to update + * @param updater a function that accepts a non-physical copy of the supplied orig element and performs + * PSI write operations in background to modify this copy + * @return a command that will perform the corresponding update to the original element + */ + public static @NotNull ModCommand psiUpdate(@NotNull PsiElement orig, @NotNull Consumer<@NotNull PsiElement> updater) { + return psiUpdate(orig, (e, ctx) -> updater.accept(e)); + } + + /** + * @param orig PsiElement to update + * @param updater a function that accepts a non-physical copy of the supplied orig element and a context to + * perform additional editor operations if necessary; and performs PSI write operations in background + * to modify this copy + * @return a command that will perform the corresponding update to the original element + */ + public static @NotNull ModCommand psiUpdate(@NotNull PsiElement orig, + @NotNull BiConsumer<@NotNull PsiElement, @NotNull PsiUpdateContext> updater) { + PsiFile origFile = orig.getContainingFile(); + VirtualFile origVirtualFile = origFile.getOriginalFile().getVirtualFile(); + Project project = origFile.getProject(); + Editor origEditor = + origVirtualFile != null && FileEditorManager.getInstance(project).getSelectedEditor(origVirtualFile) instanceof TextEditor textEditor + ? + textEditor.getEditor() + : null; + PsiFile copyFile = (PsiFile)origFile.copy(); + PsiElement copy = PsiTreeUtil.findSameElementInCopy(orig, copyFile); + PostprocessReformattingAspect aspect = PostprocessReformattingAspect.getInstance(project); + PsiDocumentManager manager = PsiDocumentManager.getInstance(project); + Document document = copyFile.getViewProvider().getDocument(); + var context = new PsiUpdateContext() { + @Nullable RangeMarker mySelectionRange = + origEditor == null ? null : + document.createRangeMarker(origEditor.getSelectionModel().getSelectionStart(), origEditor.getSelectionModel().getSelectionEnd(), + true); + @Nullable RangeMarker myCaretRange = + origEditor == null ? null : + document.createRangeMarker(origEditor.getCaretModel().getOffset(), origEditor.getCaretModel().getOffset(), true); + + @Override + public void select(@NotNull PsiElement element) { + validate(element); + manager.doPostponedOperationsAndUnblockDocument(document); + if (mySelectionRange != null) { + mySelectionRange.dispose(); + } + if (myCaretRange != null) { + myCaretRange.dispose(); + } + mySelectionRange = document.createRangeMarker(element.getTextRange()); + myCaretRange = document.createRangeMarker(element.getTextRange().getStartOffset(), element.getTextRange().getStartOffset()); + } + + @Override + public void moveTo(@NotNull PsiElement element) { + validate(element); + manager.doPostponedOperationsAndUnblockDocument(document); + if (myCaretRange != null) { + myCaretRange.dispose(); + } + myCaretRange = document.createRangeMarker(element.getTextRange().getStartOffset(), element.getTextRange().getStartOffset()); + } + + private void validate(@NotNull PsiElement element) { + if (!element.isValid()) throw new IllegalArgumentException(); + if (!PsiTreeUtil.isAncestor(copyFile, element, false)) throw new IllegalArgumentException(); + } + }; + aspect.postponeFormattingInside( + () -> aspect.forcePostprocessFormatInside(copyFile, () -> updater.accept(copy, context))); + manager.commitDocument(document); + manager.doPostponedOperationsAndUnblockDocument(document); + String oldText = origFile.getText(); + String newText = copyFile.getText(); + ModCommand command = oldText.equals(newText) ? new ModNothing() : new ModUpdatePsiFile(origFile, oldText, newText); + if (origVirtualFile != null) { + int start = -1, end = -1, caret = -1; + if (context.mySelectionRange != null && context.mySelectionRange.getEndOffset() <= newText.length()) { + start = context.mySelectionRange.getStartOffset(); + end = context.mySelectionRange.getEndOffset(); + } + if (context.myCaretRange != null && context.myCaretRange.getStartOffset() <= newText.length()) { + caret = context.myCaretRange.getStartOffset(); + } + if (start != -1 || end != -1 || caret != -1) { + command = command.andThen(new ModNavigate(origVirtualFile, start, end, caret)); + } + } + if (context.mySelectionRange != null) { + context.mySelectionRange.dispose(); + } + if (context.myCaretRange != null) { + context.myCaretRange.dispose(); + } + return command; + } +} diff --git a/platform/lang-api/src/com/intellij/codeInspection/PsiUpdateContext.java b/platform/lang-api/src/com/intellij/codeInspection/PsiUpdateContext.java new file mode 100644 index 000000000000..aadfed437001 --- /dev/null +++ b/platform/lang-api/src/com/intellij/codeInspection/PsiUpdateContext.java @@ -0,0 +1,28 @@ +// Copyright 2000-2023 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.psi.PsiElement; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiConsumer; + +/** + * A helper to perform editor command when building the {@link com.intellij.modcommand.ModCommand} + * + * @see ModCommands#psiUpdate(PsiElement, BiConsumer) + */ +public interface PsiUpdateContext { + /** + * Selects given element + * + * @param element element to select + */ + void select(@NotNull PsiElement element); + + /** + * Navigates to a given element + * + * @param element element to navigate to + */ + void moveTo(@NotNull PsiElement element); +} diff --git a/platform/lang-impl/src/com/intellij/codeInspection/actions/CleanupInspectionIntention.java b/platform/lang-impl/src/com/intellij/codeInspection/actions/CleanupInspectionIntention.java index f0d199104121..00adf0df9b86 100644 --- a/platform/lang-impl/src/com/intellij/codeInspection/actions/CleanupInspectionIntention.java +++ b/platform/lang-impl/src/com/intellij/codeInspection/actions/CleanupInspectionIntention.java @@ -1,4 +1,4 @@ -// 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. +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.codeInspection.actions; @@ -84,7 +84,7 @@ public class CleanupInspectionIntention implements IntentionAction, HighPriority @Override public boolean isAvailable(@NotNull final Project project, final Editor editor, final PsiFile file) { return myQuickfix.getClass() != EmptyIntentionAction.class && - (myQuickfix.startInWriteAction() || myQuickfix instanceof BatchQuickFix) && + (myQuickfix.startInWriteAction() || myQuickfix instanceof BatchQuickFix || myQuickfix instanceof ModCommandQuickFix) && editor != null && !(myToolWrapper instanceof LocalInspectionToolWrapper && ((LocalInspectionToolWrapper)myToolWrapper).isUnfair()); }