After prototyping my game with KorGE, I hit a roadblock. I wanted more advanced features in my game, but the support from KorGE was not that good. It has problems with complex particles, camera movement, or z-indexing. So I decided to rewrite my game in Unity and C#.
But I didn’t want to maintain the code base in 2 different languages. I wanted to keep the main codebase in Kotlin and generate most of the C# code. There are no sophisticated converters to convert Kotlin to C#. So I had to rewrite most of my logic in C#. I have quite a large shared code base between the front-end and game-server back-end. It consists of Event- and Request-DTOs for communication. I refused to copy those. I would abstain from the horror that comes with synchronizing changes across languages. So I decided to give code generation a try.
I experimented with the Kotlin annotation processor(kapt) before. I was sure that it could deliver what I needed.
An annotation processor executes as part of the compiler. It processes annotation on classes, methods, parameters, and so on.
You can use existing annotations like @Nullable
or @Nonnull
. You can also define new annotations to use. My first goal was to transform my simple Kotlin data classes into C# code. I wanted to transform the fields while ignoring all methods.
So if we would take this data class:
@ExportCSharp
data class Point(val x: Double, val y: Double) {
fun toVector() = Vector2D.of(x, y)
}
@ExportCSharp
is my annotation to mark all data classes for export.
It should produce this C# code:
public class Point{
public double x;
public double y;
}
I use Gradle as my build tool and if you write a Gradle plugin you can add an annotation processor to your plugin. You will need to make a class implementing javax.annotation.processing.AbstractProcessor
:
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@SupportedAnnotationTypes
@SupportedOptions(DataClassAnnotationProcessor.OUTPUT_DIR)
class DataClassAnnotationProcessor : AbstractProcessor() {
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
}
}
As you can see, you only need to implement one method to start with annotation processing. But, for the compiler to know which annotations you process you will need another method too.
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@SupportedAnnotationTypes
@SupportedOptions(DataClassAnnotationProcessor.OUTPUT_DIR)
class DataClassAnnotationProcessor : AbstractProcessor() {
override fun getSupportedAnnotationTypes(): Set<String> = setOf(ExportCSharp::class.java.canonicalName)
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
}
}
The annotation processor takes multiple rounds to work through all your files.
The method roundEnv.getElementsAnnotatedBy()
gives us all elements of the current round that have the specified annotation.
We can use this information to construct the C# code.
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(ExportCSharp::class.java)
.filter { it.kind == ElementKind.CLASS }
.forEach { dataClass ->
buildDataClass(dataClass)
}
private fun buildDataClass(dataClass: Element) {
val builder = StringBuilder()
// Some type need the System namespace
builder.appendLine("using System;\n\n")
// To be able to deserialize it
builder.appendLine("[Serializable]")
builder.appendLine("public class ${dataClass.simpleName}{")
dataClass.enclosedElements
.filter { it.kind == ElementKind.FIELD }
.forEach {
// Transformation of the types
builder.appendLine(buildElement(it))
}
builder.appendLine("}")
// outputDir() is just a directory in the gradle build directory
File(outputDir() + "/" + dataClass.simpleName + ".cs").writeText(builder.toString())
}
private fun buildElement(element: Element): String {
// We are searching for any Nullable annotation
val isNullable = element.annotationMirrors.any { it.toString().contains("Nullable") }
// buildType replaces Kotlin with C# types
val type = buildType(element.asType().toString(), isNullable)
return " public $type ${element.simpleName};"
}
}
This part was rather simple but one could argue that the main problem is not addressed yet.
How do I get the correct C#-types from the kotlin types? I found a rather uncreative solution to this problem. There are C# equivalents for the most basic types of Kotlin:
internal val cSharpTypes = mapOf(
"double" to "double",
"String" to "string",
"java.util.UUID" to "Guid",
"java.lang.Long" to "long?",
"java.lang.Integer" to "int?",
"feign.Response" to "string",
"boolean" to "bool",
"net.mayope.raids.common.event.AbilityEvent" to "AbilityEvent",
"java.time.LocalDateTime" to "DateTime"
)
You can transform these basic types by replacing them.
Generic types (e.g. Lists, Maps, or AtomicReferences) are harder to transform right.
So I created a recursive algorithm.
This algorithm replaces one level of generic parameters at a time.
internal fun buildType(type: String, nullable: Boolean): String {
when {
// We simply match on the typestring
type.startsWith("java.util.Map") -> {
// We remove the type, drop the '<', '>' and get the 2 type parameter by splitting at ','
val (firstType, secondType) =
type.replaceFirst("java.util.Map<", "").dropLast(1).let {
it.split(",")
}
// We return the transformed dictionary and branch one recursion for each type parameter
return "IDictionary<${buildType(firstType, false)},${buildType(secondType, false)}>"
}
// same as above but I lack a clever idea how to unionize it
type.startsWith("kotlin.collections.MutableMap") -> {
val (firstType, secondType) =
type.replaceFirst("kotlin.collections.MutableMap<", "").dropLast(1).let {
it.split(",")
}
return "IDictionary<${buildType(firstType, false)},${buildType(secondType, false)}>"
}
// simplified version of the map branch
type.startsWith("java.util.List") -> {
val firstType =
type.replaceFirst("java.util.List<", "").dropLast(1)
return "List<${buildType(firstType, false)}> "
}
// we simply ignore any AtomicReference in c# code
type.startsWith("java.util.concurrent.atomic.AtomicReference") -> {
return buildType(
type.replaceFirst("java.util.concurrent.atomic.AtomicReference<", "").dropLast(1), true
)
}
}
return replaceBasicType(type, nullable)
}
The last call will look up the basic type in the map we defined above and insert it:
internal fun replaceBasicType(type: String, nullable: Boolean) =
(cSharpTypes[type] ?: type.split(".").last()).let {
if (nullable && !it.contains("?")) {
"${it}?"
} else {
it
}
}
Finally, we will now need to reference the annotation processor in the file: src/main/resources/META-INF/services/javax.annotation.processing.Processor
:
net.mayope.raids.buildplugins.DataClassAnnotationProcessor
This lets Gradle discover the annotation processor.
It will now be executed within the compileKotlin
task. You won’t need to call it directly. It is included in the build
task.
This annotation processor will now produce one file for each class where we added @ExportCSharp
.
The Gradle plugin now packs all these classes together. It adds a namespace and exports them to a defined directory.
After gaining some experience, I added more generators. I built an export for my REST clients and created a JSON deserializer with JSON.Net.
I added both of these generators at the end of this blog post. They are not as simple as the data class export, unfortunately. If I wanted to describe and explain them, I would have to refactor them first.
The C# export enabled me to use most of my shared code for the Unity client. There exist more than 10000 Lines of generated C# Code.
It took approximately three days to write the C# generator. But it saved me an enormous amount of frustration.
I hope this post helps you to adapt and create a similar code generator. Without this generator, I would have to synchronize the code bases on every change.
I don’t think it is feasible to write a general Kotlin to C# converter. It is a handy tool for transforming data structures, and well-defined repeating logic. But as soon as you need to transform program logic, the possibilities shrink.
Files:
- ConvertCSharpPlugin.kt
- DataClassAnnotationProcessor.kt
- deserializer.kt
- ExportCSharp.kt
- ExportCSharpEnum.kt