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 566c8f2c045296ab0f1db4f85f7ab7e6f0f675fd
Author: Paul King <[email protected]>
AuthorDate: Wed May 6 18:39:06 2026 +1000

    GROOVY-11998: Better support of intersection types (part 5)
    Closure literal native intersection. StaticTypesClosureWriter generates a 
class implementing all components for (R & S) { -> ... }, docs.
---
 .../classgen/asm/sc/StaticTypesClosureWriter.java  |  31 ++++
 src/spec/doc/core-differences-java.adoc            |  19 ++
 src/spec/doc/core-semantics.adoc                   |  59 ++++++
 src/spec/test/CoercionTest.groovy                  |  97 ++++++++++
 .../lang/IntersectionClosureLiteralTest.groovy     | 203 +++++++++++++++++++++
 5 files changed, 409 insertions(+)

diff --git 
a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
 
b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
index b2e9dda3e7..24201148ea 100644
--- 
a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
+++ 
b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
@@ -44,6 +44,7 @@ import static 
org.codehaus.groovy.ast.tools.GeneralUtils.constX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
+import static 
org.codehaus.groovy.transform.stc.StaticTypesMarker.LAMBDA_MARKERS;
 
 /**
  * Writer responsible for generating closure classes in statically compiled 
mode.
@@ -69,10 +70,40 @@ public class StaticTypesClosureWriter extends ClosureWriter 
{
         for (MethodNode method : methods) {
             visitor.visitMethod(method);
         }
+        // GROOVY-11998: when the closure literal is the source of an 
intersection
+        // cast, declare the additional marker interfaces on the generated 
class so
+        // the resulting object IS-A every component without going through a
+        // runtime proxy. Markers are only added when they are true marker
+        // interfaces (no abstract methods we'd be obliged to implement).
+        addIntersectionMarkers(closureClass, expression);
         
closureClass.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, 
Boolean.TRUE);
         return closureClass;
     }
 
+    @SuppressWarnings("unchecked")
+    private static void addIntersectionMarkers(final ClassNode closureClass, 
final ClosureExpression expression) {
+        Object md = expression.getNodeMetaData(LAMBDA_MARKERS);
+        if (!(md instanceof List)) return;
+        List<ClassNode> markers = (List<ClassNode>) md;
+        for (ClassNode marker : markers) {
+            if (marker == null || !marker.isInterface()) continue;
+            if (closureClass.implementsInterface(marker)) continue;
+            // Only add interfaces with no abstract methods (true markers). For
+            // interfaces that declare unimplemented abstract methods, we'd
+            // have to synthesise method bodies — out of scope here, fall back
+            // to the runtime proxy path in IntersectionCastSupport.asType.
+            if (hasAbstractMethods(marker)) continue;
+            closureClass.addInterface(marker);
+        }
+    }
+
+    private static boolean hasAbstractMethods(final ClassNode iface) {
+        for (MethodNode m : iface.getMethods()) {
+            if (m.isAbstract() && !m.isDefault() && !m.isStatic()) return true;
+        }
+        return false;
+    }
+
     private static void createDirectCallMethod(final ClassNode closureClass, 
final MethodNode doCallMethod) {
         // in case there is no "call" method on the closure, create a "fast 
invocation" path
         // to avoid going through ClosureMetaClass by call(Object...) method
diff --git a/src/spec/doc/core-differences-java.adoc 
b/src/spec/doc/core-differences-java.adoc
index f153b566c9..a3725ce055 100644
--- a/src/spec/doc/core-differences-java.adoc
+++ b/src/spec/doc/core-differences-java.adoc
@@ -238,6 +238,25 @@ Runnable run = { println 'run' }
 list.each { println it } // or list.each(this.&println)
 ----
 
+Since Groovy 6.0, intersection-type casts 
(https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.16[JLS 
§15.16])
+are supported on lambdas and method references, so a lambda can opt into 
`Serializable`
+or other marker interfaces, just like in Java:
+
+[source,groovy]
+----
+Runnable r = (Runnable & Serializable) () -> println('hi') // serialisable 
lambda
+----
+
+Groovy additionally accepts the `as` form, where parentheses are required 
around
+the intersection:
+
+[source,groovy]
+----
+def r = { -> println 'hi' } as (Runnable & Serializable)
+----
+
+See the <<{core-semantics}#intersection-cast,Intersection-type cast and 
coercion>> section in the semantics guide.
+
 
 == GStrings
 
diff --git a/src/spec/doc/core-semantics.adoc b/src/spec/doc/core-semantics.adoc
index bd7b7ca1ad..ab9bebac5a 100644
--- a/src/spec/doc/core-semantics.adoc
+++ b/src/spec/doc/core-semantics.adoc
@@ -759,6 +759,65 @@ The type of the exception depends on the call itself:
 * `MissingMethodException` if the arguments of the call do not match those 
from the interface/class
 * `UnsupportedOperationException` if the arguments of the call match one of 
the overloaded methods of the interface/class
 
+[[intersection-cast]]
+=== Intersection-type cast and coercion
+
+Since Groovy 6.0, the cast and `as` operators accept _intersection types_ — a
+class component (at most one) and any number of interface components joined by
+`&`, mirroring Java's
+https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.16[JLS 
§15.16]
+intersection cast. The most common use is to opt a lambda or method reference
+into `Serializable`:
+
+[source,groovy]
+----
+include::../test/CoercionTest.groovy[tags=intersection_cast_lambda,indent=0]
+----
+
+For statically compiled lambdas and method references, the additional
+interfaces are threaded through `LambdaMetafactory.altMetafactory` via
+`FLAG_MARKERS` / `FLAG_SERIALIZABLE` — there is no runtime proxy and the
+result implements every component natively, so it can be serialised and
+restored:
+
+[source,groovy]
+----
+include::../test/CoercionTest.groovy[tags=intersection_cast_serializable_roundtrip,indent=0]
+----
+
+The same syntax works in the `as` form, with parentheses around the 
intersection:
+
+[source,groovy]
+----
+include::../test/CoercionTest.groovy[tags=intersection_as_coercion_marker,indent=0]
+----
+
+For closure literals and maps, `as` builds a
+{@link groovy.util.ProxyGenerator} aggregate that implements every interface
+component:
+
+[source,groovy]
+----
+include::../test/CoercionTest.groovy[tags=intersection_as_coercion_map,indent=0]
+----
+
+Method references support the same intersection-cast forms:
+
+[source,groovy]
+----
+include::../test/CoercionTest.groovy[tags=intersection_method_reference,indent=0]
+----
+
+The well-formedness rules follow the JLS:
+
+* at most one component may be a class — and if present, it must come first;
+* the class component (if any) must not be `final`;
+* primitive components are not allowed;
+* when the operand is a lambda, method reference or closure literal, exactly
+  one component may have an abstract method (the SAM-bearing functional
+  interface). The remainder must be marker-compatible interfaces (no abstract
+  methods, or abstract methods identical to the SAM).
+
 === String to enum coercion
 
 Groovy allows transparent `String` (or `GString`) to enum values coercion. 
Imagine you define the following enum:
diff --git a/src/spec/test/CoercionTest.groovy 
b/src/spec/test/CoercionTest.groovy
index f44b6f46fd..767cb23ab3 100644
--- a/src/spec/test/CoercionTest.groovy
+++ b/src/spec/test/CoercionTest.groovy
@@ -374,6 +374,103 @@ final class CoercionTest {
         '''
     }
 
+    // GROOVY-11998
+    @Test
+    void testIntersectionCastLambda() {
+        assertScript '''
+            // tag::intersection_cast_lambda[]
+            // A lambda that is also Serializable
+            Runnable r = (Runnable & java.io.Serializable) () -> println("hi")
+            assert r instanceof Runnable
+            assert r instanceof java.io.Serializable
+            r.run()
+            // end::intersection_cast_lambda[]
+        '''
+    }
+
+    // GROOVY-11998
+    @Test
+    void testIntersectionCastSerializableRoundTrip() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            // tag::intersection_cast_serializable_roundtrip[]
+            @CompileStatic
+            class T {
+                static Runnable make() {
+                    return (Runnable & java.io.Serializable) () -> 
println("hi")
+                }
+            }
+
+            // The lambda factory uses LambdaMetafactory.altMetafactory with
+            // FLAG_SERIALIZABLE so the result can be safely written to and 
read
+            // from an ObjectStream.
+            def r = T.make()
+            def baos = new ByteArrayOutputStream()
+            new ObjectOutputStream(baos).withCloseable { it.writeObject(r) }
+            def restored = null
+            new ObjectInputStream(new 
ByteArrayInputStream(baos.toByteArray())).withCloseable {
+                restored = it.readObject()
+            }
+            assert restored instanceof Runnable
+            // end::intersection_cast_serializable_roundtrip[]
+        '''
+    }
+
+    // GROOVY-11998
+    @Test
+    void testIntersectionAsCoercion() {
+        assertScript '''
+            // tag::intersection_as_coercion_marker[]
+            // Closures already implement Runnable, Serializable and 
Cloneable, so
+            // intersections of those is an identity coercion (no proxy is 
built).
+            def cl = { -> 1 }
+            def coerced = cl as (Runnable & java.io.Serializable)
+            assert coerced.is(cl) // same instance
+            // end::intersection_as_coercion_marker[]
+        '''
+    }
+
+    // GROOVY-11998
+    @Test
+    void testIntersectionAsCoercionMap() {
+        assertScript '''
+            // tag::intersection_as_coercion_map[]
+            interface Action { void perform() }
+
+            def calls = 0
+            def proxy = ([perform: { -> calls++ }] as (Action & 
java.io.Serializable))
+            assert proxy instanceof Action
+            assert proxy instanceof java.io.Serializable
+            proxy.perform()
+            proxy.perform()
+            assert calls == 2
+            // end::intersection_as_coercion_map[]
+        '''
+    }
+
+    // GROOVY-11998
+    @Test
+    void testIntersectionMethodReference() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+            import java.util.function.Function
+
+            // tag::intersection_method_reference[]
+            @CompileStatic
+            class T {
+                static Function<String, Integer> make() {
+                    return (Function<String, Integer> & java.io.Serializable) 
String::length
+                }
+            }
+
+            Function<String, Integer> f = T.make()
+            assert f instanceof java.io.Serializable
+            assert f.apply("hello") == 5
+            // end::intersection_method_reference[]
+        '''
+    }
+
     @Test
     void testAsVsAsType() {
         assertScript '''
diff --git a/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy 
b/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy
new file mode 100644
index 0000000000..b8bd4cee3b
--- /dev/null
+++ b/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy
@@ -0,0 +1,203 @@
+/*
+ *  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 groovy.lang
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for native intersection support of closure literals
+ * (GROOVY-11998 PR5).
+ *
+ * Under {@code @CompileStatic}, a closure literal cast to an intersection
+ * has its generated class declare the marker interfaces so that the cast
+ * is a no-op (no runtime proxy needed).
+ */
+final class IntersectionClosureLiteralTest {
+
+    @Test
+    void 'closure cast to (Runnable & MyMarker) implements MyMarker via 
addInterface'() {
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+
+            interface MyMarker {}
+
+            @CompileStatic
+            class T {
+                static Object castForm() {
+                    return (Runnable & MyMarker) { -> }
+                }
+            }
+
+            def c = T.castForm()
+            assert c instanceof Runnable
+            assert c instanceof MyMarker
+            // Generated closure class itself implements MyMarker - no proxy 
wrapping
+            assert MyMarker.isAssignableFrom(c.class)
+        ''')
+    }
+
+    @Test
+    void 'closure cast to (Runnable & Serializable & MyMarker) implements all 
three'() {
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+
+            interface MyMarker {}
+
+            @CompileStatic
+            class T {
+                static Object make() {
+                    return (Runnable & java.io.Serializable & MyMarker) { -> }
+                }
+            }
+
+            def c = T.make()
+            assert c instanceof Runnable
+            assert c instanceof java.io.Serializable
+            assert c instanceof MyMarker
+        ''')
+    }
+
+    @Test
+    void 'as form on closure with custom marker also picks up addInterface'() {
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+
+            interface MyMarker {}
+
+            @CompileStatic
+            class T {
+                static Object asForm() {
+                    return ({ -> "hi" } as (Runnable & MyMarker))
+                }
+            }
+
+            def c = T.asForm()
+            assert c instanceof Runnable
+            assert c instanceof MyMarker
+        ''')
+    }
+
+    @Test
+    void 'static cast form succeeds where dynamic would need a runtime 
proxy'() {
+        // Without PR5, (R & MyMarker) cast form on a closure literal would 
throw
+        // GroovyCastException at runtime because the closure isn't MyMarker.
+        // PR5 makes the generated closure class implement MyMarker directly so
+        // the cast is a true no-op.
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+
+            interface MyMarker {}
+
+            @CompileStatic
+            class T {
+                static Object castForm() {
+                    return (Runnable & MyMarker) { -> }
+                }
+            }
+
+            def c = T.castForm()
+            // The closure subclass itself declares MyMarker — verify by class 
introspection
+            assert MyMarker.isAssignableFrom(c.class)
+            assert !c.class.name.startsWith('jdk.proxy')
+            assert !c.class.name.contains('groovyProxy')
+        ''')
+    }
+
+    @Test
+    void 'closure cast to Cloneable & Serializable is identity (already 
implemented by Closure)'() {
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            class T {
+                static Object make() {
+                    def cl = { -> 1 }
+                    return cl as (Cloneable & java.io.Serializable)
+                }
+            }
+            def c = T.make()
+            assert c instanceof Cloneable
+            assert c instanceof java.io.Serializable
+        ''')
+    }
+
+    @Test
+    void 'marker interface with non-abstract default methods can still be 
added'() {
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+
+            interface MarkerWithDefault {
+                default String tag() { 'tag' }
+            }
+
+            @CompileStatic
+            class T {
+                static Object make() {
+                    return (Runnable & MarkerWithDefault) { -> }
+                }
+            }
+
+            def c = T.make()
+            assert c instanceof Runnable
+            assert c instanceof MarkerWithDefault
+            assert c.tag() == 'tag'
+        ''')
+    }
+
+    @Test
+    void 'closure literal intersection is serialisable when intersection 
includes Serializable'() {
+        def shell = new GroovyShell()
+        shell.evaluate('''
+            import groovy.transform.CompileStatic
+            import java.io.ByteArrayOutputStream
+            import java.io.ByteArrayInputStream
+            import java.io.ObjectOutputStream
+            import java.io.ObjectInputStream
+
+            interface MyMarker {}
+
+            @CompileStatic
+            class T {
+                static Object make() {
+                    return (Runnable & java.io.Serializable & MyMarker) { -> }
+                }
+            }
+
+            def c = T.make()
+            assert c instanceof java.io.Serializable
+            assert c instanceof MyMarker
+
+            def baos = new ByteArrayOutputStream()
+            new ObjectOutputStream(baos).withCloseable { it.writeObject(c) }
+            def bytes = baos.toByteArray()
+            def restored = null
+            new ObjectInputStream(new 
ByteArrayInputStream(bytes)).withCloseable {
+                restored = it.readObject()
+            }
+            assert restored instanceof Runnable
+            assert restored instanceof MyMarker
+        ''')
+    }
+}

Reply via email to