diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5c4a35d93c2..4f6723d4e54 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -63,17 +63,17 @@ and exposed as the [`CompilePhase`](src/main/java/org/codehaus/groovy/control/CompilePhase.java) enum that AST transformations and customizers attach to: -| # | Phase | What happens | Driver classes | -|---|---|---|---| -| 1 | `INITIALIZATION` | Source files opened, `CompilationUnit` configured, customizers applied | `CompilationUnit`, `CompilerConfiguration` | -| 2 | `PARSING` | ANTLR4 lexer + parser produce a CST (parse tree) | `Antlr4ParserPlugin`, `GroovyLangLexer`, `GroovyLangParser` | -| 3 | `CONVERSION` | CST → AST (`ModuleNode` / `ClassNode` / `MethodNode` / ...) | `AstBuilder` | -| 4 | `SEMANTIC_ANALYSIS` | Class resolution, import handling, validity checks the grammar can't catch | `ResolveVisitor`, `StaticImportVisitor`, `AnnotationConstantsVisitor` | -| 5 | `CANONICALIZATION` | Fill in the AST: synthesised members, generic types, most local AST transforms run here | `ASTTransformationVisitor`, `GenericsVisitor` | +| # | Phase | What happens | Driver classes | +|---|---|-----------------------------------------------------------------------------------------|---| +| 1 | `INITIALIZATION` | Source files opened, `CompilationUnit` configured, customizers applied | `CompilationUnit`, `CompilerConfiguration` | +| 2 | `PARSING` | ANTLR4 lexer + parser produce a CST (parse tree) | `Antlr4ParserPlugin`, `GroovyLangLexer`, `GroovyLangParser` | +| 3 | `CONVERSION` | CST → AST (`ModuleNode` / `ClassNode` / `MethodNode` / ...) | `AstBuilder` | +| 4 | `SEMANTIC_ANALYSIS` | Class resolution, import handling, validity checks the grammar can't catch | `ResolveVisitor`, `StaticImportVisitor`, `AnnotationConstantsVisitor` | +| 5 | `CANONICALIZATION` | Fill in the AST: synthesized members, generic types, most local AST transforms run here | `ASTTransformationVisitor`, `GenericsVisitor` | | 6 | `INSTRUCTION_SELECTION` | Optimisations and instruction-set selection; `@CompileStatic` / `@TypeChecked` run here | `OptimizerVisitor`, `StaticTypeCheckingVisitor` | -| 7 | `CLASS_GENERATION` | AST → bytecode in memory | `AsmClassGenerator`, `Verifier`, classes under `classgen/asm/` | -| 8 | `OUTPUT` | Write generated `.class` files | `CompilationUnit` output stage | -| 9 | `FINALIZATION` | Cleanup, `Janitor` callbacks | `CompilationUnit`, `Janitor` | +| 7 | `CLASS_GENERATION` | AST → bytecode in memory | `AsmClassGenerator`, `Verifier`, classes under `classgen/asm/` | +| 8 | `OUTPUT` | Write generated `.class` files | `CompilationUnit` output stage | +| 9 | `FINALIZATION` | Cleanup, `Janitor` callbacks | `CompilationUnit`, `Janitor` | Each phase iterates over all `SourceUnit`s before the next phase begins. AST transformations declare which phase they run in; the @@ -140,7 +140,7 @@ verbatim keeps the reference precise; paraphrasing tends to drift. - `org.codehaus.groovy.classgen.AsmClassGenerator` walks the AST and emits bytecode via ASM. Supporting visitors run here too: - `Verifier` (synthesises bridge methods, accessors, default + `Verifier` (synthesizes bridge methods, accessors, default constructors), `EnumVisitor`, `EnumCompletionVisitor`, `InnerClassVisitor`, `InnerClassCompletionVisitor`, `VariableScopeVisitor`, `ReturnAdder`. diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index e6931ef2d53..3eb0dedeede 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -728,7 +728,16 @@ forUpdate // EXPRESSIONS castParExpression - : LPAREN type RPAREN + : LPAREN intersectionType RPAREN + ; + +intersectionType + : type (BITAND nls type)* + ; + +coercionType + : castParExpression // (T) or (A & B & ...) + | type // T ; parExpression @@ -833,7 +842,7 @@ expression // boolean relational expressions (level 7) | left=expression nls op=INSTANCEOF nls matchingType #relationalExprAlt - | left=expression nls op=(AS | NOT_INSTANCEOF) nls type #relationalExprAlt + | left=expression nls op=(AS | NOT_INSTANCEOF) nls coercionType #relationalExprAlt | left=expression nls op=(LE | GE | GT | LT | IN | NOT_IN) nls right=expression #relationalExprAlt // equality/inequality (==/!=) (level 8) 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 bc8f2cf2b91..aaeb90b9fd7 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -58,6 +58,7 @@ import org.codehaus.groovy.ast.ImportNode; import org.codehaus.groovy.ast.MultipleAssignmentMetadata; import org.codehaus.groovy.ast.InnerClassNode; +import org.codehaus.groovy.ast.IntersectionTypeClassNode; import org.codehaus.groovy.ast.MethodNode; import org.codehaus.groovy.ast.ModifierNode; import org.codehaus.groovy.ast.ModuleNode; @@ -2475,7 +2476,32 @@ public Expression visitCommandArgument(final CommandArgumentContext ctx) { @Override public ClassNode visitCastParExpression(final CastParExpressionContext ctx) { - return this.visitType(ctx.type()); + return this.visitIntersectionType(ctx.intersectionType()); + } + + @Override + public ClassNode visitIntersectionType(final IntersectionTypeContext ctx) { + List typeCtxs = ctx.type(); + if (typeCtxs.size() == 1) { + return this.visitType(typeCtxs.get(0)); + } + ClassNode[] components = new ClassNode[typeCtxs.size()]; + Set seenNames = new HashSet<>(); + for (int i = 0, n = typeCtxs.size(); i < n; i += 1) { + ClassNode component = this.visitType(typeCtxs.get(i)); + if (!seenNames.add(component.getName())) { + throw createParsingFailedException("Duplicate type in intersection: " + component.getName(), ctx); + } + components[i] = component; + } + return configureAST(new IntersectionTypeClassNode(components), ctx); + } + + @Override + public ClassNode visitCoercionType(final CoercionTypeContext ctx) { + return ctx.castParExpression() != null + ? this.visitCastParExpression(ctx.castParExpression()) + : this.visitType(ctx.type()); } @Override @@ -3204,7 +3230,7 @@ public Expression visitRelationalExprAlt(final RelationalExprAltContext ctx) { if (expr instanceof VariableExpression && ((VariableExpression) expr).isSuperExpression()) { throw this.createParsingFailedException("Cannot cast or coerce `super`", ctx); // GROOVY-9391 } - Expression cast = CastExpression.asExpression(this.visitType(ctx.type()), expr); + Expression cast = CastExpression.asExpression(this.visitCoercionType(ctx.coercionType()), expr); return configureAST( cast, ctx); @@ -3218,14 +3244,25 @@ public Expression visitRelationalExprAlt(final RelationalExprAltContext ctx) { this.visitMatchingType(ctx.matchingType())), ctx); - case NOT_INSTANCEOF: - ctx.type().putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE); + case NOT_INSTANCEOF: { + CoercionTypeContext coercionCtx = ctx.coercionType(); + if (coercionCtx.castParExpression() != null + && coercionCtx.castParExpression().intersectionType().type().size() > 1) { + throw this.createParsingFailedException("Intersection types are not supported as the right-hand side of !instanceof", ctx); + } + ClassNode notInstType = this.visitCoercionType(coercionCtx); + // GROOVY-11998: keep IS_INSIDE_INSTANCEOF_EXPR on the parser context for the resolver + (coercionCtx.type() != null + ? coercionCtx.type() + : coercionCtx.castParExpression().intersectionType().type(0) + ).putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE); return configureAST( new BinaryExpression( (Expression) this.visit(ctx.left), this.createGroovyToken(ctx.op), - configureAST(new ClassExpression(this.visitType(ctx.type())), ctx.type())), + configureAST(new ClassExpression(notInstType), coercionCtx)), ctx); + } case GT: case GE: diff --git a/src/main/java/org/codehaus/groovy/ast/IntersectionTypeClassNode.java b/src/main/java/org/codehaus/groovy/ast/IntersectionTypeClassNode.java new file mode 100644 index 00000000000..2b443fa7f02 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/ast/IntersectionTypeClassNode.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.ast; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +import static org.objectweb.asm.Opcodes.ACC_FINAL; +import static org.objectweb.asm.Opcodes.ACC_PUBLIC; + +/** + * Represents a user-written intersection type used as the target of a cast + * expression or {@code as} coercion, e.g. + *
+ *     (Runnable & Serializable) () -> ...
+ *     value as (A & B)
+ * 
+ * + *

Distinct from the implicit lowest-upper-bound nodes that + * {@link org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor} + * synthesizes during inference: an instance of this class records the ordered + * list of components exactly as written by the user. That ordering is needed + * for cast-conversion checks, error messages and (in later phases) bytecode + * generation via {@code LambdaMetafactory.altMetafactory} markers. + * + *

Lifecycle: at parse time the components have not yet been resolved to + * bound {@link ClassNode}s, so the constructor places all components in the + * inherited {@link #getInterfaces() interfaces} array with {@code Object} as + * the placeholder superclass. After {@code ResolveVisitor} resolves each + * component it should call {@link #reclassifyComponents()} so that the + * interfaces array contains only interface components and the superclass is + * the (at most one) class component. + * + * @since 6.0.0 + */ +public final class IntersectionTypeClassNode extends ClassNode { + + private final ClassNode[] components; + + public IntersectionTypeClassNode(final ClassNode[] components) { + super("IntersectionType", ACC_PUBLIC | ACC_FINAL, ClassHelper.OBJECT_TYPE, components.clone(), MixinNode.EMPTY_ARRAY); + if (components.length < 2) { + throw new IllegalArgumentException("IntersectionTypeClassNode requires at least two components"); + } + this.components = components.clone(); + } + + /** + * Returns the components of this intersection type in user-written order. + */ + public ClassNode[] getComponents() { + return components.clone(); + } + + /** + * Reclassifies the components after resolution: separates the (at most + * one) class component from the interface components and updates the + * inherited superclass and interfaces accordingly. Components are + * resolved in place — callers do not need to substitute new instances. + */ + public void reclassifyComponents() { + ClassNode klass = null; + List ifaces = new ArrayList<>(components.length); + for (ClassNode c : components) { + if (c.isInterface()) { + ifaces.add(c); + } else { + klass = c; // STC will validate "at most one" elsewhere + } + } + setSuperClass(klass != null ? klass : ClassHelper.OBJECT_TYPE); + setInterfaces(ifaces.toArray(ClassNode.EMPTY_ARRAY)); + } + + @Override + public String getText() { + StringJoiner sj = new StringJoiner(" & ", "(", ")"); + for (ClassNode c : components) sj.add(c.toString(false)); + return sj.toString(); + } + + @Override + public String toString(final boolean showRedirect) { + return getText(); + } +} diff --git a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java index 1e3af7d388f..4680db0ce37 100644 --- a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java +++ b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java @@ -56,6 +56,7 @@ import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.FieldExpression; import org.codehaus.groovy.ast.expr.GStringExpression; +import org.codehaus.groovy.ast.IntersectionTypeClassNode; import org.codehaus.groovy.ast.expr.LambdaExpression; import org.codehaus.groovy.ast.expr.ListExpression; import org.codehaus.groovy.ast.expr.MapEntryExpression; @@ -1000,9 +1001,27 @@ public void visitBitwiseNegationExpression(final BitwiseNegationExpression expre @Override public void visitCastExpression(final CastExpression castExpression) { Expression expression = castExpression.getExpression(); + ClassNode type = castExpression.getType(); + + // 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. + 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); - ClassNode type = castExpression.getType(); if (isObjectType(type)) return; maybeInnerClassEntry(type); @@ -1019,6 +1038,47 @@ public void visitCastExpression(final CastExpression castExpression) { } } + /** + * Emits a call to {@link org.codehaus.groovy.runtime.IntersectionCastSupport} + * for an intersection-target cast or coercion on a non-functional source. + * + * Bytecode layout: + *

+     *   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
+     * 
+ */ + 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/classgen/asm/sc/AbstractFunctionalInterfaceWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/AbstractFunctionalInterfaceWriter.java index 7d437bf566e..ff74bee97b3 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/AbstractFunctionalInterfaceWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/AbstractFunctionalInterfaceWriter.java @@ -78,11 +78,32 @@ default void writeFunctionalInterfaceIndy(final MethodVisitor methodVisitor, final String samMethodDescriptor, final int implMethodKind, final ClassNode implClassNode, final MethodNode implMethodNode, final Parameter[] implMethodParameters, final boolean serializable) { + // GROOVY-11998: delegate to marker-aware overload with no extra interfaces + writeFunctionalInterfaceIndy(methodVisitor, samMethodName, invokedTypeDescriptor, + samMethodDescriptor, implMethodKind, implClassNode, implMethodNode, + implMethodParameters, serializable, ClassNode.EMPTY_ARRAY); + } + + /** + * Marker-aware variant for intersection-cast targets such as + * {@code (Runnable & Cloneable) () -> ...}. Markers are threaded through + * {@code LambdaMetafactory.altMetafactory} via {@code FLAG_MARKERS} so the + * generated lambda implements every component interface at runtime. + * + * @since 6.0.0 + */ + default void writeFunctionalInterfaceIndy(final MethodVisitor methodVisitor, + final String samMethodName, final String invokedTypeDescriptor, + final String samMethodDescriptor, final int implMethodKind, + final ClassNode implClassNode, final MethodNode implMethodNode, + final Parameter[] implMethodParameters, final boolean serializable, + final ClassNode[] markers) { + boolean useAlt = serializable || (markers != null && markers.length > 0); methodVisitor.visitInvokeDynamicInsn( samMethodName, invokedTypeDescriptor, - createBootstrapMethod(serializable), - createBootstrapMethodArguments(samMethodDescriptor, implMethodKind, implClassNode, implMethodNode, implMethodParameters, serializable) + createBootstrapMethod(useAlt), + createBootstrapMethodArguments(samMethodDescriptor, implMethodKind, implClassNode, implMethodNode, implMethodParameters, serializable, markers) ); if (serializable) { methodVisitor.visitTypeInsn(CHECKCAST, BytecodeHelper.getClassInternalName(ClassHelper.SERIALIZABLE_TYPE)); @@ -188,12 +209,12 @@ default Parameter[] createDeserializeMethodParameters() { return new Parameter[] { new Parameter(SERIALIZEDLAMBDA_TYPE, "serializedLambda") }; } - private Handle createBootstrapMethod(final boolean serializable) { + private Handle createBootstrapMethod(final boolean useAlt) { return new Handle( Opcodes.H_INVOKESTATIC, "java/lang/invoke/LambdaMetafactory", - serializable ? "altMetafactory" : "metafactory", - serializable ? "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;" + useAlt ? "altMetafactory" : "metafactory", + useAlt ? "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;" : "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", false // GROOVY-8299, GROOVY-8989, GROOVY-11265 ); @@ -202,20 +223,64 @@ private Handle createBootstrapMethod(final boolean serializable) { private Object[] createBootstrapMethodArguments(final String samMethodDescriptor, final int implMethodKind, final ClassNode implClassNode, final MethodNode implMethodNode, final Parameter[] implMethodParameters, final boolean serializable) { - Object[] arguments = !serializable ? new Object[3] : new Object[]{null, null, null, 5, 0}; + return createBootstrapMethodArguments(samMethodDescriptor, implMethodKind, implClassNode, implMethodNode, + implMethodParameters, serializable, ClassNode.EMPTY_ARRAY); + } - arguments[0] = Type.getMethodType(samMethodDescriptor); + /** + * GROOVY-11998: builds the variadic args for {@code LambdaMetafactory.altMetafactory} + * including {@code FLAG_MARKERS} when the cast target is an intersection. + * Layout per the JDK contract: + *
+     *   samMethodType, implMethod, instantiatedMethodType,
+     *   flags,
+     *   [markerCount, marker_1, ..., marker_n]   // when FLAG_MARKERS set
+     *   [bridgeCount, bridge_1, ..., bridge_n]   // when FLAG_BRIDGES set (unused)
+     * 
+ */ + private Object[] createBootstrapMethodArguments(final String samMethodDescriptor, final int implMethodKind, + final ClassNode implClassNode, final MethodNode implMethodNode, + final Parameter[] implMethodParameters, final boolean serializable, + final ClassNode[] markers) { + ClassNode[] effectiveMarkers = markers == null ? ClassNode.EMPTY_ARRAY : markers; + boolean hasMarkers = effectiveMarkers.length > 0; + boolean useAlt = serializable || hasMarkers; + + if (!useAlt) { + Object[] args = new Object[3]; + args[0] = Type.getMethodType(samMethodDescriptor); + args[1] = new Handle( + implMethodKind, + getClassInternalName(implClassNode.getName()), + implMethodNode.getName(), + getMethodDescriptor(implMethodNode), + implClassNode.isInterface()); + args[2] = createInstantiatedMethodType(samMethodDescriptor, implMethodNode, implMethodParameters); + return args; + } - arguments[1] = new Handle( + int flags = (serializable ? 1 : 0) | (hasMarkers ? 2 : 0); // FLAG_SERIALIZABLE | FLAG_MARKERS + int size = 4 + (hasMarkers ? 1 + effectiveMarkers.length : 0); + Object[] args = new Object[size]; + + args[0] = Type.getMethodType(samMethodDescriptor); + args[1] = new Handle( implMethodKind, // H_INVOKESTATIC or H_INVOKEVIRTUAL or H_INVOKEINTERFACE (GROOVY-9853) getClassInternalName(implClassNode.getName()), implMethodNode.getName(), getMethodDescriptor(implMethodNode), implClassNode.isInterface()); - - arguments[2] = createInstantiatedMethodType(samMethodDescriptor, implMethodNode, implMethodParameters); - - return arguments; + args[2] = createInstantiatedMethodType(samMethodDescriptor, implMethodNode, implMethodParameters); + args[3] = flags; + + if (hasMarkers) { + int p = 4; + args[p++] = effectiveMarkers.length; + for (ClassNode m : effectiveMarkers) { + args[p++] = Type.getObjectType(getClassInternalName(m)); + } + } + return args; } private Type createInstantiatedMethodType(final String samMethodDescriptor, final MethodNode implMethodNode, final Parameter[] implMethodParameters) { 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 b2e9dda3e71..2ae86527dfc 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.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 @@ protected ClassNode createClosureClass(final ClosureExpression expression, final 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 markers = (List) 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 synthesize 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/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java index 3b393b41c95..22899971f0a 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 @@ -56,6 +56,7 @@ import static org.codehaus.groovy.classgen.asm.sc.StaticTypesFunctionalInterfaceMetadataKey.LAMBDA_PRELOADED_RECEIVER; import static org.codehaus.groovy.classgen.asm.sc.StaticTypesFunctionalInterfaceMetadataKey.LAMBDA_SHARED_VARIABLES; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.CLOSURE_ARGUMENTS; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.LAMBDA_MARKERS; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.PARAMETER_TYPE; import static org.objectweb.asm.Opcodes.ACC_FINAL; import static org.objectweb.asm.Opcodes.ACC_PRIVATE; @@ -94,6 +95,10 @@ public void writeLambda(final LambdaExpression expression) { } boolean serializable = makeSerializableIfNeeded(expression, functionalType); + // GROOVY-11998: gather intersection-cast marker interfaces, filtering out + // Serializable (already conveyed via FLAG_SERIALIZABLE) and any interface + // already implemented by the SAM target. + ClassNode[] markers = collectLambdaMarkers(expression, functionalType); GeneratedLambda generatedLambda = getOrAddGeneratedLambda(expression, abstractMethod); ensureDeserializeLambdaSupport(expression, functionalType, abstractMethod, generatedLambda, serializable); @@ -101,7 +106,22 @@ public void writeLambda(final LambdaExpression expression) { loadLambdaReceiver(generatedLambda); } - writeLambdaFactoryInvocation(functionalType.redirect(), abstractMethod, generatedLambda, serializable); + writeLambdaFactoryInvocation(functionalType.redirect(), abstractMethod, generatedLambda, serializable, markers); + } + + @SuppressWarnings("unchecked") + private static ClassNode[] collectLambdaMarkers(final LambdaExpression expression, final ClassNode functionalType) { + Object md = expression.getNodeMetaData(LAMBDA_MARKERS); + if (!(md instanceof java.util.List)) return ClassNode.EMPTY_ARRAY; + java.util.List raw = (java.util.List) md; + java.util.List out = new java.util.ArrayList<>(raw.size()); + for (ClassNode m : raw) { + if (m == null || !m.isInterface()) continue; + if (m.equals(SERIALIZABLE_TYPE) || SERIALIZABLE_TYPE.equals(m.redirect())) continue; + if (functionalType != null && functionalType.implementsInterface(m)) continue; + out.add(m); + } + return out.toArray(ClassNode.EMPTY_ARRAY); } private static MethodNode resolveFunctionalInterfaceMethod(final ClassNode functionalType) { @@ -138,7 +158,7 @@ private void ensureDeserializeLambdaSupport(final LambdaExpression expression, f ), helperMethod); } - private void writeLambdaFactoryInvocation(final ClassNode functionalType, final MethodNode abstractMethod, final GeneratedLambda generatedLambda, final boolean serializable) { + private void writeLambdaFactoryInvocation(final ClassNode functionalType, final MethodNode abstractMethod, final GeneratedLambda generatedLambda, final boolean serializable, final ClassNode[] markers) { writeFunctionalInterfaceIndy( controller.getMethodVisitor(), abstractMethod.getName(), @@ -148,7 +168,8 @@ private void writeLambdaFactoryInvocation(final ClassNode functionalType, final generatedLambda.lambdaClass, generatedLambda.lambdaMethod, generatedLambda.lambdaMethod.getParameters(), - serializable + serializable, + markers ); if (generatedLambda.nonCapturing()) { diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesMethodReferenceExpressionWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesMethodReferenceExpressionWriter.java index 0ee559c9adb..d6f0ad21aa4 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesMethodReferenceExpressionWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesMethodReferenceExpressionWriter.java @@ -122,7 +122,8 @@ public void writeMethodReferenceExpression(final MethodReferenceExpression metho invocationReadyMethodReference.implementationMethod().getDeclaringClass(), invocationReadyMethodReference.implementationMethod(), functionalInterface.parametersWithExactType(), - functionalInterface.serializable() + functionalInterface.serializable(), + functionalInterface.markers() ); updateOperandStack(functionalInterface.functionalType(), invocation.capturing()); @@ -137,15 +138,36 @@ private FunctionalInterfaceContext resolveFunctionalInterfaceContext(final Metho if (abstractMethod == null) return null; ClassNode[] inferredParameterTypes = methodReferenceExpression.getNodeMetaData(StaticTypesMarker.CLOSURE_ARGUMENTS); + // GROOVY-11998: pick up intersection-cast markers populated by STC + @SuppressWarnings("unchecked") + java.util.List rawMarkers = (java.util.List) methodReferenceExpression.getNodeMetaData(StaticTypesMarker.LAMBDA_MARKERS); + boolean fromIntersection = rawMarkers != null && rawMarkers.stream().anyMatch(m -> + m != null && (m.equals(ClassHelper.SERIALIZABLE_TYPE) || m.implementsInterface(ClassHelper.SERIALIZABLE_TYPE))); + boolean serializable = functionalType.implementsInterface(ClassHelper.SERIALIZABLE_TYPE) || fromIntersection; + ClassNode[] markers = filterMarkers(rawMarkers, functionalType); return new FunctionalInterfaceContext( functionalType, abstractMethod, createParametersWithExactType(abstractMethod, inferredParameterTypes), createMethodDescriptor(abstractMethod), - functionalType.implementsInterface(ClassHelper.SERIALIZABLE_TYPE) + serializable, + markers ); } + private static ClassNode[] filterMarkers(final java.util.List raw, final ClassNode functionalType) { + if (raw == null || raw.isEmpty()) return ClassNode.EMPTY_ARRAY; + java.util.List out = new java.util.ArrayList<>(raw.size()); + for (ClassNode m : raw) { + if (m == null || !m.isInterface()) continue; + if (m.equals(ClassHelper.SERIALIZABLE_TYPE) + || ClassHelper.SERIALIZABLE_TYPE.equals(m.redirect())) continue; + if (functionalType != null && functionalType.implementsInterface(m)) continue; + out.add(m); + } + return out.toArray(ClassNode.EMPTY_ARRAY); + } + private MethodReferenceTarget resolveMethodReferenceTarget(final MethodReferenceExpression methodReferenceExpression) { Expression typeOrTargetRef = methodReferenceExpression.getExpression(); boolean classExpression = typeOrTargetRef instanceof ClassExpression; @@ -684,7 +706,7 @@ private static Parameter[] removeFirstParameter(final Parameter[] parameters) { */ private record FunctionalInterfaceContext(ClassNode functionalType, MethodNode abstractMethod, Parameter[] parametersWithExactType, String samMethodDescriptor, - boolean serializable) { + boolean serializable, ClassNode[] markers) { } /** diff --git a/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java b/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java index c5904834c87..ef522a4121f 100644 --- a/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java +++ b/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java @@ -32,6 +32,7 @@ import org.codehaus.groovy.ast.GenericsType.GenericsTypeName; import org.codehaus.groovy.ast.ImportNode; import org.codehaus.groovy.ast.InnerClassNode; +import org.codehaus.groovy.ast.IntersectionTypeClassNode; import org.codehaus.groovy.ast.MethodNode; import org.codehaus.groovy.ast.ModuleNode; import org.codehaus.groovy.ast.Parameter; @@ -349,6 +350,13 @@ private void resolveOrFail(final ClassNode type, final String msg, final ASTNode } private void resolveOrFail(final ClassNode type, final String msg, final ASTNode node, final boolean preferImports) { + if (type instanceof IntersectionTypeClassNode it) { // GROOVY-11998 + for (ClassNode component : it.getComponents()) { + resolveOrFail(component, msg, node, preferImports); + } + it.reclassifyComponents(); + return; + } if (type.isRedirectNode() || !type.isPrimaryClassNode()) { visitTypeAnnotations(type); // JSR 308 support } 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 00000000000..1306676b337 --- /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 6.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 ifaces = (List) filterInterfaces(components); + + if (source instanceof Closure) { + Map 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> filterInterfaces(final Class[] components) { + List> out = new ArrayList<>(components.length); + for (Class c : components) if (c.isInterface()) out.add(c); + return out; + } +} diff --git a/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java b/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java index 34bd8ae551c..fa8a32f554d 100644 --- a/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java +++ b/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java @@ -840,11 +840,16 @@ protected InnerLoader(final ClassLoader parent, final Class[] interfaces) { super(parent); if (interfaces != null) { for (Class c : interfaces) { - if (c.getClassLoader() != parent) { + // GROOVY-11999: bootstrap-loaded classes (e.g., java.lang.Runnable, + // java.io.Serializable) report a null classloader. Don't store + // null in the extras list — bootstrap is reachable via every + // non-null parent's delegation chain anyway. + ClassLoader cl = c.getClassLoader(); + if (cl != null && cl != parent) { if (internalClassLoaders == null) internalClassLoaders = new ArrayList<>(interfaces.length); - if (!internalClassLoaders.contains(c.getClassLoader())) { - internalClassLoaders.add(c.getClassLoader()); + if (!internalClassLoaders.contains(cl)) { + internalClassLoaders.add(cl); } } } @@ -884,6 +889,7 @@ public Class loadClass(final String name) throws ClassNotFoundException { // Not loaded, try to load it if (internalClassLoaders != null) { for (ClassLoader i : internalClassLoaders) { + if (i == null) continue; // GROOVY-11999: defensive, see InnerLoader ctor try { // Ignore parent delegation and just try to load locally loadedClass = i.loadClass(name); diff --git a/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java b/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java index a7a5c138199..b3e89c8a410 100644 --- a/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java +++ b/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java @@ -542,7 +542,7 @@ private void printField(final PrintWriter out, final FieldNode field, final bool } private void printMethods(final PrintWriter out, final ClassNode classNode, final boolean isEnum, final boolean isRecordStub) { - // For native record stubs, let javac auto-synthesise the canonical + // For native record stubs, let javac auto-synthesize the canonical // constructor; we cannot reliably emit a placeholder body that // satisfies record component definite-assignment rules. Non-canonical // user-declared constructors are not visible in the stub (known diff --git a/src/main/java/org/codehaus/groovy/transform/RecordBaseASTStubber.java b/src/main/java/org/codehaus/groovy/transform/RecordBaseASTStubber.java index be2b529f00f..dc41f865ff9 100644 --- a/src/main/java/org/codehaus/groovy/transform/RecordBaseASTStubber.java +++ b/src/main/java/org/codehaus/groovy/transform/RecordBaseASTStubber.java @@ -94,7 +94,7 @@ *

Native records. When the class would compile as a native JVM * record (target {@code >= JDK16} and {@code mode != EMULATE}), the stub * generator already renders {@code record Foo(...)} syntax via the - * back-channel introduced by GROOVY-11974, and {@code javac} synthesises + * back-channel introduced by GROOVY-11974, and {@code javac} synthesizes * the canonical constructor and component accessors itself. This stubber * detects that case via * {@link RecordTypeASTTransformation#wouldBeNativeRecord} and bails out; 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 013d6594b89..e268a579a8e 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -69,6 +69,7 @@ import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.ExpressionTransformer; import org.codehaus.groovy.ast.expr.FieldExpression; +import org.codehaus.groovy.ast.IntersectionTypeClassNode; import org.codehaus.groovy.ast.expr.LambdaExpression; import org.codehaus.groovy.ast.expr.ListExpression; import org.codehaus.groovy.ast.expr.MapEntryExpression; @@ -340,7 +341,9 @@ import static org.codehaus.groovy.transform.stc.StaticTypesMarker.IMPLICIT_RECEIVER; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.INFERRED_RETURN_TYPE; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.INFERRED_TYPE; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.LAMBDA_MARKERS; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.PARAMETER_TYPE; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.PRIMARY_FUNCTIONAL_TYPE; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.PV_FIELDS_ACCESS; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.PV_FIELDS_MUTATION; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.PV_METHODS_ACCESS; @@ -1625,7 +1628,7 @@ private boolean checkRestAssignability(final Expression restExpr, final ClassNod } /** - * Build a synthesised ListExpression of {@code rhs.getAt(i)} accesses, one per LHS slot, + * Build a synthesized ListExpression of {@code rhs.getAt(i)} accesses, one per LHS slot, * tagged with the inferred element type. Used by {@link #typeCheckMultipleAssignmentPositional} * when the RHS isn't a literal list/range/Tuple-typed expression. Returns the original * RHS unchanged if its static type doesn't support {@code getAt(int)}. @@ -4791,7 +4794,11 @@ public void visitArrayExpression(final ArrayExpression expression) { public void visitCastExpression(final CastExpression expression) { ClassNode target = expression.getType(); Expression source = expression.getExpression(); - applyTargetType(target, source); // GROOVY-9997 + if (target instanceof IntersectionTypeClassNode) { // GROOVY-11998 + validateAndApplyIntersectionCast((IntersectionTypeClassNode) target, expression, source); + } else { + applyTargetType(target, source); // GROOVY-9997 + } source.visit(this); @@ -4800,7 +4807,89 @@ public void visitCastExpression(final CastExpression expression) { } } + /** + * Validates JLS §4.9 well-formedness of an intersection cast target, + * and for lambda / method-reference / closure operands picks the + * SAM-bearing component as the primary functional target so that + * parameter inference (and downstream lambda factory generation) + * can proceed. Additional components are stored as + * {@link StaticTypesMarker#LAMBDA_MARKERS} on both the cast and + * source for use by the bytecode writers. + */ + private void validateAndApplyIntersectionCast(final IntersectionTypeClassNode target, + final CastExpression expression, + final Expression source) { + ClassNode[] components = target.getComponents(); + int classCount = 0; + boolean classNotFirst = false; + for (int i = 0, n = components.length; i < n; i += 1) { + ClassNode c = components[i]; + if (isPrimitiveType(c)) { + addStaticTypeError("Intersection type components must be reference types: " + prettyPrintType(c), expression); + } + if (!c.isInterface()) { + classCount += 1; + if (i != 0) classNotFirst = true; + if (Modifier.isFinal(c.getModifiers())) { + addStaticTypeError("Intersection type may not include the final class " + prettyPrintType(c), expression); + } + } + } + if (classCount > 1) { + addStaticTypeError("Intersection type may include at most one class component: " + prettyPrintType(target), expression); + } + if (classNotFirst) { + addStaticTypeError("Class component of intersection type must come first: " + prettyPrintType(target), expression); + } + + boolean isFunctionalSource = source instanceof ClosureExpression + || source instanceof MethodReferenceExpression; + if (!isFunctionalSource) return; // non-functional cast: just decompose check below + + ClassNode primary = null; + List markers = new ArrayList<>(components.length); + for (ClassNode c : components) { + if (!c.isInterface()) { markers.add(c); continue; } + MethodNode sam = findSAM(c); + if (sam == null) { + markers.add(c); // marker interface + } else if (primary == null) { + primary = c; + } else { + addStaticTypeError("Intersection type for lambda/closure has multiple functional interface components: " + + prettyPrintType(primary) + " and " + prettyPrintType(c), expression); + markers.add(c); + } + } + if (primary == null) { + addStaticTypeError("Intersection type for lambda/closure target has no functional interface component: " + prettyPrintType(target), expression); + return; + } + applyTargetType(primary, source); + expression.putNodeMetaData(PRIMARY_FUNCTIONAL_TYPE, primary); + source.putNodeMetaData(PRIMARY_FUNCTIONAL_TYPE, primary); + if (!markers.isEmpty()) { + source.putNodeMetaData(LAMBDA_MARKERS, markers); + expression.putNodeMetaData(LAMBDA_MARKERS, markers); + } + if (source instanceof LambdaExpression) { + for (ClassNode m : markers) { + if (m.equals(ClassHelper.SERIALIZABLE_TYPE) + || m.implementsInterface(ClassHelper.SERIALIZABLE_TYPE)) { + ((LambdaExpression) source).setSerializable(true); + break; + } + } + } + } + protected boolean checkCast(final ClassNode targetType, final Expression source) { + if (targetType instanceof IntersectionTypeClassNode it) { // GROOVY-11998 + for (ClassNode component : it.getComponents()) { + if (!checkCast(component, source)) return false; + } + return true; + } if (isNullConstant(source)) { return !isPrimitiveType(targetType) || isPrimitiveBoolean(targetType); // GROOVY-6577 } diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypesMarker.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypesMarker.java index 028b97b1ea7..6ccee7e84ef 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypesMarker.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypesMarker.java @@ -63,5 +63,9 @@ public enum StaticTypesMarker { /** list of {@code return null} statements recorded on a method before its body is rewritten, so a downstream checker can still report them as non-null violations */ INFERRED_NON_NULL_RETURN_VIOLATIONS, /** GEP-15: stores the resolved compound-assignment {@code MethodNode} (e.g. {@code plusAssign}) on a {@code BinaryExpression} when the static type checker has located one, signalling to codegen that the receiver should be mutated in place rather than {@code x = x.plus(y)}-desugared */ - COMPOUND_ASSIGN_TARGET + COMPOUND_ASSIGN_TARGET, + /** GROOVY-11998: for an intersection-cast lambda or method reference, the SAM-bearing component picked from the intersection */ + PRIMARY_FUNCTIONAL_TYPE, + /** GROOVY-11998: for an intersection cast on a lambda, method reference or closure, the additional marker interfaces to thread to {@code LambdaMetafactory.altMetafactory} */ + LAMBDA_MARKERS } diff --git a/src/spec/doc/core-differences-java.adoc b/src/spec/doc/core-differences-java.adoc index f153b566c9f..47b5bfd9a20 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') // serializable 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 bd7b7ca1ad5..caa17a6771d 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 +`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 f44b6f46fd1..767cb23ab3c 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 make() { + return (Function & java.io.Serializable) String::length + } + } + + Function 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-resources/core/Groovydoc_02x.groovy b/src/test-resources/core/Groovydoc_02x.groovy index 16e18c45019..8cb100208fb 100644 --- a/src/test-resources/core/Groovydoc_02x.groovy +++ b/src/test-resources/core/Groovydoc_02x.groovy @@ -25,7 +25,7 @@ // // Known runtime-doc gaps (pre-existing, independent of GROOVY-8877): // * Script-level /**@: a leading /**@ on a bare script is not attached to -// the synthesised Script class — no AST node to claim it. +// the synthesized Script class — no AST node to claim it. // * @Field lift: /**@ before an @Field declaration is lost when the AST // transform lifts the local into a FieldNode; the annotation isn't // carried across. @@ -36,7 +36,7 @@ */ void m() {} -// /**@ on a script's top-level method reaches the synthesised method. +// /**@ on a script's top-level method reaches the synthesized method. def mMethod = this.class.getDeclaredMethods().find { it.name == 'm' } assert mMethod != null assert mMethod.groovydoc.isPresent() diff --git a/src/test/groovy/bugs/Groovy11967.groovy b/src/test/groovy/bugs/Groovy11967.groovy index b3f893072a1..8a7b60c7831 100644 --- a/src/test/groovy/bugs/Groovy11967.groovy +++ b/src/test/groovy/bugs/Groovy11967.groovy @@ -24,7 +24,7 @@ import org.junit.jupiter.api.Test import static groovy.test.GroovyAssert.assertScript /** - * Regression coverage for the synthesised lower-arity bridge constructor that + * Regression coverage for the synthesized lower-arity bridge constructor that * {@code @CompileStatic} emits for a constructor with a default-valued list * parameter. The bridge inlines the default {@code [...]} literal as a * {@code new ArrayList(n)} followed by {@code .add(...)} calls; when the diff --git a/src/test/groovy/groovy/lang/IntersectionCastE2ETest.groovy b/src/test/groovy/groovy/lang/IntersectionCastE2ETest.groovy new file mode 100644 index 00000000000..5887ad76e07 --- /dev/null +++ b/src/test/groovy/groovy/lang/IntersectionCastE2ETest.groovy @@ -0,0 +1,183 @@ +/* + * 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 + +/** + * End-to-end tests for intersection-cast lambdas and method references + * (GROOVY-11998 PR3). + * + * Verifies that {@code (R & S) lambda} and {@code (R & S) Type::method} produce + * runtime instances that: + *

    + *
  • Implement every component interface ({@code instanceof} succeeds)
  • + *
  • Are serialisable when the intersection contains + * {@link java.io.Serializable}
  • + *
  • Invoke the SAM correctly
  • + *
+ */ +final class IntersectionCastE2ETest { + + @Test + void 'static lambda cast to (Runnable & Serializable) implements both and runs'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + import java.io.Serializable + + @CompileStatic + class T { + static Runnable make() { + return (Runnable & Serializable) () -> {} + } + } + + def r = T.make() + assert r instanceof Runnable + assert r instanceof Serializable + r.run() // does not throw + ''') + } + + @Test + void 'static intersection lambda is serialisable round-trip'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + import java.io.Serializable + import java.io.ByteArrayOutputStream + import java.io.ByteArrayInputStream + import java.io.ObjectOutputStream + import java.io.ObjectInputStream + + @CompileStatic + class T { + static Runnable make() { + return (Runnable & Serializable) () -> { System.out.println("hi") } + } + } + + def r = T.make() + def baos = new ByteArrayOutputStream() + new ObjectOutputStream(baos).withCloseable { it.writeObject(r) } + def bais = new ByteArrayInputStream(baos.toByteArray()) + def restored = null + new ObjectInputStream(bais).withCloseable { restored = it.readObject() } + assert restored instanceof Runnable + assert restored instanceof Serializable + restored.run() // round-trip executes + ''') + } + + @Test + void 'static lambda cast to (Runnable & Cloneable) implements Cloneable marker'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + @CompileStatic + class T { + static Runnable make() { + return (Runnable & Cloneable) () -> {} + } + } + + def r = T.make() + assert r instanceof Runnable + assert r instanceof Cloneable + ''') + } + + @Test + void 'static method reference cast to (Function & Serializable) is serialisable'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + import java.io.Serializable + import java.util.function.Function + import java.io.ByteArrayOutputStream + import java.io.ByteArrayInputStream + import java.io.ObjectOutputStream + import java.io.ObjectInputStream + + @CompileStatic + class T { + static Function make() { + return (Function & Serializable) String::length + } + } + + Function f = T.make() + assert f instanceof Function + assert f instanceof Serializable + assert f.apply("hello") == 5 + + def baos = new ByteArrayOutputStream() + new ObjectOutputStream(baos).withCloseable { it.writeObject(f) } + def bais = new ByteArrayInputStream(baos.toByteArray()) + Function restored = null + new ObjectInputStream(bais).withCloseable { restored = (Function) it.readObject() } + assert restored.apply("world") == 5 + ''') + } + + @Test + void 'static lambda with capturing variable cast to intersection works'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + import java.io.Serializable + import java.util.function.Supplier + + @CompileStatic + class T { + static Supplier make(String captured) { + return (Supplier & Serializable) () -> captured + } + } + + def s = T.make("captured-value") + assert s instanceof Supplier + assert s instanceof Serializable + assert s.get() == "captured-value" + ''') + } + + @Test + void 'intersection lambda with three components includes all markers'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + import java.io.Serializable + + @CompileStatic + class T { + static Runnable make() { + return (Runnable & Serializable & Cloneable) () -> {} + } + } + + def r = T.make() + assert r instanceof Runnable + assert r instanceof Serializable + assert r instanceof Cloneable + ''') + } +} diff --git a/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy b/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy new file mode 100644 index 00000000000..801560e6b0a --- /dev/null +++ b/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy @@ -0,0 +1,217 @@ +/* + * 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 + ''') + } + + // GROOVY-11999: this previously NPE'd inside ProxyGeneratorAdapter when + // interfaces list mixed bootstrap (Runnable) and user (MyMarker) loaders. + @Test + void 'dynamic closure as (Runnable & MyMarker) builds a multi-interface proxy'() { + def shell = new GroovyShell() + shell.evaluate(''' + interface MyMarker {} + def c = ({ -> "hi" } as (Runnable & MyMarker)) + assert c instanceof Runnable + assert c instanceof MyMarker + c.run() + ''') + } + + @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 + ''') + } +} diff --git a/src/test/groovy/groovy/lang/IntersectionCoercionTest.groovy b/src/test/groovy/groovy/lang/IntersectionCoercionTest.groovy new file mode 100644 index 00000000000..f4e31d34595 --- /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 + ''') + } +} diff --git a/src/test/groovy/groovy/transform/stc/IntersectionCastSTCTest.groovy b/src/test/groovy/groovy/transform/stc/IntersectionCastSTCTest.groovy new file mode 100644 index 00000000000..e7e01bd6fe2 --- /dev/null +++ b/src/test/groovy/groovy/transform/stc/IntersectionCastSTCTest.groovy @@ -0,0 +1,243 @@ +/* + * 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 groovy.transform.TypeChecked +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.IntersectionTypeClassNode +import org.codehaus.groovy.ast.expr.CastExpression +import org.codehaus.groovy.ast.expr.LambdaExpression +import org.codehaus.groovy.ast.tools.GenericsUtils +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.MultipleCompilationErrorsException +import org.codehaus.groovy.control.Phases +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer +import org.codehaus.groovy.control.customizers.ImportCustomizer +import org.codehaus.groovy.control.messages.SyntaxErrorMessage +import org.codehaus.groovy.transform.stc.StaticTypesMarker +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +/** + * Tests for static type checking of intersection-cast targets (GROOVY-11998 PR2). + * + * These tests stop compilation at {@link Phases#INSTRUCTION_SELECTION} so they + * exercise resolution and STC without producing class files, which keeps the + * tests focused on resolution/STC behavior delivered in this PR. + */ +final class IntersectionCastSTCTest { + + @Test + void 'STC accepts (Runnable & Serializable) lambda'() { + def cu = compileToSemantic(''' + @groovy.transform.TypeChecked + class T { + static def make() { + return (Runnable & java.io.Serializable) () -> { System.out.println("hi") } + } + } + ''') + assertNoErrors(cu) + } + + @Test + void 'STC accepts (Runnable & Serializable) closure'() { + def cu = compileToSemantic(''' + @groovy.transform.TypeChecked + class T { + static def make() { + return (Runnable & java.io.Serializable) { -> System.out.println("hi") } + } + } + ''') + assertNoErrors(cu) + } + + @Test + void 'STC sets PRIMARY_FUNCTIONAL_TYPE and LAMBDA_MARKERS on intersection-cast lambda'() { + def cu = compileToSemantic(''' + @groovy.transform.TypeChecked + class T { + static def make() { + return (Runnable & java.io.Serializable) () -> { System.out.println("hi") } + } + } + ''') + assertNoErrors(cu) + CastExpression cast = findFirstIntersectionCast(cu) + assert cast != null + + ClassNode primary = (ClassNode) cast.getNodeMetaData(StaticTypesMarker.PRIMARY_FUNCTIONAL_TYPE) + assert primary != null + assert primary.name == 'java.lang.Runnable' + + List markers = (List) cast.getNodeMetaData(StaticTypesMarker.LAMBDA_MARKERS) + assert markers != null + assert markers.size() == 1 + assert markers[0].name == 'java.io.Serializable' + } + + @Test + void 'lambda is marked Serializable when intersection includes Serializable'() { + def cu = compileToSemantic(''' + @groovy.transform.TypeChecked + class T { + static def make() { + return (Runnable & java.io.Serializable) () -> { System.out.println("hi") } + } + } + ''') + assertNoErrors(cu) + CastExpression cast = findFirstIntersectionCast(cu) + assert cast != null + assert cast.expression instanceof LambdaExpression + assert ((LambdaExpression) cast.expression).serializable + } + + @Test + void 'STC rejects intersection with two SAM-bearing interfaces for lambda target'() { + // Define two SAM-bearing interfaces inside a script + def errors = compileExpectingErrors(''' + interface A { void runIt() } + interface B { void doIt() } + @groovy.transform.TypeChecked + class T { + static def make() { + return (A & B) () -> {} + } + } + ''') + assert errors.any { it.contains('multiple functional interface components') } + } + + @Test + void 'STC rejects intersection with no functional interface for lambda target'() { + def errors = compileExpectingErrors(''' + @groovy.transform.TypeChecked + class T { + static def make() { + return (java.io.Serializable & Cloneable) () -> {} + } + } + ''') + assert errors.any { it.contains('no functional interface component') } + } + + @Test + void 'STC rejects intersection where the class component is not first'() { + def errors = compileExpectingErrors(''' + class C {} + @groovy.transform.TypeChecked + class T { + static def make(value) { + return (Runnable & C) value + } + } + ''') + assert errors.any { it.contains('Class component of intersection type must come first') } + } + + @Test + void 'STC rejects intersection with a final class component'() { + def errors = compileExpectingErrors(''' + @groovy.transform.TypeChecked + class T { + static def make(value) { + return (String & Runnable) value + } + } + ''') + assert errors.any { it.contains('may not include the final class') } + } + + @Test + void 'resolver resolves all components and reclassifies'() { + def cu = compileToSemantic(''' + @groovy.transform.TypeChecked + class T { + static def make() { + return (Runnable & java.io.Serializable) () -> { } + } + } + ''') + assertNoErrors(cu) + CastExpression cast = findFirstIntersectionCast(cu) + assert cast != null + IntersectionTypeClassNode it = (IntersectionTypeClassNode) cast.type + ClassNode[] components = it.components + assert components.length == 2 + assert components.every { it.isResolved() || !it.isPrimaryClassNode() } + // After resolution + reclassification, both components are interfaces, so superClass is Object + assert it.superClass.name == 'java.lang.Object' + assert it.interfaces*.name as Set == ['java.lang.Runnable', 'java.io.Serializable'] as Set + } + + //-------------------------------------------------------------------------- + + private static CompilationUnit compileToSemantic(String src) { + CompilerConfiguration config = new CompilerConfiguration() + ImportCustomizer imports = new ImportCustomizer() + config.addCompilationCustomizers(imports) + + CompilationUnit cu = new CompilationUnit(config, null, new GroovyClassLoader()) + cu.addSource('Test.groovy', src) + try { + cu.compile(Phases.INSTRUCTION_SELECTION) + } catch (MultipleCompilationErrorsException ignored) { + // tests inspect cu.errorCollector + } + return cu + } + + private static List compileExpectingErrors(String src) { + CompilationUnit cu = compileToSemantic(src) + return cu.errorCollector.errors.findAll { it instanceof SyntaxErrorMessage } + .collect { ((SyntaxErrorMessage) it).cause.message } + } + + private static void assertNoErrors(CompilationUnit cu) { + if (cu.errorCollector.hasErrors()) { + String msg = cu.errorCollector.errors + .findAll { it instanceof SyntaxErrorMessage } + .collect { ((SyntaxErrorMessage) it).cause.message }.join('\n') + Assertions.fail("Compilation produced errors:\n${msg}") + } + } + + private static CastExpression findFirstIntersectionCast(CompilationUnit cu) { + CastExpression[] holder = new CastExpression[1] + cu.AST.classes.each { cn -> + cn.methods.each { mn -> + if (mn.code == null) return + mn.code.visit(new org.codehaus.groovy.ast.CodeVisitorSupport() { + @Override + void visitCastExpression(CastExpression expression) { + if (holder[0] == null && expression.type instanceof IntersectionTypeClassNode) { + holder[0] = expression + } + super.visitCastExpression(expression) + } + }) + } + } + return holder[0] + } +} diff --git a/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy b/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy index 73830e1f2df..9086715547f 100644 --- a/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy +++ b/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy @@ -244,4 +244,43 @@ class ProxyGeneratorAdapterTest { static interface OtherInterface { int calc(int x) } + + static interface UserMarker {} // user-defined marker; classloader is the test classloader + + // GROOVY-11999: building a proxy whose interface list mixes a bootstrap-loaded + // interface (Runnable/Serializable) with a user-defined one used to NPE in + // InnerLoader because the bootstrap classloader (null) was added to the + // internalClassLoaders list and dereferenced during class definition. + @Test + void testProxyMixingBootstrapAndUserInterfaces() { + def closure = { -> /* doCall */ } + def closureMap = ['*': closure] + def adapter = new ProxyGeneratorAdapter( + closureMap, + Object, + [Runnable, UserMarker] as Class[], + this.class.classLoader, + false, + null) + def obj = adapter.proxy(closureMap, null) + assert obj instanceof Runnable + assert obj instanceof UserMarker + obj.run() // does not throw + } + + // GROOVY-11999: same scenario via the public ProxyGenerator entry point used + // by the runtime intersection-cast path (IntersectionCastSupport.asType). + @Test + void testInstantiateAggregateMixingBootstrapAndUserInterfaces() { + def calls = 0 + def proxy = ProxyGenerator.INSTANCE.instantiateAggregate( + ['run': { -> calls++ }], + [Runnable, java.io.Serializable, UserMarker] as List) + assert proxy instanceof Runnable + assert proxy instanceof java.io.Serializable + assert proxy instanceof UserMarker + proxy.run() + proxy.run() + assert calls == 2 + } } diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy new file mode 100644 index 00000000000..ae278f787dd --- /dev/null +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy @@ -0,0 +1,171 @@ +/* + * 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.apache.groovy.parser.antlr4 + +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.IntersectionTypeClassNode +import org.codehaus.groovy.ast.ModuleNode +import org.codehaus.groovy.ast.expr.CastExpression +import org.codehaus.groovy.ast.expr.DeclarationExpression +import org.codehaus.groovy.ast.expr.Expression +import org.codehaus.groovy.ast.expr.BinaryExpression +import org.codehaus.groovy.ast.stmt.BlockStatement +import org.codehaus.groovy.ast.stmt.ExpressionStatement +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.ParserPlugin +import org.codehaus.groovy.control.ParserPluginFactory +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertNotNull +import static org.junit.jupiter.api.Assertions.assertTrue +import static org.junit.jupiter.api.Assertions.assertEquals + +/** + * Tests for the parser-level handling of intersection types in cast expressions + * and {@code as} coercion (GROOVY-11998 PR1: grammar + AST). + */ +final class IntersectionCastParserTest { + + @Test + void 'cast with intersection type builds IntersectionTypeClassNode'() { + ClassNode type = singleCastTargetType('def r = (Runnable & java.io.Serializable) { -> }') + assert type instanceof IntersectionTypeClassNode + IntersectionTypeClassNode it = (IntersectionTypeClassNode) type + ClassNode[] components = it.components + assertEquals(2, components.length) + assertEquals('Runnable', components[0].name) + assertEquals('java.io.Serializable', components[1].name) + } + + @Test + void 'cast with three-component intersection preserves order'() { + ClassNode type = singleCastTargetType('def x = (A & B & C) value') + assert type instanceof IntersectionTypeClassNode + ClassNode[] components = ((IntersectionTypeClassNode) type).components + assertEquals(['A', 'B', 'C'], components*.name) + } + + @Test + void 'as coercion with parenthesised intersection builds IntersectionTypeClassNode'() { + ClassNode type = singleCastTargetType('def r = { -> } as (Runnable & java.io.Serializable)') + assert type instanceof IntersectionTypeClassNode + ClassNode[] components = ((IntersectionTypeClassNode) type).components + assertEquals(['Runnable', 'java.io.Serializable'], components*.name) + } + + @Test + void 'single-type cast still parses and is not an intersection node'() { + ClassNode type = singleCastTargetType('def x = (String) "hello"') + assert !(type instanceof IntersectionTypeClassNode) + assertEquals('String', type.name) + } + + @Test + void 'single-type as coercion still parses unchanged'() { + ClassNode type = singleCastTargetType('def x = "hello" as Integer') + assert !(type instanceof IntersectionTypeClassNode) + assertEquals('Integer', type.name) + } + + @Test + void 'as coercion with parenthesised single type is accepted as plain type'() { + // The new grammar allows `as (T)` which previously was a syntax error; + // it should be equivalent to `as T` and not produce an IntersectionTypeClassNode. + ClassNode type = singleCastTargetType('def x = "hello" as (Integer)') + assert !(type instanceof IntersectionTypeClassNode) + assertEquals('Integer', type.name) + } + + @Test + void 'duplicate types in intersection cast are rejected'() { + assertParseFails('def r = (Runnable & Runnable) { -> }', 'Duplicate type in intersection') + } + + @Test + void 'duplicate types in intersection as coercion are rejected'() { + assertParseFails('def r = { -> } as (Runnable & Runnable)', 'Duplicate type in intersection') + } + + @Test + void 'intersection right-hand side rejected for !instanceof'() { + assertParseFails('def b = x !instanceof (Runnable & java.io.Serializable)', + 'not supported as the right-hand side of !instanceof') + } + + @Test + void 'parenthesised single type accepted for !instanceof'() { + ModuleNode ast = buildAST('def b = x !instanceof (Runnable)') + assertNotNull(ast) + assertTrue(!ast.context.errorCollector.hasErrors()) + } + + //-------------------------------------------------------------------------- + + private static ClassNode singleCastTargetType(String src) { + ModuleNode ast = buildAST(src) + assertNotNull(ast, "AST should build for: $src") + assertTrue(!ast.context.errorCollector.hasErrors(), + "Parse should not have errors for: $src; got: ${ast.context.errorCollector.errors}") + BlockStatement block = ast.statementBlock + ExpressionStatement stmt = (ExpressionStatement) block.statements[0] + Expression expr = stmt.expression + CastExpression cast + if (expr instanceof DeclarationExpression) { + cast = (CastExpression) ((DeclarationExpression) expr).rightExpression + } else if (expr instanceof BinaryExpression) { + cast = (CastExpression) ((BinaryExpression) expr).rightExpression + } else { + cast = (CastExpression) expr + } + return cast.type + } + + private static ModuleNode buildAST(String src) { + try { + CompilerConfiguration config = new CompilerConfiguration(CompilerConfiguration.DEFAULT) + config.pluginFactory = ParserPluginFactory.antlr4() + return ParserPlugin.buildAST(src, config, new GroovyClassLoader(), null) + } catch (Throwable t) { + return null + } + } + + private static void assertParseFails(String src, String expectedMessageFragment) { + Throwable thrown = null + try { + CompilerConfiguration config = new CompilerConfiguration(CompilerConfiguration.DEFAULT) + config.pluginFactory = ParserPluginFactory.antlr4() + ModuleNode ast = ParserPlugin.buildAST(src, config, new GroovyClassLoader(), null) + if (ast == null || ast.context.errorCollector.hasErrors()) { + String allMessages = ast == null ? '' : ast.context.errorCollector.errors*.toString().join('\n') + assertTrue(ast == null || allMessages.contains(expectedMessageFragment), + "Expected error containing '$expectedMessageFragment' for: $src; got: $allMessages") + return + } + } catch (Throwable t) { + thrown = t + } + if (thrown != null) { + assertTrue(thrown.message != null && thrown.message.contains(expectedMessageFragment), + "Expected error containing '$expectedMessageFragment' for: $src; got: ${thrown.message}") + } else { + assertTrue(false, "Expected parse to fail for: $src") + } + } +} diff --git a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java index f8d7384ee4b..9122c9abcf9 100644 --- a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java +++ b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java @@ -60,7 +60,7 @@ * *

Supported block tags: {@code @see}, {@code @param}, {@code @return}, * {@code @throws} / {@code @exception}, {@code @since}, {@code @author}, - * {@code @version}, {@code @default}, plus synthesised {@code typeparam} + * {@code @version}, {@code @default}, plus synthesized {@code typeparam} * (from {@code @param }). Unknown block tags fall through to a generic * {@code

} rendering. * @@ -110,7 +110,7 @@ final class TagRenderer { COLLATED_TAGS.put("author", "Authors"); COLLATED_TAGS.put("version", "Version"); COLLATED_TAGS.put("default", "Default"); - // typeparam is synthesised from `@param desc` + // typeparam is synthesized from `@param desc` COLLATED_TAGS.put("typeparam", "Type Parameters"); } diff --git a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/antlr4/GroovydocJavaVisitor.java b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/antlr4/GroovydocJavaVisitor.java index b967353c0f9..49e673e65d4 100644 --- a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/antlr4/GroovydocJavaVisitor.java +++ b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/antlr4/GroovydocJavaVisitor.java @@ -190,7 +190,7 @@ public void visit(AnnotationMemberDeclaration n, Object arg) { currentClassDoc.add(fieldDoc); applyJavadocComment(n.getJavadocComment(), fieldDoc); n.getDefaultValue().ifPresent(defValue -> { - // For Markdown-form comments (no `*` line prefix), the synthesised + // For Markdown-form comments (no `*` line prefix), the synthesized // @default tag goes on a bare line; traditional /** */ form keeps // the `* ` prefix for visual parity with existing continuation lines. String prefix = fieldDoc.isMarkdown() ? "\n@default " : "\n* @default "; diff --git a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java index 9a1e936da01..52a9a9b2830 100644 --- a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java +++ b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java @@ -371,7 +371,7 @@ public void testSnippetTagExternalFormWithRegion() throws Exception { String fixtureSourcePath = "src/test/resources/docfiles-fixture"; String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles"; - // Synthesise a class on the fly that uses {@snippet file=... region=...} + // Synthesize a class on the fly that uses {@snippet file=... region=...} // Actually, just use a testfile in src/test/groovy that we add to sourcepath. // Here we reuse a class with an inline doc reference via the resource-dir. Path tmp = Files.createTempDirectory("snippet-region-"); @@ -1613,7 +1613,7 @@ public void testAbstractMethodEnumWithPerConstantBodiesDoesNotProduceAnonymousIn String klass = "EnumWithAbstractMethodAndConstantBodies"; Properties props = new Properties(); // phase 7 = CLASS_GENERATION, by which point the per-constant anonymous - // inner classes have been synthesised. The fix must keep them out of + // inner classes have been synthesized. The fix must keep them out of // the doc output at any phase. props.put("phaseOverride", "7"); GroovyDocTool tool = makeHtmltool(new ArrayList<>(), null, props); diff --git a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/ScriptWithMarkdownTopLevelDoc.groovy b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/ScriptWithMarkdownTopLevelDoc.groovy index 81dc5491487..a97bb0a5dc8 100644 --- a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/ScriptWithMarkdownTopLevelDoc.groovy +++ b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/ScriptWithMarkdownTopLevelDoc.groovy @@ -18,7 +18,7 @@ */ /// GROOVY-11542 + GROOVY-8877 fixture: a **Markdown** script-level doc that -/// should be lifted to the synthesised Script class via the same rules used +/// should be lifted to the synthesized Script class via the same rules used /// for traditional Javadoc comments. package org.codehaus.groovy.tools.groovydoc.testfiles