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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.github.arsmotorin.ofrat

/**
* Declares a platform-resolved type alias. One name that becomes a different concrete type
* per target platform, like a chameleon changing color to match its surroundings. :)
*
* Place `@Chameleon` on a `val` carrier together with a platform annotation
* ([FabricOnly], [PaperOnly], [NeoForgeOnly], or any [PlatformOnly]). The carrier's name
* becomes the alias name and its declared type becomes the alias target. `OFRAT`'s `Gradle`
* codegen reads these carriers and emits a real `typealias` for the active platform only, so you
* never write the `typealias` keyword, and you never get a redeclaration clash from the variants.
*
* ## Usage
*
* ```kotlin
* // src/main/chameleons/Platform.kt that read by codegen, never compiled directly:
* @PaperOnly @Chameleon val PlatPlayer: org.bukkit.entity.Player
* @FabricOnly @Chameleon val PlatPlayer: net.minecraft.server.level.ServerPlayer
* ```
*
* After codegen, the shared source can use the single name:
*
* ```kotlin
* // Written once instead of a @PaperOnly / @FabricOnly pair:
* fun getVersion(player: PlatPlayer): Semver? = versions[player.uid]
* ```
*
* ## Why a `val` carrier
*
* Kotlin's grammar requires every annotation to sit on a declaration, so the `typealias` keyword
* cannot be dropped outright. `val` is the lightest legal carrier and unlike `class` / `object`
* supertype forms, it works for `final` platform types (e.g. `ServerPlayer`). The carrier is only
* metadata: it is never compiled, only parsed.
*
* ## Classpath note
*
* Unlike platform-stripped code, string resolves chameleon carriers, so the non-target
* platform's type need not be on the classpath at all.
*
* @see PlatformOnly
* @see FabricOnly
* @see PaperOnly
*/
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
annotation class Chameleon
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package io.github.arsmotorin.ofrat
* ```
*
* After this definition, `@SpigotOnly` declarations are automatically stripped from all
* non-`Spigot` compilations no changes to the plugin required.
* non-`Spigot` compilations, no changes to the plugin required.
*
* @param platform lowercase platform identifier (e.g. `"fabric"`, `"paper"`, `"neoforge"`).
* Must match the `ofrat { target = "..." }` value in the consuming module's build script.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package io.github.arsmotorin.ofrat.gradle

/**
* Codegen behind `@Chameleon`.
*
* Parses lightweight `val` carriers annotated with a platform annotation plus `@Chameleon`, and
* emits a real `typealias` for the requested platform only. Keeping this logic free of any Gradle
* type makes it directly unit-testable; [GenerateChameleonsTask] is a thin wrapper around it.
*
* The parser is intentionally simple, so it matches the constrained carrier grammar by regex rather
* than running a full Kotlin frontend:
*
* ```kotlin
* @PaperOnly @Chameleon val PlatPlayer: org.bukkit.entity.Player
* @FabricOnly @Chameleon val PlatPlayer: net.minecraft.server.level.ServerPlayer
* ```
*/
object ChameleonGenerator {
/** A source file fed to the generator. [content] is the raw Kotlin text. */
data class SourceFile(val content: String)

/** A generated file: [relativePath] under the output dir, with Kotlin [content]. */
data class GeneratedFile(val relativePath: String, val content: String)

/** A single parsed `@Chameleon` carrier. */
private data class Carrier(val platform: String, val name: String, val type: String, val pkg: String)

/** Built-in platform annotation simple names mapped to their platform key. */
private val BUILTIN_PLATFORMS = mapOf(
"FabricOnly" to "fabric",
"PaperOnly" to "paper",
"NeoForgeOnly" to "neoforge",
)

private val PACKAGE = Regex("""(?m)^\s*package\s+([\w.]+)""")

/** A run of annotations immediately followed by `val Name: Type` (type captured to line end). */
private val CARRIER = Regex("""((?:@[\w.]+(?:\([^)]*\))?\s+)+)val\s+(\w+)\s*:\s*([^\n/=]+)""")

/** Matches each `@Name` (optionally `@Name("arg")`) inside an annotation run. */
private val ANNOTATION = Regex("""@([\w.]+)(?:\(\s*"([^"]*)"\s*\))?""")

/**
* Generates the platform `typealias` declarations for [platform] from the chameleon [sources].
*
* Carriers targeting other platforms are skipped. The result is grouped into one file per
* package so each generated `typealias` lands in the package its carrier declared.
*/
fun generate(sources: List<SourceFile>, platform: String): List<GeneratedFile> {
val target = platform.trim().lowercase()
val carriers = sources.flatMap { parse(it.content) }.filter { it.platform == target }

return carriers.groupBy { it.pkg }.map { (pkg, group) ->
val header = if (pkg.isEmpty()) "" else "package $pkg\n\n"
val aliases = group
.distinctBy { it.name }
.joinToString("\n") { "typealias ${it.name} = ${it.type.trim()}" }
val fileName = if (pkg.isEmpty()) "Chameleons.kt" else "${pkg.replace('.', '/')}/Chameleons.kt"
GeneratedFile(fileName, "$header$aliases\n")
}
}

/** Extracts every chameleon carrier from a single file's [content]. */
private fun parse(content: String): List<Carrier> {
val pkg = PACKAGE.find(content)?.groupValues?.get(1).orEmpty()
return CARRIER.findAll(content).mapNotNull { match ->
val annotationRun = match.groupValues[1]
val name = match.groupValues[2]
val type = match.groupValues[3]

val annotations = ANNOTATION.findAll(annotationRun).toList()
val isChameleon = annotations.any { it.groupValues[1].substringAfterLast('.') == "Chameleon" }
if (!isChameleon) return@mapNotNull null

val platform = annotations.firstNotNullOfOrNull { platformOf(it) } ?: return@mapNotNull null
Carrier(platform, name, type, pkg)
}.toList()
}

/** Resolves the platform key carried by a single annotation match, or null if it carries none. */
private fun platformOf(match: MatchResult): String? {
val simpleName = match.groupValues[1].substringAfterLast('.')
val explicitValue = match.groupValues[2]
return when {
simpleName == "PlatformOnly" && explicitValue.isNotEmpty() -> explicitValue.lowercase()
else -> BUILTIN_PLATFORMS[simpleName]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.github.arsmotorin.ofrat.gradle

import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import java.io.File

/**
* Gradle task that realizes `@Chameleon` carriers into per-platform `typealias` declarations.
*
* Reads every `.kt` file under [chameleonsDir], delegates parsing and generation to
* [ChameleonGenerator] for the configured [platform], and writes the result into [outputDir]
* (which the `OFRAT` Gradle plugin wires onto the Kotlin compilation's source set).
*/
abstract class GenerateChameleonsTask : DefaultTask() {
/** Directory holding the `@Chameleon` carrier files. Not itself a compiled source root. */
@get:InputDirectory @get:Optional abstract val chameleonsDir: DirectoryProperty

/** Target platform key (e.g. `"paper"`); carriers for other platforms are skipped. */
@get:Input abstract val platform: Property<String>

/** Directory the generated `typealias` files are written to. */
@get:OutputDirectory abstract val outputDir: DirectoryProperty

/** Parses the carriers and writes the generated aliases for the active platform. */
@TaskAction fun generate() {
val out = outputDir.get().asFile
out.deleteRecursively()
out.mkdirs()

val dir = chameleonsDir.orNull?.asFile
if (dir == null || !dir.exists()) return

val sources = dir.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.map { ChameleonGenerator.SourceFile(it.readText()) }
.toList()

ChameleonGenerator.generate(sources, platform.get()).forEach { generated ->
File(out, generated.relativePath).apply {
parentFile.mkdirs()
writeText(generated.content)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,13 @@ abstract class OfratExtension {
* Custom values are supported via `@PlatformOnly("myplatform")`.
*/
var target: String? = null

/**
* Path (relative to the project dir) to the directory holding `@Chameleon` carrier files.
*
* These files declare platform-resolved type aliases and are not compiled directly. The
* `OFRAT` `Gradle` plugin parses them and generates a real `typealias` per [target]. Defaults to
* `src/main/chameleons`. Set to `null` to disable chameleon codegen entirely.
*/
var chameleonsDir: String? = "src/main/chameleons"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.github.arsmotorin.ofrat.compiler.PlatformCommandLineProcessor.Companio
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.File

/**
* Gradle plugin that wires `OFRAT` into any Kotlin project.
Expand Down Expand Up @@ -42,6 +43,30 @@ class OfratGradlePlugin : Plugin<Project> {
"-P", "plugin:$PLUGIN_ID:platform=$target"
)
}

registerChameleonCodegen(project, extension, target)
}
}

/**
* Wires the `@Chameleon` codegen task: parses carriers under [OfratExtension.chameleonsDir],
* generates per-platform `typealias` files into a build directory, adds that directory to the
* Kotlin compilation source, and makes compilation depend on the generation.
*/
private fun registerChameleonCodegen(project: Project, extension: OfratExtension, target: String) {
val dirPath = extension.chameleonsDir?.trim() ?: return
val outputDir = File(project.layout.buildDirectory.get().asFile, "generated/ofrat/chameleons")

val task = project.tasks.register("generateChameleons", GenerateChameleonsTask::class.java) { t ->
t.platform.set(target)
t.outputDir.set(outputDir)
val carriersDir = project.file(dirPath)
if (carriersDir.exists()) t.chameleonsDir.set(project.layout.projectDirectory.dir(dirPath))
}

project.tasks.withType(KotlinCompile::class.java).configureEach { compile ->
compile.dependsOn(task)
compile.source(outputDir)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package io.github.arsmotorin.ofrat

import io.github.arsmotorin.ofrat.gradle.ChameleonGenerator
import io.github.arsmotorin.ofrat.gradle.ChameleonGenerator.SourceFile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Unit tests for the `@Chameleon` codegen. Verifies that platform-resolved type aliases are
* emitted for the active platform only, regardless of annotation order or package.
*/
class ChameleonGeneratorTest {

private val carriers = SourceFile(
"""
package com.example

@PaperOnly @Chameleon val PlatPlayer: org.bukkit.entity.Player
@FabricOnly @Chameleon val PlatPlayer: net.minecraft.server.level.ServerPlayer
""".trimIndent()
)

@Test fun `emits the paper target for a carrier pair`() {
val generated = ChameleonGenerator.generate(listOf(carriers), "paper")
assertEquals(1, generated.size)
assertEquals("com/example/Chameleons.kt", generated[0].relativePath)
assertTrue("package com.example" in generated[0].content)
assertTrue("typealias PlatPlayer = org.bukkit.entity.Player" in generated[0].content)
assertTrue("ServerPlayer" !in generated[0].content, "fabric target must not leak into paper output")
}

@Test fun `emits the fabric target for a carrier pair`() {
val generated = ChameleonGenerator.generate(listOf(carriers), "fabric")
assertTrue("typealias PlatPlayer = net.minecraft.server.level.ServerPlayer" in generated[0].content)
assertTrue("org.bukkit" !in generated[0].content, "paper target must not leak into fabric output")
}

@Test fun `annotation order does not matter`() {
val reordered = SourceFile(
"""
@Chameleon @PaperOnly val PlatPos: org.bukkit.Location
""".trimIndent()
)
val generated = ChameleonGenerator.generate(listOf(reordered), "paper")
assertTrue("typealias PlatPos = org.bukkit.Location" in generated[0].content)
}

@Test fun `carrier without Chameleon is ignored`() {
val notAChameleon = SourceFile(
"""
@PaperOnly val ordinaryProperty: String
""".trimIndent()
)
val generated = ChameleonGenerator.generate(listOf(notAChameleon), "paper")
assertTrue(generated.isEmpty(), "a platform-annotated val without @Chameleon is not a carrier")
}

@Test fun `custom PlatformOnly value resolves the platform`() {
val custom = SourceFile(
"""
@PlatformOnly("spigot") @Chameleon val PlatServer: org.bukkit.Server
""".trimIndent()
)
assertTrue(ChameleonGenerator.generate(listOf(custom), "spigot").isNotEmpty())
assertTrue(ChameleonGenerator.generate(listOf(custom), "paper").isEmpty())
}

@Test fun `nullable and generic targets are preserved`() {
val fancy = SourceFile(
"""
@PaperOnly @Chameleon val PlatList: kotlin.collections.List<org.bukkit.entity.Player>?
""".trimIndent()
)
val generated = ChameleonGenerator.generate(listOf(fancy), "paper")
assertTrue(
"typealias PlatList = kotlin.collections.List<org.bukkit.entity.Player>?" in generated[0].content,
"generic + nullable target must survive verbatim",
)
}
}
Loading