· 17 min read Posted by Tadeas Kriz

The Power of SKIE Subplugins

Generate Swift code for your Kotlin Multiplatform project using SKIE subplugins.
Wolfgang Weiser - https://unsplash.com/photos/a-large-metal-machine-sitting-inside-of-a-building-vEZH5p6GuIo
Credit: Wolfgang Weiser - https://unsplash.com/photos/a-large-metal-machine-sitting-inside-of-a-building-vEZH5p6GuIo

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.

  1. General features SKIE doesn’t have yet,
  2. 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 return null, as default is not to generate Identifiable conformance.
  • The pair of deserialize and serialize methods, to map IdentifiableConfig? to and from String?.
  • 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 of IdentifiableConfig.

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.