From 879add0d45ad28f286414a4c54efb5353f4f3939 Mon Sep 17 00:00:00 2001 From: Marcin Mikosik Date: Tue, 21 Oct 2025 17:59:51 +0000 Subject: [PATCH] [java] IDEA-377910 Add AnnotateAsNullMarkedFix #IDEA-377910 Fixed Merge-request: IJ-MR-177841 Merged-by: Marcin Mikosik GitOrigin-RevId: 14af00bf03e62781182559847f35b7d18e1c2a91 --- .../messages/JavaAnalysisBundle.properties | 4 + .../intention/AddAnnotationPsiFix.java | 15 +- .../nullable/AnnotateAsNullMarkedFix.java | 357 ++++++++++++++++++ .../MultipleElementsModCommandAction.java | 105 ++++++ .../nullable/NullableStuffInspectionBase.java | 8 +- .../afterAnnotateEnclosingClass.java | 11 + ...AnnotateEnclosingClassRemovesNullable.java | 11 + ...fterAnnotateEnclosingClassTwoLevelsUp.java | 13 + .../afterAnnotateEnclosingMethod.java | 11 + ...emovesAllNullUnmarkedInEnclosingScope.java | 13 + .../afterAnnotateRemovesNullUnmarked.java | 11 + ...teRemovesNullUnmarkedInEnclosingScope.java | 11 + ...afterRemoveNullUnmarkInsideNullMarked.java | 14 + ...terRemoveNullUnmarkedNextToNullMarked.java | 12 + .../beforeAnnotateEnclosingClass.java | 8 + ...AnnotateEnclosingClassRemovesNullable.java | 8 + ...foreAnnotateEnclosingClassTwoLevelsUp.java | 10 + .../beforeAnnotateEnclosingMethod.java | 8 + ...emovesAllNullUnmarkedInEnclosingScope.java | 12 + .../beforeAnnotateRemovesNullUnmarked.java | 9 + ...teRemovesNullUnmarkedInEnclosingScope.java | 9 + ...eforeRemoveNullUnmarkInsideNullMarked.java | 15 + ...oreRemoveNullUnmarkedNextToNullMarked.java | 13 + .../AnnotateAsNullMarkedFixPackageTest.java | 104 +++++ .../quickfix/AnnotateAsNullMarkedFixTest.java | 36 ++ platform/analysis-api/api-dump.txt | 2 + .../codeInspection/LocalQuickFix.java | 5 + .../modcommand/ModCommandService.java | 2 + .../ModCommandActionQuickFixWrapper.java | 11 + .../modcommand/ModCommandServiceImpl.java | 6 + 30 files changed, 846 insertions(+), 8 deletions(-) create mode 100644 java/java-analysis-impl/src/com/intellij/codeInspection/nullable/AnnotateAsNullMarkedFix.java create mode 100644 java/java-analysis-impl/src/com/intellij/codeInspection/nullable/MultipleElementsModCommandAction.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClass.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassRemovesNullable.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassTwoLevelsUp.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingMethod.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesAllNullUnmarkedInEnclosingScope.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarked.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarkedInEnclosingScope.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkInsideNullMarked.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkedNextToNullMarked.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClass.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassRemovesNullable.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassTwoLevelsUp.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingMethod.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesAllNullUnmarkedInEnclosingScope.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarked.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarkedInEnclosingScope.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkInsideNullMarked.java create mode 100644 java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkedNextToNullMarked.java create mode 100644 java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixPackageTest.java create mode 100644 java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixTest.java diff --git a/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties b/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties index 831ed144adc9..ebe4580071e5 100644 --- a/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties +++ b/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties @@ -96,6 +96,10 @@ inspection.i18n.quickfix.annotate=Annotate\u2026 inspection.i18n.quickfix.annotate.as=Annotate as ''@{0}'' inspection.i18n.quickfix.annotate.element=Annotate {0} ''{1}''\u2026 inspection.i18n.quickfix.annotate.element.as=Annotate {0} ''{1}'' as ''@{2}'' +inspection.i18n.quickfix.annotate.container.as=Annotate container as ''@{0}'' +inspection.i18n.quickfix.annotate.choose.container=Choose Container +inspection.i18n.quickfix.remove.annotation=Remove ''@{0}'' +inspection.i18n.quickfix.remove.annotation.from.element=Remove ''@{0}'' from {1} ''{2}'' inspection.reference.default.package= error.message.invalid.java.type=Invalid Java type move.0.to.the.beginning=Move ''{0}'' to the beginning diff --git a/java/java-analysis-impl/src/com/intellij/codeInsight/intention/AddAnnotationPsiFix.java b/java/java-analysis-impl/src/com/intellij/codeInsight/intention/AddAnnotationPsiFix.java index 12481fe4d048..b62881d2e5e3 100644 --- a/java/java-analysis-impl/src/com/intellij/codeInsight/intention/AddAnnotationPsiFix.java +++ b/java/java-analysis-impl/src/com/intellij/codeInsight/intention/AddAnnotationPsiFix.java @@ -24,6 +24,7 @@ import com.intellij.util.ArrayUtil; import com.intellij.util.ObjectUtils; import com.siyeh.ig.psiutils.CommentTracker; import one.util.streamex.StreamEx; +import org.jetbrains.annotations.Nls; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -90,11 +91,7 @@ public class AddAnnotationPsiFix extends LocalQuickFixOnPsiElement implements Lo final String name = PsiFormatUtil.formatSimple((PsiNamedElement)modifierListOwner); if (name != null) { JavaElementKind type = JavaElementKind.fromElement(modifierListOwner).lessDescriptive(); - if (shortName == null) { - return JavaAnalysisBundle.message("inspection.i18n.quickfix.annotate.element", type.object(), name); - } - return JavaAnalysisBundle - .message("inspection.i18n.quickfix.annotate.element.as", type.object(), name, shortName); + return calcText(shortName, type, name); } } if (shortName == null) { @@ -103,6 +100,14 @@ public class AddAnnotationPsiFix extends LocalQuickFixOnPsiElement implements Lo return JavaAnalysisBundle.message("inspection.i18n.quickfix.annotate.as", shortName); } + public static @Nls @NotNull String calcText(@Nullable String shortName, @NotNull JavaElementKind type, @NotNull String name) { + if (shortName == null) { + return JavaAnalysisBundle.message("inspection.i18n.quickfix.annotate.element", type.object(), name); + } + return JavaAnalysisBundle + .message("inspection.i18n.quickfix.annotate.element.as", type.object(), name, shortName); + } + public static @Nullable PsiModifierListOwner getContainer(PsiFile file, int offset) { return getContainer(file, offset, false); } diff --git a/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/AnnotateAsNullMarkedFix.java b/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/AnnotateAsNullMarkedFix.java new file mode 100644 index 000000000000..97a4547ec403 --- /dev/null +++ b/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/AnnotateAsNullMarkedFix.java @@ -0,0 +1,357 @@ +// 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.nullable; + +import com.intellij.codeInsight.intention.AddAnnotationPsiFix; +import com.intellij.codeInspection.nullable.MultipleElementsModCommandAction.Change; +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.java.analysis.JavaAnalysisBundle; +import com.intellij.java.library.JavaLibraryUtil; +import com.intellij.modcommand.*; +import com.intellij.openapi.module.Module; +import com.intellij.openapi.module.ModuleUtilCore; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.psi.*; +import com.intellij.psi.codeStyle.CodeStyleManager; +import com.intellij.psi.codeStyle.JavaCodeStyleManager; +import com.intellij.psi.util.JavaElementKind; +import com.intellij.psi.util.PsiFormatUtil; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import static com.intellij.psi.PsiPackage.PACKAGE_INFO_FILE; +import static com.intellij.psi.util.PsiTreeUtil.getChildOfType; + + +@NotNullByDefault +public final class AnnotateAsNullMarkedFix implements ModCommandAction { + private static final String NULL_MARKED_FQN = "org.jspecify.annotations.NullMarked"; + private static final String NULL_UNMARKED_FQN = "org.jspecify.annotations.NullUnmarked"; + private final @Nls String familyName; + private final List actions; + + /** + * Creates a quick fix for the PsiTypeElement which is highlighted as expected to be marked with not-null annotation. + * For each container (as specified in JSpecify spec) which encloses passed typeElement + * a quick-fix is generated that either adds @NullMarked annotation or removes @NullUnmarked annotation + * if-and-only-if such modification solves a nullability problem (missing expected not-null annotation). + * If no quick-fix is found, then null is returned. + * If only one quick-fix is found, then it will be returned. + * If multiple quick-fixes are found, then a quick-fix that displays chooser with available quick-fixes is returned. + * + * @param typeElement element with missing not-null annotation + * @param nullables list of nullable annotations that should be removed from typeElement when applying any quick-fix + */ + public static ModCommandAction createAnnotateAsNullMarkedFix(PsiTypeElement typeElement, List nullables) { + return new AnnotateAsNullMarkedFix(provideNullMarkedFixes(typeElement, nullables)); + } + + private AnnotateAsNullMarkedFix(List actions) { + this.actions = actions; + this.familyName = + JavaAnalysisBundle.message("inspection.i18n.quickfix.annotate.container.as", StringUtil.getShortName(NULL_MARKED_FQN)); + } + + @Override + public @Nullable Presentation getPresentation(ActionContext context) { + return switch (actions.size()) { + case 0 -> null; + case 1 -> actions.getFirst().getPresentation(context); + default -> Presentation.of(familyName); + }; + } + + @Override + public String getFamilyName() { + return familyName; + } + + @Override + public ModCommand perform(ActionContext context) { + return ModCommand.chooseAction(JavaAnalysisBundle.message("inspection.i18n.quickfix.annotate.choose.container"), actions); + } + + private static List provideNullMarkedFixes(PsiTypeElement typeElement, List nullables) { + if (!isJSpecifyLibraryAvailable(typeElement.getContainingFile())) { + return List.of(); + } + + List> childrenChanges = new ArrayList<>(); + addChangesThatRemoveNullableAnnotations(typeElement, nullables, childrenChanges); + List> containers = getContainersThatCanBeAnnotatedWithNullMarked(typeElement); + + var modCommandActions = new ArrayList(); + for (int i = 0; i < containers.size(); i++) { + Container container = containers.get(i); + if (container.hasNullMarkedAnnotation()) { + if (container.hasNullUnmarkedAnnotation()) { + // jspecify spec treats case when both @NullMarked and @NullUnmarked annotations are present + // as the case when none is present + var text = container.textForRemoveAnnotationAction(NULL_UNMARKED_FQN); + Change removeUnmarked = container.newChangeThatRemovesAnnotation(NULL_UNMARKED_FQN); + modCommandActions.add(new MultipleElementsModCommandAction(text, ContainerUtil.append(childrenChanges, removeUnmarked))); + } + else { + break; + } + } + else { + if (container.hasNullUnmarkedAnnotation()) { + Change removeUnmarked = container.newChangeThatRemovesAnnotation(NULL_UNMARKED_FQN); + String enclosing = annotationFromNearestAnnotatedParentContainer(containers, i); + if (enclosing != null && enclosing.equals(NULL_MARKED_FQN)) { + var text = container.textForRemoveAnnotationAction(NULL_UNMARKED_FQN); + modCommandActions.add(new MultipleElementsModCommandAction(text, ContainerUtil.append(childrenChanges, removeUnmarked))); + break; + } + else { + var text = container.textForAddAnnotationAction(NULL_MARKED_FQN); + Change addMarked = container.newChangeThatAddsAnnotation(NULL_MARKED_FQN); + var changes = ContainerUtil.append(childrenChanges, removeUnmarked, addMarked); + modCommandActions.add(new MultipleElementsModCommandAction(text, changes)); + } + childrenChanges = ContainerUtil.append(childrenChanges, removeUnmarked); + } + else { + var text = container.textForAddAnnotationAction(NULL_MARKED_FQN); + Change addMarked = container.newChangeThatAddsAnnotation(NULL_MARKED_FQN); + modCommandActions.add(new MultipleElementsModCommandAction(text, ContainerUtil.append(childrenChanges, addMarked))); + } + } + } + return modCommandActions; + } + + private static boolean isJSpecifyLibraryAvailable(PsiFile file) { + Module module = ModuleUtilCore.findModuleForFile(file); + if (module == null) return false; + return JavaLibraryUtil.hasLibraryClass(module, NULL_MARKED_FQN) && JavaLibraryUtil.hasLibraryClass(module, NULL_UNMARKED_FQN); + } + + /** + * @return effective annotation on the nearest enclosing container that affects container at index i. + */ + private static @Nullable String annotationFromNearestAnnotatedParentContainer(List> containers, + int i) { + for (int j = i + 1; j < containers.size(); j++) { + Container container = containers.get(j); + String annotation = container.getEffectiveAnnotation(); + if (annotation != null) { + return annotation; + } + } + return null; + } + + private static List> getContainersThatCanBeAnnotatedWithNullMarked(PsiElement element) { + List> result = new ArrayList<>(); + PsiModifierListOwner parent = PsiTreeUtil.getParentOfType(element, PsiModifierListOwner.class, true); + while (parent != null) { + if (parent.getModifierList() != null && (parent instanceof PsiClass || parent instanceof PsiMethod)) { + result.add(new ModifierListOwnerContainer(parent, parent.getModifierList())); + } + parent = PsiTreeUtil.getParentOfType(parent, PsiModifierListOwner.class, true); + } + + var containingFile = element.getContainingFile(); + if (containingFile == null) return result; + var directory = containingFile.getContainingDirectory(); + if (directory == null) return result; + var packageStatement = getChildOfType(containingFile, PsiPackageStatement.class); + if (packageStatement == null) return result; + var packageFqn = packageStatement.getPackageName(); + result.add(new PackageContainer(directory, packageFqn)); + + return result; + } + + private static void addChangesThatRemoveNullableAnnotations(PsiTypeElement typeElement, List nullables, + List> changes) { + var annotationFqn = ContainerUtil.filter(nullables, annotation -> typeElement.hasAnnotation(annotation)); + changes.addAll(ContainerUtil.map(annotationFqn, a -> new Change<>(typeElement, (updater, element) -> removeAnnotation(element, a)))); + } + + /** + * Container (PsiElement) on which JSpecify container annotation (NullMarked/NullUnmarked) can be added or removed. + */ + private static sealed abstract class Container permits PackageContainer, ModifierListOwnerContainer { + private final boolean hasNullMarkedAnnotation; + private final boolean hasNullUnmarkedAnnotation; + + private Container(@Nullable PsiModifierList modifierList) { + this.hasNullMarkedAnnotation = hasAnnotation(modifierList, NULL_MARKED_FQN); + this.hasNullUnmarkedAnnotation = hasAnnotation(modifierList, NULL_UNMARKED_FQN); + } + + private static boolean hasAnnotation(@Nullable PsiModifierList modifierList, String annotationFqn) { + return modifierList != null && modifierList.hasAnnotation(annotationFqn); + } + + private boolean hasNullMarkedAnnotation() { + return hasNullMarkedAnnotation; + } + + private boolean hasNullUnmarkedAnnotation() { + return hasNullUnmarkedAnnotation; + } + + public @Nullable String getEffectiveAnnotation() { + // jspecify spec treats the case when both annotations are present as the case when none is present + if (hasNullMarkedAnnotation && hasNullUnmarkedAnnotation) return null; + if (hasNullMarkedAnnotation) return NULL_MARKED_FQN; + if (hasNullUnmarkedAnnotation) return NULL_UNMARKED_FQN; + return null; + } + + public abstract Change newChangeThatAddsAnnotation(String annotationFqn); + + public abstract Change newChangeThatRemovesAnnotation(String annotationFqn); + + @IntentionName + public abstract String textForAddAnnotationAction(String annotationFqn); + + @IntentionName + public abstract String textForRemoveAnnotationAction(String annotationFqn); + } + + /** + * Provides Container operations for PsiModifierListOwner. + * Add/Remove annotation operations are applied to modifier list. + */ + private static final class ModifierListOwnerContainer extends Container { + private final PsiModifierListOwner modifierListOwner; + private final PsiModifierList modifierList; + + private ModifierListOwnerContainer(PsiModifierListOwner modifierListOwner, PsiModifierList modifierList) { + super(modifierList); + this.modifierListOwner = modifierListOwner; + this.modifierList = modifierList; + } + + @Override + public Change newChangeThatAddsAnnotation(String annotationFqn) { + return new Change<>(modifierList, (updater, m) -> updater.highlight( + JavaCodeStyleManager.getInstance(m.getProject()).shortenClassReferences(m.addAnnotation(annotationFqn)))); + } + + @Override + public Change newChangeThatRemovesAnnotation(String annotationFqn) { + return new Change<>(modifierList, (updater, modifierList) -> removeAnnotation(modifierList, annotationFqn)); + } + + @Override + public String textForAddAnnotationAction(String annotationFqn) { + return AddAnnotationPsiFix.calcText(modifierListOwner, annotationFqn); + } + + @Override + public String textForRemoveAnnotationAction(String annotationFqn) { + if (modifierListOwner instanceof PsiNamedElement) { + String name = PsiFormatUtil.formatSimple((PsiNamedElement)modifierListOwner); + if (name != null) { + var javaElementKind = JavaElementKind.fromElement(modifierListOwner).lessDescriptive(); + return createTextForRemoveAnnotationAction(annotationFqn, name, javaElementKind); + } + } + return JavaAnalysisBundle.message("inspection.i18n.quickfix.remove.annotation", StringUtil.getShortName(annotationFqn)); + } + } + + /** + * Provides container operations for a package. + * Remove/Add operations are applied to package statement in package-info.java file + * which is created on the fly if it doesn't exist. + */ + private static final class PackageContainer extends Container { + private final PsiDirectory directory; + private final String packageFqn; + + private PackageContainer(PsiDirectory directory, String packageFqn) { + super(getPackageInfoModifierList(directory)); + this.directory = directory; + this.packageFqn = packageFqn; + } + + private static @Nullable PsiModifierList getPackageInfoModifierList(PsiDirectory directory) { + directory.findFile(PACKAGE_INFO_FILE); + var packageInfoFile = directory.findFile(PACKAGE_INFO_FILE); + if (packageInfoFile == null) return null; + if (!(packageInfoFile instanceof PsiJavaFile packageInfoJavaFile)) return null; + var packageStatement = packageInfoJavaFile.getPackageStatement(); + if (packageStatement == null) return null; + return packageStatement.getAnnotationList(); + } + + @Override + public Change newChangeThatAddsAnnotation(String annotationFqn) { + return new Change<>(directory, (updater, dir) -> { + var packageInfoFile = updater.getWritable(getPackageInfoFile(updater, dir)); + var packageStatement = getPackageStatement(packageInfoFile, dir.getProject()); + var elementFactory = JavaPsiFacade.getElementFactory(dir.getProject()); + var annotation = elementFactory.createAnnotationFromText("@" + annotationFqn, packageStatement); + var addedAnnotation = packageInfoFile.addBefore(annotation, packageStatement); + JavaCodeStyleManager.getInstance(dir.getProject()).shortenClassReferences(addedAnnotation); + CodeStyleManager.getInstance(directory.getProject()).reformat(packageInfoFile); + updater.moveCaretTo(packageStatement); + updater.highlight(addedAnnotation); + }); + } + + private PsiPackageStatement getPackageStatement(PsiFile packageInfoFile, Project project) { + if (packageInfoFile instanceof PsiJavaFile packageInfoJavaFile) { + var packageStatement = packageInfoJavaFile.getPackageStatement(); + if (packageStatement != null) return packageStatement; + } + var elementFactory = JavaPsiFacade.getElementFactory(project); + var packageStatement = elementFactory.createPackageStatement(packageFqn); + return (PsiPackageStatement)packageInfoFile.addBefore(packageStatement, packageInfoFile.getFirstChild()); + } + + private static PsiFile getPackageInfoFile(ModPsiUpdater updater, PsiDirectory directory) { + var packageInfoFile = directory.findFile(PACKAGE_INFO_FILE); + if (packageInfoFile == null) { + packageInfoFile = updater.getWritable(directory).createFile(PACKAGE_INFO_FILE); + } + return packageInfoFile; + } + + @Override + public Change newChangeThatRemovesAnnotation(String annotationFqn) { + return new Change<>(directory, (updater, dir) -> { + var packageInfoFile = updater.getWritable(getPackageInfoFile(updater, dir)); + var packageStatement = getPackageStatement(packageInfoFile, dir.getProject()); + var modifierList = packageStatement.getAnnotationList(); + if (modifierList == null) return; + removeAnnotation(modifierList, annotationFqn); + }); + } + + @Override + public String textForAddAnnotationAction(String annotationFqn) { + return AddAnnotationPsiFix.calcText(StringUtil.getShortName(annotationFqn), JavaElementKind.PACKAGE, packageFqn); + } + + @Override + public String textForRemoveAnnotationAction(String annotationFqn) { + return createTextForRemoveAnnotationAction(annotationFqn, packageFqn, JavaElementKind.PACKAGE); + } + } + + private static void removeAnnotation(PsiAnnotationOwner annotationOwner, String annotationFqn) { + var annotation = annotationOwner.findAnnotation(annotationFqn); + if (annotation != null) { + annotation.delete(); + } + } + + private static @Nls String createTextForRemoveAnnotationAction(String annotationFqn, String name, JavaElementKind elementKind) { + String shortName = StringUtil.getShortName(annotationFqn); + return JavaAnalysisBundle.message("inspection.i18n.quickfix.remove.annotation.from.element", shortName, elementKind.object(), name); + } +} diff --git a/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/MultipleElementsModCommandAction.java b/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/MultipleElementsModCommandAction.java new file mode 100644 index 000000000000..124e87f21e21 --- /dev/null +++ b/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/MultipleElementsModCommandAction.java @@ -0,0 +1,105 @@ +// 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.nullable; + +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.modcommand.*; +import com.intellij.psi.PsiElement; +import com.intellij.psi.SmartPointerManager; +import com.intellij.psi.SmartPsiElementPointer; +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +/** + * A ModCommand action that performs multiple PsiElement changes. + * Each change is passed a a {@link Change} object + * that contains PsiElement to be modified (target) and the operation to performed. + * Internally this action uses {@link SmartPsiElementPointer}s to track the target elements. + */ +@NotNullByDefault +class MultipleElementsModCommandAction implements ModCommandAction { + @IntentionName + private final String familyName; + private final List> changes; + + /** + * Creates a new action that performs multiple PsiElement changes. + * + * @param familyName the name of the action family to be displayed in the UI + * @param changes list of changes to be performed on PsiElements + */ + MultipleElementsModCommandAction(@IntentionName String familyName, List> changes) { + this.changes = ContainerUtil.map(changes, change -> change.toSmartChange()); + this.familyName = familyName; + } + + @Override + public String getFamilyName() { + return familyName; + } + + @Override + public Presentation getPresentation(ActionContext context) { + return Presentation.of(familyName); + } + + @Override + public ModCommand perform(ActionContext context) { + return ModCommand.psiUpdate(context, updater -> applyOperations(changes, updater)); + } + + private static void applyOperations(List> smartChanges, ModPsiUpdater updater) { + List> changes = convertSmartChangesToChanges(updater, smartChanges); + if (changes == null) return; + for (Change change : changes) { + change.invokeOperation(updater); + } + } + + private static @Nullable List> convertSmartChangesToChanges(ModPsiUpdater updater, + List> smartChanges) { + List> changes = new ArrayList<>(); + for (SmartChange smartChange : smartChanges) { + Change change = smartChange.toChange(updater); + if (change == null) return null; + changes.add(change); + } + return changes; + } + + /** + * Represents a change operation to be performed on a PsiElement. + * + * @param target the PsiElement to be modified + * @param operation the operation to be performed on the target element + * @param the type of PsiElement + */ + public record Change(T target, BiConsumer operation) { + SmartChange toSmartChange() { + return new SmartChange<>(SmartPointerManager.createPointer(target), operation); + } + + public void invokeOperation(ModPsiUpdater updater) { + operation().accept(updater, target()); + } + } + + /** + * Represents a change operation to be performed on a PsiElement tracked by SmartPsiElementPointer. + * + * @param target smart pointer to the PsiElement to be modified + * @param operation the operation to be performed on the target element + * @param the type of PsiElement + */ + private record SmartChange(SmartPsiElementPointer target, BiConsumer operation) { + private @Nullable Change toChange(ModPsiUpdater updater) { + T writable = updater.getWritable(target.getElement()); + if (writable == null) return null; + return new Change<>(writable, operation); + } + } +} diff --git a/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/NullableStuffInspectionBase.java b/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/NullableStuffInspectionBase.java index 658ed8da962f..36fef291ea65 100644 --- a/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/NullableStuffInspectionBase.java +++ b/java/java-analysis-impl/src/com/intellij/codeInspection/nullable/NullableStuffInspectionBase.java @@ -48,6 +48,7 @@ import java.util.*; import java.util.function.Consumer; import static com.intellij.codeInsight.AnnotationUtil.*; +import static com.intellij.codeInspection.nullable.AnnotateAsNullMarkedFix.createAnnotateAsNullMarkedFix; import static com.intellij.patterns.PsiJavaPatterns.psiElement; import static com.intellij.patterns.PsiJavaPatterns.psiMethod; import static com.intellij.util.ObjectUtils.tryCast; @@ -442,12 +443,13 @@ public class NullableStuffInspectionBase extends AbstractBaseJavaLocalInspection !(typeNullability == Nullability.UNKNOWN && type instanceof PsiWildcardType wildcardType && !wildcardType.isExtends())) { String annotationToAdd = manager.getDefaultAnnotation(Nullability.NOT_NULL, reference); PsiClass annotationClass = JavaPsiFacade.getInstance(project).findClass(annotationToAdd, element.getResolveScope()); - AddTypeAnnotationFix fix = null; + List fixes = new ArrayList<>(); if (annotationClass != null && AnnotationTargetUtil.findAnnotationTarget(annotationClass, PsiAnnotation.TargetType.TYPE_USE) != null) { - fix = new AddTypeAnnotationFix(typeArgument, annotationToAdd, manager.getNullables()); + fixes.add(LocalQuickFix.from(new AddTypeAnnotationFix(typeArgument, annotationToAdd, manager.getNullables()))); } - reportProblem(holder, typeArgument, fix, "non.null.type.argument.is.expected"); + fixes.add(LocalQuickFix.from(createAnnotateAsNullMarkedFix(typeArgument, manager.getNullables()), false)); + reportProblem(holder, typeArgument, fixes.toArray(LocalQuickFix.EMPTY_ARRAY), "non.null.type.argument.is.expected"); } } } diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClass.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClass.java new file mode 100644 index 000000000000..4490ecc52cfd --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClass.java @@ -0,0 +1,11 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +@NullMarked +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassRemovesNullable.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassRemovesNullable.java new file mode 100644 index 000000000000..4490ecc52cfd --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassRemovesNullable.java @@ -0,0 +1,11 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +@NullMarked +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassTwoLevelsUp.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassTwoLevelsUp.java new file mode 100644 index 000000000000..5b16a4cf4992 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingClassTwoLevelsUp.java @@ -0,0 +1,13 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +@NullMarked +class Enclosing { + class InnerEnclosing { + public void foo(NonNullHolder<String> holder) { + } + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingMethod.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingMethod.java new file mode 100644 index 000000000000..41d5f104c49f --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateEnclosingMethod.java @@ -0,0 +1,11 @@ +// "Annotate container as '@NullMarked'|->Annotate method 'foo()' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +class Enclosing { + @NullMarked + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesAllNullUnmarkedInEnclosingScope.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesAllNullUnmarkedInEnclosingScope.java new file mode 100644 index 000000000000..28a503694f78 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesAllNullUnmarkedInEnclosingScope.java @@ -0,0 +1,13 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +@NullMarked +class Enclosing { + class Inner { + public void foo(NonNullHolder<String> holder) { + } + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarked.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarked.java new file mode 100644 index 000000000000..41d5f104c49f --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarked.java @@ -0,0 +1,11 @@ +// "Annotate container as '@NullMarked'|->Annotate method 'foo()' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +class Enclosing { + @NullMarked + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarkedInEnclosingScope.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarkedInEnclosingScope.java new file mode 100644 index 000000000000..4490ecc52cfd --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterAnnotateRemovesNullUnmarkedInEnclosingScope.java @@ -0,0 +1,11 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +import org.jspecify.annotations.NullMarked; + +class NonNullHolder {} + +@NullMarked +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkInsideNullMarked.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkInsideNullMarked.java new file mode 100644 index 000000000000..92d8c1d14eff --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkInsideNullMarked.java @@ -0,0 +1,14 @@ +// "Annotate container as '@NullMarked'|->Remove '@NullUnmarked' from class 'Enclosing'" "true" + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.NullUnmarked; + +class NonNullHolder {} + +@NullMarked +class Outer { + class Enclosing { + public void foo(NonNullHolder<String> holder) { + } + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkedNextToNullMarked.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkedNextToNullMarked.java new file mode 100644 index 000000000000..e731566fa171 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/afterRemoveNullUnmarkedNextToNullMarked.java @@ -0,0 +1,12 @@ +// "Annotate container as '@NullMarked'|->Remove '@NullUnmarked' from class 'Enclosing'" "true" + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.NullUnmarked; + +class NonNullHolder {} + +@NullMarked +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClass.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClass.java new file mode 100644 index 000000000000..fb95585510da --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClass.java @@ -0,0 +1,8 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassRemovesNullable.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassRemovesNullable.java new file mode 100644 index 000000000000..e6eeb6256a16 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassRemovesNullable.java @@ -0,0 +1,8 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + public void foo(NonNullHolder<@org.jetbrains.annotations.Nullable String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassTwoLevelsUp.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassTwoLevelsUp.java new file mode 100644 index 000000000000..974965c89d6e --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingClassTwoLevelsUp.java @@ -0,0 +1,10 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + class InnerEnclosing { + public void foo(NonNullHolder<String> holder) { + } + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingMethod.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingMethod.java new file mode 100644 index 000000000000..a9d51bc69946 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateEnclosingMethod.java @@ -0,0 +1,8 @@ +// "Annotate container as '@NullMarked'|->Annotate method 'foo()' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesAllNullUnmarkedInEnclosingScope.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesAllNullUnmarkedInEnclosingScope.java new file mode 100644 index 000000000000..e7c977515f47 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesAllNullUnmarkedInEnclosingScope.java @@ -0,0 +1,12 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + @org.jspecify.annotations.NullUnmarked + class Inner { + @org.jspecify.annotations.NullUnmarked + public void foo(NonNullHolder<String> holder) { + } + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarked.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarked.java new file mode 100644 index 000000000000..4e60c5c9be77 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarked.java @@ -0,0 +1,9 @@ +// "Annotate container as '@NullMarked'|->Annotate method 'foo()' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + @org.jspecify.annotations.NullUnmarked + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarkedInEnclosingScope.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarkedInEnclosingScope.java new file mode 100644 index 000000000000..91c85cd44046 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeAnnotateRemovesNullUnmarkedInEnclosingScope.java @@ -0,0 +1,9 @@ +// "Annotate container as '@NullMarked'|->Annotate class 'Enclosing' as '@NullMarked'" "true" + +class NonNullHolder {} + +class Enclosing { + @org.jspecify.annotations.NullUnmarked + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkInsideNullMarked.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkInsideNullMarked.java new file mode 100644 index 000000000000..65d8a6f6c346 --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkInsideNullMarked.java @@ -0,0 +1,15 @@ +// "Annotate container as '@NullMarked'|->Remove '@NullUnmarked' from class 'Enclosing'" "true" + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.NullUnmarked; + +class NonNullHolder {} + +@NullMarked +class Outer { + @NullUnmarked + class Enclosing { + public void foo(NonNullHolder<String> holder) { + } + } +} diff --git a/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkedNextToNullMarked.java b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkedNextToNullMarked.java new file mode 100644 index 000000000000..bb57f9def37f --- /dev/null +++ b/java/java-tests/testData/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix/beforeRemoveNullUnmarkedNextToNullMarked.java @@ -0,0 +1,13 @@ +// "Annotate container as '@NullMarked'|->Remove '@NullUnmarked' from class 'Enclosing'" "true" + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.NullUnmarked; + +class NonNullHolder {} + +@NullUnmarked +@NullMarked +class Enclosing { + public void foo(NonNullHolder<String> holder) { + } +} diff --git a/java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixPackageTest.java b/java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixPackageTest.java new file mode 100644 index 000000000000..924c23cfca26 --- /dev/null +++ b/java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixPackageTest.java @@ -0,0 +1,104 @@ +// 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.codeInspection.nullable.NullableStuffInspection; +import com.intellij.openapi.application.impl.NonBlockingReadActionImpl; +import com.intellij.openapi.module.Module; +import com.intellij.openapi.roots.ContentEntry; +import com.intellij.openapi.roots.ModifiableRootModel; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.testFramework.LightProjectDescriptor; +import com.intellij.testFramework.fixtures.DefaultLightProjectDescriptor; +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase; +import com.intellij.testFramework.fixtures.MavenDependencyUtil; +import com.intellij.ui.ChooserInterceptor; +import com.intellij.ui.UiInterceptors; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.NotNull; + +import java.util.regex.Pattern; + + +public class AnnotateAsNullMarkedFixPackageTest extends LightJavaCodeInsightFixtureTestCase { + @Language("JAVA") + private static final String ENCLOSING_CLASS_FILE_TEXT = """ + package com.example; + + class NonNullHolder {} + + public class Enclosing { + public void foo(NonNullHolder< String> holder) { + } + } + """; + + @Override + protected @NotNull LightProjectDescriptor getProjectDescriptor() { + return new DefaultLightProjectDescriptor() { + @Override + public void configureModule(@NotNull Module module, @NotNull ModifiableRootModel model, @NotNull ContentEntry contentEntry) { + MavenDependencyUtil.addFromMaven(model, "org.jspecify:jspecify:1.0.0"); + } + }; + } + + public void testAnnotatePackageAsNullMarkedWhenPackageInfoIsMissing() { + configureJavaFileFromPathAndText("com/example/Enclosing.java", ENCLOSING_CLASS_FILE_TEXT); + + runAnnotatePackageAsNullMarkedAction(); + + assertJavaFileContent("com/example/package-info.java", """ + @NullMarked + package com.example; + + import org.jspecify.annotations.NullMarked;"""); + } + + public void testAnnotatePackageAsNullMarkedWhenPackageInfoIsPresent() { + configureJavaFileFromPathAndText("com/example/Enclosing.java", ENCLOSING_CLASS_FILE_TEXT); + myFixture.addFileToProject("com/example/package-info.java", "package com.example;"); + + runAnnotatePackageAsNullMarkedAction(); + + assertJavaFileContent("com/example/package-info.java", """ + @NullMarked + package com.example; + + import org.jspecify.annotations.NullMarked;"""); + } + + public void testAnnotatePackageAsNullMarkedWhenPackageInfoIsPresentAndContainsNullUnmarkedAnnotation() { + configureJavaFileFromPathAndText("com/example/Enclosing.java", ENCLOSING_CLASS_FILE_TEXT); + myFixture.addFileToProject("com/example/package-info.java", """ + @NullUnmarked + package com.example; + + import org.jspecify.annotations.NullUnmarked;"""); + + runAnnotatePackageAsNullMarkedAction(); + + assertJavaFileContent("com/example/package-info.java", """ + @NullMarked + package com.example; + + import org.jspecify.annotations.NullMarked; + import org.jspecify.annotations.NullUnmarked;"""); + } + + private void runAnnotatePackageAsNullMarkedAction() { + myFixture.enableInspections(new NullableStuffInspection()); + var intentionAction = myFixture.findSingleIntention("Annotate container as '@NullMarked'"); + UiInterceptors.register(new ChooserInterceptor(null, Pattern.quote("Annotate package 'com.example' as '@NullMarked'"))); + myFixture.launchAction(intentionAction); + NonBlockingReadActionImpl.waitForAsyncTaskCompletion(); + } + + private void configureJavaFileFromPathAndText(String path, @Language("JAVA") String text) { + VirtualFile file = myFixture.addFileToProject(path, text).getVirtualFile(); + myFixture.configureFromExistingVirtualFile(file); + } + + private void assertJavaFileContent(String path, @Language("JAVA") String expected) { + myFixture.checkResult(path, expected, false); + } +} diff --git a/java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixTest.java b/java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixTest.java new file mode 100644 index 000000000000..9db8f44ba84c --- /dev/null +++ b/java/java-tests/testSrc/com/intellij/codeInsight/daemon/impl/quickfix/AnnotateAsNullMarkedFixTest.java @@ -0,0 +1,36 @@ +// 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.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.nullable.NullableStuffInspection; +import com.intellij.openapi.module.Module; +import com.intellij.openapi.roots.ContentEntry; +import com.intellij.openapi.roots.ModifiableRootModel; +import com.intellij.testFramework.LightProjectDescriptor; +import com.intellij.testFramework.fixtures.DefaultLightProjectDescriptor; +import com.intellij.testFramework.fixtures.MavenDependencyUtil; +import org.jetbrains.annotations.NotNull; + +public class AnnotateAsNullMarkedFixTest extends LightQuickFixParameterizedTestCase { + @Override + protected String getBasePath() { + return "/codeInsight/daemonCodeAnalyzer/quickFix/annotateAsNullMarkedFix"; + } + + @Override + protected @NotNull LightProjectDescriptor getProjectDescriptor() { + return new SimpleLightProjectDescriptor(getModuleTypeId(), getProjectJDK()) { + @Override + protected void configureModule(@NotNull Module module, @NotNull ModifiableRootModel model, @NotNull ContentEntry contentEntry) { + DefaultLightProjectDescriptor.addJetBrainsAnnotationsWithTypeUse(model); + MavenDependencyUtil.addFromMaven(model, "org.jspecify:jspecify:1.0.0"); + } + }; + } + + @Override + protected LocalInspectionTool @NotNull [] configureLocalInspectionTools() { + return new LocalInspectionTool[]{new NullableStuffInspection()}; + } +} diff --git a/platform/analysis-api/api-dump.txt b/platform/analysis-api/api-dump.txt index fb7167b8ac89..dcbe2dfd3c07 100644 --- a/platform/analysis-api/api-dump.txt +++ b/platform/analysis-api/api-dump.txt @@ -299,6 +299,7 @@ com.intellij.codeInspection.LocalQuickFix - sf:EMPTY_ARRAY:com.intellij.codeInspection.LocalQuickFix[] - availableInBatchMode():Z - s:from(com.intellij.modcommand.ModCommandAction):com.intellij.codeInspection.LocalQuickFix +- s:from(com.intellij.modcommand.ModCommandAction,Z):com.intellij.codeInspection.LocalQuickFix - generatePreview(com.intellij.openapi.project.Project,com.intellij.codeInspection.ProblemDescriptor):com.intellij.codeInsight.intention.preview.IntentionPreviewInfo - getRangesToHighlight(com.intellij.openapi.project.Project,com.intellij.codeInspection.ProblemDescriptor):java.util.List - s:notNullElements(com.intellij.codeInspection.LocalQuickFix):com.intellij.codeInspection.LocalQuickFix[] @@ -637,6 +638,7 @@ com.intellij.modcommand.ModCommandService - a:wrap(com.intellij.modcommand.ModCommandAction):com.intellij.codeInsight.intention.IntentionAction - a:wrapToLocalQuickFixAndIntentionActionOnPsiElement(com.intellij.modcommand.ModCommandAction,com.intellij.psi.PsiElement):com.intellij.codeInspection.LocalQuickFixAndIntentionActionOnPsiElement - a:wrapToQuickFix(com.intellij.modcommand.ModCommandAction):com.intellij.codeInspection.LocalQuickFix +- a:wrapToQuickFix(com.intellij.modcommand.ModCommandAction,Z):com.intellij.codeInspection.LocalQuickFix com.intellij.modcommand.ModPsiUpdater - com.intellij.modcommand.ModPsiNavigator - a:cancel(java.lang.String):V diff --git a/platform/analysis-api/src/com/intellij/codeInspection/LocalQuickFix.java b/platform/analysis-api/src/com/intellij/codeInspection/LocalQuickFix.java index 5987a0b6015a..88506f9cb4b7 100644 --- a/platform/analysis-api/src/com/intellij/codeInspection/LocalQuickFix.java +++ b/platform/analysis-api/src/com/intellij/codeInspection/LocalQuickFix.java @@ -124,4 +124,9 @@ public interface LocalQuickFix extends QuickFix, FileModifier static @Nullable LocalQuickFix from(@Nullable ModCommandAction action) { return action == null ? null : ModCommandService.getInstance().wrapToQuickFix(action); } + + @Contract("null, _ -> null; !null, _ -> !null") + static @Nullable LocalQuickFix from(@Nullable ModCommandAction action, boolean availableInBatchMode) { + return action == null ? null : ModCommandService.getInstance().wrapToQuickFix(action, availableInBatchMode); + } } diff --git a/platform/analysis-api/src/com/intellij/modcommand/ModCommandService.java b/platform/analysis-api/src/com/intellij/modcommand/ModCommandService.java index c2bccae86d3f..fc9a3fb89f70 100644 --- a/platform/analysis-api/src/com/intellij/modcommand/ModCommandService.java +++ b/platform/analysis-api/src/com/intellij/modcommand/ModCommandService.java @@ -39,6 +39,8 @@ public interface ModCommandService { */ @NotNull LocalQuickFix wrapToQuickFix(@NotNull ModCommandAction action); + @NotNull LocalQuickFix wrapToQuickFix(@NotNull ModCommandAction action, boolean availableInBatchMode); + /** * @param fix {@link LocalQuickFix} * @return a {@link ModCommandAction} which is wrapped inside the supplied quick-fix; null if the supplied quick-fix diff --git a/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandActionQuickFixWrapper.java b/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandActionQuickFixWrapper.java index 4b259ec69854..fe487328dcd6 100644 --- a/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandActionQuickFixWrapper.java +++ b/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandActionQuickFixWrapper.java @@ -13,9 +13,20 @@ import org.jetbrains.annotations.NotNull; final class ModCommandActionQuickFixWrapper extends ModCommandQuickFix implements ReportingClassSubstitutor { private final ModCommandAction myAction; + private boolean myAvailableInBatchMode; ModCommandActionQuickFixWrapper(@NotNull ModCommandAction action) { + this(action, true); + } + + ModCommandActionQuickFixWrapper(@NotNull ModCommandAction action, boolean availableInBatchMode) { myAction = action; + myAvailableInBatchMode = availableInBatchMode; + } + + @Override + public boolean availableInBatchMode() { + return myAvailableInBatchMode; } ModCommandAction getAction() { diff --git a/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandServiceImpl.java b/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandServiceImpl.java index c54633be650e..bf0b87e0c91d 100644 --- a/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandServiceImpl.java +++ b/platform/analysis-impl/src/com/intellij/lang/impl/modcommand/ModCommandServiceImpl.java @@ -24,6 +24,7 @@ import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @ApiStatus.Internal @@ -44,6 +45,11 @@ public final class ModCommandServiceImpl implements ModCommandService { return new ModCommandActionQuickFixWrapper(action); } + @Override + public @NotNull LocalQuickFix wrapToQuickFix(@NotNull ModCommandAction action, boolean availableInBatchMode) { + return new ModCommandActionQuickFixWrapper(action, availableInBatchMode); + } + @Override public @Nullable ModCommandAction unwrap(@NotNull LocalQuickFix fix) { if (fix instanceof ModCommandActionQuickFixWrapper wrapper) {