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 1f28e07b60d3f46b29a6b7cd133dd47c750c90ef Author: Paul King <[email protected]> AuthorDate: Wed May 6 18:07:10 2026 +1000 GROOVY-11998: Better support of intersection types (part 4) as coercion. Closure asType overload, ProxyGenerator plumbing, dynamic castToType(Object, Class[]). --- .../groovy/classgen/AsmClassGenerator.java | 52 ++++++- .../groovy/runtime/IntersectionCastSupport.java | 103 ++++++++++++ .../groovy/lang/IntersectionCoercionTest.groovy | 173 +++++++++++++++++++++ 3 files changed, 325 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java index c98b943da6..4680db0ce3 100644 --- a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java +++ b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java @@ -1005,15 +1005,20 @@ public class AsmClassGenerator extends ClassGenerator { // GROOVY-11998: lambda / method-reference factory invocations already // emit an object that implements every component of an intersection - // target via altMetafactory FLAG_MARKERS, so the outer cast is a no-op - // here. Non-functional intersection casts are out of scope for PR3 - // and fall through to the default handling below. + // target via altMetafactory FLAG_MARKERS, so the outer cast is a no-op. if (type instanceof IntersectionTypeClassNode && (expression instanceof LambdaExpression || expression instanceof MethodReferenceExpression)) { expression.visit(this); return; } + // GROOVY-11998: non-functional intersection casts route through the + // runtime helper IntersectionCastSupport, which strict-casts for `()` + // and may build a multi-interface proxy via ProxyGenerator for `as`. + if (type instanceof IntersectionTypeClassNode) { + emitIntersectionCastCall((IntersectionTypeClassNode) type, expression, castExpression.isCoerce()); + return; + } expression.visit(this); @@ -1033,6 +1038,47 @@ public class AsmClassGenerator extends ClassGenerator { } } + /** + * Emits a call to {@link org.codehaus.groovy.runtime.IntersectionCastSupport} + * for an intersection-target cast or coercion on a non-functional source. + * + * Bytecode layout: + * <pre> + * visit(source) // pushes source on the JVM stack + * bipush/iconst N // component count + * anewarray Class // create Class[N] + * for each component i: + * dup; bipush i; ldc Type; aastore + * invokestatic IntersectionCastSupport.{castTo|asType}(Object, Class[]) Object + * </pre> + */ + private void emitIntersectionCastCall(final IntersectionTypeClassNode it, final Expression source, final boolean coerce) { + source.visit(this); // leaves source on the operand / JVM stack as Object-ish + controller.getOperandStack().box(); // ensure boxed reference (handles primitive sources) + + MethodVisitor mv = controller.getMethodVisitor(); + ClassNode[] components = it.getComponents(); + + BytecodeHelper.pushConstant(mv, components.length); + mv.visitTypeInsn(ANEWARRAY, "java/lang/Class"); + for (int i = 0; i < components.length; i += 1) { + mv.visitInsn(DUP); + BytecodeHelper.pushConstant(mv, i); + BytecodeHelper.visitClassLiteral(mv, components[i]); + mv.visitInsn(AASTORE); + } + + mv.visitMethodInsn( + INVOKESTATIC, + "org/codehaus/groovy/runtime/IntersectionCastSupport", + coerce ? "asType" : "castTo", + "(Ljava/lang/Object;[Ljava/lang/Class;)Ljava/lang/Object;", + false + ); + + controller.getOperandStack().replace(ClassHelper.OBJECT_TYPE); + } + @Override public void visitNotExpression(final NotExpression expression) { controller.getUnaryExpressionHelper().writeNotExpression(expression); diff --git a/src/main/java/org/codehaus/groovy/runtime/IntersectionCastSupport.java b/src/main/java/org/codehaus/groovy/runtime/IntersectionCastSupport.java new file mode 100644 index 0000000000..06eec2654b --- /dev/null +++ b/src/main/java/org/codehaus/groovy/runtime/IntersectionCastSupport.java @@ -0,0 +1,103 @@ +/* + * 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.runtime; + +import groovy.lang.Closure; +import groovy.util.ProxyGenerator; +import org.codehaus.groovy.runtime.typehandling.GroovyCastException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Runtime support for intersection-type cast and {@code as} coercion + * (GROOVY-11998). Compiler-generated bytecode for {@code (A & B) value} and + * {@code value as (A & B)} routes through these helpers when the target is + * an intersection type and the source is not a native lambda or method + * reference (those cases are handled at compile time via + * {@code LambdaMetafactory.altMetafactory} markers). + * + * @since 5.0.0 + */ +public final class IntersectionCastSupport { + + private IntersectionCastSupport() {} + + /** + * Strict cast: every component must already be assignment-compatible with + * the source's runtime class. Throws {@link GroovyCastException} otherwise. + */ + public static Object castTo(final Object source, final Class<?>[] components) { + if (source == null) return null; + for (Class<?> c : components) { + if (!c.isInstance(source)) { + throw new GroovyCastException(source, c); + } + } + return source; + } + + /** + * Coercion: produce an object that satisfies every component. For + * {@link Closure} and {@link Map} sources, a multi-interface proxy is + * built via {@link ProxyGenerator}. For other sources, falls back to + * {@link #castTo} so the behaviour is at least as strict as a cast. + */ + public static Object asType(final Object source, final Class<?>[] components) { + if (source == null) return null; + + if (isInstanceOfAll(source, components)) { + return source; + } + + Class<?> baseClass = pickBaseClass(components); + @SuppressWarnings({"unchecked", "rawtypes"}) + List<Class> ifaces = (List) filterInterfaces(components); + + if (source instanceof Closure) { + Map<String, Closure> closureMap = new HashMap<>(); + closureMap.put("*", (Closure<?>) source); // wildcard: all SAM calls go to the closure + return ProxyGenerator.INSTANCE.instantiateAggregate(closureMap, ifaces, baseClass, null); + } + if (source instanceof Map) { + return ProxyGenerator.INSTANCE.instantiateAggregate((Map) source, ifaces, baseClass, null); + } + + // Strict fallback: throws GroovyCastException with a useful message + return castTo(source, components); + } + + private static boolean isInstanceOfAll(final Object source, final Class<?>[] components) { + for (Class<?> c : components) if (!c.isInstance(source)) return false; + return true; + } + + private static Class<?> pickBaseClass(final Class<?>[] components) { + for (Class<?> c : components) if (!c.isInterface()) return c; + return null; + } + + private static List<Class<?>> filterInterfaces(final Class<?>[] components) { + List<Class<?>> out = new ArrayList<>(components.length); + for (Class<?> c : components) if (c.isInterface()) out.add(c); + return out; + } +} diff --git a/src/test/groovy/groovy/lang/IntersectionCoercionTest.groovy b/src/test/groovy/groovy/lang/IntersectionCoercionTest.groovy new file mode 100644 index 0000000000..f4e31d3459 --- /dev/null +++ b/src/test/groovy/groovy/lang/IntersectionCoercionTest.groovy @@ -0,0 +1,173 @@ +/* + * 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.codehaus.groovy.runtime.typehandling.GroovyCastException +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertThrows + +/** + * Tests for runtime intersection-type cast and {@code as} coercion on + * non-functional sources (GROOVY-11998 PR4). + * + * The functional-source cases (lambdas / method references) are covered + * by {@code IntersectionCastE2ETest}; this suite focuses on the dynamic + * runtime path that goes through {@code IntersectionCastSupport}. + */ +final class IntersectionCoercionTest { + + @Test + void 'dynamic strict cast accepts a value that already implements all components'() { + def shell = new GroovyShell() + def result = shell.evaluate(''' + class Both implements Runnable, Cloneable { + void run() {} + } + def b = new Both() + return (Runnable & Cloneable) b + ''') + assert result instanceof Runnable + assert result instanceof Cloneable + } + + @Test + void 'dynamic strict cast throws on missing component'() { + def shell = new GroovyShell() + def thrown = assertThrows(GroovyCastException) { + shell.evaluate(''' + def s = "hello" + return (Runnable & java.io.Serializable) s + ''') + } + // String is Serializable but not Runnable + assert thrown.message.contains('Runnable') || thrown.message.contains('Cannot cast') + } + + @Test + void 'dynamic as on a closure builds a proxy implementing all interfaces'() { + def shell = new GroovyShell() + def result = shell.evaluate(''' + interface Greeter { String greet(String name) } + def proxy = ({ String n -> "Hello, " + n } as (Greeter & java.io.Serializable)) + return [proxy, proxy.greet("world")] + ''') + def (proxy, greeting) = result + assert proxy instanceof java.io.Serializable + // The proxy's class implements Greeter via ProxyGenerator + assert proxy.class.interfaces.any { it.name == 'Greeter' } + assert greeting == 'Hello, world' + } + + @Test + void 'dynamic as on a closure that already implements all components is identity'() { + // Closure already implements Runnable AND Serializable, so `as (Runnable & Serializable)` + // should not wrap; identity coercion. + def shell = new GroovyShell() + def result = shell.evaluate(''' + def cl = { -> 1 } + def coerced = cl as (Runnable & java.io.Serializable) + return coerced.is(cl) + ''') + assert result == true + } + + @Test + void 'dynamic as on a map builds a proxy implementing all interfaces'() { + def shell = new GroovyShell() + def result = shell.evaluate(''' + interface Action { void perform() } + def calls = 0 + def proxy = ([perform: { -> calls++ }] as (Action & java.io.Serializable)) + proxy.perform() + proxy.perform() + return [proxy, calls] + ''') + def (proxy, calls) = result + assert proxy instanceof java.io.Serializable + assert proxy.class.interfaces.any { it.name == 'Action' } + assert calls == 2 + } + + @Test + void 'static cast on a value that already implements all components passes'() { + def shell = new GroovyShell() + def result = shell.evaluate(''' + import groovy.transform.CompileStatic + + class Both implements Runnable, Cloneable { + void run() {} + } + + @CompileStatic + class T { + static Object make() { + Both b = new Both() + return (Runnable & Cloneable) b + } + } + T.make() + ''') + assert result instanceof Runnable + assert result instanceof Cloneable + } + + @Test + void 'static as coercion on a closure builds a multi-interface proxy'() { + def shell = new GroovyShell() + def result = shell.evaluate(''' + import groovy.transform.CompileStatic + interface Greeter { String greet(String name) } + @CompileStatic + class T { + static Object make() { + def cl = { String n -> "Hello, " + n } + return (cl as (Greeter & java.io.Serializable)) + } + } + def p = T.make() + return [p, p.greet("world")] + ''') + def (proxy, greeting) = result + assert proxy instanceof java.io.Serializable + assert proxy.class.interfaces.any { it.name == 'Greeter' } + assert greeting == 'Hello, world' + } + + @Test + void 'three-component intersection coerces correctly'() { + def shell = new GroovyShell() + def result = shell.evaluate(''' + interface One { String one() } + interface Two { String two() } + def proxy = ([one: { 'a' }, two: { 'b' }] as (One & Two & java.io.Serializable)) + return [proxy.one(), proxy.two(), proxy instanceof java.io.Serializable] + ''') + assert result == ['a', 'b', true] + } + + @Test + void 'null sources pass through cast and as'() { + def shell = new GroovyShell() + shell.evaluate(''' + assert ((Runnable & java.io.Serializable) null) == null + assert (null as (Runnable & java.io.Serializable)) == null + ''') + } +}
