diff --git a/baseline-error-prone/src/main/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitch.java b/baseline-error-prone/src/main/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitch.java new file mode 100644 index 000000000..b2294d3d3 --- /dev/null +++ b/baseline-error-prone/src/main/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitch.java @@ -0,0 +1,185 @@ +/* + * (c) Copyright 2024 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.baseline.errorprone; + +import com.google.auto.service.AutoService; +import com.google.errorprone.BugPattern; +import com.google.errorprone.BugPattern.SeverityLevel; +import com.google.errorprone.VisitorState; +import com.google.errorprone.bugpatterns.BugChecker; +import com.google.errorprone.matchers.Description; +import com.google.errorprone.util.ASTHelpers; +import com.sun.source.tree.CaseTree; +import com.sun.source.tree.ExpressionTree; +import com.sun.source.tree.SwitchExpressionTree; +import com.sun.source.tree.SwitchTree; +import com.sun.source.tree.Tree; +import com.sun.tools.javac.code.Symbol.ClassSymbol; +import com.sun.tools.javac.code.Type; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.Modifier; + +/** + * Detects unnecessary {@code default} clauses in already-exhaustive switch statements over Conjure unions + * (sealed types). + *

+ * When a switch statement is already exhaustive (all variants are explicitly handled) but still includes + * a {@code default} clause, the Java compiler will not break when new union variants are added, + * preventing the compiler from forcing code owners to explicitly acknowledge and handle new types. + *

+ * By removing the {@code default} clause from exhaustive switches, the compiler helps ensure consumers to + * consciously decide how to handle new variants before their code compiles again. + */ +@AutoService(BugChecker.class) +@BugPattern( + link = "https://github.com/palantir/gradle-baseline#baseline-error-prone-checks", + linkType = BugPattern.LinkType.CUSTOM, + severity = SeverityLevel.ERROR, + summary = "Unnecessary default clause in exhaustive switch statement on Conjure union.") +public final class ConjureUnionExhaustiveSwitch extends BugChecker + implements BugChecker.SwitchTreeMatcher, BugChecker.SwitchExpressionTreeMatcher { + + @Override + public Description matchSwitch(SwitchTree tree, VisitorState _state) { + return checkSwitchForSealedType(tree.getExpression(), tree.getCases(), tree); + } + + @Override + public Description matchSwitchExpression(SwitchExpressionTree tree, VisitorState _state) { + return checkSwitchForSealedType(tree.getExpression(), tree.getCases(), tree); + } + + private Description checkSwitchForSealedType(ExpressionTree expression, List cases, Tree tree) { + Type switchType = ASTHelpers.getType(expression); + if (switchType == null || !isConjureUnion(switchType)) { + return Description.NO_MATCH; + } + + if (cases.stream().anyMatch(this::hasGuard) || cases.stream().noneMatch(ASTHelpers::isSwitchDefault)) { + return Description.NO_MATCH; + } + + if (!(switchType.tsym instanceof ClassSymbol classSymbol)) { + return Description.NO_MATCH; + } + + Optional> permittedSubtypes = Optional.ofNullable(classSymbol.getPermittedSubclasses()); + if (permittedSubtypes.isEmpty()) { + return Description.NO_MATCH; + } + Set permittedTypeSet = new HashSet<>(permittedSubtypes.get()); + + Set handledTypes = new HashSet<>(); + for (CaseTree caseTree : cases) { + if (ASTHelpers.isSwitchDefault(caseTree)) { + continue; + } + + for (Tree label : getLabels(caseTree)) { + extractType(label).ifPresent(type -> addHandledType(type, handledTypes)); + } + } + + // If all permitted types are explicitly handled, the switch is exhaustive and the default is unnecessary + if (handledTypes.containsAll(permittedTypeSet)) { + return buildDescription(tree).build(); + } + + return Description.NO_MATCH; + } + + private void addHandledType(Type type, Set handledTypes) { + if (type.tsym instanceof ClassSymbol classSymbol) { + Optional> permitted = Optional.ofNullable(classSymbol.getPermittedSubclasses()); + if (permitted.isPresent()) { + // Handle matching on `Known` type + handledTypes.addAll(permitted.get()); + return; + } + } + + // Default case + handledTypes.add(type); + } + + private boolean hasGuard(CaseTree caseTree) { + try { + Method getGuardMethod = CaseTree.class.getMethod("getGuard"); + return getGuardMethod.invoke(caseTree) != null; + } catch (ReflectiveOperationException _e) { + return false; + } + } + + @SuppressWarnings("unchecked") + private List getLabels(CaseTree caseTree) { + try { + // `getLabels` is a preview feature - annotation indicates we should use reflection to invoke + Method getLabelsMethod = CaseTree.class.getMethod("getLabels"); + return (List) getLabelsMethod.invoke(caseTree); + } catch (ReflectiveOperationException _e) { + return Collections.emptyList(); + } + } + + private Optional extractType(Tree label) { + try { + // Always `CaseLabelTree` (preview type) for sealed type + Method getPatternMethod = label.getClass().getMethod("getPattern"); + Tree pattern = (Tree) getPatternMethod.invoke(label); + if (pattern != null) { + return Optional.ofNullable(ASTHelpers.getType(pattern)); + } + } catch (ReflectiveOperationException _e) { + // Ignore + } + return Optional.empty(); + } + + private boolean isConjureUnion(Type type) { + if (type.asElement() == null || !type.asElement().getModifiers().contains(Modifier.SEALED)) { + return false; + } + + return isUnionClass(type.asElement()) + || (isKnownInterface(type.asElement()) && isEnclosedBySealedClass(type.asElement())); + } + + private boolean isUnionClass(javax.lang.model.element.Element classElement) { + return classElement.getKind() == ElementKind.CLASS + && ASTHelpers.getEnclosedElements((com.sun.tools.javac.code.Symbol) classElement).stream() + .anyMatch(this::isKnownInterface); + } + + private boolean isKnownInterface(javax.lang.model.element.Element element) { + return element.getKind() == ElementKind.INTERFACE + && element.getModifiers().contains(Modifier.SEALED) + && "Known".equals(element.getSimpleName().toString()); + } + + private boolean isEnclosedBySealedClass(javax.lang.model.element.Element element) { + return element.getEnclosingElement() != null + && element.getEnclosingElement().getKind() == ElementKind.CLASS + && element.getEnclosingElement().getModifiers().contains(Modifier.SEALED); + } +} diff --git a/baseline-error-prone/src/test/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitchTest.java b/baseline-error-prone/src/test/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitchTest.java new file mode 100644 index 000000000..161a59d05 --- /dev/null +++ b/baseline-error-prone/src/test/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitchTest.java @@ -0,0 +1,251 @@ +/* + * (c) Copyright 2024 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.baseline.errorprone; + +import com.google.errorprone.CompilationTestHelper; +import org.junit.jupiter.api.Test; + +public class ConjureUnionExhaustiveSwitchTest { + + @Test + void testConjureUnionExhaustiveSwitchWithUnnecessaryDefault() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + // BUG: Diagnostic contains: Unnecessary default clause + switch (shape) { + case Circle circle -> System.out.println("Circle"); + case Square square -> System.out.println("Square"); + case Unknown unknown -> System.out.println("Unknown"); + default -> System.out.println("Should never reach here"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionNonExhaustiveSwitchWithDefault() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + switch (shape) { + case Circle circle -> System.out.println("Circle"); + default -> System.out.println("Unknown"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionSwitchWithoutDefault() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + switch (shape) { + case Circle circle -> System.out.println("Circle"); + case Square square -> System.out.println("Square"); + case Unknown unknown -> System.out.println("Unknown"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionKnownInterfaceExhaustiveSwitchWithUnnecessaryDefault() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape.Known known) { + // BUG: Diagnostic contains: Unnecessary default clause + switch (known) { + case Circle circle -> System.out.println("Circle"); + case Square square -> System.out.println("Square"); + default -> System.out.println("Should never reach here"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionKnownInterfaceNonExhaustiveSwitchWithDefault() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape.Known known) { + switch (known) { + case Circle circle -> System.out.println("Circle"); + default -> System.out.println("Other"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testNonConjureUnionSealedClassWithDefault() { + String source = """ + sealed abstract class Shape permits Circle, Square {} + final class Circle extends Shape {} + final class Square extends Shape {} + public class Test { + public void test(Shape shape) { + switch (shape) { + case Circle circle -> System.out.println("Circle"); + default -> System.out.println("Unknown"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionExhaustiveSwitchWithGuardedCasesIgnored() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known { + final int radius; + Circle(int radius) { this.radius = radius; } + } + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + // This should not trigger - guarded cases mean we ignore the switch + switch (shape) { + case Circle circle when circle.radius > 10 -> System.out.println("Big Circle"); + case Circle circle -> System.out.println("Small Circle"); + case Square square -> System.out.println("Square"); + case Unknown unknown -> System.out.println("Unknown"); + default -> System.out.println("Fallback"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionSwitchWithNullCaseNotExhaustive() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + // This should not trigger - null case doesn't count toward exhaustiveness + switch (shape) { + case null -> System.out.println("Null"); + case Circle circle -> System.out.println("Circle"); + default -> System.out.println("Other"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionSwitchUsingKnownInterfaceExhaustive() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + // BUG: Diagnostic contains: Unnecessary default clause + switch (shape) { + case Shape.Known known -> System.out.println("Known"); + case Unknown unknown -> System.out.println("Unknown"); + default -> System.out.println("Should never reach here"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + @Test + void testConjureUnionSwitchUsingKnownInterfaceNonExhaustive() { + String source = """ + sealed abstract class Shape permits Circle, Square, Unknown { + sealed interface Known permits Circle, Square {} + } + final class Circle extends Shape implements Shape.Known {} + final class Square extends Shape implements Shape.Known {} + final class Unknown extends Shape {} + public class Test { + public void test(Shape shape) { + switch (shape) { + case Shape.Known known -> System.out.println("Known"); + default -> System.out.println("Unknown"); + } + } + } + """; + helper().addSourceLines("Test.java", source).doTest(); + } + + private CompilationTestHelper helper() { + return CompilationTestHelper.newInstance(ConjureUnionExhaustiveSwitch.class, getClass()); + } +}