· 17 min read Posted by Tadeas Kriz
The Power of SKIE Subplugins
WARNING: While SKIE subplugin APIs are powerful and versatile, they are not documented and we make no guarantees to the API stability.
When we started working on SKIE, we wanted to bring support for plugins. However, the more we learnt about what’s necessary for SKIE to work well, the more we realized that plugin support would be way more work than we could take on. As SKIE evolved, so did the APIs and abstractions SKIE uses internally. And although SKIE doesn’t have an official plugin support, we have a so-called subplugin API which we use in SKIE and its tests.
We call plugins for SKIE subplugins, because SKIE itself is kind of a Kotlin Compiler plugin.
Preface
Today we’d like to pull the curtain on SKIE subplugin API and present what can be done with it. Throughout all this, please keep in mind that we can’t make no guarantees on the SKIE subplugin API stability. Therefore all we’ll be showing in this post is meant to live in your Kotlin Multiplatform project. While it’s possible to build a plugin as a third party library, we don’t recommend it due to the complexity it brings.
Click to learn more about why…
SKIE currently supports Kotlin versions from 1.8.0 to 2.1.0. To achieve this, we have a complex build configuration that allows us to specify “version breaks”. These are Kotlin versions that are different from previous versions and require a separate compilation of SKIE runtime code (which is bundled to your KMP application) and shims used by SKIE to communicate with Kotlin compiler.
When a SKIE subplugin needs to provide some configuration, it has to declare a KMP module which will then be used by the target KMP application, which makes it part of the runtime. That would then require any SKIE subplugin to compile this library the same way SKIE compiles its runtime library due to Kotlin/Native ABI differences. Otherwise the subplugin wouldn’t be able to support the whole range of Kotlin versions SKIE supports. What makes everything more complex for SKIE subplugins would be supporting multiple versions of SKIE (especially since the subplugin API may change in any release).
All that changes if you decide to build a SKIE subplugin for your own Kotlin Multiplatform project. Your project will always have a specific version of Kotlin and SKIE, and you have full control over changing these versions. Therefore each time you update SKIE to a newer version, even if the subplugin API changes, Kotlin compiler will surface those changes as compilation errors in your subplugin’s sources.
What do we build
Before we jump into the “how”, let’s talk a bit about what we can build. SKIE subplugins can leverage all of SKIE’s API and in certain cases can even interact with the Kotlin/Native compiler API. The API surface is too large to enumerate it all, so instead let’s split features into two categories.
- General features SKIE doesn’t have yet,
- Specific features useful for your project.
General features SKIE doesn’t have yet
Adding new features to SKIE is complex, because we need to make sure those features work for all users, are properly tested and don’t break other SKIE features. The SKIE test suite contains hundreds of handwritten acceptance tests that verify specific features and how they interact with each other. We also test SKIE against thousands of KMP libraries available at Maven Central, to make sure SKIE features work with existing libraries.
Therefore our backlog of proposed features is quite long, and a feature you really need might take time to get built into SKIE. As long as you follow the SKIE changelog when updating SKIE, it’s safe to implement a feature like that for your project using a SKIE subplugin. Just keep in mind that when we add the feature to SKIE itself, it might work differently to yours and you may need to decide which one to keep and configure SKIE accordingly.
Specific features useful for your project
Some features are so niche, they may never be added to SKIE. It might be because those features couldn’t be generalized enough to work with all the other features, or are truly specific to your project. In this case, go and build a subplugin.
Whatever you decide to build, please let us know either on the Kotlinlang Slack in #touchlab-tools channel, or through a discussion in the SKIE GitHub repository. We’re always happy to see novel SKIE uses.
For this post,
we’ll build a feature that many people asked for and may one day be added to SKIE.
An @Identifiable
annotation,
which makes SKIE generate an Identifiable
conformance for a Kotlin type.
How
We’ll build all of this in KaMPKit, but you may build it directly in your KMP project.
Configuring Gradle
Let’s start by creating a new Kotlin Multiplatform module,
which will contain the @Identifiable
annotations,
let’s call it example-skie-subplugin-api
(you may choose a different name).
This module should declare support for the same targets your project does (for KaMPKit that’d be iOS and Android).
Additionally,
make sure to include the jvm()
target to make it accessible for our subplugin code too.
The build.gradle.kts
should look something like this:
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
jvm()
}
android {
namespace = "co.touchlab.example.skie_subplugin_api"
compileSdk = 34
}
Notice we apply the kotlin("multiplatform")
and id("com.android.library")
plugins without specifying a version.
Since we’re adding this code to KaMPKit,
it already has versions for these plugins configured in the build.gradle.kts
in the root of the repository.
Then create the @Identifiable
annotation in the commonMain
sourceset and add a String
parameter called property
.
Then add a new companion object into the annotation class
and declare a SELF_IDENTITY
constant inside of it.
The value of this constant is up to you,
but it should be something unique that won’t conflict with any property name.
For our example we’ll use <self>
as property names can’t contain angle brackets.
Then assign this constant as a default value for the property
declaration.
We’ll use this parameter to change the behavior of the generated Swift code.
When the value is equal to SELF_IDENTITY
,
the target class’ instance will be its identity.
If set to a specific property name,
that property will determine the identity.
package co.touchlab.example
@Target(AnnotationTarget.CLASS)
annotation class Identifiable(
val property: String = SELF_IDENTITY,
) {
companion object {
const val SELF_IDENTITY = "<self>"
}
}
Then create a new JVM module
and call it example-skie-subplugin
(you may choose a different name).
Open the build.gradle.kts
of the newly created module,
and configure it like so:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
}
dependencies {
compileOnly("co.touchlab.skie:kotlin-compiler-core:0.10.0")
implementation(project(":example-skie-subplugin-api"))
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs += "-Xcontext-receivers"
}
}
IMPORTANT: Make sure to use the same SKIE version for the kotlin-compiler-core
dependency as you use for the SKIE plugin applied in shared
module.
We recommend using the gradle/libs.versions.toml
file to manage all your dependencies and versions,
but for this tutorial we used the dependency directly for simplicity.
NOTE: We won’t be looking at ways to provide configuration from Gradle build scripts to the subplugin. While it’s important if you need to configure behavior of 3rd party dependencies, it makes the whole project setup much more complex.
Notice we have to add support for context receivers,
by adding the compiler argument -Xcontext-receivers
.
SKIE API uses context receivers
and therefore to use the API,
we need to enable them in our subplugin module.
This module is using the kotlin("jvm")
Gradle plugin.
Make sure to put kotlin("jvm") version "2.1.0" apply false
to the build.gradle.kts
in the root of your project.
Then open the build.gradle.kts
of the shared
module (or a module where you apply SKIE, if you’re not using KaMPKit)
and add the module as a SKIE subplugin dependency
along with adding the -api
module as a dependency to the commonMain
source set:
dependencies {
skieSubPlugin(project(":example-skie-subplugin"))
}
kotlin {
commonMain.dependencies {
implementation(project(":example-skie-subplugin-api"))
}
}
Sync the Gradle project in IntelliJ IDEA or Android Studio
and apply the @Identifiable
to one of the public classes in the shared
module’s code,
for example the BreedViewModel
.
@Identifiable
class BreedViewModel(
We’ll also create a class on which we provide the property
parameter,
let’s call it IdentifiableWithPropertyTest
:
@Identifiable(property = "name")
class IdentifiableWithPropertyTest(
val name: String,
)
That should be enough for SKIE to load our plugin,
but the implementation is currently empty,
so nothing would happen when you build.
So let’s head to the example-skie-subplugin
code to implement it.
Creating a custom SKIE phase
Simiar to compilers,
SKIE has phases that run during the compilation.
To see all phases implemented in SKIE,
look for classes implementing the ScheduledPhase
interface.
There are multiple phase types and each is used for different type of work
and you can see all of them in LinkerPhaseScheduler
class.
For our example,
we’ll be implementing a SirPhase
,
which allows us to access all exported declarations as their SIR
and to generate new SIR.
SIR is a representation of Swift declarations,
both for Kotlin-exported declarations and new generated declarations.
Create a new Kotlin file in the example-skie-subplugin
module
and declare a class GenerateIdentifiableConformancesPhase
in it.
Make this class implement the interface SirPhase
.
Then override the function execute()
and add a single println
inside to verify our phase is invoked once we register it in the next section.
object GenerateIdentifiableConformancesPhase: SirPhase {
context(SirPhase.Context)
override suspend fun execute() {
println("This is where we'll generate Identifiable conformance")
}
}
Registering SKIE phases
The phase we just created needs to first be registered to the SKIE scheduler,
otherwise SKIE wouldn’t know about it.
Create a new Kotlin file in the example-skie-subplugin
module
and declare a class ExampleSkieSubplugin
inside of it.
It should implement the SkiePluginRegistrar
interface,
which allows us to register a custom SKIE phase.
We need to implement the register(initPhaseContext: InitPhase.Context)
function,
where we’ll access the skiePhaseScheduler
from the context
and register our new phase.
We’ll need to find the ConvertSirIrFilesToSourceFilesPhase
,
which is the SKIE phase that generates new Swift files from SIR declarations.
Declaring new SIR after that phase would result in our Swift code not being generated.
And if we were to add our phase too early,
it could miss out on some of the lowering SKIE does.
class ExampleSkieSubplugin: SkiePluginRegistrar {
override fun register(initPhaseContext: InitPhase.Context) {
initPhaseContext.skiePhaseScheduler.sirPhases.modify {
val indexOfCodeGenerator = indexOfFirst { phase ->
phase is ConvertSirIrFilesToSourceFilesPhase
}
add(indexOfCodeGenerator, GenerateIdentifiableConformancesPhase)
}
}
}
Lastly we need to register ExampleSkieSubplugin
so that SKIE can load it using the Java services API.
Create a new file called co.touchlab.skie.spi.SkiePluginRegistrar
in src/main/resources/META-INF/services
in the example-skie-subplugin
module.
To this file add a single line containing the fully qualified name of ExampleSkieSubplugin
.
co.touchlab.example.ExampleSkieSubplugin
After this,
run the linkDebugFrameworkIosSimulatorArm64
(or any other link
task) in the shared
module.
Assuming everything is setup correctly,
you should see the message “This is where we’ll generate Identifiable conformance” in the Gradle output.
Adding configuration key for @Identifiable
SKIE doesn’t expose annotations directly in its API, instead it relies on declaring configuration keys that can be used to expose configuration annotations. SKIE is usually applied to the module producing Kotlin Multiplatform framework, so users often need to configure third party dependencies they don’t have control over. And since they can’t edit the sources of said dependencies, they can configure SKIE’s behavior using a Gradle DSL.
Therefore we need to declare our own configuration key,
which will expose our @Identifiable
annotation to our custom SKIE phase.
SKIE declares multiple base key types which handle serialization for us,
but in our case we’ll implement the ConfigurationKey<T>
directly for full control.
We’ll also declare a data class
to hold our configuration information.
Let’s create that first,
by declaring a new data class IdentifiableConfig
with a single String?
property propertyName
.
Since we couldn’t use optional in our @Identifiable
annotation for the property’s name,
we’ll need to map to it to match our IdentifiableConfig
.
That way our SKIE phase won’t have to compare the propertyName
to Identifiable.SELF_IDENTITY
,
but will just check if the propertyName
is null.
data class IdentifiableConfig(
val propertyName: String?,
)
Next we need to declare the configuration key itself.
It should be an object,
since we need it to be unique and implement the ConfigurationKey<IdentifiableConfig?>
and ConfigurationScope.Class
interfaces.
The IdentifiableConfig
type argument needs to be optional,
because we only want to generate the Identifiable
conformance for classes with the annotation.
The ConfigurationScope.Class
interface tells SKIE this key can only be applied to classes.
object IdentifiableConfigurationKey: ConfigurationKey<IdentifiableConfig?>, ConfigurationScope.Class {
}
There are a few declarations that we need to implement from the ConfigurationKey<T>
interface:
- The
defaultValue
property which will returnnull
, as default is not to generateIdentifiable
conformance. - The pair of
deserialize
andserialize
methods, to mapIdentifiableConfig?
to and fromString?
. - The
hasAnnotationValue
method which checks if a passed in configuration target has@Identifiable
annotation. - Lastly the
getAnnotationValue
method which gets the@Identifiable
annotation and maps it to an instance ofIdentifiableConfig
.
How you implement these methods is up to you and your usecase.
For our example,
we’ve taken a simple route of mapping null
value to “not identfiable”,
a value of Identifiable.SELF_IDENTITY
as “self-identifiable”
and any other value as “identifiable by {value} property”.
package co.touchlab.example
import co.touchlab.skie.configuration.ConfigurationKey
import co.touchlab.skie.configuration.ConfigurationScope
import co.touchlab.skie.configuration.ConfigurationTarget
import co.touchlab.skie.configuration.findAnnotation
object IdentifiableConfigurationKey: ConfigurationKey<IdentifiableConfig?>, ConfigurationScope.Class {
override fun deserialize(value: String?): IdentifiableConfig? {
return when (value) {
null -> null
Identifiable.SELF_IDENTITY -> IdentifiableConfig(null)
else -> IdentifiableConfig(value)
}
}
override fun serialize(value: IdentifiableConfig?): String? {
return when {
value == null -> null
value.propertyName == null -> Identifiable.SELF_IDENTITY
else -> value.propertyName
}
}
override val defaultValue: IdentifiableConfig? = null
override fun hasAnnotationValue(configurationTarget: ConfigurationTarget): Boolean =
configurationTarget.findAnnotation<Identifiable>() != null
override fun getAnnotationValue(configurationTarget: ConfigurationTarget): IdentifiableConfig? =
configurationTarget.findAnnotation<Identifiable>()?.let {
deserialize(it.property)
}
}
The deserialize
and serialize
methods are used for the Gradle DSL configuration,
so we wouldn’t need to implement them,
since we won’t be using it.
However, we’re including them for completness.
Last thing we need to do is register this newly created configuration key to SKIE in our ExampleSkieSubplugin
.
Without this step,
SKIE wouldn’t consider our key when analyzing the Kotlin declarations
and we wouldn’t be able to query for the configuration.
class ExampleSkieSubplugin: SkiePluginRegistrar {
override val customConfigurationKeys: Set<ConfigurationKey<*>> = setOf(
IdentifiableConfigurationKey,
)
override fun register(initPhaseContext: InitPhase.Context) { ... }
}
Implementing our SKIE phase
Now that we’ve got everything set up,
we can implement the SKIE phase to find all exported classes with the @Identifiable
annotation
and generate Swift extensions for those classes to add conformance for Swift’s Identifiable
protocol.
Head back to the GenerateIdentifiableConformancesPhase
declaration
and remove the println
inside the execute
method.
We’ll implement the whole plugin in this single method,
but for a more complex plugin you should separate the implementation to multiple methods,
or classes in order to minimize technical debt.
First we need to declare a SirClass
the Identifiable
protocol.
SKIE contains some of Swift builtin declarations in the SirBuiltins
class,
which we can access through the sirBuiltins
property in the execute
method (it’s available through the SirPhase.Context
).
However, only types that we needed for existing SKIE features are available and the Identifiable
protocol isn’t one of them.
val identifiableProtocol = SirClass(
kind = SirClass.Kind.Protocol,
baseName = "Identifiable",
parent = sirBuiltins.Swift.declarationParent,
origin = sirBuiltins.Swift.origin,
)
Because the Identifiable
protocol is declared in the Swift standard library,
we’re using the declarationParent
and origin
from the Swift
module declared in SirBuiltins
.
This is important,
because SKIE’s SIR declarations need to match the Swift code they represent.
Then we can access the exported Kotlin classes using the kirProvider.kotlinClasses
.
For each of these classes,
we need to ask for our IdentifiableConfig
configuration with our configuration key
and skip the classes that don’t have it.
If the propertyName
in the config isn’t null,
we also want to find a property with that name inside the class.
kirProvider.kotlinClasses.forEach { kirClass ->
val identifiableConfig = kirClass.configuration[IdentifiableConfigurationKey] ?: return@forEach
val identityProperty = identifiableConfig.propertyName?.let { propertyName ->
kirClass.callableDeclarations
.filterIsInstance<KirProperty>()
.singleOrNull { it.kotlinName == propertyName } ?: error("Property $propertyName not found in class ${kirClass.kotlinFqName}!")
}
}
Notice we use the kotlinName
to compare with propertyName
,
because the name of the property in Swift might differ from how we originally called it in our Kotlin declaration.
Before we create a new SIR declaration which will add Identifiable
conformance to our class,
let’s take a look at the Swift code we expect to generate.
In Swift we can add protocol conformance to existing classes using Swift’s extension
declarations.
When no property
parameter is provided to our @Identifiable
annotation,
we want to generate a simple conformance extension like the one below:
extension BreedViewModel: Identifiable { }
But when the property
parameter is provided,
we want to generate an id
property declaration,
returning the value of the specified property.
For our example class IdentifiableWithPropertyTest
,
the expected Swift code should look like:
extension IdentifiableWithPropertyTest: Identifiable {
public var id: String {
return name
}
}
We’ll also need to refer to the SirClass
declaration of the KirClass
,
and the SirProperty
for the KirProperty
that we’ll use as the identity property in case it was specified.
For our example,
we can use the primarySirClass
and primarySirProperty
.
val sirClass = kirClass.primarySirClass
val sirIdentityProperty = identityProperty?.primarySirProperty
Now let’s create the new declarations.
SKIE’s SIR handles automatically adding declarations to parents,
as long as we create new instances of these declarations in a scope of a parent.
To do that,
we’ll be using the .apply { }
method
and simply instantiate the declarations inside of it.
To begin we need a SirIrFile
instance which will be the parent for our extension
declaration.
Since we have an instance of a KirClass
,
we can use namespaceProvider.getNamespaceFile
to get the file.
Then we create an instance of SirExtension
,
providing it with the target class declaration and our identifiable protocol type from above.
namespaceProvider.getNamespaceFile(kirClass).apply {
SirExtension(
classDeclaration = sirClass,
superTypes = listOf(
identifiableProtocol.defaultType,
)
)
}
Notice the defaultType
,
which gives us a SirType
instance for a given SirClass
declaration.
This distinction becomes important for declarations with type parameters,
where you’d use toType
function and provide all the type argument instances.
Now that we have the extension declaration in place,
we need to declare the id
property if an identity property was specified.
For that we’ll instantiate a SirProperty
inside the SirExtension
scope,
providing it the type of our identity property.
And since we need this property to be a computed one,
we’ll instantiate a SirGetter
inside the property’s scope
and configure it with the Swift code returning the value of the identity property.
SirExtension(...).apply {
if (sirIdentityProperty != null) {
SirProperty(
identifier = "id",
type = sirIdentityProperty.type,
).apply {
SirGetter().apply {
bodyBuilder.add {
addStatement("return %L", sirIdentityProperty.identifier)
}
}
}
}
}
The bodyBuilder
holds lambdas used to generate Swift code.
Currently SKIE uses SwiftPoet API to generate the Swift,
but this might change in a future release.
Now run the linkDebugFrameworkIosSimulatorArm64
(or any other link
task) in the shared
module.
The build should be successful,
and we can take a look at the generated Swift code,
to verify it’s correct.
The SKIE-generated Swift sources for this task are located in build/skie/binaries/debugFramework/DEBUG/iosSimulatorArm64/swift/generated
.
The file Shared/Shared.BreedViewModel.swift
should contain:
extension shared.BreedViewModel : Swift.Identifiable {
}
And the file Shared/Shared.IdentifiableWithPropertyTest.swift
should contain:
extension shared.IdentifiableWithPropertyTest : Swift.Identifiable {
public var id: Swift.String {
return name
}
}
Conclusion
Congratulations,
you just wrote a SKIE subplugin.
Depending on your Swift proficiency,
you may have noticed we glossed over some important details in the plugin implementation.
For example,
consider what would happen if a Kotlin class has an id
property already,
or if the @Identifiable
annotation’s property
parameter is set to id
.
Before we bring a new feature to SKIE,
we research how it should work,
what are the limitations
and what we can do to workaround the limitations.
For a custom subplugins that only affect your own project,
you can ignore certain issues,
since you can always fix it if the issue arise.
We’ve also just barely scratched the surface of the SKIE API and what’s possible with SKIE subplugins.
IMPORTANT: One last time we’d like to mention that the SKIE API for subplugins is not stable and we make no guarantees to its stability and existence between versions. If something is missing in the API that your company needs, book a meeting with our sales team to talk about it.
You can find the full implementation at touchlab/SKIE-subplugin-example.