[java-inspections] IDEA-349198 Catch deconstruction on null value

GitOrigin-RevId: 6eac3a29ad4cefa69c3a7fa69a85c46d98187eb6
This commit is contained in:
Mikhail Pyltsin
2026-02-09 19:50:58 +00:00
committed by intellij-monorepo-bot
parent 2d970ca167
commit 6d178d8680
19 changed files with 576 additions and 20 deletions
@@ -974,12 +974,9 @@ public final class SwitchUtils {
* Evaluates the exhaustiveness state of a switch block.
*
* @param switchBlock the PsiSwitchBlock to evaluate
* @param considerNestedDeconstructionPatterns flag indicating whether to consider nested deconstruction patterns. It is necessary to take into account,
* because nested deconstruction patterns don't cover null values
* @return exhaustiveness state.
*/
public static @NotNull SwitchExhaustivenessState evaluateSwitchCompleteness(@NotNull PsiSwitchBlock switchBlock,
boolean considerNestedDeconstructionPatterns) {
public static @NotNull SwitchExhaustivenessState evaluateSwitchCompleteness(@NotNull PsiSwitchBlock switchBlock) {
PsiExpression selector = switchBlock.getExpression();
if (selector == null) return SwitchExhaustivenessState.MALFORMED;
PsiType selectorType = selector.getType();
@@ -998,15 +995,6 @@ public final class SwitchUtils {
return SwitchExhaustivenessState.EXHAUSTIVE_NO_DEFAULT;
}
if (!needToCheckCompleteness && !isEnumSelector) return SwitchExhaustivenessState.INCOMPLETE;
// It is necessary because deconstruction patterns don't cover cases
// when some of their components are null and deconstructionPattern too
if (!considerNestedDeconstructionPatterns) {
labelElements = ContainerUtil.filter(
labelElements, label -> !(label instanceof PsiDeconstructionPattern deconstructionPattern &&
ContainerUtil.or(
deconstructionPattern.getDeconstructionList().getDeconstructionComponents(),
component -> component instanceof PsiDeconstructionPattern)));
}
boolean hasError = hasExhaustivenessError(switchBlock, labelElements);
// if a switch block is needed to check completeness and switch is incomplete we let highlighting to inform about it as it's a compilation error
if (!hasError) {
@@ -486,6 +486,12 @@
groupKey="group.names.nullability.problems" groupBundle="messages.InspectionsBundle" enabledByDefault="true"
level="WARNING"
implementationClass="com.intellij.codeInspection.nullable.NullableStuffInspection"/>
<localInspection groupPathKey="group.path.names.probable.bugs" language="JAVA" bundle="messages.JavaBundle"
key="inspection.match.exception.problems.display.name"
hasStaticDescription="true"
groupKey="group.names.probable.bugs" groupBundle="messages.InspectionsBundle" enabledByDefault="true"
level="WARNING"
implementationClass="com.intellij.codeInspection.MatchExceptionInspection"/>
<localInspection groupPath="Java" language="JAVA" shortName="UnsatisfiedRange" bundle="messages.JavaAnalysisBundle"
key="inspection.unsatisfied.range.display.name"
groupKey="group.names.probable.bugs" groupBundle="messages.InspectionsBundle" enabledByDefault="true" level="WARNING"
@@ -0,0 +1,35 @@
<html>
<body>
Reports patterns in switch expressions and statements that may throw
<code>MatchException</code> at runtime
due to <code>null</code> values in deconstruction patterns.
<p>
The inspection analyzes record components with <code>@Nullable</code> annotation to detect cases where
deconstruction patterns may not cover <code>null</code> values.
</p>
<p><b>Example:</b></p>
<pre><code>
sealed interface II {
record AI() implements II {}
record BI() implements II {}
}
record RI(@Nullable II value) {}
private static II getII(RI ri) {
return switch (ri) {
case RI(II.BI bi) -> bi; // MatchException if ri.value is null
case RI(II.AI ai) -> ai;
};
}
</code></pre>
<p>
Use the provided quick-fix to add a <code>default</code> branch to handle unmatched cases safely.
</p>
<!-- tooltip end -->
<p><small>New in 2026.1</small>
</body>
</html>
@@ -340,7 +340,7 @@ public final class EnhancedSwitchMigrationInspection extends AbstractBaseJavaLoc
if (branch.isDefault()) return true;
if (existsDefaultLabelElement(branch.myLabelStatement)) return true;
}
SwitchUtils.SwitchExhaustivenessState completenessResult = evaluateSwitchCompleteness(switchStatement, true);
SwitchUtils.SwitchExhaustivenessState completenessResult = evaluateSwitchCompleteness(switchStatement);
return completenessResult == SwitchUtils.SwitchExhaustivenessState.EXHAUSTIVE_CAN_ADD_DEFAULT ||
completenessResult == SwitchUtils.SwitchExhaustivenessState.EXHAUSTIVE_NO_DEFAULT;
}
@@ -0,0 +1,210 @@
// Copyright 2000-2026 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.Nullability;
import com.intellij.codeInsight.TypeNullability;
import com.intellij.codeInsight.intention.QuickFixFactory;
import com.intellij.java.JavaBundle;
import com.intellij.java.codeserver.core.JavaPsiSwitchUtil;
import com.intellij.java.syntax.parser.JavaKeywords;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.JavaElementVisitor;
import com.intellij.psi.PsiCaseLabelElement;
import com.intellij.psi.PsiCaseLabelElementList;
import com.intellij.psi.PsiClass;
import com.intellij.psi.PsiDeconstructionPattern;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementFactory;
import com.intellij.psi.PsiElementVisitor;
import com.intellij.psi.PsiExpression;
import com.intellij.psi.PsiKeyword;
import com.intellij.psi.PsiModifier;
import com.intellij.psi.PsiPattern;
import com.intellij.psi.PsiRecordComponent;
import com.intellij.psi.PsiStatement;
import com.intellij.psi.PsiSwitchBlock;
import com.intellij.psi.PsiSwitchExpression;
import com.intellij.psi.PsiSwitchLabelStatementBase;
import com.intellij.psi.PsiSwitchStatement;
import com.intellij.psi.PsiType;
import com.intellij.psi.PsiTypeElement;
import com.intellij.psi.util.PsiUtil;
import com.intellij.util.IncorrectOperationException;
import com.intellij.util.ObjectUtils;
import com.intellij.util.containers.ContainerUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class MatchExceptionInspection extends AbstractBaseJavaLocalInspectionTool {
@Override
public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) {
return new JavaElementVisitor() {
@Override
public void visitSwitchExpression(@NotNull PsiSwitchExpression expression) {
super.visitSwitchExpression(expression);
checkSwitchBlock(expression);
}
@Override
public void visitSwitchStatement(@NotNull PsiSwitchStatement statement) {
super.visitSwitchStatement(statement);
checkSwitchBlock(statement);
}
private void checkSwitchBlock(@NotNull PsiSwitchBlock switchBlock) {
//fast exit
if (JavaPsiSwitchUtil.findDefaultElement(switchBlock) != null) {
return;
}
PsiPattern pattern = findPatternCanProduceMatchException(switchBlock, Set.of(), true);
if (pattern == null) {
return;
}
QuickFixFactory quickFixFactory = QuickFixFactory.getInstance();
holder.problem(pattern, JavaBundle.message("inspection.match.exception.problems.message"))
.fix(Objects.requireNonNull(quickFixFactory.createAddSwitchDefaultFix(switchBlock, null).asModCommandAction()))
.register();
}
};
}
/**
* Identifies a pattern in the branches of a given {@code PsiSwitchBlock} that could potentially
* produce a match exception during execution.
*
* @param switchBlock the {@link PsiSwitchBlock} to analyze, representing a switch statement or
* switch expression in Java code. It must not be null.
* @param skipDominatingElements a set of elements that should be skipped when searching for a pattern.
* @param necessaryNullable flag indicating whether the pattern must be certainly nullable.
* @return a {@link PsiPattern} that could potentially cause a match exception, or {@code null}
* if no such pattern is found.
*/
public static @Nullable PsiPattern findPatternCanProduceMatchException(@NotNull PsiSwitchBlock switchBlock,
@NotNull Set<@NotNull PsiElement> skipDominatingElements,
boolean necessaryNullable) {
List<PsiElement> branches = JavaPsiSwitchUtil.getSwitchBranches(switchBlock);
for (PsiElement branch : branches) {
if (!(branch instanceof PsiDeconstructionPattern psiDeconstructionPattern)) continue;
PsiPattern deconstructionComponent = findDeconstructionComponentCanProduceMatchException(switchBlock,
psiDeconstructionPattern,
psiDeconstructionPattern,
skipDominatingElements,
necessaryNullable);
if (deconstructionComponent != null) return deconstructionComponent;
}
return null;
}
private static @Nullable PsiPattern findDeconstructionComponentCanProduceMatchException(
@NotNull PsiSwitchBlock switchBlock,
@NotNull PsiDeconstructionPattern psiDeconstructionPattern,
@NotNull PsiDeconstructionPattern topLevelDeconstructionPattern,
@NotNull Set<@NotNull PsiElement> skipDominatingElements,
boolean necessaryNullable) {
PsiTypeElement typeElement = psiDeconstructionPattern.getTypeElement();
PsiType recordType = typeElement.getType();
PsiClass recordClass = PsiUtil.resolveClassInClassTypeOnly(recordType);
if (recordClass == null || !recordClass.isRecord()) {
return null;
}
PsiRecordComponent[] recordComponents = recordClass.getRecordComponents();
@NotNull PsiPattern @NotNull [] deconstructionComponents =
psiDeconstructionPattern.getDeconstructionList().getDeconstructionComponents();
if (deconstructionComponents.length != recordComponents.length) {
return null;
}
for (int i = 0; i < recordComponents.length; i++) {
PsiPattern deconstructionComponent = deconstructionComponents[i];
if (deconstructionComponent instanceof PsiDeconstructionPattern nestedDeconstructionPattern) {
PsiPattern canProduceMatchException =
findDeconstructionComponentCanProduceMatchException(switchBlock,
nestedDeconstructionPattern,
topLevelDeconstructionPattern,
skipDominatingElements, necessaryNullable);
if (canProduceMatchException != null) return canProduceMatchException;
}
PsiRecordComponent component = recordComponents[i];
PsiType componentType = component.getType();
TypeNullability nullability = componentType.getNullability();
PsiExpression expression = switchBlock.getExpression();
if (expression == null) return null;
if (necessaryNullable && nullability.nullability() != Nullability.NULLABLE) continue;
if (!necessaryNullable && nullability.nullability() == Nullability.NOT_NULL) continue;
PsiClass componentClass = PsiUtil.resolveClassInClassTypeOnly(componentType);
if (componentClass == null) continue;
if (deconstructionComponent instanceof PsiDeconstructionPattern ||
componentClass.hasModifierProperty(PsiModifier.SEALED)) {
if (!hasDominated(switchBlock, topLevelDeconstructionPattern, deconstructionComponent, componentClass, skipDominatingElements)) {
return deconstructionComponent;
}
}
}
return null;
}
private static boolean hasDominated(@NotNull PsiSwitchBlock block,
@NotNull PsiDeconstructionPattern pattern,
@NotNull PsiPattern deconstructionComponent,
@NotNull PsiClass sealedClass,
@NotNull Set<@NotNull PsiElement> skipDominatingElements) {
String text = pattern.getText();
TextRange textRange = pattern.getTextRange();
TextRange componentTextRange = deconstructionComponent.getTextRange();
TextRange toChange = componentTextRange.shiftLeft(textRange.getStartOffset());
String newPatternTe = StringUtil.replaceSubstring(text, toChange, sealedClass.getQualifiedName() + " someVariable");
PsiPattern newPattern = createPatternFromText(newPatternTe, block);
if (newPattern == null) return true;
List<PsiElement> branches = JavaPsiSwitchUtil.getSwitchBranches(block);
PsiExpression expression = block.getExpression();
if (expression == null) return true;
PsiType selectorType = expression.getType();
if (selectorType == null) return true;
for (PsiElement branch : branches) {
if (skipDominatingElements.contains(branch) ||
//case null, default
(isNullOrDefault(branch) &&
ContainerUtil.exists(skipDominatingElements, e -> JavaPsiSwitchUtil.isInCaseNullDefaultLabel(e)))) {
continue;
}
boolean dominated = JavaPsiSwitchUtil.isDominated(newPattern, branch, selectorType);
if (dominated) return true;
}
return false;
}
private static boolean isNullOrDefault(@NotNull PsiElement branch) {
return ExpressionUtil.isNullLiteral(branch) ||
(branch instanceof PsiKeyword && JavaKeywords.DEFAULT.equals(branch.getText()));
}
private static @Nullable PsiPattern createPatternFromText(@NotNull String patternText, @NotNull PsiElement context) {
PsiElementFactory factory = PsiElementFactory.getInstance(context.getProject());
String labelText = "case " + patternText + "->{}";
PsiStatement statement;
try {
statement = factory.createStatementFromText(labelText, context);
}
catch (IncorrectOperationException e) {
return null;
}
PsiSwitchLabelStatementBase label = ObjectUtils.tryCast(statement, PsiSwitchLabelStatementBase.class);
if (label == null) return null;
PsiCaseLabelElementList list = label.getCaseLabelElementList();
if (list == null) return null;
PsiCaseLabelElement element = list.getElements()[0];
if (!(element instanceof PsiPattern pattern)) return null;
return pattern;
}
}
@@ -73,7 +73,7 @@ public final class SwitchStatementsWithoutDefaultInspection extends AbstractBase
infoMode = true;
}
else {
SwitchExhaustivenessState completenessResult = evaluateSwitchCompleteness(statement, true);
SwitchExhaustivenessState completenessResult = evaluateSwitchCompleteness(statement);
if (completenessResult == SwitchExhaustivenessState.UNNECESSARY ||
completenessResult == SwitchExhaustivenessState.MALFORMED ||
completenessResult == SwitchExhaustivenessState.EMPTY ||
@@ -16,6 +16,7 @@
package com.siyeh.ig.controlflow;
import com.intellij.codeInspection.LocalQuickFix;
import com.intellij.codeInspection.MatchExceptionInspection;
import com.intellij.codeInspection.ProblemHighlightType;
import com.intellij.codeInspection.dataFlow.fix.DeleteSwitchLabelFix;
import com.intellij.codeInspection.options.OptPane;
@@ -283,12 +284,19 @@ public final class UnnecessaryDefaultInspection extends BaseInspection {
if (!(type instanceof PsiClassType)) {
return null;
}
final PsiElement result = JavaPsiSwitchUtil.findDefaultElement(switchBlock);
if (result == null) {
final PsiElement defaultElement = JavaPsiSwitchUtil.findDefaultElement(switchBlock);
if (defaultElement == null) {
return null;
}
final SwitchExhaustivenessState completenessResult = evaluateSwitchCompleteness(switchBlock, false);
return completenessResult == SwitchExhaustivenessState.EXHAUSTIVE_CAN_ADD_DEFAULT
? result : null;
final SwitchExhaustivenessState completenessResult = evaluateSwitchCompleteness(switchBlock);
if (completenessResult != SwitchExhaustivenessState.EXHAUSTIVE_CAN_ADD_DEFAULT) {
return null;
}
if (MatchExceptionInspection.findPatternCanProduceMatchException(switchBlock, Set.of(defaultElement), false) != null) {
return null;
}
return defaultElement;
}
}
@@ -0,0 +1,24 @@
import org.jetbrains.annotations.Nullable;
class Test2 {
private static void testSwitchPattern() {
final var nullLevel3 = new Level1(new Level2(null));
System.out.println(getLevel3(nullLevel3));
}
private static String getLevel3(@Nullable final Level1 level1) {
return switch (level1) {
case Level1(Level2(<warning descr="Pattern matching may throw 'MatchException'">Level3(var something)</warning>)) -> something;
case null -> null;
};
}
record Level3(String text) {
}
record Level2(@Nullable Level3 something) {
}
record Level1(Level2 level2) {
}
}
@@ -0,0 +1,25 @@
import org.jetbrains.annotations.Nullable;
class Test {
private static void testSwitchPattern() {
final var nullLevel2 = new Level1(null);
System.out.println(getLevel3(null));
System.out.println(getLevel3(nullLevel2));
}
private static Level3 getLevel3(@Nullable final Level1 level1) {
return switch (level1) {
case Level1(<warning descr="Pattern matching may throw 'MatchException'">Level2(var something)</warning>) -> something;
case null -> null;
};
}
static class Level3 {
}
record Level2(Level3 something) {
}
record Level1(@Nullable Level2 level2) {
}
}
@@ -0,0 +1,29 @@
import org.jetbrains.annotations.Nullable;
class TestExample2 {
public static void main(String[] args) {
getII(new RI(new RI2(null)));
}
sealed interface II {
record AI() implements II {
}
record BI() implements II {
}
}
record RI(RI2 value) {
}
record RI2(@Nullable II value) {
}
private static II getII(RI ri) {
return switch (ri) {
case RI(RI2(<warning descr="Pattern matching may throw 'MatchException'">II.BI bi</warning>)) -> bi;
case RI(RI2(II.AI ai)) -> ai;
};
}
}
@@ -0,0 +1,18 @@
import org.jetbrains.annotations.Nullable;
class TestExample {
sealed interface II {
record AI() implements II {}
record BI() implements II {}
}
record RI(@Nullable II value) {}
private static II getII(RI ri) {
return switch (ri) {
case RI(<warning descr="Pattern matching may throw 'MatchException'">II.BI bi</warning>) -> bi;
case RI(II.AI ai) -> ai;
};
}
}
@@ -0,0 +1,25 @@
import org.jetbrains.annotations.Nullable;
class Test2 {
private static void testSwitchPattern() {
final var nullLevel3 = new Level1(new Level2(null));
System.out.println(getLevel3(nullLevel3));
}
private static String getLevel3(@Nullable final Level1 level1) {
return switch (level1) {
case Level1(Level2(Level3(var something))) -> something;
case Level1(Level2 t) -> "some";
case null -> null;
};
}
record Level3(String text) {
}
record Level2(@Nullable Level3 something) {
}
record Level1(Level2 level2) {
}
}
@@ -0,0 +1,24 @@
import org.jetbrains.annotations.Nullable;
class Test2 {
private static void testSwitchPattern() {
final var full = new Level1(new Level2(new Level3(null)));
System.out.println(getLevel3(full));
}
private static String getLevel3(@Nullable final Level1 level1) {
return switch (level1) {
case Level1(Level2(Level3(var something))) -> something;
case null -> null;
};
}
record Level3(@Nullable String text) {
}
record Level2(Level3 something) {
}
record Level1(Level2 level2) {
}
}
@@ -0,0 +1,26 @@
import org.jetbrains.annotations.Nullable;
class Test {
private static void testSwitchPattern() {
final var nullLevel2 = new Level1(null);
System.out.println(getLevel3(null));
System.out.println(getLevel3(nullLevel2));
}
private static Level3 getLevel3(@Nullable final Level1 level1) {
return switch (level1) {
case Level1(Level2(var something)) -> something;
case null -> null;
default -> null;
};
}
static class Level3 {
}
record Level2(Level3 something) {
}
record Level1(@Nullable Level2 level2) {
}
}
@@ -0,0 +1,22 @@
import org.jetbrains.annotations.Nullable;
class TestExample {
sealed interface II {
record AI() implements II {
}
record BI() implements II {
}
}
record RI(@Nullable II value) {
}
private static II getII(RI ri) {
return switch (ri) {
case RI(II.BI bi) -> bi;
case RI(II.AI ai) -> ai;
case RI(II a) -> a;
};
}
}
@@ -0,0 +1,19 @@
import org.jetbrains.annotations.Nullable;
class TestExample {
sealed interface II {
record AI() implements II {}
record BI() implements II {}
}
record RI(@Nullable II value) {}
private static II getII(RI ri) {
return switch (ri) {
case RI(II.BI bi) -> bi;
case RI(II.AI ai) -> ai;
case null, default -> null;
};
}
}
@@ -0,0 +1,49 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.java.codeInspection;
import com.intellij.JavaTestUtil;
import com.intellij.codeInspection.MatchExceptionInspection;
import com.intellij.testFramework.LightProjectDescriptor;
import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase;
import org.jetbrains.annotations.NotNull;
public final class MatchExceptionInspectionTest extends LightJavaCodeInsightFixtureTestCase {
@Override
protected String getBasePath() {
return JavaTestUtil.getRelativeJavaTestDataPath() + "/inspection/matchException";
}
@NotNull
@Override
protected LightProjectDescriptor getProjectDescriptor() {
return JAVA_21;
}
@Override
protected void setUp() throws Exception {
super.setUp();
myFixture.enableInspections(new MatchExceptionInspection());
}
private void doTest() {
myFixture.testHighlighting(getTestName(false) + ".java");
}
public void testMatchExceptionNestedDeconstruction() { doTest(); }
public void testMatchExceptionSealedClass() { doTest(); }
public void testMatchExceptionDoubleNestedDeconstruction() { doTest(); }
public void testNoMatchExceptionMostNestedDeconstruction() { doTest(); }
public void testMatchExceptionNestedSealedClass() { doTest(); }
public void testNoMatchExceptionNestedDeconstructionWithDefault() { doTest(); }
public void testNoMatchExceptionSealedClassWithNullDefault() { doTest(); }
public void testNoMatchExceptionDoubleNestedDeconstructionWithDominated() { doTest(); }
public void testNoMatchExceptionSealedClassWithDominated() { doTest(); }
}
@@ -179,6 +179,52 @@ public class UnnecessaryDefaultInspectionTest extends LightJavaInspectionTestCas
"}");
}
public void testDefaultWithDominance() {
doTest("""
import org.jetbrains.annotations.Nullable;
class TestExample {
sealed interface II {
record AI() implements II {}
record BI() implements II {}
}
record RI(@Nullable II value) {}
private static II getII(RI ri) {
return switch (ri) {
case RI(II.BI bi) -> bi;
case RI(II.AI ai) -> ai;
default -> null;
};
}
}
""");
}
public void testNullDefaultWithDominance() {
doTest("""
import org.jetbrains.annotations.Nullable;
class TestExample {
sealed interface II {
record AI() implements II {}
record BI() implements II {}
}
record RI(@Nullable II value) {}
private static II getII(RI ri) {
return switch (ri) {
case RI(II.BI bi) -> bi;
case RI(II.AI ai) -> ai;
case null, default -> null;
};
}
}
""");
}
@Override
protected String[] getEnvironmentClasses() {
return new String[] {
@@ -568,6 +568,8 @@ inspection.nullable.problems.notnull.to.nullable.assignment.conflicts=Report ass
inspection.nullable.problems.redundant.nullability.inside.container=Report redundant nullability annotation in the scope of annotated container
inspection.optional.get.without.is.present.message=<code>{0}.#ref()</code> without ''isPresent()'' check
inspection.optional.get.without.is.present.method.reference.message=<code>#ref</code> without 'isPresent()' check
inspection.match.exception.problems.display.name=Possible MatchException in switch pattern matching
inspection.match.exception.problems.message=Pattern matching may throw 'MatchException'
inspection.overflowing.loop.index.inspection.description=Loop executes zero or billions of times
inspection.overflowing.loop.index.inspection.name=Loop executes zero or billions of times
inspection.overwritten.key.map.message=Duplicate Map key