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/13 09:54:59 UTC

(groovy) 01/05: GROOVY-12399: arrow switch statement with expression arms is compiled as a switch expression and throws when unmatched

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

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

commit ee370a647797c8670efa63141fe70e2b0c256f3a
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Sep 11 14:27:14 2026 +1000

    GROOVY-12399: arrow switch statement with expression arms is compiled as a switch expression and throws when unmatched
    
    A switch with -> arms is parsed as a SwitchExpression (switchStatement is
    colon-only). Since GROOVY-12255 it was only rewritten to a SwitchStatement
    when an arrow block arm did not yield, so a statement whose arms are single
    expressions (or single-statement blocks) kept the expression rules: an
    unmatched selector threw IllegalStateException and, under @TypeChecked /
    @CompileStatic, a missing default was a compile error.
    
        switch (i) {
            case 1 -> r = 'one'
        }
    
    As in Java (JEP 361) the arrow only decides fall-through; the position
    decides whether a value is produced. The parser now builds a SwitchStatement
    for any switch whose value is not used, whatever the arm shape; expression
    positions (declaration, return, argument, the arm of an enclosing switch
    expression) keep the strict rules.
    
    Implicit-return position (the last statement of a method, closure or script
    body, followed through blocks, if/else branches, try/catch blocks and
    colon-switch arms as ReturnAdder does) has no Java equivalent: the switch
    keeps its value, as `collect { switch (it) {...} }` needs, but an unmatched
    selector yields null instead of throwing and no exhaustiveness is required,
    which is what 4.x/5.x did. The SwitchExpression carries
    SwitchExpressionUtils.UNMATCHED_YIELDS_NULL; the writer completes with null
    and the type checker skips the exhaustiveness check.
---
 COMPATIBILITY.md                                   |  13 +-
 .../groovy/ast/tools/SwitchExpressionUtils.java    |  11 +
 .../apache/groovy/parser/antlr4/AstBuilder.java    | 100 +++++++-
 .../classgen/asm/SwitchExpressionWriter.java       |  23 +-
 .../transform/stc/StaticTypeCheckingVisitor.java   |   4 +
 src/spec/doc/core-semantics.adoc                   |  10 +
 .../codehaus/groovy/classgen/Groovy12255.groovy    |   7 +-
 .../codehaus/groovy/classgen/Groovy12399.groovy    | 280 +++++++++++++++++++++
 8 files changed, 432 insertions(+), 16 deletions(-)

diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index b5b73636c1..65cb6caf54 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -356,10 +356,15 @@ Release notes for the 6.0 beta line should call this out (JIRA
 Groovy 6 compiles switch expressions (JEP 361) as first-class AST
 (`SwitchExpression` / `YieldStatement`) instead of desugaring them to an
 immediately-called closure around a switch statement (the GROOVY-9272
-implementation shipped in 4.x / 5.x). A `switch` used as a statement
-whose `->` block arms do not `yield` is a `SwitchStatement` (those blocks
-need not `yield`); the same syntax in expression position is a
-`SwitchExpression` (every path must `yield` or `throw`).
+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 method,
+closure or script body) 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
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 34f7668c0a..50f1842ca7 100644
--- a/src/main/java/org/apache/groovy/ast/tools/SwitchExpressionUtils.java
+++ b/src/main/java/org/apache/groovy/ast/tools/SwitchExpressionUtils.java
@@ -41,6 +41,17 @@ import java.util.List;
  */
 public final class SwitchExpressionUtils {
 
+    /**
+     * Node metadata key on a {@code SwitchExpression} in implicit-return position
+     * (the last statement of a method, closure or script body): 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 8363a81ef0..525ef0da5b 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -164,6 +164,7 @@ 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;
@@ -2449,18 +2450,101 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
     @Override
     public Statement visitCommandExprAlt(final CommandExprAltContext ctx) {
         Expression expr = this.visitCommandExpression(ctx.commandExpression());
-        // Statement-position `switch` with `->` is parsed as a SwitchExpression
-        // (switchStatement is colon-only). If an arrow block does not yield,
-        // rewrite it to SwitchStatement so the block need not yield (JEP 361
-        // statement rules). Expression-position incomplete arms already errored.
-        if (expr instanceof SwitchExpression se
-                && ctx.getParent() instanceof ExpressionStmtAltContext
-                && switchExpressionHasIncompleteArm(se)) {
-            return configureAST(switchExpressionAsStatement(se), ctx);
+        // 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 method, closure or
+        // script body) 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 (isImplicitReturnPosition(statement)) {
+                se.putNodeMetaData(UNMATCHED_YIELDS_NULL, Boolean.TRUE);
+            }
         }
         return configureAST(new ExpressionStatement(expr), ctx);
     }
 
+    /**
+     * Whether the value of a switch expression statement is used: it is the
+     * expression of an arrow arm of an enclosing switch expression, or it is
+     * in implicit-return position.
+     */
+    private static boolean isSwitchValueUsed(final StatementContext statement) {
+        ParserRuleContext blockStatements = statement.getParent() instanceof BlockStatementContext bs ? bs.getParent() : null;
+        if (blockStatements != null && blockStatements.getParent() instanceof SwitchBlockStatementExpressionGroupContext) {
+            return true;
+        }
+        return isImplicitReturnPosition(statement);
+    }
+
+    /**
+     * Whether {@code statement} is where the compiler would add an implicit
+     * {@code return} (see {@code ReturnAdder}): the last statement of a method,
+     * lambda, closure or script body, 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 static 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 || 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 {@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 3a9c293aa4..4950ae17a2 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/SwitchExpressionWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/SwitchExpressionWriter.java
@@ -34,6 +34,8 @@ 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;
@@ -213,12 +215,31 @@ public class SwitchExpressionWriter {
         if (defaultStatement != null && !defaultStatement.isEmpty()) {
             defaultStatement.visit(controller.getAcg());
             if (maybeFallsThrough(defaultStatement)) {
-                throwUnmatchedSelector(selectorIndex, selectorType);
+                writeUnmatchedSelector(expression, selectorIndex, selectorType);
             }
             return;
         }
         if (completeEnum) {
             throwIncompatibleClassChangeError();
+        } else {
+            writeUnmatchedSelector(expression, selectorIndex, selectorType);
+        }
+    }
+
+    /**
+     * Completes the expression for an unmatched selector: {@code null} (cast
+     * to the result type) 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 (Boolean.TRUE.equals(expression.getNodeMetaData(UNMATCHED_YIELDS_NULL))) {
+            CompileStack.SwitchExpressionContext context = controller.getCompileStack().requireSwitchExpressionContext();
+            OperandStack operandStack = controller.getOperandStack();
+            controller.getMethodVisitor().visitInsn(ACONST_NULL);
+            operandStack.push(ClassHelper.OBJECT_TYPE);
+            operandStack.doGroovyCast(context.resultType);
+            operandStack.remove(1);
+            controller.getMethodVisitor().visitJumpInsn(GOTO, context.endLabel);
         } else {
             throwUnmatchedSelector(selectorIndex, selectorType);
         }
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 f0e37de4f5..83ce23c485 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
@@ -158,6 +158,7 @@ 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;
@@ -5282,6 +5283,9 @@ 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 6564b49593..c9b8130fee 100644
--- a/src/spec/doc/core-semantics.adoc
+++ b/src/spec/doc/core-semantics.adoc
@@ -216,6 +216,16 @@ selector throws `IllegalStateException`. Under `@TypeChecked` /
 `@CompileStatic` a non-exhaustive switch expression (no `default`, and not a
 complete enum) is a compile-time error.
 
+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 method, closure or script body 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.
+
 [source,groovy]
 ----
 include::../test/SemanticsTest.groovy[tags=switch_expression,indent=0]
diff --git a/src/test/groovy/org/codehaus/groovy/classgen/Groovy12255.groovy b/src/test/groovy/org/codehaus/groovy/classgen/Groovy12255.groovy
index 722e666fb3..e981e3a8f3 100644
--- a/src/test/groovy/org/codehaus/groovy/classgen/Groovy12255.groovy
+++ b/src/test/groovy/org/codehaus/groovy/classgen/Groovy12255.groovy
@@ -537,7 +537,7 @@ final class Groovy12255 {
             enum Flag { ON, OFF }
 
             String m(Flag f) {
-                switch (f) {
+                return switch (f) {
                     case Flag.ON  -> 'on'
                     case Flag.OFF -> 'off'
                 }
@@ -1045,11 +1045,12 @@ final class Groovy12255 {
 
     @Test
     void typeCheckedUnmatchedIsError() {
-        // exhaustiveness is a static type-checker error
+        // exhaustiveness is a static type-checker error (in expression position;
+        // implicit-return position yields null when unmatched, GROOVY-12399)
         def err = shouldFail(MultipleCompilationErrorsException, '''
             @groovy.transform.TypeChecked
             def meth(int a) {
-                switch (a) {
+                return switch (a) {
                     case 1 -> 'one'
                 }
             }
diff --git a/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy b/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy
new file mode 100644
index 0000000000..622178af3a
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/classgen/Groovy12399.groovy
@@ -0,0 +1,280 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.codehaus.groovy.classgen
+
+import groovy.transform.CompileStatic
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * GROOVY-12399: a {@code switch} with arrow arms is a statement or an
+ * expression by position, as in Java (JEP 361), not by syntax. A switch
+ * whose value is not used does nothing when unmatched; a switch in
+ * implicit-return position keeps its value and yields {@code null} when
+ * unmatched, as in 4.x/5.x; every other expression position keeps the
+ * strict rules of GROOVY-12255.
+ */
+final class Groovy12399 {
+
+    private static void assertBoth(final String script) {
+        assertScript(script)
+        new GroovyShell(staticConfig()).evaluate(script)
+    }
+
+    private static CompilerConfiguration staticConfig() {
+        def config = new CompilerConfiguration()
+        config.addCompilationCustomizers(new ASTTransformationCustomizer(CompileStatic))
+        return config
+    }
+
+    @Test
+    void statementWithExpressionArmDoesNothingWhenUnmatched() {
+        assertBoth '''
+            def f(int i) {
+                def r = 'none'
+                switch (i) {
+                    case 1 -> r = 'one'
+                }
+                r
+            }
+            assert f(1) == 'one'
+            assert f(42) == 'none'
+        '''
+    }
+
+    @Test
+    void statementWithClassLabelDoesNothingWhenUnmatched() {
+        assertBoth '''
+            def f(Object o) {
+                def r = 'none'
+                switch (o) {
+                    case String -> r = 'str'
+                }
+                r
+            }
+            assert f('x') == 'str'
+            assert f(42) == 'none'
+        '''
+    }
+
+    @Test
+    void statementWithSingleStatementBlockArmDoesNothingWhenUnmatched() {
+        assertBoth '''
+            def f(int i) {
+                def r = 'none'
+                switch (i) {
+                    case 1 -> { r = 'one' }
+                }
+                r
+            }
+            assert f(42) == 'none'
+        '''
+    }
+
+    @Test
+    void statementAsLastStatementOfLoopBody() {
+        assertBoth '''
+            def log = []
+            for (i in [1, 2, 3]) {
+                switch (i) {
+                    case 2 -> log << 'two'
+                }
+            }
+            assert log == ['two']
+        '''
+    }
+
+    @Test
+    void statementInsideIfBranchNotInReturnPosition() {
+        assertBoth '''
+            def f(boolean b, int i) {
+                def r = 'none'
+                if (b) {
+                    switch (i) {
+                        case 1 -> r = 'one'
+                    }
+                    r = r + '!'
+                }
+                r
+            }
+            assert f(true, 42) == 'none!'
+        '''
+    }
+
+    @Test
+    void statementNeedsNoDefaultWhenTypeChecked() {
+        assertScript '''
+            import groovy.transform.TypeChecked
+            @TypeChecked
+            def f(Object o) {
+                def r = 'none'
+                switch (o) {
+                    case String -> r = 'str'
+                }
+                r
+            }
+            assert f(42) == 'none'
+        '''
+    }
+
+    @Test
+    void expressionPositionsStillThrowWhenUnmatched() {
+        for (form in ['def r = switch (i) { case 1 -> \'one\' }; r',
+                      'return switch (i) { case 1 -> \'one\' }',
+                      'Objects.toString(switch (i) { case 1 -> \'one\' })']) {
+            assertScript """
+                import static groovy.test.GroovyAssert.shouldFail
+                def f(int i) {
+                    $form
+                }
+                assert f(1) == 'one'
+                def e = shouldFail(IllegalStateException) { f(42) }
+                assert e.message.contains('does not cover the value 42')
+            """
+        }
+    }
+
+    @Test
+    void expressionPositionsStillRequireExhaustivenessWhenTypeChecked() {
+        for (form in ['def r = switch (i) { case 1 -> \'one\' }; r',
+                      'return switch (i) { case 1 -> \'one\' }']) {
+            def err = shouldFail """
+                import groovy.transform.TypeChecked
+                @TypeChecked
+                def f(int i) {
+                    $form
+                }
+            """
+            assert err.message.contains('does not cover all possible input values')
+        }
+    }
+
+    @Test
+    void implicitReturnKeepsValueAndYieldsNullWhenUnmatched() {
+        assertBoth '''
+            def f(int i) {
+                switch (i) {
+                    case 1 -> 'one'
+                }
+            }
+            assert f(1) == 'one'
+            assert f(42) == null
+        '''
+    }
+
+    @Test
+    void implicitReturnInClosure() {
+        assertBoth '''
+            def names = [1, 2, 3].collect {
+                switch (it) {
+                    case 1 -> 'one'
+                    case 2 -> 'two'
+                }
+            }
+            assert names == ['one', 'two', null]
+        '''
+    }
+
+    @Test
+    void implicitReturnAsLastScriptStatement() {
+        assert new GroovyShell().evaluate('''
+            def i = 1
+            switch (i) {
+                case 1 -> 'one'
+            }
+        ''') == 'one'
+        assert new GroovyShell().evaluate('''
+            def i = 42
+            switch (i) {
+                case 1 -> 'one'
+            }
+        ''') == null
+    }
+
+    @Test
+    void implicitReturnThroughIfElseTryAndColonSwitch() {
+        assertBoth '''
+            def viaIf(boolean b, int i) {
+                if (b) {
+                    switch (i) { case 1 -> 'one' }
+                } else {
+                    'else'
+                }
+            }
+            assert viaIf(true, 1) == 'one'
+            assert viaIf(true, 42) == null
+            assert viaIf(false, 1) == 'else'
+
+            def viaTry(int i) {
+                try {
+                    switch (i) { case 1 -> 'one' }
+                } catch (e) {
+                    'caught'
+                }
+            }
+            assert viaTry(1) == 'one'
+            assert viaTry(42) == null
+
+            def viaColonSwitch(int outer, int i) {
+                switch (outer) {
+                    case 0:
+                        switch (i) { case 1 -> 'one' }
+                        break
+                    default:
+                        'other'
+                }
+            }
+            assert viaColonSwitch(0, 1) == 'one'
+            assert viaColonSwitch(0, 42) == null
+            assert viaColonSwitch(9, 1) == 'other'
+        '''
+    }
+
+    @Test
+    void implicitReturnNeedsNoDefaultWhenTypeChecked() {
+        assertScript '''
+            import groovy.transform.TypeChecked
+            @TypeChecked
+            def f(int i) {
+                switch (i) {
+                    case 1 -> 'one'
+                }
+            }
+            assert f(1) == 'one'
+            assert f(42) == null
+        '''
+    }
+
+    @Test
+    void implicitReturnWithNonYieldingBlockArmIsAStatement() {
+        assertBoth '''
+            def f(int i) {
+                switch (i) {
+                    case 1 -> { println 'one' }
+                }
+            }
+            assert f(1) == null
+            assert f(42) == null
+        '''
+    }
+}