diff --git a/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/Chameleon.kt b/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/Chameleon.kt new file mode 100644 index 0000000..53f2735 --- /dev/null +++ b/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/Chameleon.kt @@ -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 diff --git a/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/PlatformOnly.kt b/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/PlatformOnly.kt index 49e3dfd..05bc164 100644 --- a/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/PlatformOnly.kt +++ b/annotations/src/main/kotlin/io/github/arsmotorin/ofrat/PlatformOnly.kt @@ -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. diff --git a/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/ChameleonGenerator.kt b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/ChameleonGenerator.kt new file mode 100644 index 0000000..e7ff80d --- /dev/null +++ b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/ChameleonGenerator.kt @@ -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, platform: String): List { + 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 { + 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] + } + } +} diff --git a/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/GenerateChameleonsTask.kt b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/GenerateChameleonsTask.kt new file mode 100644 index 0000000..536751d --- /dev/null +++ b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/GenerateChameleonsTask.kt @@ -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 + + /** 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) + } + } + } +} diff --git a/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratExtension.kt b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratExtension.kt index c690b7e..ec757b3 100644 --- a/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratExtension.kt +++ b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratExtension.kt @@ -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" } diff --git a/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratGradlePlugin.kt b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratGradlePlugin.kt index 96b06be..edee89e 100644 --- a/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratGradlePlugin.kt +++ b/plugin/src/main/kotlin/io/github/arsmotorin/ofrat/gradle/OfratGradlePlugin.kt @@ -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. @@ -42,6 +43,30 @@ class OfratGradlePlugin : Plugin { "-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) } } } diff --git a/plugin/src/test/kotlin/io/github/arsmotorin/ofrat/ChameleonGeneratorTest.kt b/plugin/src/test/kotlin/io/github/arsmotorin/ofrat/ChameleonGeneratorTest.kt new file mode 100644 index 0000000..fdd553f --- /dev/null +++ b/plugin/src/test/kotlin/io/github/arsmotorin/ofrat/ChameleonGeneratorTest.kt @@ -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? + """.trimIndent() + ) + val generated = ChameleonGenerator.generate(listOf(fancy), "paper") + assertTrue( + "typealias PlatList = kotlin.collections.List?" in generated[0].content, + "generic + nullable target must survive verbatim", + ) + } +}