diff --git a/pkl-core/src/main/java/org/pkl/core/ast/type/TypeNode.java b/pkl-core/src/main/java/org/pkl/core/ast/type/TypeNode.java index 925dc3029..96a5138cd 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/type/TypeNode.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/type/TypeNode.java @@ -777,6 +777,102 @@ protected boolean acceptTypeNode(boolean visitTypeArguments, TypeNodeConsumer co } } + public static final class ClassValueTypeNode extends ValidatingObjectSlotTypeNode { + @Child private TypeNode typeArgumentNode; + + public ClassValueTypeNode(SourceSection sourceSection, TypeNode typeArgumentNode) { + super(sourceSection); + this.typeArgumentNode = typeArgumentNode; + validate(); + } + + @Override + protected Object executeLazily(VirtualFrame frame, Object value) { + var typeArgumentClass = getTypeArgumentClass(); + if (value instanceof VmClass vmClass + && (typeArgumentClass == null || typeArgumentClass.isSuperclassOf(vmClass))) { + return value; + } + + throw typeMismatch(value, BaseModule.getClassClass()); + } + + private @Nullable VmClass getTypeArgumentClass() { + var unaliasedTypeArgumentNode = getUnaliasedTypeArgumentNode(); + return unaliasedTypeArgumentNode instanceof TypeVariableNode + ? null + : unaliasedTypeArgumentNode.getVmClass(); + } + + private TypeNode getUnaliasedTypeArgumentNode() { + var result = typeArgumentNode; + while (result instanceof TypeAliasTypeNode typeAliasTypeNode) { + result = typeAliasTypeNode.getAliasedTypeNode(); + } + return result; + } + + @Override + protected String getValidationErrorKey() { + return "invalidClassTypeArgument"; + } + + @Override + protected @Nullable Node getViolatingNode() { + var unaliasedTypeArgumentNode = getUnaliasedTypeArgumentNode(); + if (unaliasedTypeArgumentNode instanceof TypeVariableNode) return null; + + return unaliasedTypeArgumentNode.getVmClass() != null + && unaliasedTypeArgumentNode.getVmTypeAlias() == null + && !(unaliasedTypeArgumentNode instanceof FinalModuleTypeNode) + && !(unaliasedTypeArgumentNode instanceof NonFinalModuleTypeNode) + && !unaliasedTypeArgumentNode.isParametric() + ? null + : unaliasedTypeArgumentNode; + } + + @Override + protected boolean isIncludedInTrace(Node node) { + return node instanceof ClassValueTypeNode; + } + + @Override + public VmClass getVmClass() { + return BaseModule.getClassClass(); + } + + @Override + public VmList getTypeArgumentMirrors() { + return VmList.of(typeArgumentNode.getMirror()); + } + + @Override + public boolean doIsEquivalentTo(TypeNode other) { + if (!(other instanceof ClassValueTypeNode classValueTypeNode)) { + return false; + } + return typeArgumentNode.isEquivalentTo(classValueTypeNode.typeArgumentNode); + } + + @Override + protected PType doExport() { + return new PType.Class(BaseModule.getClassClass().export(), typeArgumentNode.doExport()); + } + + @Override + protected boolean acceptTypeNode(boolean visitTypeArguments, TypeNodeConsumer consumer) { + if (visitTypeArguments) { + return consumer.accept(this) && typeArgumentNode.acceptTypeNode(true, consumer); + } + return consumer.accept(this); + } + + @Override + protected boolean isParametric() { + return true; + } + } + public static class NullableTypeNode extends WriteFrameSlotTypeNode { @Child private TypeNode elementTypeNode; diff --git a/pkl-core/src/main/java/org/pkl/core/ast/type/UnresolvedTypeNode.java b/pkl-core/src/main/java/org/pkl/core/ast/type/UnresolvedTypeNode.java index 22544d37b..10c1368e4 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/type/UnresolvedTypeNode.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/type/UnresolvedTypeNode.java @@ -278,9 +278,8 @@ public TypeNode execute(VirtualFrame frame) { return FunctionNClassTypeNodeGen.create(sourceSection, resolvedTypeArgumentNodes); } - // erase `x: Class` to `x: Class` for now (cf. function types) if (clazz.isClassClass()) { - return new FinalClassTypeNode(sourceSection, clazz); + return new ClassValueTypeNode(sourceSection, typeArgumentNodes[0].execute(frame)); } if (clazz.isVarArgsClass()) { diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index 30bdff601..61cc52030 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -212,6 +212,9 @@ Expected a module as argument, but got an object that amends a module. wrongTypeArgumentCount=\ Expected {0} type argument(s) but got {1}. +invalidClassTypeArgument=\ +`Class` type arguments must be class types. + duplicateDefinition=\ Duplicate definition of member `{0}`. diff --git a/pkl-core/src/test/kotlin/org/pkl/core/ClassTypeTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/ClassTypeTest.kt new file mode 100644 index 000000000..33e6515aa --- /dev/null +++ b/pkl-core/src/test/kotlin/org/pkl/core/ClassTypeTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed 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 + * + * https://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.pkl.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.pkl.core.runtime.BaseModule + +class ClassTypeTest { + @Test + fun `Class type checks preserve type argument`() { + Evaluator.preconfigured().use { evaluator -> + val output = + evaluator.evaluateOutputText( + ModuleSource.text( + """ + open class A + open class B + class C extends A + typealias AAlias = A + typealias ClassOf = Class + + output { + text = + "\(C is Class)\n" + + "\(C is Class)\n" + + "\(A is Class)\n" + + "\(A is Class)\n" + + "\(C is Class)\n" + + "\(C is ClassOf)\n" + + "\(C is ClassOf)\n" + + "\(List is Class)" + } + """ + .trimIndent() + ) + ) + + assertThat(output).isEqualTo("true\nfalse\ntrue\nfalse\ntrue\ntrue\nfalse\ntrue") + } + } + + @Test + fun `Class type annotation rejects a non-subclass`() { + Evaluator.preconfigured().use { evaluator -> + val exception = + assertThrows { + evaluator.evaluate( + ModuleSource.text( + """ + open class A + open class B + value: Class = B + """ + .trimIndent() + ) + ) + } + + assertThat(exception).hasMessageContaining("Expected value of type `Class`") + } + } + + @Test + fun `Class type argument is preserved in exported schema`() { + Evaluator.preconfigured().use { evaluator -> + val schema = + evaluator.evaluateSchema( + ModuleSource.text( + """ + class A + value: Class = A + """ + .trimIndent() + ) + ) + + val classType = schema.moduleClass.properties.getValue("value").type as PType.Class + assertThat(classType.pClass).isEqualTo(BaseModule.getClassClass().export()) + + val typeArgument = classType.typeArguments.single() as PType.Class + assertThat(typeArgument.pClass).isSameAs(schema.classes.getValue("A")) + } + } + + @Test + fun `stdlib Class type argument can be a type variable`() { + Evaluator.preconfigured().use { evaluator -> + val output = + evaluator.evaluateOutputText( + ModuleSource.text( + """ + output { + text = List(1, "Pigeon").filterIsInstance(String).first + } + """ + .trimIndent() + ) + ) + + assertThat(output).isEqualTo("Pigeon") + } + } + + @Test + fun `Class type arguments must be class types`() { + listOf( + "Class", + "Class", + "Class", + "Class>", + "Class", + "Class>>", + "Class<\"A\">", + "Class", + "Class", + "Class", + "Class", + ) + .forEach { type -> + Evaluator.preconfigured().use { evaluator -> + val exception = + assertThrows { + evaluator.evaluate( + ModuleSource.text( + """ + open class A + open class B + typealias ListOfString = List + typealias ClassOf = Class + value: $type = A + """ + .trimIndent() + ) + ) + } + + assertThat(exception).hasMessageContaining("`Class` type arguments must be class types.") + } + } + } +}