Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
top-level extension property getters (`pkg.prop`, receiver in `receiverTypeName`). Properties take no
part in overload naming or in a constructor's name check; a property key another binding already
holds is declined, and so is a property whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope). `Status: implemented` — `GP/artifact/PropertyBindingTest.kt`.
The public instance functions of a Kotlin-public `object` bind under the object's name like a
static (`Arrangement.spacedBy`, #53), except `Any`'s members, ones that also exist as a
`@JvmStatic` static, and `@Composable` ones (declined); an `internal` or file-private object is
skipped. `Status: implemented` — `GP/artifact/ObjectMemberBindingTest.kt`.
- **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` —
`GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's
declarations are declined with reasons; no klib declaration is bound at run time yet.
Expand Down Expand Up @@ -261,7 +265,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
`tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in
its module is `Any`. A property is a `@property` (with a setter for a `var`) of its receiver's stub
class, its docstring carrying its table key after `Kotlin property: `; where that class has no stub,
a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`).
a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). An object constant is annotated with its
declared type even when that type is nested in the object (`Alignment.End: Horizontal`, a class of the
object's own module; elsewhere `androidx.compose.ui.Alignment.Horizontal`); the object's own type
stays `Any` (#53, `GP/stubs/ObjectStubTest.kt`).
- **B-8** CI generates the stubs over the Compose version the build resolves and publishes them
(`.github/workflows/stubs.yml`): workflow artifact `kotlin-stubs` on every push to `develop`, with a
README naming the Compose version and the commit, and `kotlin-stubs.zip` on every `v*` tag's release.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ internal object ArtifactScanner {
* `KotlinClassMetadata` instead. */
private const val KOTLIN_KIND_CLASS = 1

/** What `kotlin.Any` gives every object and an `object` may override; not part of its API. */
private val ANY_MEMBER_SIGNATURES = setOf(
"toString" to "()Ljava/lang/String;",
"hashCode" to "()I",
"equals" to "(Ljava/lang/Object;)Z",
)

/**
* Every binding this jar (or, for a test fixture, this directory of `.class` files -- see
* `ArtifactClasspath`) offers, sorted by name.
Expand Down Expand Up @@ -398,6 +405,9 @@ internal object ArtifactScanner {
functions = functionsOf(metadata.kmClass).filterNot { it.isExtension },
ownerNode = node,
classpath = classpath,
// The rule `objectConstantCandidates` uses: Kotlin visibility from metadata, never the
// JVM flags (an `internal object` is a JVM-public class).
bindObjectMembers = metadata.kmClass.visibility == Visibility.PUBLIC,
) + constructorCandidates(metadata.kmClass, node, classpath) +
objectConstantCandidates(metadata.kmClass, node, classpath) +
memberPropertyCandidates(metadata.kmClass, node, classpath)
Expand Down Expand Up @@ -566,10 +576,20 @@ internal object ArtifactScanner {
functions: List<ResolvedFunction>,
ownerNode: ClassNode,
classpath: ArtifactClasspath,
bindObjectMembers: Boolean = false,
): List<Candidate> {
val bySignature = functions.associateBy { it.jvmSignature.name to it.jvmSignature.descriptor }
// A Kotlin `object`'s functions are instance methods of its singleton (issue #53:
// `Arrangement.spacedBy`); generated source reaches them as `Owner.name(...)`, so they bind
// like a static. A composable one is declined below -- its thunk is a static call.
val isObject = ownerIsClass && bindObjectMembers && ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) }
val staticSignatures = ownerNode.methods.filter { it.access.hasFlag(Opcodes.ACC_STATIC) }.map { it.name to it.desc }.toSet()
return ownerNode.methods
.filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) && it.access.hasFlag(Opcodes.ACC_STATIC) }
.filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) }
.filter {
it.access.hasFlag(Opcodes.ACC_STATIC) ||
(isObject && (it.name to it.desc) !in staticSignatures && (it.name to it.desc) !in ANY_MEMBER_SIGNATURES)
}
.filter { !it.access.hasFlag(Opcodes.ACC_BRIDGE) }
.filter { !it.name.startsWith("<") && '$' !in it.name }
.mapNotNull { method ->
Expand Down Expand Up @@ -602,6 +622,12 @@ internal object ArtifactScanner {
declinedCandidate(owner, ownerIsClass, suspending, classpath, "suspend", isComposable(method))
}
if (function.isSuspend) return@mapNotNull declinedCandidate(owner, ownerIsClass, function, classpath, "suspend", isComposable(method))
if (!method.access.hasFlag(Opcodes.ACC_STATIC) && isComposable(method)) {
return@mapNotNull declinedCandidate(
owner, ownerIsClass, function, classpath,
"a @Composable member of an object: its generated call is a static invocation", true,
)
}
// A member whose Kotlin-declared parameter count does not match its JVM parameter
// count has an implicit JVM parameter metadata does not account for -- a value
// class's own instance turned into an unboxed receiver, or a composable's synthetic
Expand Down Expand Up @@ -1394,11 +1420,15 @@ internal object ArtifactScanner {
valueClassUnboxOwners = declaredSlots.map { it.unboxOwner } + List(syntheticTags.size) { null },
),
),
// `paramTags` is slot-aligned, so an extension's receiver is slot 0 and `model.parameters`
// (receiver excluded) starts at slot 1. Reading it unshifted gave `NavigationBarItem`'s
// `colors` the tag of `alwaysShowLabel`: `bool` in the stub (issue #53).
declaration = model.copy(
bindingName = qualifiedName,
returnBoundaryTag = returnTag,
receiverBoundaryTag = if (function.isExtension) paramTags.firstOrNull() else null,
parameters = model.parameters.mapIndexed { index, parameter ->
parameter.copy(boundaryTag = paramTags.getOrNull(index))
parameter.copy(boundaryTag = paramTags.getOrNull(index + (if (function.isExtension) 1 else 0)))
},
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import python.multiplatform.gradle.model.ValueClassModel
* `PaddingValues` the interface) cannot be both in one Python module, and the runtime resolves the
* name to the function, so such a type is annotated `Any`.
*
* A Kotlin `object` (`Alignment`, `Arrangement`) is a module of its constants and functions, and the
* types nested in it (`Alignment.Horizontal`) are classes of that same module, so
* `Alignment.End` is annotated `Horizontal` rather than `Any` (issue #53). The object's own type
* (`Alignment.Center: Alignment`) has no class to name and stays `Any`.
*
* ### Extension functions are also methods
*
* The runtime attaches an extension to the receiver's proxy class (`PythonxAdapter._attach`), reached
Expand Down Expand Up @@ -282,8 +287,22 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
private fun classReference(type: KotlinTypeModel, ctx: ModuleOut): String =
classReference(type.qualifiedName, type.valueClass, ctx)

/**
* [classRefOf], except that a type nested in a Kotlin `object` whose constants and functions are
* a module of their own (issue #53) is a class **of that module**: `Alignment.Horizontal` is
* `Horizontal` in `androidx.compose.ui.Alignment`, not a class `Alignment` in `androidx.compose.ui`
* (a module and a class cannot share the name, which is why this used to fall back to `Any`).
* The object's own type (`Alignment`) has no such home and stays unresolved here.
*/
private fun resolveRef(qualifiedName: String): ClassRef? {
val ref = classRefOf(qualifiedName) ?: return null
val objectModule = "${ref.pkg}.${ref.top}"
if (ref.path.size < 2 || objectModule !in moduleNames || ref.top in takenNames[ref.pkg].orEmpty()) return ref
return ClassRef(objectModule, ref.path.drop(1))
}

private fun classReference(qualifiedName: String, valueClass: ValueClassModel?, ctx: ModuleOut): String {
val ref = classRefOf(qualifiedName) ?: return ANY
val ref = resolveRef(qualifiedName) ?: return ANY
if (ref.top in takenNames[ref.pkg].orEmpty() || "${ref.pkg}.${ref.top}" in moduleNames) return ANY
if (qualifiedName !in classes || classes[qualifiedName] == null) classes[qualifiedName] = valueClass ?: classes[qualifiedName]
val path = ref.path.joinToString(".")
Expand Down Expand Up @@ -437,7 +456,7 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
.groupBy({ it.first }, { it.second })

private fun renderClass(qualifiedName: String) {
val ref = classRefOf(qualifiedName) ?: return
val ref = resolveRef(qualifiedName) ?: return
val ctx = out(ref.pkg)
val node = ctx.node(ref.path)
node.qualifiedName = qualifiedName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,4 +610,33 @@ class ComposableBindingTest {
}
}
}

/**
* Issue #53: an extension composable's declared parameters carry **their own** boundary tags.
*
* The composable path tags every JVM slot, receiver first, and the declaration model lists the
* value parameters without the receiver; pairing them by index shifted every tag by one, so
* `RowScope.NavigationBarItem`'s `colors: NavigationBarItemColors` read the tag of the
* `alwaysShowLabel: Boolean` before it and the stub annotated it `bool`.
*/
@Test
fun anExtensionComposablesParametersAreTaggedByTheirOwnType() {
val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return
val items = ArtifactScanner.scanDeclarations(
material3,
includePrefixes = listOf("androidx.compose.material3"),
classpath = composeClasspath(),
).filter { it.simpleName == "NavigationBarItem" && it.bindingName != null }
assertTrue(items.isNotEmpty(), "NavigationBarItem is bound")
items.forEach { item ->
assertEquals("androidx.compose.foundation.layout.RowScope", item.receiver?.qualifiedName)
assertEquals("OBJECT", item.receiverBoundaryTag, "the receiver's own tag")
val byName = item.parameters.associateBy { it.name }
assertEquals("BOOLEAN", byName["selected"]?.boundaryTag)
byName["alwaysShowLabel"]?.let { assertEquals("BOOLEAN", it.boundaryTag) }
val colors = byName.getValue("colors")
assertEquals("androidx.compose.material3.NavigationBarItemColors", colors.type.qualifiedName)
assertEquals("OBJECT", colors.boundaryTag, "${item.bindingName}: colors is an object, not the Boolean before it")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package python.multiplatform.gradle.artifact

import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/**
* Issue #53: the functions of a Kotlin `object` are bound, not only its constants.
*
* `Arrangement.spacedBy` is an instance method of the `Arrangement` singleton -- `INSTANCE.spacedBy`
* in bytecode -- so a walk that looked only at `ACC_STATIC` methods bound `Arrangement.End` and never
* `Arrangement.spacedBy`, and the stub (which renders what is bound) could not carry it. Generated
* Kotlin source calls it as `Arrangement.spacedBy(x)`, which the compiler lowers and mangles; nothing
* here looks a JVM method up by name.
*/
class ObjectMemberBindingTest {

private val fixtureClasses: File
get() = File(Class.forName("fixture.artifactobject.Gap").protectionDomain.codeSource.location.toURI())

private fun declarations() =
ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactobject"))

private val spacing = "fixture.artifactobject.Spacing"

@Test
fun anObjectFunctionIsBoundUnderTheObjectsName() {
val describe = declarations().single { it.simpleName == "describe" }
assertEquals("$spacing.describe", describe.bindingName)
assertEquals(spacing, describe.owner)
assertTrue(describe.ownerIsClass)
assertEquals("FUNCTION", describe.kind)
assertEquals("fixture.artifactobject.Gap", describe.parameters.single().type.qualifiedName)
assertEquals("kotlin.String", describe.returnType.qualifiedName)
}

@Test
fun theOverloadsOfAnObjectFunctionAreToldApart() {
val bound = declarations().filter { it.simpleName == "spacedBy" }.mapNotNull { it.bindingName }.sorted()
assertEquals(listOf("$spacing.spacedBy__Int", "$spacing.spacedBy__Int_String"), bound)
}

@Test
fun theObjectsConstantIsStillBound() {
val tight = declarations().single { it.simpleName == "Tight" }
assertEquals("STATIC_GETTER", tight.kind)
assertEquals("$spacing.Tight", tight.bindingName)
}

@Test
fun anObjectsToStringOverrideIsNotBound() {
assertFalse(declarations().any { it.simpleName == "toString" && it.bindingName != null })
}

@Test
fun aJvmStaticObjectMemberIsBoundOnce() {
val twice = declarations().filter { it.simpleName == "twice" }
assertEquals(listOf("fixture.artifactobject.StaticSpacing.twice"), twice.mapNotNull { it.bindingName })
}

@Test
fun anOrdinaryClassInstanceMethodIsStillNotBound() {
assertTrue(declarations().none { it.simpleName == "instanceMethod" && it.bindingName != null })
}

@Test
fun anInternalObjectsMembersAreNotBoundOrDeclared() {
assertTrue(declarations().none { it.simpleName == "hiddenFunction" })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Fixture, not production source: compiled into this module's own test classes and walked as
// bytecode by `ObjectMemberBindingTest`, the way `defaultsfixture` is walked by `DefaultOmissionTest`.
//
// A package of its own, so that the tests which pin another fixture package's output as an exact
// list are not disturbed.
package fixture.artifactobject

class Gap(val size: Int)

/** The `Arrangement` shape: a constant, an overloaded function, and a function taking the object's own
* kind of value -- all *instance* methods of a singleton, which JVM bytecode cannot call statically. */
object Spacing {
val Tight: Gap = Gap(1)

fun spacedBy(count: Int): Gap = Gap(count)

fun spacedBy(count: Int, label: String): Gap = Gap(count + label.length)

fun describe(gap: Gap): String = "gap " + gap.size

override fun toString(): String = "Spacing"
}

/** `@JvmStatic` members are already static; they must not be bound twice. */
object StaticSpacing {
@JvmStatic
fun twice(count: Int): Int = count * 2
}

/** Not an object: an ordinary class's instance methods need an instance Python cannot make here. */
class Plain {
fun instanceMethod(count: Int): Int = count
}

/** JVM-public, Kotlin-internal: generated source outside this module cannot name it. */
internal object HiddenSpacing {
fun hiddenFunction(count: Int): Int = count
}
Loading
Loading