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 982c75e2127eb4163799c2b7548009b334afbfc5
Author: Paul King <[email protected]>
AuthorDate: Thu Jul 9 22:53:37 2026 +1000

    GROOVY-12143: lambda hoisting (GEP-27)
    
    A statically compiled lambda targeting a functional interface currently 
generates a
    per-lambda class extending groovy.lang.Closure, even though its runtime 
value is a
    LambdaMetafactory-produced functional-interface instance. For a capturing 
lambda the
    class also holds the captured state (Reference-wrapped) and is instantiated 
per lambda.
    
    Like the JVM does for Java lambdas, hoist the lambda body onto the 
enclosing class as a
    private static method and bootstrap LambdaMetafactory directly against it, 
so no per-lambda
    class is generated:
    
    - Non-capturing: re-home the already-static doCall onto the enclosing 
class; the metafactory
      produces the same zero-allocation singleton.
    - Read-only capturing: emit a static method taking the captured values as 
leading parameters
      and have the metafactory capture those values directly - eliminating the 
lambda class, its
      instance, and the Reference wrapping. Captured Reference-field reads in 
the doCall are
      repointed to the value parameters (codegen then loads the value directly).
    
    Only lambdas sitting directly in a real method are hoisted. Serializable, 
nested, instance-
    accessing, and mutated-capture lambdas (which need the shared Reference) 
are unchanged and
    keep their generated class. Behaviour and metadata are preserved (verified: 
Predicate/
    BiFunction, multi-capture, mixed capture/SAM types, lambdas in static 
methods, qualified
    outer static calls, serializable round-trips, singleton identity for 
non-capturing, mutated-
    capture mutation across invocations, parameter type annotations on the 
hoisted method);
    stack traces now name a $lambda$ method on the enclosing class (Java-like).
    
    Gated by an opt-in system property, groovy.target.lambda.hoist (default 
off), read per
    lambda so it can be toggled without a JVM restart, while IDE/tooling 
compatibility with the
    changed generated-class shape is verified. Default off keeps existing 
behaviour unchanged.
    
    Testing:
    - LambdaHoistTest covers the opt-in path (non-capturing and 
read-only-capturing hoist,
      singleton identity, multi-capture behaviour) and the fall-back cases 
(mutated capture,
      instance-access, serializable, nested, default-off).
    - Existing tests that assert the pre-hoist generated-class structure
      (LambdaTest.NonCapturingLambdaOptimizationTest, 
LambdaTest.NativeLambdaBytecodeTest,
      LambdaTest.testLambdaClassIsntSynthetic, 
TypeAnnotationsTest.testTypeAnnotationsForLambda)
      are annotated @DisabledIfSystemProperty so they skip when the flag is on; 
the build
      forwards -P/-Dgroovy.target.lambda.hoist to the test JVM.
    - The full core test suite passes with the flag on (0 failures; only the 
pre-hoist
      bytecode-shape tests skip) and is unchanged with it off.
    
    See GEP-27 (Compact Closure and Lambda Compilation) for the broader design.
---
 .../main/groovy/org.apache.groovy-tested.gradle    |   7 +
 .../classgen/asm/sc/StaticTypesLambdaWriter.java   | 208 +++++++++++++++-
 .../groovy/transform/stc/LambdaHoistTest.groovy    | 265 +++++++++++++++++++++
 .../groovy/groovy/transform/stc/LambdaTest.groovy  |   7 +
 .../groovy/classgen/asm/TypeAnnotationsTest.groovy |   3 +
 5 files changed, 487 insertions(+), 3 deletions(-)

diff --git a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle 
b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
index 7bd903679b..f8a4c752e5 100644
--- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
+++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
@@ -80,6 +80,13 @@ tasks.withType(Test).configureEach {
     systemProperty 'http.agent',
         "Apache-Maven/3.9.14 (Java ${System.getProperty('java.version')}; 
${System.getProperty('os.name')} ${System.getProperty('os.version')})"
     systemProperty 'groovy.force.illegal.access', 
findProperty('groovy.force.illegal.access')
+    // Forward the opt-in non-capturing lambda-hoist flag (GEP-27) to the test 
JVM so that the
+    // bytecode-shape tests, which assert the pre-hoist generated-class 
structure, skip under
+    // -P/-Dgroovy.target.lambda.hoist=true (see LambdaTest). Set only when 
present.
+    def lambdaHoist = findProperty('groovy.target.lambda.hoist') ?: 
System.getProperty('groovy.target.lambda.hoist')
+    if (lambdaHoist != null) {
+        systemProperty 'groovy.target.lambda.hoist', lambdaHoist
+    }
     def testdb = System.properties['groovy.testdb.props']
     if (testdb) {
         systemProperty 'groovy.testdb.props', testdb
diff --git 
a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
 
b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
index 001857cf3c..1c2d643053 100644
--- 
a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
+++ 
b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
@@ -18,16 +18,25 @@
  */
 package org.codehaus.groovy.classgen.asm.sc;
 
+import org.apache.groovy.util.SystemUtil;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
 import org.codehaus.groovy.ast.ConstructorNode;
+import org.codehaus.groovy.ast.FieldNode;
 import org.codehaus.groovy.ast.InnerClassNode;
 import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
 import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.LambdaExpression;
+import org.codehaus.groovy.ast.expr.PostfixExpression;
+import org.codehaus.groovy.ast.expr.PrefixExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
 import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.syntax.Types;
 import org.codehaus.groovy.classgen.BytecodeInstruction;
 import org.codehaus.groovy.classgen.BytecodeSequence;
 import org.codehaus.groovy.classgen.asm.BytecodeHelper;
@@ -39,10 +48,14 @@ import 
org.codehaus.groovy.classgen.asm.WriterControllerFactory;
 import org.codehaus.groovy.transform.sc.StaticCompilationMetadataKeys;
 import org.objectweb.asm.MethodVisitor;
 
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
+import static org.apache.groovy.ast.tools.ClassNodeUtils.addGeneratedMethod;
 import static org.codehaus.groovy.ast.ClassHelper.CLOSURE_TYPE;
 import static org.codehaus.groovy.ast.ClassHelper.GENERATED_LAMBDA_TYPE;
 import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE;
@@ -62,6 +75,7 @@ import static org.objectweb.asm.Opcodes.ACC_FINAL;
 import static org.objectweb.asm.Opcodes.ACC_PRIVATE;
 import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
 import static org.objectweb.asm.Opcodes.ACC_STATIC;
+import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
 import static org.objectweb.asm.Opcodes.ALOAD;
 import static org.objectweb.asm.Opcodes.CHECKCAST;
 import static org.objectweb.asm.Opcodes.DUP;
@@ -105,6 +119,11 @@ public class StaticTypesLambdaWriter extends LambdaWriter 
implements AbstractFun
         ClassNode[] markers = collectLambdaMarkers(expression, functionalType);
         GeneratedLambda generatedLambda = getOrAddGeneratedLambda(expression, 
abstractMethod);
 
+        if (generatedLambda.isHoistedCapturing()) {
+            writeHoistedCapturingLambda(functionalType.redirect(), 
abstractMethod, generatedLambda, serializable, markers);
+            return;
+        }
+
         ensureDeserializeLambdaSupport(expression, functionalType, 
abstractMethod, generatedLambda, serializable);
         if (generatedLambda.isCapturing() && 
!isPreloadedLambdaReceiver(generatedLambda)) {
             loadLambdaReceiver(generatedLambda);
@@ -253,29 +272,206 @@ public class StaticTypesLambdaWriter extends 
LambdaWriter implements AbstractFun
 
     private GeneratedLambda getOrAddGeneratedLambda(final LambdaExpression 
expression, final MethodNode abstractMethod) {
         return generatedLambdas.computeIfAbsent(expression, expr -> {
+            // Build the lambda class first: this resolves 
capture/instance-member analysis correctly (a
+            // doCall on the lambda class sees the enclosing class as an 
outer), and makes doCall static iff
+            // the lambda is non-capturing.
             ClassNode lambdaClass = createLambdaClass(expr, ACC_FINAL | 
ACC_PUBLIC | ACC_STATIC, abstractMethod);
+            MethodNode lambdaMethod = getLambdaMethod(lambdaClass);
+
+            // Like Java, a non-capturing (static doCall), non-serializable 
lambda needs no generated class:
+            // hoist its static impl onto the enclosing class and bootstrap 
the metafactory directly against it.
+            // Opt-in (default off) while IDE/tooling compatibility is 
verified; set -Dgroovy.target.lambda.hoist=true.
+            // Only hoist lambdas sitting directly in a real method: a lambda 
inside a closure/lambda would be
+            // hoisted onto that generated function rather than a user class, 
so leave those as generated classes.
+            boolean baseHoistable = isLambdaHoistEnabled() && 
!controller.isInGeneratedFunction()
+                    && !expr.isSerializable() && 
!containsNestedFunction(expr.getCode());
+
+            if (baseHoistable && !requiresLambdaInstance(lambdaMethod)) {
+                MethodNode implMethod = 
hoistLambdaMethodToEnclosingClass(lambdaMethod);
+                return new GeneratedLambda(controller.getClassNode(), 
implMethod, null, Parameter.EMPTY_ARRAY, true, false, null);
+            }
+
+            // Capturing but needing no 'this': hoist onto the enclosing class 
as a static method taking the
+            // captured values as leading params, with the metafactory 
capturing them directly (like Java) -
+            // eliminating the lambda class, its instance, and the Reference 
wrapping. Read-only captures only.
+            Parameter[] shared = getStoredLambdaSharedVariables(expr);
+            if (baseHoistable && shared.length != 0 && 
!lambdaAnalyzer.accessesInstanceMembers(lambdaMethod)
+                    && !writesAnyCapture(lambdaMethod.getCode(), shared)) {
+                GeneratedLambda hoisted = hoistCapturingLambda(lambdaClass, 
lambdaMethod, shared);
+                if (hoisted != null) return hoisted;
+            }
+
             controller.getAcg().addInnerClass(lambdaClass);
             lambdaClass.addInterface(GENERATED_LAMBDA_TYPE);
             
lambdaClass.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, 
Boolean.TRUE);
             lambdaClass.putNodeMetaData(WriterControllerFactory.class, 
(WriterControllerFactory) x -> controller);
-            MethodNode lambdaMethod = getLambdaMethod(lambdaClass);
             return new GeneratedLambda(
                 lambdaClass,
                 lambdaMethod,
                 getGeneratedConstructor(lambdaClass),
                 getStoredLambdaSharedVariables(expr),
                 !requiresLambdaInstance(lambdaMethod),
-                lambdaAnalyzer.accessesInstanceMembers(lambdaMethod)
+                lambdaAnalyzer.accessesInstanceMembers(lambdaMethod),
+                null
             );
         });
     }
 
+    /**
+     * Re-homes a non-capturing lambda's already-prepared {@code static 
doCall} (types resolved, outer static
+     * references qualified) as a {@code private static} synthetic method on 
the enclosing class, so no per-lambda
+     * inner class is generated and the metafactory bootstraps directly 
against the enclosing class.
+     */
+    private MethodNode hoistLambdaMethodToEnclosingClass(final MethodNode 
lambdaMethod) {
+        MethodNode implMethod = new MethodNode(nextLambdaImplMethodName(),
+                ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, 
lambdaMethod.getReturnType(),
+                lambdaMethod.getParameters(), lambdaMethod.getExceptions(), 
lambdaMethod.getCode());
+        implMethod.setSourcePosition(lambdaMethod);
+        
implMethod.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, 
Boolean.TRUE);
+        addGeneratedMethod(controller.getClassNode(), implMethod);
+        // Added after the ReturnAdder phase (an inner class would get it via 
full compilation), so wire the
+        // implicit return for an expression-bodied lambda here (idempotent 
when returns already present).
+        new org.codehaus.groovy.classgen.ReturnAdder().visitMethod(implMethod);
+        return implMethod;
+    }
+
+    /**
+     * Hoists a read-only capturing lambda onto the enclosing class as a 
static method taking the captured
+     * values as leading parameters. The lambda-class {@code doCall} already 
reads its captures from Reference
+     * fields (accessedVariable = FieldNode); repointing those accesses to 
plain value parameters makes codegen
+     * load the value directly, and the metafactory then captures the values 
as factory arguments.
+     */
+    private GeneratedLambda hoistCapturingLambda(final ClassNode lambdaClass, 
final MethodNode lambdaMethod, final Parameter[] shared) {
+        Parameter[] samParams = lambdaMethod.getParameters();
+        Parameter[] valueParams = new Parameter[shared.length];
+        Map<String, Parameter> byName = new HashMap<>();
+        for (int i = 0; i < shared.length; i++) {
+            Parameter p = new Parameter(shared[i].getOriginType(), 
shared[i].getName());
+            valueParams[i] = p;
+            byName.put(p.getName(), p);
+        }
+        Parameter[] implParams = new Parameter[valueParams.length + 
samParams.length];
+        System.arraycopy(valueParams, 0, implParams, 0, valueParams.length);
+        System.arraycopy(samParams, 0, implParams, valueParams.length, 
samParams.length);
+
+        Statement body = lambdaMethod.getCode();
+        rebindCapturedFieldsToValueParams(body, byName);
+
+        MethodNode implMethod = new MethodNode(nextLambdaImplMethodName(),
+                ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, 
lambdaMethod.getReturnType(),
+                implParams, lambdaMethod.getExceptions(), body);
+        implMethod.setSourcePosition(lambdaMethod);
+        
implMethod.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, 
Boolean.TRUE);
+        addGeneratedMethod(controller.getClassNode(), implMethod);
+        new org.codehaus.groovy.classgen.ReturnAdder().visitMethod(implMethod);
+
+        // nonCapturing=true so no lambda instance is loaded; 
capturedValueParams drives the dedicated factory.
+        return new GeneratedLambda(controller.getClassNode(), implMethod, 
null, Parameter.EMPTY_ARRAY, true, false, valueParams);
+    }
+
+    /** Repoints captured Reference-field accesses in a hoisted body to plain 
value parameters. */
+    private static void rebindCapturedFieldsToValueParams(final Statement 
body, final Map<String, Parameter> valueParams) {
+        body.visit(new CodeVisitorSupport() {
+            @Override public void visitVariableExpression(final 
VariableExpression ve) {
+                Parameter p = valueParams.get(ve.getName());
+                if (p != null && ve.getAccessedVariable() instanceof 
FieldNode) {
+                    ve.setAccessedVariable(p);
+                    ve.setClosureSharedVariable(false);
+                }
+            }
+        });
+    }
+
+    /**
+     * True if the body assigns (or increments) a captured variable — such a 
lambda needs the shared Reference,
+     * so decline the value-capture hoist. Matched by name (conservative: 
never value-captures a mutated capture).
+     */
+    private static boolean writesAnyCapture(final Statement body, final 
Parameter[] shared) {
+        Set<String> names = new HashSet<>();
+        for (Parameter p : shared) names.add(p.getName());
+        boolean[] written = {false};
+        body.visit(new CodeVisitorSupport() {
+            @Override public void visitBinaryExpression(final BinaryExpression 
be) {
+                if (Types.isAssignment(be.getOperation().getType())) 
flagIfCaptured(be.getLeftExpression());
+                super.visitBinaryExpression(be);
+            }
+            @Override public void visitPostfixExpression(final 
PostfixExpression pe) { flagIfCaptured(pe.getExpression()); 
super.visitPostfixExpression(pe); }
+            @Override public void visitPrefixExpression(final PrefixExpression 
pe) { flagIfCaptured(pe.getExpression()); super.visitPrefixExpression(pe); }
+            private void flagIfCaptured(final Expression e) {
+                if (e instanceof VariableExpression && 
names.contains(((VariableExpression) e).getName())) {
+                    written[0] = true;
+                }
+            }
+        });
+        return written[0];
+    }
+
+    /**
+     * Emits the invokedynamic for a hoisted capturing lambda: push the 
captured values as metafactory factory
+     * arguments and bootstrap against the static impl method on the enclosing 
class (H_INVOKESTATIC).
+     */
+    private void writeHoistedCapturingLambda(final ClassNode functionalType, 
final MethodNode abstractMethod, final GeneratedLambda gl, final boolean 
serializable, final ClassNode[] markers) {
+        Parameter[] captured = gl.capturedValueParams();
+        for (Parameter p : captured) {
+            new VariableExpression(p.getName()).visit(controller.getAcg()); // 
load captured value (unwraps the Reference)
+            controller.getOperandStack().doGroovyCast(p.getType());
+        }
+        Parameter[] full = gl.lambdaMethod().getParameters();
+        Parameter[] samParams = Arrays.copyOfRange(full, captured.length, 
full.length);
+        writeFunctionalInterfaceIndy(
+            controller.getMethodVisitor(),
+            abstractMethod.getName(),
+            createFunctionalInterfaceFactoryDescriptor(functionalType, 
captured),
+            createMethodDescriptor(abstractMethod),
+            H_INVOKESTATIC,
+            controller.getClassNode(),
+            gl.lambdaMethod(),
+            samParams,
+            serializable,
+            markers);
+        controller.getOperandStack().replace(functionalType, captured.length);
+    }
+
     /** {@inheritDoc} */
     @Override
     protected ClassNode createClosureClass(final ClosureExpression expression, 
final int modifiers) {
         return staticTypesClosureWriter.createClosureClass(expression, 
modifiers);
     }
 
+    private static boolean containsNestedFunction(final Statement code) {
+        if (code == null) return false;
+        boolean[] found = {false};
+        code.visit(new CodeVisitorSupport() {
+            @Override public void visitClosureExpression(final 
ClosureExpression e) { found[0] = true; }
+            @Override public void visitLambdaExpression(final LambdaExpression 
e) { found[0] = true; }
+        });
+        return found[0];
+    }
+
+    /**
+     * Whether non-capturing SAM lambdas are hoisted onto the enclosing class 
(no generated lambda class).
+     * Opt-in via {@code -Dgroovy.target.lambda.hoist=true} while IDE/tooling 
compatibility is verified;
+     * the property is read per lambda so it can be toggled without a JVM 
restart.
+     */
+    private static boolean isLambdaHoistEnabled() {
+        return SystemUtil.getBooleanSafe("groovy.target.lambda.hoist");
+    }
+
+    private int lambdaImplCounter;
+
+    private String nextLambdaImplMethodName() {
+        ClassNode enclosing = controller.getClassNode();
+        MethodNode enclosingMethod = controller.getMethodNode();
+        String owner = (enclosingMethod != null) ? 
enclosingMethod.getName().replace('<', '_').replace('>', '_') : "init";
+        String base = "$lambda$" + owner + "$" + (lambdaImplCounter++);
+        String name = base;
+        int extra = 0;
+        while (!enclosing.getMethods(name).isEmpty()) {
+            name = base + "$" + (extra++);
+        }
+        return name;
+    }
+
     /**
      * Creates the synthetic inner class that backs a statically compiled 
lambda expression.
      */
@@ -419,12 +615,18 @@ public class StaticTypesLambdaWriter extends LambdaWriter 
implements AbstractFun
      */
     private record GeneratedLambda(ClassNode lambdaClass, MethodNode 
lambdaMethod, ConstructorNode constructor,
                                    Parameter[] sharedVariables, boolean 
nonCapturing,
-                                   boolean accessingInstanceMembers) {
+                                   boolean accessingInstanceMembers, 
Parameter[] capturedValueParams) {
 
         private boolean isCapturing() {
             return !nonCapturing;
         }
 
+        /** Non-null when the lambda body is hoisted onto the enclosing class 
as a static method that takes the
+         *  captured values as leading parameters, and the metafactory 
captures those values directly. */
+        private boolean isHoistedCapturing() {
+            return capturedValueParams != null;
+        }
+
         private int getImplMethodKind() {
             return nonCapturing ? H_INVOKESTATIC : H_INVOKEVIRTUAL;
         }
diff --git a/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy 
b/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy
new file mode 100644
index 0000000000..9814ef5dad
--- /dev/null
+++ b/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy
@@ -0,0 +1,265 @@
+/*
+ *  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.transform.stc
+
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.Phases
+import org.junit.jupiter.api.Test
+import org.objectweb.asm.ClassReader
+import org.objectweb.asm.ClassVisitor
+import org.objectweb.asm.Label
+import org.objectweb.asm.MethodVisitor
+import org.objectweb.asm.Opcodes
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Tests for the opt-in {@code groovy.target.lambda.hoist} optimization: a 
non-capturing, non-serializable
+ * lambda targeting a functional interface has its body compiled as a {@code 
private static} method on the
+ * enclosing class (with LambdaMetafactory bootstrapped directly against it), 
so no per-lambda class is
+ * generated. Capturing, instance-accessing, serializable and nested cases 
fall back to a generated lambda
+ * class. The property is read per lambda, so it can be toggled per 
compilation here.
+ */
+final class LambdaHoistTest {
+
+    private static final String PROP = 'groovy.target.lambda.hoist'
+
+    /** Compiles the source with hoisting on/off and returns the sorted 
generated class names. */
+    private static List<String> classNames(String src, boolean hoist) {
+        withHoist(hoist) {
+            def cu = new CompilationUnit(new CompilerConfiguration())
+            cu.addSource('L.groovy', src)
+            cu.compile(Phases.CLASS_GENERATION)
+            cu.classes.collect { it.name }.sort()
+        }
+    }
+
+    /** Evaluates the source (with a trailing expression) with hoisting 
on/off. */
+    private static Object eval(String src, String tail, boolean hoist) {
+        withHoist(hoist) { new GroovyShell().evaluate(src + '\n' + tail) }
+    }
+
+    private static <T> T withHoist(boolean hoist, Closure<T> work) {
+        String previous = System.getProperty(PROP)
+        if (hoist) System.setProperty(PROP, 'true') else 
System.clearProperty(PROP)
+        try {
+            work.call()
+        } finally {
+            if (previous != null) System.setProperty(PROP, previous) else 
System.clearProperty(PROP)
+        }
+    }
+
+    private static int lambdaClassCount(List<String> names) {
+        names.count { it.contains('_lambda') }
+    }
+
+    private static final String SRC = '''import groovy.transform.CompileStatic
+        import java.util.function.*
+        @CompileStatic
+        class L {
+            Function<Integer,Integer> nonCap()      { (Integer x) -> x * 2 }
+            Function<Integer,Integer> cap(int base) { (Integer x) -> x + base }
+            static Function<Integer,Integer> inStatic() { (Integer x) -> x * 3 
}
+            Function<Integer,Integer> callsStatic() { (Integer x) -> helper(x) 
}
+            static int helper(int n) { n + 1000 }
+        }'''
+
+    @Test
+    void samLambdasAreHoistedWhenEnabled() {
+        def names = classNames(SRC, true)
+        // Non-capturing and read-only-capturing SAM lambdas are all hoisted 
onto L: no lambda class remains.
+        assertEquals(0, lambdaClassCount(names), "expected no lambda classes, 
got: $names")
+        // The hoisted impls are private static $lambda$ methods on L 
(captured values become leading params).
+        def hoisted = eval(SRC, 'new L()', 
true).getClass().declaredMethods.findAll { it.name.startsWith('$lambda$') }
+        assertTrue(hoisted.size() >= 4, "expected hoisted \$lambda\$ methods 
on L, got: ${hoisted*.name}")
+    }
+
+    @Test
+    void hoistedLambdasBehaveIdenticallyAndStaySingletons() {
+        def l = eval(SRC, 'new L()', true)
+        assertEquals(42, l.nonCap().apply(21))
+        assertEquals(105, l.cap(100).apply(5))           // read-only 
capturing lambda, hoisted with value captures
+        assertEquals(12, l.class.inStatic().apply(4))    // lambda in a static 
method
+        assertEquals(1005, l.callsStatic().apply(5))     // qualified outer 
static call
+        // non-capturing metafactory instance is a zero-alloc singleton
+        assertTrue(l.nonCap().is(l.nonCap()))
+    }
+
+    @Test
+    void readOnlyCapturingLambdaIsHoisted() {
+        String src = '''import groovy.transform.CompileStatic
+            import java.util.function.*
+            @CompileStatic
+            class C {
+                Function<Integer,Integer>            add(int base)        { 
(Integer x) -> x + base }
+                BiFunction<Integer,Integer,Integer>  muladd(int a, int b) { 
(Integer x, Integer y) -> x * a + y + b }
+                Function<String,String>              prefix(String p)     { 
(String s) -> p + s }
+            }'''
+        assertEquals(0, lambdaClassCount(classNames(src, true)), 'read-only 
capturing lambdas should be hoisted')
+        def c = eval(src, 'new C()', true)
+        assertEquals(105, c.add(100).apply(5))
+        assertEquals(24, c.muladd(10, 1).apply(2, 3))
+        assertEquals('xy', c.prefix('x').apply('y'))
+    }
+
+    @Test
+    void mutatedCaptureLambdaIsNotHoisted() {
+        String src = '''import groovy.transform.CompileStatic
+            import java.util.function.Supplier
+            @CompileStatic
+            class M { Supplier<Integer> counter() { int c = 0; 
(Supplier<Integer>) (() -> { c += 1; c }) } }'''
+        // Mutating a captured variable needs the shared Reference, so the 
lambda keeps its class.
+        assertEquals(1, lambdaClassCount(classNames(src, true)), 
'mutated-capture lambda must keep its class')
+        def s = eval(src, 'new M().counter()', true)
+        assertEquals([1, 2, 3], [s.get(), s.get(), s.get()])   // mutation 
preserved across invocations
+    }
+
+    @Test
+    void disabledByDefaultKeepsTheLambdaClass() {
+        def names = classNames(SRC, false)
+        // With hoisting off, every lambda (incl. the non-capturing ones) 
generates a class, as today.
+        assertTrue(lambdaClassCount(names) >= 4, "expected lambda classes when 
disabled, got: $names")
+        // ...and still behaves correctly.
+        assertEquals(42, eval(SRC, 'new L().nonCap().apply(21)', false))
+    }
+
+    @Test
+    void instanceAccessingLambdaIsNotHoisted() {
+        String src = '''import groovy.transform.CompileStatic
+            import java.util.function.IntSupplier
+            @CompileStatic
+            class Counter { int count = 7; IntSupplier supplier() { () -> 
count } }'''
+        def names = classNames(src, true)
+        assertEquals(1, lambdaClassCount(names), "instance-accessing lambda 
must keep its class: $names")
+        assertEquals(7, eval(src, 'new Counter().supplier().getAsInt()', true))
+    }
+
+    @Test
+    void serializableNonCapturingLambdaFallsBackAndRoundTrips() {
+        String src = '''import groovy.transform.CompileStatic
+            import java.util.function.Function
+            @CompileStatic
+            class S {
+                static Function<Integer,Integer> make() {
+                    (Function<Integer,Integer> & Serializable) (Integer x) -> 
x * 2
+                }
+            }'''
+        def names = classNames(src, true)
+        assertEquals(1, lambdaClassCount(names), "serializable lambda must 
keep its class: $names")
+        def roundTripped = eval(src, '''
+            def f = S.make()
+            def out = new ByteArrayOutputStream()
+            new ObjectOutputStream(out).writeObject(f)
+            def f2 = new ObjectInputStream(new 
ByteArrayInputStream(out.toByteArray())).readObject()
+            ((java.util.function.Function) f2).apply(10)
+        ''', true)
+        assertEquals(20, roundTripped)
+    }
+
+    @Test
+    void lambdaContainingNestedFunctionIsNotHoisted() {
+        String src = '''import groovy.transform.CompileStatic
+            import java.util.function.Supplier
+            @CompileStatic
+            class N {
+                Supplier<List<Integer>> outer() { () -> [1, 2, 3].collect { it 
* 2 } }
+            }'''
+        def names = classNames(src, true)
+        // The outer lambda nests a closure, so it is not hoisted (kept flat 
for the first step).
+        assertFalse(lambdaClassCount(names) == 0, "nested-function case should 
keep a lambda class: $names")
+        assertEquals([2, 4, 6], eval(src, 'new N().outer().get()', true))
+    }
+
+    @Test
+    void hoistedLambdaMethodCarriesDebugMetadata() {
+        // A multi-line, read-only-capturing lambda: locals declared in the 
body, captured value (base) and
+        // SAM parameter (x). Tooling (IDE debuggers) needs the hoisted method 
to keep line numbers per body
+        // line and named local variables, so step/breakpoint/inspect work as 
they do on the enclosing method.
+        String src = '''import groovy.transform.CompileStatic
+            import java.util.function.Function
+            @CompileStatic
+            class D {
+                Function<Integer,Integer> build(int base) {
+                    return (Integer x) -> {
+                        int a = x + base
+                        int b = a * 2
+                        return b - 1
+                    }
+                }
+            }'''
+        byte[] bytes = compiledBytes(src, 'D', true)
+        def method = hoistedMethod(bytes)
+        assertTrue(method != null, 'expected a hoisted $lambda$ method on D')
+        def info = debugInfo(bytes, method)
+        // one LineNumberTable entry per body statement (the three lines of 
the block body)
+        assertTrue(info.lineCount >= 3, "expected >=3 line-number entries, got 
${info.lineCount}")
+        // the captured value, the SAM parameter and the in-body local must 
all be named for the debugger
+        assertTrue(info.locals.containsAll(['base', 'x', 'a', 'b']),
+                "expected named locals base/x/a/b in LocalVariableTable, got 
${info.locals}")
+        assertTrue(info.synthetic, 'hoisted impl should be ACC_SYNTHETIC so 
tools hide it')
+    }
+
+    /** Compiles the source with hoisting on/off (debug info on) and returns 
the named class's bytes. */
+    private static byte[] compiledBytes(String src, String className, boolean 
hoist) {
+        withHoist(hoist) {
+            def config = new CompilerConfiguration(debug: true)
+            def cu = new CompilationUnit(config)
+            cu.addSource("${className}.groovy", src)
+            cu.compile(Phases.CLASS_GENERATION)
+            cu.classes.find { it.name == className }.bytes
+        }
+    }
+
+    /** Name of the first hoisted {@code $lambda$} method in the class, or 
null if none. */
+    private static String hoistedMethod(byte[] bytes) {
+        String found = null
+        new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
+            @Override
+            MethodVisitor visitMethod(int access, String name, String desc, 
String sig, String[] exc) {
+                if (found == null && name.startsWith('$lambda$')) found = name
+                return null
+            }
+        }, ClassReader.SKIP_CODE)
+        found
+    }
+
+    /** LineNumberTable entry count, LocalVariableTable names and the 
synthetic flag for the given method. */
+    private static Map debugInfo(byte[] bytes, String methodName) {
+        int[] lineCount = [0]
+        List<String> locals = []
+        boolean[] synthetic = [false]
+        new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
+            @Override
+            MethodVisitor visitMethod(int access, String name, String desc, 
String sig, String[] exc) {
+                if (name != methodName) return null
+                synthetic[0] = (access & Opcodes.ACC_SYNTHETIC) != 0
+                return new MethodVisitor(Opcodes.ASM9) {
+                    @Override
+                    void visitLineNumber(int line, Label start) { 
lineCount[0]++ }
+                    @Override
+                    void visitLocalVariable(String n, String d, String s, 
Label st, Label e, int idx) { locals << n }
+                }
+            }
+        }, 0)
+        [lineCount: lineCount[0], locals: locals, synthetic: synthetic[0]]
+    }
+}
diff --git a/src/test/groovy/groovy/transform/stc/LambdaTest.groovy 
b/src/test/groovy/groovy/transform/stc/LambdaTest.groovy
index f3a784ef9a..692d60df9a 100644
--- a/src/test/groovy/groovy/transform/stc/LambdaTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/LambdaTest.groovy
@@ -21,6 +21,7 @@ package groovy.transform.stc
 import org.codehaus.groovy.classgen.asm.AbstractBytecodeTestCase
 import org.junit.jupiter.api.Nested
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.condition.DisabledIfSystemProperty
 
 import static groovy.test.GroovyAssert.assertScript
 import static groovy.test.GroovyAssert.shouldFail
@@ -1877,6 +1878,8 @@ final class LambdaTest {
 
     // GROOVY-9770
     @Test
+    @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 
'true',
+        disabledReason = 'asserts the pre-hoist generated lambda class exists 
(GEP-27)')
     void testLambdaClassIsntSynthetic() {
         assertScript shell, '''
             class Foo {
@@ -1893,6 +1896,8 @@ final class LambdaTest {
 
     // GROOVY-11905
     @Nested
+    @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 
'true',
+        disabledReason = 'asserts the pre-hoist non-capturing lambda bytecode 
shape (doCall on the generated lambda class); superseded when GEP-27 hoisting 
is enabled')
     class NonCapturingLambdaOptimizationTest extends AbstractBytecodeTestCase {
         @Test
         void testNonCapturingLambdaWithFunctionInStaticMethod() {
@@ -3427,6 +3432,8 @@ final class LambdaTest {
     }
 
     @Nested
+    @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 
'true',
+        disabledReason = 'asserts the pre-hoist native lambda bytecode shape 
(doCall on the generated lambda class); superseded when GEP-27 hoisting is 
enabled')
     class NativeLambdaBytecodeTest extends AbstractBytecodeTestCase {
         @Test
         void testNonCapturingLambdaUsesCaptureFreeInvokeDynamic() {
diff --git 
a/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy 
b/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy
index ad7eb8cab4..13cbf2e3e6 100644
--- 
a/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy
+++ 
b/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy
@@ -19,6 +19,7 @@
 package org.codehaus.groovy.classgen.asm
 
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.condition.DisabledIfSystemProperty
 
 final class TypeAnnotationsTest extends AbstractBytecodeTestCase {
 
@@ -288,6 +289,8 @@ final class TypeAnnotationsTest extends 
AbstractBytecodeTestCase {
 
     // GROOVY-11479
     @Test
+    @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 
'true',
+        disabledReason = 'inspects the pre-hoist generated lambda class 
(Foo$_lambda1); with GEP-27 hoisting the type annotation is carried on the 
enclosing-class $lambda$ method instead')
     void testTypeAnnotationsForLambda() {
         def bytecode = compile(classNamePattern: 'Foo\\$_lambda1', method: 
'doCall', imports + '''
             @Retention(RUNTIME) @Target(TYPE_USE) @interface TypeAnno0 { }


Reply via email to