Skip to content

Commit 9154caa

Browse files
authored
Merge pull request #239 from thisisthepy/feat/klib-member-functions
Klib walker: bind member functions of classes and interfaces (#237)
2 parents d8eafe0 + 555edcf commit 9154caa

6 files changed

Lines changed: 349 additions & 11 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,19 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
410410
declined (#73). `Status: implemented`, `GP/artifact/TextStateBindingTest.kt`,
411411
`python-multiplatform-compose/fixtures/compose/.../TextFieldStateRenderTest.kt`.
412412
- **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial`. It reads
413-
top-level functions only: no class constructor, property or member function yet (B-1, #215).
413+
top-level functions and, since #237, the **member functions** of the klib's classes; no class
414+
constructor or property yet. A member binds as #215's `METHOD` `Owner.name` (receiver in `args[0]`,
415+
`(args[0] as Owner).name(...)`, served on the proxy, never a module attribute) when its class can
416+
cross as a handle and is an interface, a final class or an enum class: the pinned compiler's klib
417+
ABI records modality but no visibility, so a member of an open, abstract or sealed class, which
418+
may be `protected`, is declined with that reason, and an `object`'s functions (statics on the jar
419+
side, #53) are declined too. Also declined, with reasons: members of a generic class, `suspend`,
420+
`@Deprecated`, `@PublishedApi`, `@Composable`, member extensions and context receivers, a member
421+
named like a property, data-class `copy`/`componentN`, and anything without a klib boundary type;
422+
`Any`'s members and an enum's static `valueOf`/`values`/`entries` are not bound. From
423+
`kotlinx-coroutines-core` this binds `CompletableJob.complete`, `Job.start`,
424+
`Semaphore.tryAcquire`/`release` and others; `GP/artifact/KlibMemberFunctionTest.kt`, and on the iOS
425+
simulator `WalkedKlibArtifactMemberFunctionTest`.
414426
Since #234 a **Kotlin object crosses as a handle**: a type whose class is a public, non-generic,
415427
non-value `class`, `interface`, `enum class` or `object` of the walked klib is an `OBJECT` slot
416428
or result, non-null or nullable, cast in generated source as the jar producer casts one, and a
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package fixture.artifact
2+
3+
import python.multiplatform.ffi.Python3
4+
import python.multiplatform.ffi.pythonx.PythonxAdapter
5+
import python.multiplatform.ffi.upcall.PythonProxySource
6+
import python.multiplatform.generated.FunctionTable
7+
import python.multiplatform.generated.artifacts.ArtifactTable
8+
import python.multiplatform.reflection.UpcallTable
9+
import python.native.ffi.UpcallEntry
10+
import kotlin.test.AfterTest
11+
import kotlin.test.BeforeTest
12+
import kotlin.test.Test
13+
14+
/**
15+
* Issue #237 on Kotlin/Native: member functions of the walked klib's interfaces, called from Python on
16+
* the handles its factories return, through the generated table and the binding layer -- #215's path,
17+
* from a klib.
18+
*
19+
* - `Job(None)` is a `CompletableJob`: `complete()` is `True` the first time and `False` after, and
20+
* `start()`, declared on its supertype `Job`, is reached through the ancestry the table carries.
21+
* - A `Semaphore` of two permits gives two and refuses a third until one is released.
22+
*
23+
* Red before #237: `complete` and `try_acquire` are `AttributeError`s, because no member was bound.
24+
*/
25+
class WalkedKlibArtifactMemberFunctionTest {
26+
27+
@BeforeTest
28+
fun installBothLayers() {
29+
Python3.initialize(silent = true)
30+
UpcallTable.clear()
31+
UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
32+
val mainGlobals = Python3.import("__main__").getAttr("__dict__")
33+
check(UpcallEntry.publish(mainGlobals.pointer)) { "the upcall bootstrap could not be published" }
34+
PythonProxySource.install()
35+
PythonxAdapter.install(emptySet())
36+
}
37+
38+
@AfterTest
39+
fun cleanup() {
40+
UpcallTable.clear()
41+
}
42+
43+
@Test
44+
fun aJobsMembersRunOnTheKotlinJob() {
45+
Python3.exec(
46+
"""
47+
from kotlinx.coroutines import Job
48+
_km_job = Job(None)
49+
assert _km_job.complete() is True, 'complete() did not complete the job'
50+
assert _km_job.complete() is False, 'a completed job completed twice'
51+
_km_lazy = Job(None)
52+
assert _km_lazy.start() is False, 'Job() is already active, so start() is False'
53+
del _km_job, _km_lazy
54+
""".trimIndent(),
55+
)
56+
}
57+
58+
@Test
59+
fun aSemaphoresPermitsAreCountedByKotlin() {
60+
Python3.exec(
61+
"""
62+
from kotlinx.coroutines.sync import Semaphore
63+
_km_s = Semaphore(2, 0)
64+
assert _km_s.try_acquire() is True
65+
assert _km_s.try_acquire() is True
66+
assert _km_s.try_acquire() is False, 'a third permit out of two'
67+
_km_s.release()
68+
assert _km_s.try_acquire() is True, 'a released permit was not given back'
69+
del _km_s
70+
""".trimIndent(),
71+
)
72+
}
73+
}

‎python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,33 @@ class WalkedKlibArtifactTableTest {
5757
/**
5858
* The fixed text: everything `KlibScanner` bound out of the whole `kotlinx-coroutines-core` klib
5959
* under the `kotlinx.coroutines` namespace. Zero before issue #234, when no Kotlin object could
60-
* cross the klib boundary; since then the factories whose types are the klib's own classes.
61-
* `KlibScannerTest.aRealThirdPartyKlibIsScannedWithoutCrashingAndBindsItsHandleFactories` pins the
60+
* cross the klib boundary; since then the factories whose types are the klib's own classes, and
61+
* since #237 the members of its interfaces and final classes.
62+
* `KlibScannerTest.aRealThirdPartyKlibIsScannedWithoutCrashingAndBindsItsHandleFactoriesAndMembers` pins the
6263
* same list walking the klib directly; if the two disagree, the wiring between `KlibScanner` and
6364
* this generated table is what broke.
6465
*/
6566
@Test
66-
fun theWalkerBindsExactlyTheKlibsHandleFactories() {
67+
fun theWalkerBindsExactlyTheKlibsHandleFactoriesAndMembers() {
6768
ArtifactTable.registerInto()
6869
assertEquals(
6970
listOf(
71+
"kotlinx.coroutines.ChildJob.parentCancelled",
72+
"kotlinx.coroutines.CompletableJob.complete",
73+
"kotlinx.coroutines.DisposableHandle.dispose",
7074
"kotlinx.coroutines.Job",
75+
"kotlinx.coroutines.Job.attachChild",
76+
"kotlinx.coroutines.Job.start",
7177
"kotlinx.coroutines.MainScope",
78+
"kotlinx.coroutines.Runnable.run",
7279
"kotlinx.coroutines.SupervisorJob",
80+
"kotlinx.coroutines.TimeoutCancellationException.createCopy",
7381
"kotlinx.coroutines.newFixedThreadPoolContext",
7482
"kotlinx.coroutines.newSingleThreadContext",
7583
"kotlinx.coroutines.sync.Mutex",
7684
"kotlinx.coroutines.sync.Semaphore",
85+
"kotlinx.coroutines.sync.Semaphore.release",
86+
"kotlinx.coroutines.sync.Semaphore.tryAcquire",
7787
),
7888
UpcallTable.entries().map { it.name }.filter { it.startsWith("kotlinx.") }.sorted(),
7989
)

‎python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt‎

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import org.jetbrains.kotlin.library.abi.AbiClassifierReference
66
import org.jetbrains.kotlin.library.abi.AbiDeclarationContainer
77
import org.jetbrains.kotlin.library.abi.AbiCompoundName
88
import org.jetbrains.kotlin.library.abi.AbiFunction
9+
import org.jetbrains.kotlin.library.abi.AbiModality
10+
import org.jetbrains.kotlin.library.abi.AbiProperty
911
import org.jetbrains.kotlin.library.abi.AbiQualifiedName
1012
import org.jetbrains.kotlin.library.abi.AbiType
1113
import org.jetbrains.kotlin.library.abi.AbiTypeArgument
@@ -187,6 +189,7 @@ internal object KlibScanner {
187189
return emptyList()
188190
}
189191

192+
val classes = classesOf(abi.topLevelDeclarations)
190193
val candidates = abi.topLevelDeclarations.declarations
191194
.filterIsInstance<AbiFunction>()
192195
.filter { matchesInclude(it.qualifiedName, includePrefixes) }
@@ -196,10 +199,132 @@ internal object KlibScanner {
196199
// receiver handle to be worth anything. Filtered rather than declined for the same reason
197200
// `ArtifactScanner` never builds a model for `<init>`.
198201
.filter { !it.isConstructor }
199-
.mapNotNull { candidateOrNull(it, classesOf(abi.topLevelDeclarations)) }
202+
.mapNotNull { candidateOrNull(it, classes) }
200203

201-
// Same rule, same implementation as the JVM producer -- see this object's KDoc.
202-
return ArtifactScanner.disambiguateOverloads(candidates)
204+
// Issue #237: the member functions of the klib's classes, as #215 binds them from a jar.
205+
val members = classes.values
206+
.filter { matchesInclude(it.qualifiedName, includePrefixes) }
207+
.filter { !ArtifactScanner.isExcludedPackage(it.qualifiedName.packageName.toString(), excludePackages) }
208+
.flatMap { memberCandidatesOf(it, classes) }
209+
210+
// Same rule, same implementation as the JVM producer -- see this object's KDoc. A member's key
211+
// is `Owner.name`, so its overload set is its class's alone.
212+
return ArtifactScanner.disambiguateOverloads(candidates + members)
213+
}
214+
215+
/**
216+
* The member functions of [cls] (issue #237), as `METHOD` entries `Owner.name` -- the shape #215
217+
* gives a jar's members and KSP gives its own: `args[0]` is the receiver, not counted in `arity`,
218+
* and the generated call is `(args[0] as Owner).name(...)`. The binding layer serves them as
219+
* methods of the receiver's proxy, and never as a module attribute.
220+
*
221+
* The class must be able to cross as a handle ([handleClassOrNull]); a generic one's members are
222+
* declined, as on the jar side. A member is declined, with its reason, when it is `suspend`,
223+
* `@Deprecated`, `@PublishedApi`, `@Composable`, a member extension or one with context receivers,
224+
* named like a property of its class (the property's name is the attribute), or typed by something
225+
* without a klib boundary type. Constructors are not read here (stage 3).
226+
*/
227+
private fun memberCandidatesOf(cls: AbiClass, classes: Map<String, AbiClass>): List<Candidate> {
228+
val owner = cls.qualifiedName.dotted()
229+
val ownerType = KotlinTypeModel(owner)
230+
val generic = cls.typeParameters.isNotEmpty()
231+
if (!generic && cls.kind != AbiClassKind.OBJECT && handleClassOrNull(ownerType, classes) == null) return emptyList()
232+
val propertyNames = cls.declarations.filterIsInstance<AbiProperty>()
233+
.mapTo(HashSet()) { it.qualifiedName.relativeName.toString().substringAfterLast('.') }
234+
// The pinned compiler's klib ABI records modality but no visibility, and lists the `protected`
235+
// members of an open, abstract or sealed class beside its public ones; source outside a subclass
236+
// cannot call a `protected` one. An interface has none, and a final class's are effectively
237+
// private, so only those members bind. An `object`'s functions are statics on the jar side (#53),
238+
// called without a receiver, which this `METHOD` shape is not.
239+
val unbindable = when {
240+
cls.kind == AbiClassKind.OBJECT -> "a function of an object: bound as a static on the jar side (#53), not yet from a klib"
241+
cls.kind == AbiClassKind.CLASS && cls.modality != AbiModality.FINAL ->
242+
"a member of a non-final class: this klib ABI does not say whether it is protected"
243+
else -> null
244+
}
245+
val enum = cls.kind == AbiClassKind.ENUM_CLASS
246+
return cls.declarations.filterIsInstance<AbiFunction>()
247+
.filter { !it.isConstructor }
248+
// `Any`'s members are not the class's API, as on the jar side; an enum class's
249+
// `valueOf`/`values`/`entries` are statics the ABI lists among its members, and no
250+
// instance call reaches them.
251+
.filter { function -> !isAnyMember(function) && !(enum && function.leafName() in ENUM_STATICS) }
252+
.mapNotNull { function -> memberCandidateOrNull(function, owner, ownerType, generic, unbindable, propertyNames, classes) }
253+
}
254+
255+
private fun memberCandidateOrNull(
256+
function: AbiFunction,
257+
owner: String,
258+
ownerType: KotlinTypeModel,
259+
generic: Boolean,
260+
unbindable: String?,
261+
propertyNames: Set<String>,
262+
classes: Map<String, AbiClass>,
263+
): Candidate? {
264+
val name = function.leafName()
265+
val model = declarationModelOf(function)?.copy(
266+
simpleName = name,
267+
owner = owner,
268+
ownerIsClass = true,
269+
receiver = ownerType,
270+
receiverBoundaryTag = OBJECT_TAG,
271+
kind = METHOD_KIND,
272+
) ?: return null
273+
val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) }
274+
when {
275+
function.isSuspend -> return declined("suspend")
276+
generic -> return declined("a member of a generic class: not bound yet")
277+
unbindable != null -> return declined(unbindable)
278+
function.hasAnnotation(DEPRECATED) ->
279+
return declined("@Deprecated: its level is not in this klib ABI, and a HIDDEN one cannot be called from source")
280+
function.hasAnnotation(PUBLISHED_API) ->
281+
return declined("@PublishedApi internal -- binary-visible for inlining, not part of the module's public Kotlin API")
282+
function.hasAnnotation(COMPOSABLE) -> return declined("a @Composable member function: not bound on a proxy")
283+
function.hasExtensionReceiverParameter -> return declined("a member extension: it needs two receivers")
284+
function.contextReceiverParametersCount > 0 ->
285+
return declined("${function.contextReceiverParametersCount} context receiver(s), whose types this klib ABI does not expose")
286+
name in propertyNames -> return declined("shares its name with a property of the class, which keeps the attribute")
287+
// A data class's `copy`/`componentN` are synthetics the jar side never binds (U-4), and this
288+
// ABI cannot tell them from a hand-written member of the same name.
289+
name == "copy" || DATA_COMPONENT.matches(name) ->
290+
return declined("a data-class synthetic (copy/componentN), or a member this klib ABI cannot tell from one")
291+
}
292+
val returnType = klibBoundaryTypeOf(model.returnType, classes)
293+
?: return declined("no boundary type for return ${model.returnType.render()}")
294+
val boundaryTypes = model.parameters.map { parameter ->
295+
klibBoundaryTypeOf(parameter.type, classes)
296+
?: return declined("no boundary type for parameter ${parameter.type.render()}")
297+
}
298+
if (boundaryTypes.any { it.isReturnOnly }) return declined("a parameter of a return-only boundary type")
299+
300+
val key = "$owner.$name"
301+
val argumentExpressions = boundaryTypes.mapIndexed { index, type -> type.read("args[${index + 1}]") }
302+
val body = returnType.wrapReturn("(args[0] as $owner).$name(${argumentExpressions.joinToString(", ")})")
303+
val callable = ArtifactCallable(
304+
name = key,
305+
arity = boundaryTypes.size,
306+
paramTags = boundaryTypes.map { it.tag },
307+
returnTag = returnType.tag,
308+
// The receiver is read even with no declared parameter, so the body always takes `args`.
309+
lambdaBody = "{ args -> $body }",
310+
receiverTypeName = owner,
311+
paramTypeNames = model.parameters.map { it.type.qualifiedName },
312+
returnTypeName = model.returnType.qualifiedName,
313+
returnSupertypes = if (returnType.tag == OBJECT_TAG) supertypesOf(model.returnType.qualifiedName, classes) else emptyList(),
314+
// Every argument is passed, as for a top-level klib entry (see [candidateOrNull]).
315+
paramHasDefault = List(boundaryTypes.size) { false },
316+
kind = METHOD_KIND,
317+
)
318+
return Candidate(
319+
callable = callable,
320+
declaration = model.copy(
321+
bindingName = key,
322+
returnBoundaryTag = returnType.tag,
323+
parameters = model.parameters.mapIndexed { index, parameter ->
324+
parameter.copy(boundaryTag = boundaryTypes[index].tag, declaresDefault = false)
325+
},
326+
),
327+
)
203328
}
204329

205330
/**
@@ -471,6 +596,21 @@ internal object KlibScanner {
471596

472597
private const val OBJECT_TAG = "OBJECT"
473598

599+
private const val METHOD_KIND = "METHOD"
600+
601+
private val ENUM_STATICS = setOf("valueOf", "values", "entries")
602+
603+
private val DATA_COMPONENT = Regex("component[1-9][0-9]*")
604+
605+
private fun AbiFunction.leafName(): String = qualifiedName.relativeName.toString().substringAfterLast('.')
606+
607+
/** `toString()`, `hashCode()` and `equals(other)`: what every class has from `Any`. */
608+
private fun isAnyMember(function: AbiFunction): Boolean = when (function.leafName()) {
609+
"toString", "hashCode" -> function.valueParameters.isEmpty()
610+
"equals" -> function.valueParameters.size == 1
611+
else -> false
612+
}
613+
474614
/**
475615
* The walked klib's class [type] names, when a handle of it can cross (issue #234): a public
476616
* (the ABI lists nothing else) `class`, `interface`, `enum class` or `object` that is not a value

0 commit comments

Comments
 (0)