Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions pkl-core/src/main/java/org/pkl/core/ast/type/TypeNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,8 @@ public TypeNode execute(VirtualFrame frame) {
return FunctionNClassTypeNodeGen.create(sourceSection, resolvedTypeArgumentNodes);
}

// erase `x: Class<Foo>` 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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`.

Expand Down
156 changes: 156 additions & 0 deletions pkl-core/src/test/kotlin/org/pkl/core/ClassTypeTest.kt
Original file line number Diff line number Diff line change
@@ -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<Type> = Class<Type>

output {
text =
"\(C is Class<A>)\n" +
"\(C is Class<B>)\n" +
"\(A is Class<A>)\n" +
"\(A is Class<B>)\n" +
"\(C is Class<AAlias>)\n" +
"\(C is ClassOf<A>)\n" +
"\(C is ClassOf<B>)\n" +
"\(List is Class<List>)"
}
"""
.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<PklException> {
evaluator.evaluate(
ModuleSource.text(
"""
open class A
open class B
value: Class<A> = 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> = 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<A | B>",
"Class<A?>",
"Class<A(isSubclassOf(A))>",
"Class<List<String>>",
"Class<ListOfString>",
"Class<ClassOf<List<String>>>",
"Class<\"A\">",
"Class<Int8>",
"Class<module>",
"Class<nothing>",
"Class<unknown>",
)
.forEach { type ->
Evaluator.preconfigured().use { evaluator ->
val exception =
assertThrows<PklException> {
evaluator.evaluate(
ModuleSource.text(
"""
open class A
open class B
typealias ListOfString = List<String>
typealias ClassOf<Type> = Class<Type>
value: $type = A
"""
.trimIndent()
)
)
}

assertThat(exception).hasMessageContaining("`Class` type arguments must be class types.")
}
}
}
}