From 8a84a82466b8379fef8635b552395f90e81a36a5 Mon Sep 17 00:00:00 2001 From: Kunal Kak Date: Mon, 10 Nov 2025 11:20:16 -0500 Subject: [PATCH 1/4] init --- .../ConjureUnionExhaustiveSwitch.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 baseline-error-prone/src/main/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitch.java 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..179ff5279 --- /dev/null +++ b/baseline-error-prone/src/main/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitch.java @@ -0,0 +1,96 @@ +/* + * (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.Type; +import java.util.List; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.Modifier; + +/** + * Detects usage of {@code default} clauses in switch statements over Conjure unions (sealed types). + *

+ * When a switch statement includes a {@code default} clause, the Java compiler will not break + * when new union variants are added. This prevents the compiler from forcing code owners to + * explicitly acknowledge and handle new types. By using exhaustive switches without {@code default} + * clauses, the compiler ensures all consumers must consciously decide how to handle new variants + * before their code compiles again. + *

+ * This warning can and should be suppressed via {@code @SuppressWarnings("ConjureUnionExhaustiveSwitch")} + * in cases where the consumer explicitly doesn't care about new variants and has a well-defined + * fallback behavior. + */ +@AutoService(BugChecker.class) +@BugPattern( + link = "https://github.com/palantir/gradle-baseline#baseline-error-prone-checks", + linkType = BugPattern.LinkType.CUSTOM, + severity = SeverityLevel.WARNING, + summary = "Avoid using default clause in switch statements on Conjure unions. " + + "Use exhaustive switch statements instead to ensure all cases are handled explicitly.") +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().noneMatch(ASTHelpers::isSwitchDefault)) { + return Description.NO_MATCH; + } + + return buildDescription(tree).build(); + } + + private boolean isConjureUnion(Type type) { + if (type.asElement() == null + || type.asElement().getKind() != ElementKind.CLASS + || !type.asElement().getModifiers().contains(Modifier.SEALED)) { + return false; + } + + // Check if it has a nested interface called "Known" + // Empty conjure unions won't have a Known interface, but they won't be used in switches either by definition + return ASTHelpers.getEnclosedElements(type.asElement()).stream() + .anyMatch(element -> element.getKind() == ElementKind.INTERFACE + && element.getModifiers().contains(Modifier.SEALED) + && "Known".equals(element.getSimpleName().toString())); + } +} From a649d33091b61b381fdc9c420d4fa6be7d42f993 Mon Sep 17 00:00:00 2001 From: Kunal Kak Date: Mon, 10 Nov 2025 11:45:01 -0500 Subject: [PATCH 2/4] add tests --- .../ConjureUnionExhaustiveSwitchTest.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 baseline-error-prone/src/test/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitchTest.java 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..059ad4211 --- /dev/null +++ b/baseline-error-prone/src/test/java/com/palantir/baseline/errorprone/ConjureUnionExhaustiveSwitchTest.java @@ -0,0 +1,89 @@ +/* + * (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 testConjureUnionSwitchWithDefault() { + 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: Avoid using default clause + 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 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(); + } + + private CompilationTestHelper helper() { + return CompilationTestHelper.newInstance(ConjureUnionExhaustiveSwitch.class, getClass()); + } +} From 68238c27fe175c7c4ead55269b6e65ed7530f7b2 Mon Sep 17 00:00:00 2001 From: Kunal Kak Date: Tue, 18 Nov 2025 01:38:11 -0500 Subject: [PATCH 3/4] switch sev to error --- .../baseline/errorprone/ConjureUnionExhaustiveSwitch.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 179ff5279..cfcf906cd 100644 --- 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 @@ -50,7 +50,7 @@ @BugPattern( link = "https://github.com/palantir/gradle-baseline#baseline-error-prone-checks", linkType = BugPattern.LinkType.CUSTOM, - severity = SeverityLevel.WARNING, + severity = SeverityLevel.ERROR, summary = "Avoid using default clause in switch statements on Conjure unions. " + "Use exhaustive switch statements instead to ensure all cases are handled explicitly.") public final class ConjureUnionExhaustiveSwitch extends BugChecker From 0a8415534f78fec689d639c295da8f032745d2a8 Mon Sep 17 00:00:00 2001 From: Kunal Kak Date: Tue, 25 Nov 2025 16:00:00 -0500 Subject: [PATCH 4/4] update to only handle defaults on exhaustove switches --- .../ConjureUnionExhaustiveSwitch.java | 133 +++++++++++--- .../ConjureUnionExhaustiveSwitchTest.java | 166 +++++++++++++++++- 2 files changed, 275 insertions(+), 24 deletions(-) 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 index cfcf906cd..b2294d3d3 100644 --- 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 @@ -28,31 +28,34 @@ 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 usage of {@code default} clauses in switch statements over Conjure unions (sealed types). + * Detects unnecessary {@code default} clauses in already-exhaustive switch statements over Conjure unions + * (sealed types). *

- * When a switch statement includes a {@code default} clause, the Java compiler will not break - * when new union variants are added. This prevents the compiler from forcing code owners to - * explicitly acknowledge and handle new types. By using exhaustive switches without {@code default} - * clauses, the compiler ensures all consumers must consciously decide how to handle new variants - * before their code compiles again. + * 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. *

- * This warning can and should be suppressed via {@code @SuppressWarnings("ConjureUnionExhaustiveSwitch")} - * in cases where the consumer explicitly doesn't care about new variants and has a well-defined - * fallback behavior. + * 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 = "Avoid using default clause in switch statements on Conjure unions. " - + "Use exhaustive switch statements instead to ensure all cases are handled explicitly.") + summary = "Unnecessary default clause in exhaustive switch statement on Conjure union.") public final class ConjureUnionExhaustiveSwitch extends BugChecker implements BugChecker.SwitchTreeMatcher, BugChecker.SwitchExpressionTreeMatcher { @@ -72,25 +75,111 @@ private Description checkSwitchForSealedType(ExpressionTree expression, List> 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().getKind() != ElementKind.CLASS - || !type.asElement().getModifiers().contains(Modifier.SEALED)) { + if (type.asElement() == null || !type.asElement().getModifiers().contains(Modifier.SEALED)) { return false; } - // Check if it has a nested interface called "Known" - // Empty conjure unions won't have a Known interface, but they won't be used in switches either by definition - return ASTHelpers.getEnclosedElements(type.asElement()).stream() - .anyMatch(element -> element.getKind() == ElementKind.INTERFACE - && element.getModifiers().contains(Modifier.SEALED) - && "Known".equals(element.getSimpleName().toString())); + 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 index 059ad4211..161a59d05 100644 --- 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 @@ -22,7 +22,31 @@ public class ConjureUnionExhaustiveSwitchTest { @Test - void testConjureUnionSwitchWithDefault() { + 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 {} @@ -32,7 +56,6 @@ final class Square extends Shape implements Shape.Known {} final class Unknown extends Shape {} public class Test { public void test(Shape shape) { - // BUG: Diagnostic contains: Avoid using default clause switch (shape) { case Circle circle -> System.out.println("Circle"); default -> System.out.println("Unknown"); @@ -65,6 +88,50 @@ public void test(Shape shape) { 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 = """ @@ -83,6 +150,101 @@ public void test(Shape shape) { 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()); }