diff --git a/README.md b/README.md index 1cec136..39329de 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,17 @@ A Kotlin (K2) compiler plugin that generates enum-like operations — `entries`, `valueOf`, `label` and friends — for `sealed class` / `sealed interface` hierarchies at compile time. +Hierarchies keep their expressive power — data-carrying entries, exhaustive `when` with smart +casts, open leaves — and gain the operational API of enums on top. No runtime reflection involved, working on all Kotlin Multiplatform targets. -## Motivation - -Enumerating the subclasses of a sealed hierarchy without reflection is a long-standing Kotlin -request ([KT-25871](https://youtrack.jetbrains.com/issue/KT-25871)). -The existing answer, `KClass.sealedSubclasses`, is JVM-only, requires `kotlin-reflect`, and breaks -under R8 — impractical for multiplatform and Android projects. - -This plugin fills the gap with compile-time generation: sealed hierarchies keep their expressive -power (data-carrying entries, exhaustive `when` with smart casts, open leaves) and gain the -operational API of enums on top. - ## Setup ### Gradle ```kotlin plugins { - kotlin("multiplatform") version "2.4.10" // or kotlin("jvm") — any Kotlin target plugin + kotlin("jvm") version "2.4.10" // or kotlin("multiplatform") — any Kotlin target plugin id("io.github.projectmapk.sealed-class-enumizer") version "2.4.10-0.1.0" } ``` @@ -40,7 +31,8 @@ sealedClassEnumizer { } ``` -### Maven +
+Maven Declare the plugin as a dependency of kotlin-maven-plugin and name it in `compilerPlugins`; the compiler plugin follows as a transitive dependency. It applies to every kotlin-maven-plugin @@ -88,6 +80,8 @@ The project-wide default label case is a property: The same option can be given through kotlin-maven-plugin's `pluginOptions` (`sealed-class-enumizer:labelCase=...`), which takes precedence over the property. +
+ ### IntelliJ IDEA IntelliJ's K2 mode does not load third-party compiler plugins by default, so generated @@ -95,7 +89,10 @@ declarations show as unresolved in the editor even though Gradle builds succeed ([KTIJ-29248](https://youtrack.jetbrains.com/issue/KTIJ-29248)). To make resolution and completion work, disable the registry flag `kotlin.k2.only.bundled.compiler.plugins.enabled` (Help | Find Action… | "Registry…"), then -re-sync the project. +re-sync the project: + +![The Registry dialog with `kotlin.k2.only.bundled.compiler.plugins.enabled` unchecked](assets/intellij-registry-flag.png) + This IDE capability is experimental; command-line and Gradle builds are unaffected either way. Once the flag is disabled, IntelliJ resolves the generated declarations and renders them inline: @@ -118,6 +115,7 @@ sealed interface SI { SI.Enumish.entries // [Bar, Foo] — compiler-provided order SI.Enumish.valueOf("Foo") // label-based lookup; IllegalArgumentException when absent SI.Enumish.valueOfOrNull("X") // null-returning variant (an addition over enums) +SI.Enumish.entries.map { it.enumizedClass } // [Bar::class, Foo::class] — all leaf classes // from a value to its kind val si: SI = SI.Foo(42) @@ -136,6 +134,10 @@ Notes: - `entries` order is the compiler-provided inheritor order (FQN-based), not declaration order. Do not persist positions in the list; persist `label` or a custom property instead. +- `entries.map { it.enumizedClass }` lists every leaf class without reflection — + `KClass.sealedSubclasses` needs the JVM and `kotlin-reflect`, and silently breaks under R8 + ([KT-25871](https://youtrack.jetbrains.com/issue/KT-25871), + [KT-37292](https://youtrack.jetbrains.com/issue/KT-37292)). - `ordinal` and `Comparable` are deliberately not provided: such numbers change on renames and must not be persisted. - Leaves may stay open (`open` / `abstract` class, `interface`, `fun interface`): subtypes defined @@ -155,10 +157,8 @@ always-available singletons, accepted wherever an enum would have been: ```kotlin @Enumize sealed interface Status { - val remarks: String - - data class Active(override val remarks: String) : Status - data class Suspended(override val remarks: String) : Status + data class Active(val remarks: String) : Status + data class Suspended(val remarks: String) : Status data object Deleted : Status } @@ -182,6 +182,94 @@ This automates the hand-written workaround of giving every leaf a companion that shared marker interface ([background article, Japanese](https://qiita.com/wrongwrong/items/e32179fb851a721007a6)). +### Listing every case + +Data-carrying leaves have no instances to enumerate, so "all statuses" for a picker or a report +axis traditionally means a hand-maintained list — one that silently goes stale when a leaf is +added. +`entries` is compiler-generated and complete by construction: + +```kotlin +// a filter UI offering every status — new leaves show up without touching this code +val statusOptions: List = Status.Enumish.entries.map { it.label } + +// aggregation axes that keep empty groups (groupBy alone would drop them) +val fooCountByStatus: Map = + Status.Enumish.entries.associateWith { 0 } + + foos.groupingBy { it.status.asEnumish() }.eachCount() +``` + +### Round-tripping labels + +Persisting "which case" — a DB column, a query parameter, an analytics event — normally takes a +hand-written string mapping that must follow the hierarchy. +`label` / `valueOf` are that mapping, generated; unlike `value::class.simpleName`, labels are +compile-time constants — never null and unaffected by R8 / minification renaming: + +```kotlin +// outbound: labels are the wire form +fun statusQuery(selection: Set): String = + selection.joinToString("&") { "status=${it.label}" } + +// inbound: GET /foos?status=Active&status=Deleted — parsed kinds feed searchFoo from above +fun handle(rawStatuses: List): List { + val statuses = rawStatuses.map { + requireNotNull(Status.Enumish.valueOfOrNull(it)) { "unknown status: $it" } + } + return repository.searchFoo(*statuses.toTypedArray()) +} +``` + +`@EnumishLabel` keeps persisted labels stable across leaf renames — see +[Label customization](#label-customization). + +### Verifying per-kind wiring + +Not all per-kind wiring fits an exhaustive `when` — handler registries assembled by DI or icon +sets contributed by feature modules live in data, where the compiler cannot check completeness. +`entries` turns "one per leaf" into a single assertion: + +```kotlin +// the map is assembled elsewhere — no single `when` site exists +class StatusRenderer(private val cells: Map) { + init { + val missing = Status.Enumish.entries - cells.keys + require(missing.isEmpty()) { "statuses without a renderer: ${missing.map { it.label }}" } + } +} +``` + +### Exhaustive tests over every leaf + +JUnit's `@EnumSource` has no sealed counterpart, and community substitutes build on +`sealedSubclasses` — JVM-only reflection again. +`entries` drives a test over every leaf on any target, and `enumizedClass` states the expected +type of a value obtained through a kind: + +```kotlin +// production code: a per-kind factory (form defaults, DB seeding, fixtures, …) +fun defaultStatusOf(kind: Status.Enumish): Status = + when (kind) { + Status.Active -> Status.Active(remarks = "") + Status.Suspended -> Status.Suspended(remarks = "payment failed") + Status.Deleted -> Status.Deleted + } + +class DefaultStatusTest { + @Test + fun `every status yields a default of its own type`() { + for (kind in Status.Enumish.entries) { + assertEquals(kind.enumizedClass, defaultStatusOf(kind)::class) + } + } +} +``` + +A new leaf fails the factory's `when` at compile time; a branch fabricating the wrong case fails +the `enumizedClass` assertion. +Values of an open leaf's absorbed subtypes report their runtime class via `::class` — compare +kinds instead (`assertEquals(kind, value.asEnumish())`) in such hierarchies. + ## Generated API Conceptually the plugin generates the following (in compiler internals — no source files are diff --git a/assets/intellij-registry-flag.png b/assets/intellij-registry-flag.png new file mode 100644 index 0000000..eae00ac Binary files /dev/null and b/assets/intellij-registry-flag.png differ