diff --git a/src/main/java/groovy/lang/Delegate.java b/src/main/java/groovy/lang/Delegate.java index 648189560b9..35f457c51f2 100644 --- a/src/main/java/groovy/lang/Delegate.java +++ b/src/main/java/groovy/lang/Delegate.java @@ -160,7 +160,7 @@ /** * Whether to carry over annotations from the methods of the delegate - * to your delegating method. Currently Closure annotation members are + * to your delegating method. Currently, Closure annotation members are * not supported. * * @return true if generated delegate methods should keep method annotations diff --git a/src/main/java/groovy/transform/NonSealed.java b/src/main/java/groovy/transform/NonSealed.java index b6e9169f55a..f87af19c1d1 100644 --- a/src/main/java/groovy/transform/NonSealed.java +++ b/src/main/java/groovy/transform/NonSealed.java @@ -18,7 +18,6 @@ */ package groovy.transform; -import org.apache.groovy.lang.annotation.Incubating; import org.codehaus.groovy.transform.GroovyASTTransformationClass; import java.lang.annotation.Documented; @@ -33,7 +32,6 @@ * @since 4.0.0 */ @Documented -@Incubating @Retention(RetentionPolicy.SOURCE) @Target(ElementType.TYPE) @GroovyASTTransformationClass("org.codehaus.groovy.transform.NonSealedASTTransformation") diff --git a/src/main/java/groovy/transform/Sealed.java b/src/main/java/groovy/transform/Sealed.java index 7e75f83c621..9e78cdfec9a 100644 --- a/src/main/java/groovy/transform/Sealed.java +++ b/src/main/java/groovy/transform/Sealed.java @@ -18,7 +18,6 @@ */ package groovy.transform; -import org.apache.groovy.lang.annotation.Incubating; import org.codehaus.groovy.transform.GroovyASTTransformationClass; import java.lang.annotation.Documented; @@ -33,7 +32,6 @@ * @since 4.0.0 */ @Documented -@Incubating @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @GroovyASTTransformationClass({ diff --git a/src/main/java/groovy/transform/SealedOptions.java b/src/main/java/groovy/transform/SealedOptions.java index ef03e613657..8107d4f5424 100644 --- a/src/main/java/groovy/transform/SealedOptions.java +++ b/src/main/java/groovy/transform/SealedOptions.java @@ -18,8 +18,6 @@ */ package groovy.transform; -import org.apache.groovy.lang.annotation.Incubating; - import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -32,7 +30,6 @@ * @since 4.0.0 */ @Documented -@Incubating @Retention(RetentionPolicy.SOURCE) @Target(ElementType.TYPE) public @interface SealedOptions { diff --git a/src/main/java/org/codehaus/groovy/ast/ClassNode.java b/src/main/java/org/codehaus/groovy/ast/ClassNode.java index bd48a1cd08a..5ed4541825a 100644 --- a/src/main/java/org/codehaus/groovy/ast/ClassNode.java +++ b/src/main/java/org/codehaus/groovy/ast/ClassNode.java @@ -629,7 +629,6 @@ public void setMixins(final MixinNode[] mixins) { /** * @return permitted subclasses of sealed type, may initially be empty in early compiler phases */ - @Incubating public List getPermittedSubclasses() { if (redirect != null) return redirect.getPermittedSubclasses(); @@ -637,7 +636,6 @@ public List getPermittedSubclasses() { return permittedSubclasses; } - @Incubating public void setPermittedSubclasses(final List permittedSubclasses) { if (redirect != null) { redirect.setPermittedSubclasses(permittedSubclasses); @@ -1916,7 +1914,6 @@ public boolean isRecord() { * @return {@code true} for native and emulated (annotation based) sealed classes * @since 4.0.0 */ - @Incubating public boolean isSealed() { if (redirect != null) return redirect.isSealed(); return !getAnnotations(ClassHelper.SEALED_TYPE).isEmpty() || !getPermittedSubclasses().isEmpty(); 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 2e930e5367c..17da992767d 100644 --- a/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java +++ b/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java @@ -18,8 +18,11 @@ */ package org.codehaus.groovy.tools.javac; +import groovy.transform.NonSealed; import groovy.transform.PackageScope; import groovy.transform.PackageScopeTarget; +import groovy.transform.Sealed; +import groovy.transform.SealedOptions; import org.apache.groovy.ast.tools.ExpressionUtils; import org.apache.groovy.io.StringBuilderWriter; import org.codehaus.groovy.ast.AnnotatedNode; @@ -113,6 +116,9 @@ import static org.codehaus.groovy.ast.tools.GenericsUtils.createGenericsSpec; import static org.codehaus.groovy.ast.tools.WideningCategories.isFloatingCategory; import static org.codehaus.groovy.ast.tools.WideningCategories.isLongCategory; +import static org.codehaus.groovy.transform.SealedASTTransformation.getEffectivePermittedSubclasses; +import static org.codehaus.groovy.transform.SealedASTTransformation.sealedSkipAnnotationFromSource; +import static org.codehaus.groovy.transform.SealedASTTransformation.wouldBeNativeSealed; /** * Generates Java stub source for Groovy classes during joint compilation. @@ -125,6 +131,9 @@ public class JavaStubGenerator { private final List constructors = new ArrayList<>(); private final Map propertyMethods = new LinkedHashMap<>(); private final static ClassNode PACKAGE_SCOPE_TYPE = makeCached(PackageScope.class); + private final static ClassNode SEALED_TYPE = makeCached(Sealed.class); + private final static ClassNode NON_SEALED_TYPE = makeCached(NonSealed.class); + private final static ClassNode SEALED_OPTIONS_TYPE = makeCached(SealedOptions.class); private ModuleNode currentModule; @@ -371,6 +380,21 @@ protected FinalVariableAnalyzer.VariableNotFinalCallback getFinalVariablesCallba // as plain classes (the existing behaviour). boolean isRecordStub = !isEnum && !isInterface && !isAnnotationDefinition && isNativeRecordStub(classNode); + // Emit native sealed declarations (sealed keyword + permits clause) + // when the class will receive native sealed bytecode. EMULATE mode + // is intentionally skipped: GEP-13 specifies emulated sealed types + // are invisible to the Java compiler. + boolean isSealedStub = !isAnnotationDefinition && !isEnum + && isNativeSealedStub(classNode); + // Java requires final / sealed / non-sealed on every permitted + // subtype of a sealed type. Groovy allows implicit non-sealed, so + // emit the non-sealed keyword in the stub when the parent is + // sealed (or a sealed interface is implemented) and this class + // is neither final nor sealed. + boolean isNonSealedStub = !isAnnotationDefinition && !isEnum && !isInterface && !isRecordStub + && !isSealedStub + && (classNode.getModifiers() & Opcodes.ACC_FINAL) == 0 + && hasSealedSupertype(classNode); printAnnotations(out, classNode); int flags = classNode.getModifiers(); @@ -382,6 +406,9 @@ protected FinalVariableAnalyzer.VariableNotFinalCallback getFinalVariablesCallba if (isRecordStub) flags &= ~Opcodes.ACC_FINAL; printModifiers(out, flags); + if (isSealedStub) out.print("sealed "); + else if (isNonSealedStub) out.print("non-sealed "); + if (isInterface) { if (isAnnotationDefinition) { out.print("@"); @@ -427,6 +454,17 @@ protected FinalVariableAnalyzer.VariableNotFinalCallback getFinalVariablesCallba out.print(" "); printType(out, interfaces[interfaces.length - 1]); } + if (isSealedStub) { + List permitted = getEffectivePermittedSubclasses(classNode); + if (!permitted.isEmpty()) { + out.println(); + out.print(" permits "); + for (int i = 0, n = permitted.size(); i < n; i += 1) { + if (i > 0) out.print(", "); + printType(out, permitted.get(i)); + } + } + } out.println(" {"); printFields(out, classNode, isInterface, isRecordStub); @@ -452,6 +490,31 @@ private boolean isNativeRecordStub(final ClassNode classNode) { return RecordTypeASTTransformation.wouldBeNativeRecord(classNode, targetBytecode); } + private boolean isNativeSealedStub(final ClassNode classNode) { + if (currentModule == null || currentModule.getContext() == null) return false; + String targetBytecode = currentModule.getContext().getConfiguration().getTargetBytecode(); + return wouldBeNativeSealed(classNode, targetBytecode); + } + + private boolean hasSealedSupertype(final ClassNode classNode) { + if (isNativeSupertypeSealed(classNode.getSuperClass())) return true; + ClassNode[] ifaces = classNode.getInterfaces(); + if (ifaces != null) { + for (ClassNode iface : ifaces) { + if (isNativeSupertypeSealed(iface)) return true; + } + } + return false; + } + + private boolean isNativeSupertypeSealed(final ClassNode supertype) { + if (supertype == null) return false; + // Already-compiled (e.g. JDK17+ sealed Java class loaded from bytecode). + if (supertype.isSealed() && !supertype.isPrimaryClassNode()) return true; + // Same compilation unit, will be emitted as native sealed. + return isNativeSealedStub(supertype); + } + private void printRecordHeader(final PrintWriter out, final ClassNode classNode) { List components = getInstanceProperties(classNode); out.print('('); @@ -1037,9 +1100,19 @@ private void printParams(final PrintWriter out, final MethodNode methodNode) { } private void printAnnotations(final PrintWriter out, final AnnotatedNode annotated) { + // @NonSealed and @SealedOptions have SOURCE retention, so they never appear in + // the bytecode surface a Java consumer sees — suppress them from the stub. + // @Sealed has RUNTIME retention; suppress it only when the source emits the + // sealed keyword and @SealedOptions(alwaysAnnotate=false) (parallels the + // bytecode-side filter in AsmClassGenerator). + boolean skipSealedAnno = annotated instanceof ClassNode cn + && isNativeSealedStub(cn) && sealedSkipAnnotationFromSource(cn); for (AnnotationNode annotation : annotated.getAnnotations()) { - if (!annotation.getClassNode().equals(PACKAGE_SCOPE_TYPE)) - printAnnotation(out, annotation); + ClassNode type = annotation.getClassNode(); + if (type.equals(PACKAGE_SCOPE_TYPE)) continue; + if (type.equals(NON_SEALED_TYPE) || type.equals(SEALED_OPTIONS_TYPE)) continue; + if (skipSealedAnno && type.equals(SEALED_TYPE)) continue; + printAnnotation(out, annotation); } } diff --git a/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java b/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java index 7a71b17334f..c2c10b84447 100644 --- a/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java +++ b/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java @@ -21,18 +21,21 @@ import groovy.transform.Sealed; import groovy.transform.SealedMode; import groovy.transform.SealedOptions; -import org.apache.groovy.lang.annotation.Incubating; import org.codehaus.groovy.ast.ASTNode; import org.codehaus.groovy.ast.AnnotatedNode; import org.codehaus.groovy.ast.AnnotationNode; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.expr.ClassExpression; +import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.ListExpression; import org.codehaus.groovy.ast.expr.PropertyExpression; import org.codehaus.groovy.control.CompilePhase; import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.control.SourceUnit; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static org.codehaus.groovy.ast.ClassHelper.make; @@ -109,7 +112,6 @@ public void visit(ASTNode[] nodes, SourceUnit source) { * * @return true for a native sealed class */ - @Incubating public static boolean sealedNative(AnnotatedNode node) { return node.getNodeMetaData(SealedMode.class) == SealedMode.NATIVE; } @@ -121,11 +123,101 @@ public static boolean sealedNative(AnnotatedNode node) { * * @return true if a {@code Sealed} annotation is not required for this node */ - @Incubating public static boolean sealedSkipAnnotation(AnnotatedNode node) { return Boolean.FALSE.equals(node.getNodeMetaData(SEALED_ALWAYS_ANNOTATE_KEY)); } + /** + * Reports whether {@code cNode} would receive native sealed bytecode for the + * given {@code targetBytecode} level. Independent of compile-phase ordering, + * so callers earlier than {@code SEMANTIC_ANALYSIS} (e.g. the joint-compile + * stub generator) can use it. + * + * @param cNode the class being checked + * @param targetBytecode the target bytecode level + * (see {@link CompilerConfiguration#getTargetBytecode()}) + */ + public static boolean wouldBeNativeSealed(final ClassNode cNode, final String targetBytecode) { + if (cNode == null || !cNode.isSealed()) return false; + if (targetBytecode == null || targetBytecode.trim().isEmpty()) return false; + if (!isAtLeast(targetBytecode, CompilerConfiguration.JDK17)) return false; + List opts = cNode.getAnnotations(SEALED_OPTIONS_TYPE); + AnnotationNode options = opts.isEmpty() ? null : opts.get(0); + return getMode(options, "mode") != SealedMode.EMULATE; + } + + /** + * Returns the explicitly-declared permitted-subclass list for {@code cNode}, + * either from the populated {@code permittedSubclasses} field (after the + * transform has run) or from the {@code @Sealed} annotation's + * {@code permittedSubclasses} member. An empty list means no explicit list + * is declared in source — inference happens later via + * {@code SealedCompletionASTTransformation}. Independent of compile-phase + * ordering, so callers earlier than {@code SEMANTIC_ANALYSIS} can use it. + */ + public static List getDeclaredPermittedSubclasses(final ClassNode cNode) { + if (cNode == null) return Collections.emptyList(); + List populated = cNode.getPermittedSubclasses(); + if (!populated.isEmpty()) return populated; + List result = new ArrayList<>(); + for (AnnotationNode anno : cNode.getAnnotations(SEALED_TYPE)) { + Expression m = anno.getMember("permittedSubclasses"); + if (m instanceof ListExpression le) { + for (Expression e : le.getExpressions()) { + if (e instanceof ClassExpression ce) result.add(ce.getType()); + } + } else if (m instanceof ClassExpression ce) { + result.add(ce.getType()); + } + } + return result; + } + + /** + * Returns the effective permitted-subclass list for {@code cNode}: the + * explicitly-declared list when present, otherwise the inferred list + * obtained by scanning the enclosing compilation unit for direct + * subtypes (mirroring {@code SealedCompletionASTTransformation}). Phase + * independent, so callers earlier than {@code CANONICALIZATION} can use it. + */ + public static List getEffectivePermittedSubclasses(final ClassNode cNode) { + List declared = getDeclaredPermittedSubclasses(cNode); + if (!declared.isEmpty()) return declared; + if (cNode == null || cNode.getModule() == null) return declared; + if (cNode.getAnnotations(SEALED_TYPE).isEmpty()) return declared; + List inferred = new ArrayList<>(); + for (ClassNode possibleSubclass : cNode.getModule().getClasses()) { + if (possibleSubclass.equals(cNode)) continue; + if (cNode.equals(possibleSubclass.getSuperClass())) { + inferred.add(possibleSubclass); + continue; + } + for (ClassNode iface : possibleSubclass.getInterfaces()) { + if (cNode.equals(iface)) { + inferred.add(possibleSubclass); + break; + } + } + } + return inferred; + } + + /** + * Reports whether the source for {@code cNode} uses + * {@code @SealedOptions(alwaysAnnotate=false)} — independent of compile-phase + * ordering, so callers earlier than {@code SEMANTIC_ANALYSIS} can use it. + */ + public static boolean sealedSkipAnnotationFromSource(final ClassNode cNode) { + if (cNode == null) return false; + for (AnnotationNode opts : cNode.getAnnotations(SEALED_OPTIONS_TYPE)) { + Expression m = opts.getMember("alwaysAnnotate"); + if (m instanceof ConstantExpression ce && Boolean.FALSE.equals(ce.getValue())) { + return true; + } + } + return false; + } + private static SealedMode getMode(AnnotationNode node, String name) { if (node != null) { final Expression member = node.getMember(name); diff --git a/src/spec/doc/_sealed.adoc b/src/spec/doc/_sealed.adoc index a64766483dc..de707b27365 100644 --- a/src/spec/doc/_sealed.adoc +++ b/src/spec/doc/_sealed.adoc @@ -19,7 +19,7 @@ ////////////////////////////////////////// -= Sealed hierarchies (incubating) += Sealed hierarchies Sealed classes, interfaces and traits restrict which subclasses can extend/implement them. Prior to sealed classes, class hierarchy designers had two main options: @@ -143,7 +143,7 @@ EMULATE:: Indicates the class is sealed using the `@Sealed` annotation. This mechanism works with the Groovy compiler for JDK8+ but is not recognised by the Java compiler. AUTO:: -Produces a native record for JDK17+ and emulates the record otherwise. +Produces a native sealed class for JDK17+ and emulates the sealed nature otherwise. Whether you use the `sealed` keyword or the `@Sealed` annotation is independent of the mode. diff --git a/src/test/groovy/bugs/Groovy11292.groovy b/src/test/groovy/bugs/Groovy11292.groovy deleted file mode 100644 index 3b5c3dfd094..00000000000 --- a/src/test/groovy/bugs/Groovy11292.groovy +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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 bugs - -import org.codehaus.groovy.control.CompilerConfiguration -import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit -import org.junit.jupiter.api.Test - -import static groovy.test.GroovyAssert.assertScript - -final class Groovy11292 { - - @Test - void testClassWithNonSealedParent1() { - assertScript '''import java.lang.ref.SoftReference // non-sealed type - - class TestReference extends SoftReference { - TestReference(T referent) { - super(referent) - } - } - - assert new TestReference(null) - ''' - } - - @Test - void testClassWithNonSealedParent2() { - def config = new CompilerConfiguration( - targetDirectory: File.createTempDir(), - jointCompilationOptions: [memStub: true] - ) - - def parentDir = File.createTempDir() - try { - def a = new File(parentDir, 'A.java') - a.write ''' - public sealed class A permits B {} - ''' - def b = new File(parentDir, 'B.java') - b.write ''' - public non-sealed class B extends A {} - ''' - def c = new File(parentDir, 'C.groovy') - c.write ''' - class C extends B {} - ''' - def d = new File(parentDir, 'D.groovy') - d.write ''' - class D extends C {} - ''' - def e = new File(parentDir, 'E.groovy') - e.write ''' - class E extends B {} - ''' - def f = new File(parentDir, 'F.groovy') - f.write ''' - class F extends E {} - ''' - - def loader = new GroovyClassLoader(this.class.classLoader) - def cu = new JavaAwareCompilationUnit(config, loader) - cu.addSources(a, b, c, d, e, f) - cu.compile() - } finally { - config.targetDirectory.deleteDir() - parentDir.deleteDir() - } - } - - // GROOVY-11768 - @Test - void testClassWithNonSealedParent3() { - def config = new CompilerConfiguration( - targetDirectory: File.createTempDir(), - jointCompilationOptions: [memStub: true] - ) - - def parentDir = File.createTempDir() - try { - def a = new File(parentDir, 'A.java') - a.write ''' - public abstract sealed class A permits B {} - ''' - def b = new File(parentDir, 'B.java') - b.write ''' - public abstract non-sealed class B extends A {} - ''' - def c = new File(parentDir, 'C.java') - c.write ''' - public class C extends B {} - ''' - def d = new File(parentDir, 'D.groovy') - d.write ''' - class D extends C {} - ''' - - def loader = new GroovyClassLoader(this.class.classLoader) - def cu = new JavaAwareCompilationUnit(config, loader) - cu.addSources(a, b, c, d) - cu.compile() - } finally { - config.targetDirectory.deleteDir() - parentDir.deleteDir() - } - } -} diff --git a/src/test/groovy/org/codehaus/groovy/transform/SealedJointCompilationTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/SealedJointCompilationTest.groovy new file mode 100644 index 00000000000..9f1a23b97b5 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/transform/SealedJointCompilationTest.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 org.codehaus.groovy.transform + +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript +import static groovy.test.GroovyAssert.isAtLeastJdk +import static groovy.test.GroovyAssert.shouldFail +import static org.junit.jupiter.api.Assumptions.assumeTrue + +/** + * Joint-compilation and decompiled-types matrix for sealed types per + * GEP-13 §"Joint compilation and decompiled types": + * + *
    + *
  • a Groovy class extending a Java sealed class is checked against the + * Java type's {@code permits} set;
  • + *
  • a Java class extending a Groovy sealed class likewise honours the + * Groovy {@code permits} set;
  • + *
  • for a class loaded from bytecode (without source available), + * non-sealed status is computed as: the parent is sealed AND + * this type is neither final nor sealed.
  • + *
+ * + * The reverse-direction tests rely on native sealed bytecode, which + * requires JDK 17+. + */ +final class SealedJointCompilationTest { + + private static void compileSources(Map sources) { + def config = new CompilerConfiguration( + targetDirectory: File.createTempDir(), + jointCompilationOptions: [memStub: true] + ) + def parentDir = File.createTempDir() + try { + def files = sources.collect { name, content -> + def f = new File(parentDir, name) + f.write(content) + f + } + def loader = new GroovyClassLoader(SealedJointCompilationTest.classLoader) + def cu = new JavaAwareCompilationUnit(config, loader) + cu.addSources(files as File[]) + cu.compile() + } finally { + config.targetDirectory.deleteDir() + parentDir.deleteDir() + } + } + + // GROOVY-11292 + @Test + void testExtendingDecompiledNonSealedJdkClass() { + assertScript '''import java.lang.ref.SoftReference // non-sealed type + + class TestReference extends SoftReference { + TestReference(T referent) { + super(referent) + } + } + + assert new TestReference(null) + ''' + } + + // GROOVY-11292 + @Test + void testJavaSealedJavaNonSealedGroovyDescendants() { + compileSources([ + 'A.java' : 'public sealed class A permits B {}', + 'B.java' : 'public non-sealed class B extends A {}', + 'C.groovy': 'class C extends B {}', + 'D.groovy': 'class D extends C {}', + 'E.groovy': 'class E extends B {}', + 'F.groovy': 'class F extends E {}' + ]) + } + + // GROOVY-11768 + @Test + void testJavaSealedAbstractNonSealedJavaIntermediateGroovyDescendant() { + compileSources([ + 'A.java' : 'public abstract sealed class A permits B {}', + 'B.java' : 'public abstract non-sealed class B extends A {}', + 'C.java' : 'public class C extends B {}', + 'D.groovy': 'class D extends C {}' + ]) + } + + @Test + void testJavaSealedDirectGroovyPermitted() { + compileSources([ + 'A.java' : 'public sealed class A permits B {}', + 'B.groovy': 'final class B extends A {}' + ]) + } + + @Test + void testJavaSealedGroovyNotPermitted() { + shouldFail { + compileSources([ + 'A.java' : 'public sealed class A permits B {}', + 'B.java' : 'public final class B extends A {}', + 'C.groovy': 'class C extends A {}' + ]) + } + } + + @Test + void testGroovySealedJavaPermitted() { + assumeTrue(isAtLeastJdk('17.0')) + compileSources([ + 'A.groovy': 'sealed class A permits B {}', + 'B.java' : 'public final class B extends A {}' + ]) + } + + // Groovy sealed -> Groovy implicit-non-sealed intermediate -> Java descendant. + // The stub for the Groovy intermediate must declare non-sealed for javac to + // accept it as a permitted subtype of the sealed parent. + @Test + void testGroovySealedImplicitNonSealedGroovyIntermediateJavaDescendant() { + assumeTrue(isAtLeastJdk('17.0')) + compileSources([ + 'A.groovy': 'sealed class A permits B {}', + 'B.groovy': 'class B extends A {}', + 'C.java' : 'public class C extends B {}' + ]) + } + + // Inferred permits (sealed in source, permitted subtypes inferred from the + // same compilation unit). Stub-gen must surface the inferred list so javac + // sees the sealed contract. + @Test + void testGroovySealedInferredPermitsJavaConsumer() { + assumeTrue(isAtLeastJdk('17.0')) + // Positive: Java permitted subtype that IS the inferred one. + compileSources([ + 'AB.groovy': '@groovy.transform.Sealed class A {}\nfinal class B extends A {}', + // No-op file referring to A's permitted subtype B just to exercise the surface. + 'Use.java' : 'public class Use { public B b() { return null; } }' + ]) + // Negative: Java tries to extend A but isn't in the inferred permits. + shouldFail { + compileSources([ + 'AB.groovy': '@groovy.transform.Sealed class A {}\nfinal class B extends A {}', + 'C.java' : 'public final class C extends A {}' + ]) + } + } + + @Test + void testGroovySealedJavaNotPermitted() { + assumeTrue(isAtLeastJdk('17.0')) + shouldFail { + compileSources([ + 'A.groovy': 'sealed class A permits B {}', + 'B.java' : 'public final class B extends A {}', + 'C.java' : 'public final class C extends A {}' + ]) + } + } +} diff --git a/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy index 95bca0d7585..85f38f7a968 100644 --- a/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy +++ b/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy @@ -263,4 +263,117 @@ class SealedTransformTest { new Foo() ''' } + + // GEP-13: 'sealed' and 'permits' are restricted identifiers, not reserved + // keywords, so they remain usable as identifiers outside type-declaration + // contexts. + @Test + void testRestrictedIdentifiers() { + assertScript ''' + def sealed = 42 + def permits = [1, 2, 3] + assert sealed == 42 + assert permits == [1, 2, 3] + ''' + assertScript ''' + int compute(int sealed, List permits) { sealed + permits.size() } + assert compute(10, [1, 2]) == 12 + ''' + } + + // GEP-13: a permitted subtype with no sealed-related modifier is implicitly + // non-sealed; descendants past that boundary are unconstrained. + @Test + void testImplicitNonSealedPropagation() { + assertScript ''' + sealed interface Shape permits Polygon, Circle {} + final class Circle implements Shape {} + class Polygon implements Shape {} // implicit non-sealed + class RegularPolygon extends Polygon {} // unrestricted + class Hexagon extends RegularPolygon {} // unrestricted + + assert new Circle() instanceof Shape + assert new Polygon() instanceof Shape + assert new RegularPolygon() instanceof Shape + assert new Hexagon() instanceof Shape + ''' + } + + // GEP-13: anonymous classes are not in the permits set and so cannot + // extend or implement a sealed type. + @Test + void testAnonymousClassNotPermitted() { + shouldFail(MultipleCompilationErrorsException, ''' + sealed interface Shape permits Circle {} + final class Circle implements Shape {} + def x = new Shape() {} + ''') + } + + // GEP-13: coercion-generated proxies must observe the permits set; + // 'x as Sealed' from a non-permitted source must fail. + @Test + void testCoercionFromNonPermittedSource() { + shouldFail ''' + sealed interface Bar permits Foo {} + final class Foo implements Bar {} + def b = (new Object()) as Bar + ''' + } + + // GEP-13: @Sealed has RUNTIME retention; @NonSealed and @SealedOptions + // have SOURCE retention. EMULATE mode ensures @Sealed is the sole + // runtime carrier of sealed info, so its presence at runtime proves + // the retention contract. + @Test + void testAnnotationRetentions() { + assertScript ''' + import groovy.transform.NonSealed + import groovy.transform.Sealed + import groovy.transform.SealedMode + import groovy.transform.SealedOptions + + @SealedOptions(mode = SealedMode.EMULATE) + sealed class Shape permits Circle {} + @NonSealed class Circle extends Shape {} + + assert Shape.getAnnotation(Sealed) != null + assert Circle.getAnnotation(NonSealed) == null + assert Shape.getAnnotation(SealedOptions) == null + ''' + } + + // GEP-13: @Delegate targeting a sealed type implicitly makes the + // enclosing class implement that sealed type, which is illegal when + // the enclosing class is not in the permits set. + // GEP-13: @Delegate must not introduce a non-permitted subtype of a + // sealed type. For sealed interfaces, DelegateASTTransformation omits + // the 'implements' clause (see GROOVY-7288); for sealed classes the + // wrapper never extends the target at all (single inheritance). + @Test + void testDelegateDoesNotMakeWrapperASealedSubtype() { + // Sealed interface — implicit and explicit interfaces=true. + assertScript ''' + sealed interface Bar permits Foo {} + final class Foo implements Bar { void hi() {} } + + class WrapperImplicit { @Delegate Bar inner = new Foo() } + class WrapperExplicit { @Delegate(interfaces=true) Bar inner = new Foo() } + + assert !(new WrapperImplicit() instanceof Bar) + assert !WrapperImplicit.interfaces.contains(Bar) + assert !(new WrapperExplicit() instanceof Bar) + assert !WrapperExplicit.interfaces.contains(Bar) + ''' + // Sealed class — wrapper neither extends nor implements it. + assertScript ''' + sealed class Bar permits Foo { String name } + final class Foo extends Bar { Foo() { name = 'foo' } } + + class Wrapper { @Delegate Bar inner = new Foo() } + + assert Wrapper.superclass == Object + assert !(new Wrapper() instanceof Bar) + ''' + } }