You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/09/14 11:21:29 UTC

(groovy) 10/20: GROOVY-12408: a switch that is the last statement of a body is a statement

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch GROOVY_6_0_X
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 97fb434ed1136822a93b101f6b6cd60a1af0092a
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sun Sep 13 20:38:31 2026 +1000

    GROOVY-12408: a switch that is the last statement of a body is a statement
    
    GROOVY-12399 restored Java's rule that position, not arm shape, decides
    whether a switch produces a value, but carved out a third case: a switch
    in implicit-return position stayed an expression that yielded null when
    unmatched, so that a method whose body is a bare switch kept its 5.x
    value. That carve-out cost more than it bought.
    
    It made one piece of syntax mean two things. Under @CompileStatic a
    `case label` with a declared-Object label resolved statically in that
    position and dynamically once any statement followed the switch, so
    adding a trailing line changed how labels matched. It also required
    about 110 lines in AstBuilder to recognise implicit-return position,
    duplicating what ReturnAdder already knows, on a different tree.
    
    The carve-out existed because a SwitchStatement was a second-class
    citizen in static compilation, not because the semantics needed it.
    GROOVY-12405 gave statements the same jump-table dispatch and
    GROOVY-12406 the same duplicate-label diagnostic, so nothing is left to
    protect. A switch whose value is not used is now a statement wherever it
    appears, and Groovy's implicit return does to it what it does to any
    other statement, via ReturnAdder.
    
    Three behaviours move, each toward consistency:
    
    - A primitive-returning method whose bare switch matches nothing now
      yields the type's default, as `int f(int i) { if (i == 1) return 1 }`
      and the colon form always have. The arrow form was the only shape in
      the language that threw there, GroovyCastException dynamically and
      NullPointerException when statically compiled.
    - `case label` in that position matches as it does when the value is
      discarded, and as it did in 5.x.
    - The node is a SwitchStatement, which is visible to AST transforms.
      COMPATIBILITY.md already warns that audience about GROOVY-12255.
    
    Deleted: the UNMATCHED_YIELDS_NULL marker and its three readers, and
    isImplicitReturnPosition, returnsValue, isLastStatement and
    isLenientPosition from AstBuilder. isSwitchValueUsed keeps the recursion
    added for nested arrow arms, since an arm of a switch that is itself a
    statement still produces nothing.
---
 COMPATIBILITY.md                                   |  33 +++--
 .../groovy/ast/tools/SwitchExpressionUtils.java    |  12 --
 .../apache/groovy/parser/antlr4/AstBuilder.java    | 142 +++------------------
 .../classgen/asm/SwitchExpressionWriter.java       |  31 +----
 .../asm/sc/StaticTypesSwitchExpressionWriter.java  |   5 +-
 .../transform/stc/StaticTypeCheckingVisitor.java   |   4 -
 src/spec/doc/core-semantics.adoc                   |  11 +-
 .../stc/TypeCheckingExtensionsTest.groovy          |   4 +-
 .../codehaus/groovy/classgen/Groovy12399.groovy    |  38 +++---
 .../sc/SwitchExpressionStaticCompileTest.groovy    |   4 +-
 10 files changed, 68 insertions(+), 216 deletions(-)

diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index fc7a95fcd1..9b54168beb 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -358,20 +358,25 @@ Groovy 6 compiles switch expressions (JEP 361) as first-class AST
 immediately-called closure around a switch statement (the GROOVY-9272
 implementation shipped in 4.x / 5.x). As in Java (JEP 361) the `->` arm
 only decides fall-through; the position decides whether a value is produced
-(GROOVY-12399). A `switch` whose value is not used is a `SwitchStatement`,
-whatever its arm shape, and does nothing when unmatched. In expression
-position it is a `SwitchExpression` (every path must `yield` or `throw`).
-A switch in implicit-return position (the last statement of a closure, a
-script or a method that returns a value) keeps its value, but an unmatched
-selector yields `null` rather than throwing and no exhaustiveness is
-required, as in 4.x / 5.x; write `return switch (...) {...}` for the strict
-expression rules.
-
-**Who is affected (runtime behaviour).** A dynamic switch expression whose
-selector matches no arm now throws `IllegalStateException`. Previously the
-desugared closure completed without a `return` and the expression evaluated
-to `null`. Under `@TypeChecked` / `@CompileStatic`, a non-exhaustive switch
-expression (no `default`, and not a complete enum) is a compile-time error.
+(GROOVY-12399, GROOVY-12408). A `switch` whose value is not used is a
+`SwitchStatement`, whatever its arm shape, and does nothing when unmatched.
+That includes the last statement of a closure, a script or a method: the
+implicit return applies to the statement, as it does to an `if` or a
+colon-form `switch`. In expression position it is a `SwitchExpression` and
+every path must `yield` or `throw`; write `return switch (...) {...}`, or
+assign the result, to ask for that.
+
+**Who is affected (runtime behaviour).** A switch *expression* whose selector
+matches no arm throws `IllegalStateException`, where the 4.x / 5.x desugared
+closure completed without a `return` and evaluated to `null`. A switch
+*statement* does nothing, as before. The difference is visible where a method
+body is a bare `switch`: it is a statement, so an unmatched selector leaves
+the method's default value (`null`, or `0` / `false` for a primitive return
+type) rather than the `null` a 5.x expression produced, and rather than the
+`GroovyCastException` 5.x raised when coercing that `null` to a primitive.
+Under `@TypeChecked` / `@CompileStatic`, a non-exhaustive switch expression
+(no `default`, and not a complete enum) is a compile-time error; a statement
+needs no `default`.
 
 **Who is affected (AST tools).** Visitors, macros, and AST transforms that
 assumed a switch expression was a `MethodCallExpression` wrapping a
diff --git a/src/main/java/org/apache/groovy/ast/tools/SwitchExpressionUtils.java b/src/main/java/org/apache/groovy/ast/tools/SwitchExpressionUtils.java
index e9922929e6..34f7668c0a 100644
--- a/src/main/java/org/apache/groovy/ast/tools/SwitchExpressionUtils.java
+++ b/src/main/java/org/apache/groovy/ast/tools/SwitchExpressionUtils.java
@@ -41,18 +41,6 @@ import java.util.List;
  */
 public final class SwitchExpressionUtils {
 
-    /**
-     * Node metadata key on a {@code SwitchExpression} in implicit-return position
-     * (the last statement of a closure, a script or a method that returns a
-     * value): the switch keeps its value, but an unmatched selector yields
-     * {@code null} instead of throwing
-     * and the type checker does not require exhaustiveness (GROOVY-12399).
-     *
-     * @since 6.0.0
-     */
-    public static final String UNMATCHED_YIELDS_NULL = "_SWITCH_UNMATCHED_YIELDS_NULL";
-
-
     private SwitchExpressionUtils() {
     }
 
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index d8e0e70922..8a3560d20d 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -164,7 +164,6 @@ import java.util.stream.Collectors;
 
 import static groovy.lang.Tuple.tuple;
 import static org.apache.groovy.parser.antlr4.GroovyParser.*;
-import static org.apache.groovy.ast.tools.SwitchExpressionUtils.UNMATCHED_YIELDS_NULL;
 import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureAST;
 import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureEndPosition;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.assignX;
@@ -1170,7 +1169,7 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
      */
     private boolean isSwitchUsedAsStatement(final SwitchExpressionContext ctx) {
         StatementContext statement = switchAsStatement(ctx);
-        return statement != null && (!isSwitchValueUsed(statement) || isLenientPosition(statement));
+        return statement != null && !isSwitchValueUsed(statement);
     }
 
     /** Whether a switch-expression group uses arrow labels, making its sole statement the arm value. */
@@ -2449,48 +2448,30 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
         // A `switch` with `->` is parsed as a SwitchExpression (switchStatement is
         // colon-only), but as in Java (JEP 361) the arrow only decides fall-through;
         // the position decides whether a value is produced. A switch whose value is
-        // not used, or whose arrow block arms do not yield, is a SwitchStatement.
-        // In implicit-return position (the last statement of a closure, a script or
-        // a method that returns a value) the value is used, but an unmatched selector
-        // yields null rather than throwing, as it did in 4.x/5.x (GROOVY-12399).
-        if (expr instanceof SwitchExpression se && ctx.getParent() instanceof ExpressionStmtAltContext statement) {
-            if (switchExpressionHasIncompleteArm(se) || !isSwitchValueUsed(statement)) {
-                return configureAST(switchExpressionAsStatement(se), ctx);
-            }
-            if (isLenientPosition(statement)) {
-                se.putNodeMetaData(UNMATCHED_YIELDS_NULL, Boolean.TRUE);
-            }
+        // not used, or whose arrow block arms do not yield, is a SwitchStatement,
+        // including where it is the last statement of a method or closure: the
+        // implicit return then applies to the statement, as it does to any other
+        // (GROOVY-12408).
+        if (expr instanceof SwitchExpression se && ctx.getParent() instanceof ExpressionStmtAltContext statement
+                && (switchExpressionHasIncompleteArm(se) || !isSwitchValueUsed(statement))) {
+            return configureAST(switchExpressionAsStatement(se), ctx);
         }
         return configureAST(new ExpressionStatement(expr), ctx);
     }
 
     /**
-     * Whether the value of a switch expression statement is used: it is in
-     * implicit-return position, or it is the expression of an arrow arm whose
-     * own switch produces a value. An arm of a switch that is itself a
-     * statement produces nothing, so a switch nested there is a statement too.
+     * Whether the value of a switch expression statement is used, which it is
+     * only as the expression of an arrow arm whose own switch produces a value.
+     * An arm of a switch that is itself a statement produces nothing, so a
+     * switch nested there is a statement too.
      */
     private boolean isSwitchValueUsed(final StatementContext statement) {
         SwitchExpressionContext arm = enclosingArrowArmSwitch(statement);
-        if (arm != null) {
-            StatementContext owner = switchAsStatement(arm);
-            return owner == null || isSwitchValueUsed(owner);
-        }
-        return !isColonArm(statement) && isImplicitReturnPosition(statement);
-    }
-
-    /**
-     * Whether an unmatched selector completes the switch with {@code null}
-     * instead of throwing: implicit-return position, or an arrow arm of a
-     * switch that is itself lenient (GROOVY-12399).
-     */
-    private boolean isLenientPosition(final StatementContext statement) {
-        SwitchExpressionContext arm = enclosingArrowArmSwitch(statement);
-        if (arm != null) {
-            StatementContext owner = switchAsStatement(arm);
-            return owner != null && isLenientPosition(owner);
+        if (arm == null) {
+            return false;
         }
-        return !isColonArm(statement) && isImplicitReturnPosition(statement);
+        StatementContext owner = switchAsStatement(arm);
+        return owner == null || isSwitchValueUsed(owner);
     }
 
     /** The switch expression whose arrow arm consists of {@code statement}, else null. */
@@ -2506,13 +2487,6 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
         return null;
     }
 
-    /** Whether {@code statement} is in a colon arm, whose statements are discarded; its value comes from {@code yield}. */
-    private static boolean isColonArm(final StatementContext statement) {
-        ParserRuleContext blockStatements = statement.getParent() instanceof BlockStatementContext bs ? bs.getParent() : null;
-        return blockStatements != null && blockStatements.getParent() instanceof SwitchBlockStatementExpressionGroupContext group
-                && !isArrowGroup(group);
-    }
-
     /**
      * The statement that {@code ctx} alone makes up, or null when the switch is
      * part of a larger expression such as {@code foo(switch (x) {...})}, whose
@@ -2527,90 +2501,6 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
                 && statement.getStart() == ctx.getStart() && statement.getStop() == ctx.getStop() ? statement : null;
     }
 
-    /**
-     * Whether {@code statement} is where the compiler would add an implicit
-     * {@code return} (see {@code ReturnAdder}): the last statement of a lambda,
-     * closure or script body, or of a method that returns a value (not a
-     * {@code void} method or a constructor), followed through nested blocks, the
-     * branches of an {@code if}, the try and catch blocks of a {@code try}, a
-     * {@code synchronized} block and the arms of a colon-form switch statement
-     * (allowing for a trailing {@code break}). Loop bodies and {@code finally}
-     * blocks are not return positions.
-     */
-    private boolean isImplicitReturnPosition(final ParserRuleContext statement) {
-        ParserRuleContext parent = statement.getParent();
-        if (parent instanceof LabeledStmtAltContext || parent instanceof ConditionalStmtAltContext
-                || parent instanceof TryCatchStmtAltContext) {
-            return isImplicitReturnPosition(parent);
-        }
-        if (parent instanceof IfElseStatementContext || parent instanceof SwitchStatementContext
-                || parent instanceof TryCatchStatementContext) {
-            return isImplicitReturnPosition(parent); // the containing statement alternative
-        }
-        if (parent instanceof CatchClauseContext) {
-            return isImplicitReturnPosition(parent.getParent());
-        }
-        if (parent instanceof MethodBodyContext) {
-            return returnsValue(parent.getParent());
-        }
-        if (parent instanceof LambdaBodyContext || parent instanceof ClosureContext) {
-            return true;
-        }
-        if (parent instanceof BlockStmtAltContext || parent instanceof SynchronizedStmtAltContext) {
-            return isImplicitReturnPosition(parent);
-        }
-        if (parent instanceof BlockStatementsOptContext) {
-            return isImplicitReturnPosition(parent.getParent()); // block or closure
-        }
-        if (parent instanceof BlockContext) {
-            return isImplicitReturnPosition(parent.getParent()); // methodBody, lambdaBody, blockStmtAlt, synchronized, try, catch, finally, class body...
-        }
-        if (parent instanceof BlockStatementContext) {
-            return isLastStatement((BlockStatementsContext) parent.getParent(), parent) && isImplicitReturnPosition(parent.getParent());
-        }
-        if (parent instanceof BlockStatementsContext blockStatements) {
-            ParserRuleContext owner = blockStatements.getParent();
-            if (owner instanceof SwitchBlockStatementGroupContext) {
-                return isImplicitReturnPosition(owner.getParent()); // the switch statement
-            }
-            return isImplicitReturnPosition(owner); // blockStatementsOpt
-        }
-        if (parent instanceof ScriptStatementContext) {
-            ParserRuleContext script = parent.getParent();
-            ScriptStatementContext last = null;
-            for (ParseTree child : script.children) {
-                if (child instanceof ScriptStatementContext candidate && candidate.statement() != null) last = candidate;
-            }
-            return last == parent;
-        }
-        return false;
-    }
-
-    /**
-     * Whether the method or constructor declaring a body returns a value: not a
-     * {@code void} method, a constructor or a compact constructor, whose bodies
-     * get no implicit {@code return} (see {@code ReturnAdder}).
-     */
-    private boolean returnsValue(final ParserRuleContext declaration) {
-        if (!(declaration instanceof MethodDeclarationContext method)) {
-            return false; // compact constructor
-        }
-        if (asBoolean(method.returnType())) {
-            return method.returnType().VOID() == null;
-        }
-        ClassNode classNode = classNodeStack.peek();
-        return classNode == null || !this.visitMethodName(method.methodName()).equals(classNode.getNodeMetaData(CLASS_NAME));
-    }
-
-    /** Whether {@code blockStatement} is the last one of {@code blockStatements}, allowing for a trailing {@code break}. */
-    private static boolean isLastStatement(final BlockStatementsContext blockStatements, final ParserRuleContext blockStatement) {
-        List<? extends BlockStatementContext> list = blockStatements.blockStatement();
-        int n = list.size();
-        if (n == 0) return false;
-        if (list.get(n - 1) == blockStatement) return true;
-        return n > 1 && list.get(n - 2) == blockStatement && list.get(n - 1).statement() instanceof BreakStmtAltContext;
-    }
-
     @Override
     public Expression visitCommandExpression(final CommandExpressionContext ctx) {
         boolean hasArgumentList = asBoolean(ctx.enhancedArgumentListInPar());
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/SwitchExpressionWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/SwitchExpressionWriter.java
index e3c3b9ef51..3a9c293aa4 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/SwitchExpressionWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/SwitchExpressionWriter.java
@@ -34,8 +34,6 @@ import org.objectweb.asm.MethodVisitor;
 import java.util.List;
 
 import static org.codehaus.groovy.ast.tools.GeneralUtils.maybeFallsThrough;
-import static org.apache.groovy.ast.tools.SwitchExpressionUtils.UNMATCHED_YIELDS_NULL;
-import static org.objectweb.asm.Opcodes.ACONST_NULL;
 import static org.objectweb.asm.Opcodes.ATHROW;
 import static org.objectweb.asm.Opcodes.DUP;
 import static org.objectweb.asm.Opcodes.GOTO;
@@ -215,39 +213,12 @@ public class SwitchExpressionWriter {
         if (defaultStatement != null && !defaultStatement.isEmpty()) {
             defaultStatement.visit(controller.getAcg());
             if (maybeFallsThrough(defaultStatement)) {
-                writeUnmatchedSelector(expression, selectorIndex, selectorType);
+                throwUnmatchedSelector(selectorIndex, selectorType);
             }
             return;
         }
         if (completeEnum) {
             throwIncompatibleClassChangeError();
-        } else {
-            writeUnmatchedSelector(expression, selectorIndex, selectorType);
-        }
-    }
-
-    /**
-     * Whether an unmatched selector completes the expression with {@code null}
-     * rather than throwing: a switch in implicit-return position (GROOVY-12399).
-     *
-     * @since 6.0.0
-     */
-    protected static boolean unmatchedYieldsNull(final SwitchExpression expression) {
-        return Boolean.TRUE.equals(expression.getNodeMetaData(UNMATCHED_YIELDS_NULL));
-    }
-
-    /**
-     * Completes the expression for an unmatched selector: {@code null} for a
-     * switch in implicit-return position, otherwise an
-     * {@code IllegalStateException} (GROOVY-12399).
-     */
-    private void writeUnmatchedSelector(final SwitchExpression expression, final int selectorIndex, final ClassNode selectorType) {
-        if (unmatchedYieldsNull(expression)) {
-            // the result type is always a reference type, so a plain null needs no
-            // cast; like `yield`, leave the operand stack model empty at the jump
-            MethodVisitor mv = controller.getMethodVisitor();
-            mv.visitInsn(ACONST_NULL);
-            mv.visitJumpInsn(GOTO, controller.getCompileStack().requireSwitchExpressionContext().endLabel);
         } else {
             throwUnmatchedSelector(selectorIndex, selectorType);
         }
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesSwitchExpressionWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesSwitchExpressionWriter.java
index f6523bc531..956e400909 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesSwitchExpressionWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesSwitchExpressionWriter.java
@@ -219,10 +219,7 @@ public class StaticTypesSwitchExpressionWriter extends SwitchExpressionWriter {
         CompileStack compileStack = controller.getCompileStack();
 
         boolean hasDefault = expression.getDefaultStatement() != null && !expression.getDefaultStatement().isEmpty();
-        // in implicit-return position an unmatched selector, null or a constant
-        // added after compilation, yields null like any other (GROOVY-12399)
-        boolean complete = !hasDefault && !unmatchedYieldsNull(expression)
-                && group.keys.size() == enumConstantCount(enumType);
+        boolean complete = !hasDefault && group.keys.size() == enumConstantCount(enumType);
 
         // a null selector matches no constant label; with no default it must
         // throw ISE, never the complete-enum ICCE
diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
index 1accc410da..1ad292bf24 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
@@ -158,7 +158,6 @@ import java.util.stream.IntStream;
 
 import static org.apache.groovy.ast.tools.ClassNodeUtils.getNestHost;
 import static org.apache.groovy.ast.tools.MethodNodeUtils.withDefaultArgumentMethods;
-import static org.apache.groovy.ast.tools.SwitchExpressionUtils.UNMATCHED_YIELDS_NULL;
 import static org.apache.groovy.ast.tools.SwitchExpressionUtils.enumConstantName;
 import static org.apache.groovy.ast.tools.SwitchExpressionUtils.intConstant;
 import static org.apache.groovy.ast.tools.SwitchExpressionUtils.isIntegralType;
@@ -5285,9 +5284,6 @@ trying: for (ClassNode[] signature : signatures) {
         if (expression.getDefaultStatement() != null && !expression.getDefaultStatement().isEmpty()) {
             return;
         }
-        if (Boolean.TRUE.equals(expression.getNodeMetaData(UNMATCHED_YIELDS_NULL))) {
-            return; // GROOVY-12399: implicit-return position yields null when unmatched
-        }
         Expression selector = expression.getExpression();
         ClassNode selectorType = getType(selector);
         if (selectorType != null && selectorType.isEnum() && coversAllEnumConstants(expression, selectorType)) {
diff --git a/src/spec/doc/core-semantics.adoc b/src/spec/doc/core-semantics.adoc
index cd31974002..d3768c234a 100644
--- a/src/spec/doc/core-semantics.adoc
+++ b/src/spec/doc/core-semantics.adoc
@@ -220,11 +220,12 @@ As in Java, the arrow only decides fall-through; whether a `switch` is a
 statement or an expression is decided by its position
 (link:https://issues.apache.org/jira/browse/GROOVY-12399[GROOVY-12399]).
 A `switch` whose value is not used is a statement, whatever its arm shape,
-and simply does nothing when unmatched. A `switch` that is the last statement
-of a closure, a script or a method that returns a value is that body's implicit
-return value: it keeps its value, but an unmatched selector yields `null`
-rather than throwing and no exhaustiveness is required. Use
-`return switch (...) {...}` there for the strict expression rules.
+and simply does nothing when unmatched. That includes a `switch` that is the
+last statement of a closure, a script or a method: the implicit return applies
+to the statement, exactly as it does to an `if` or a colon-form `switch`, so an
+unmatched selector leaves the body's value at the return type's default. Use
+`return switch (...) {...}`, or assign the result, for the expression rules
+(link:https://issues.apache.org/jira/browse/GROOVY-12408[GROOVY-12408]).
 
 [source,groovy]
 ----
diff --git a/src/test/groovy/groovy/transform/stc/TypeCheckingExtensionsTest.groovy b/src/test/groovy/groovy/transform/stc/TypeCheckingExtensionsTest.groovy
index c8db0b3325..9064dd5a59 100644
--- a/src/test/groovy/groovy/transform/stc/TypeCheckingExtensionsTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/TypeCheckingExtensionsTest.groovy
@@ -625,7 +625,7 @@ final class TypeCheckingExtensionsTest extends StaticTypeCheckingTestCase {
         extension = 'groovy/transform/stc/SwitchExpressionIsCaseExtension.groovy'
         shouldFailWithMessages '''
             def m(Object o) {
-                switch (o) {
+                return switch (o) {
                     case String -> 's'
                     default -> 'd'
                 }
@@ -634,7 +634,7 @@ final class TypeCheckingExtensionsTest extends StaticTypeCheckingTestCase {
 
         shouldFailWithMessages '''
             def m(int n) {
-                switch (n) {
+                return switch (n) {
                     case 1..10 -> 'in'
                     default -> 'out'
                 }
diff --git a/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy b/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy
index b46220f418..797524b75b 100644
--- a/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy
+++ b/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy
@@ -22,7 +22,6 @@ import groovy.transform.CompileStatic
 import org.codehaus.groovy.control.CompilationUnit
 import org.codehaus.groovy.control.CompilerConfiguration
 import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
-import org.codehaus.groovy.runtime.typehandling.GroovyCastException
 import org.junit.jupiter.api.Test
 
 import static groovy.test.GroovyAssert.assertScript
@@ -364,25 +363,22 @@ final class Groovy12399 {
     }
 
     @Test
-    void implicitReturnWithPrimitiveReturnTypeUnboxesNullWhenUnmatched() {
-        // The switch keeps its value, so a primitive return type converts the
-        // null of an unmatched selector. Dynamically the value is untyped and
-        // the conversion is a Groovy cast; under @CompileStatic the inferred
-        // type is Integer and the conversion is an unboxing, which is what
-        // Groovy does for any null wrapper, as `int m() { Integer i = null; i }`
-        // shows in either mode. Note that every other way a method falls off
-        // its end yields the type's default value instead.
-        def script = """
+    void implicitReturnWithPrimitiveReturnTypeYieldsTheDefaultWhenUnmatched() {
+        // The switch is a statement, so an unmatched selector falls off the end
+        // of the method and the implicit return supplies the type's default,
+        // exactly as it does for `int f(int i) { if (i == 1) return 1 }` or for
+        // a colon-form switch. Until GROOVY-12408 the arrow form was the one
+        // shape in the language that threw here instead (GroovyCastException
+        // dynamically, NullPointerException when statically compiled).
+        assertBoth """
             int f(int i) {
                 switch (i) {
                     case 1 -> 1
                 }
             }
             assert f(1) == 1
-            f(2)
+            assert f(2) == 0
         """
-        shouldFail(GroovyCastException) { assertScript(script) }
-        shouldFail(NullPointerException) { new GroovyShell(staticConfig()).evaluate(script) }
     }
 
     @Test
@@ -435,11 +431,11 @@ final class Groovy12399 {
     }
 
     @Test
-    void voidMethodAndConstructorEndWithAStatement() {
+    void voidConstructorAndValueMethodsEndWithAStatement() {
         assertScript '''
             import groovy.transform.ASTTest
             import org.codehaus.groovy.ast.expr.SwitchExpression
-            import org.codehaus.groovy.ast.stmt.ExpressionStatement
+            import org.codehaus.groovy.ast.stmt.ReturnStatement
             import org.codehaus.groovy.ast.stmt.SwitchStatement
             import static org.codehaus.groovy.control.CompilePhase.SEMANTIC_ANALYSIS
 
@@ -454,16 +450,24 @@ final class Groovy12399 {
                 })
                 void v(int i) { switch (i) { case 1 -> println 'one' } }
 
+                // the value of a method whose last statement is a switch comes
+                // from the implicit return, so the switch is a statement here too
                 @ASTTest(phase=SEMANTIC_ANALYSIS, value={
-                    def last = node.code.statements[-1]
-                    assert last instanceof ExpressionStatement && last.expression instanceof SwitchExpression
+                    assert node.code.statements[-1] instanceof SwitchStatement
                 })
                 def value(int i) { switch (i) { case 1 -> 'one' } }
+
+                @ASTTest(phase=SEMANTIC_ANALYSIS, value={
+                    def last = node.code.statements[-1]
+                    assert last instanceof ReturnStatement && last.expression instanceof SwitchExpression
+                })
+                def asked(int i) { return switch (i) { case 1 -> 'one'; default -> 'other' } }
             }
             def c = new C(2)
             c.v(2)
             assert c.value(1) == 'one'
             assert c.value(2) == null
+            assert c.asked(1) == 'one'
         '''
     }
 
diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/SwitchExpressionStaticCompileTest.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/SwitchExpressionStaticCompileTest.groovy
index 76548b86e6..8e147d87d7 100644
--- a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/SwitchExpressionStaticCompileTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/SwitchExpressionStaticCompileTest.groovy
@@ -126,7 +126,7 @@ final class SwitchExpressionStaticCompileTest extends AbstractBytecodeTestCase {
         def bytecode = compile(method: 'm', '''\
             @groovy.transform.CompileStatic
             String m(int n) {
-                switch (n) {
+                return switch (n) {
                     case 1        -> 'one'
                     case 300..400 -> 'range'
                     default       -> 'other'
@@ -269,7 +269,7 @@ final class SwitchExpressionStaticCompileTest extends AbstractBytecodeTestCase {
         assertScript '''
             @groovy.transform.CompileStatic
             String m(Object o, Object label) {
-                switch (o) {
+                return switch (o) {
                     case label -> 's'
                     default -> 'd'
                 }