- Kotlin 100%
|
All checks were successful
Publish Package (decant) TeamCity build finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| decant-api | ||
| decant-compiler-plugin | ||
| decant-gradle-plugin | ||
| gradle | ||
| samples/ide-check | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
Decant
Decant is a Kotlin compiler plugin that writes object mappers. Assign the values that differ with plain Kotlin, and Decant fills in the rest from properties with the same name, converting them where needed. Every mapping error shows up in the editor while you type, not only when you build.
@Mapper
fun Playlist.toDto(): PlaylistDto = mapping {
to.isPublic = visibility == Visibility.PUBLIC
to.owner = owner ?: "unknown"
// title, items, cover, ...: read from `Playlist` and converted by other mappers
}
The compiler replaces mapping { } with a direct constructor call. There is no reflection, no generated source file and no lambda at run time.
Decant takes the idea of Mappie and moves all checks into the compiler's analysis phase, the same phase IntelliJ IDEA runs, so the IDE reports what the build reports.
Contents
- Setup
- Writing mappers
- The mapping block
- Conversions
- Enums and sealed types
- Builder targets (protobuf)
- Mappers from other modules
- Compiler errors
- Configuration
- IDE support
- Compatibility
- Coming from Mappie
- Building
- Publishing
Setup
Decant publishes to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to your repositories:
// settings.gradle.kts
pluginManagement {
repositories {
maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
mavenCentral()
}
}
Then apply the plugin next to the Kotlin plugin:
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.10" // or kotlin("multiplatform")
id("xyz.nillpoe.decant") version "1.0.0-2.4.10"
}
The plugin adds the xyz.nillpoe.decant:decant-api library to implementation (or commonMainImplementation in a multiplatform project). The library supports every Kotlin target: JVM, Android, JS, Wasm and Kotlin/Native.
To try a local build instead, run ./gradlew publishToMavenLocal in this repository and add mavenLocal() to both repository blocks.
Writing mappers
A mapper is a function whose body is mapping. Its sources are the function's extension receiver and parameters, and its target is the return type, which you must write out.
// An extension function: the receiver is the source.
@Mapper
fun Playlist.toDto(): PlaylistDto = mapping()
// A plain function works the same way.
@Mapper
fun toDto(playlist: Playlist): PlaylistDto = mapping()
// Several sources: every parameter is a source.
fun EmailMessage.toRequest(attachments: List<EmailAttachment>): SendRequest = mapping {
to.attachments = attachments.takeIf { it.isNotEmpty() }?.map { it.toRequestAttachment() }
}
// A mapper with dependencies lives in a class.
@Component
class MediaMappers(private val signer: UrlSigner) {
@Mapper
fun Image.toDto(): ImageDto = mapping {
to.url = signer.sign(path)
}
}
@Mapper lets other mappings use the function to convert values implicitly. It needs exactly one source: an extension receiver or a single parameter. A function that is not a mapping can be a mapper too:
@Mapper
fun Duration.toMinutes(): Long = inWholeMinutes
On an object, @Mapper makes every single-source function in it a mapper. In a class, a mapper can only be used by other functions of the same class, which have the instance to call it on.
There are no mapList helpers. Call the mapper yourself (playlists.map { it.toDto() }); inside a mapping, Decant converts lists and sets for you.
The mapping block
Inside mapping { }, write to.property = value. The value is any Kotlin expression. Kotlin checks it against the property's exact type, so type inference works as usual:
@Mapper
fun Video.toDto(): VideoDto {
val base = title.lowercase()
return mapping {
val slug = base.replace(' ', '-') // local values are fine
to.slug = slug
to.type = MediaType.HLS
to.tags = emptyList() // inferred as List<String>
to.note = null // nullable properties take null
to.version = default() // the constructor parameter's default value
to.cover = convert(image) // Decant finds the conversion
to.stats = mapping { // builds a nested object from the same sources
to.ratio = likes.toDouble() / views
}
}
}
Every constructor parameter you do not assign takes, in order:
- the source parameter with the same name,
- or the source property with the same name, converted if needed,
- or its default value.
If none of them applies, or two sources have a property with that name, the mapping does not compile.
Rules:
- The block runs from top to bottom before the constructor is called. Assign each property once, at the top level of the block. For a conditional value, assign an expression:
to.x = if (a) b else c. toonly stands for the target. Readingto.xis an error.- Decant picks the primary constructor, or the only public constructor.
Conversions
Implicit mappings and convert(value) use the same rules, in this order. When two candidates tie at one step, the mapping does not compile and the error lists them.
- The types already fit. The value is used as it is.
- A
@Mapper. Among the mappers that accept the value, the one for the most specific type wins, like overload resolution: aVideoMedia.Progressivegoes to theProgressivemapper, not theVideoMediaone. Mappers of the enclosing class win over others. - Value classes. A value class with one property wraps and unwraps its underlying type (
AccountIdandLong). - Enums and sealed types. See below.
- Lossless number widening.
Byte,ShortandInttoLong;IntandFloattoDouble. - Collections.
List,Set,CollectionandIterableconvert element by element.T?converts toR?and keepsnull.
Decant never converts implicitly where a value could change or be lost: toString(), parsing strings, Long to Int, nullable to non-null, java.time and Duration. Write those yourself, or declare a @Mapper once and use it everywhere in the module.
Java classes work as sources: getName() reads as name, isActive() as isActive, and public fields are read directly.
Enums and sealed types
mapping() converts an enum or a sealed value as a whole when the target is an enum, a sealed type or an abstract type:
- enum entries and
objectsubtypes go to the target entry with the same name; - other subtypes of a sealed type go through their own mapper.
Handle exceptions with Kotlin's when, and let else -> mapping() do the rest. Decant only checks the cases the earlier branches did not handle.
@Mapper
fun Status.toDto(): StatusDto = when (this) {
Status.BANNED -> StatusDto.HIDDEN
else -> mapping()
}
// Replaces a hand-written `when` over every subtype.
@Mapper
fun Video.toDto(): VideoDto = mapping()
If an entry has no counterpart, or a subtype has no mapper, the mapping does not compile. When you add a subtype and forget its DTO, the build stops.
A property of a sealed type converts the same way, even without a mapper for the sealed type itself: media: VideoMedia becomes a VideoPreviewDto through the mappers of Progressive and Hls.
Builder targets (protobuf)
Some types are built with Target { this.x = ... } instead of a constructor. kotlinx-rpc protobuf messages look like this: an interface with a nested Builder whose properties are vars, and a companion invoke(Builder.() -> Unit). Decant builds such targets through the builder:
@Mapper
fun Authorization.toProto(): AuthorizationItem = mapping()
@Mapper
fun AuthorizationItem.toDomain(): Authorization = mapping()
- A builder property without a source keeps the builder's default. It is not an error.
- A
nullvalue leaves the property unset, like an optional protobuf field.to.note = note?.uppercase()is allowed even thoughnoteis not nullable in the message. - Protobuf enums, which kotlinx-rpc generates as sealed classes of objects plus
UNRECOGNIZED(number), map by entry name. HandleUNRECOGNIZEDin an earlierwhenbranch:
@Mapper
fun CrawlCapabilityProto.toDomain(): CrawlCapability = when (this) {
is CrawlCapabilityProto.UNRECOGNIZED -> throw IllegalArgumentException("Unknown crawl capability: $number")
else -> mapping()
}
Mappers from other modules
Decant finds the @Mapper declarations of the module it compiles on its own. Mappers of other modules come through @UseMappers, which names objects:
// In the shared module
@Mapper
object MediaMappers {
fun Image.toDto(): ImageDto = mapping()
}
// In the app module, on the file, a class or a function
@file:UseMappers(MediaMappers::class)
In an object that is not @Mapper itself, only its functions marked @Mapper count. Top-level mapper functions of another module cannot be named in an annotation; call them yourself, or group them in an object.
The rule is the same in the IDE and in the build, so a mapping never compiles in one and fails in the other.
Compiler errors
Every error says what is wrong and how to fix it. Some examples:
e: `PlaylistDto.ownerId` (AccountId) has no source.
`Playlist` has no property `ownerId`. Similar: `ownerAccountId` (AccountId), `owner` (Account).
Assign it: to.ownerId = ownerAccountId
e: `ProfileDto.count` cannot be set from property `Profile.count` of type `Long`: the parameter is `Int`,
and there is no conversion because no `@Mapper` takes `Long` and returns `Int` (mappers from other
modules need `@UseMappers`). Assign it explicitly (`to.count = ...`), or declare a `@Mapper`.
e: Decant cannot turn `OutcomeProto` into `Outcome`: these subtypes of `OutcomeProto` cannot be converted:
`UNRECOGNIZED` (it is not an object, so handle it in an earlier `when` branch, or declare a `@Mapper` for it).
| Error | When |
|---|---|
DECANT_NO_SOURCE |
A target has no source and no default value |
DECANT_AMBIGUOUS_SOURCE |
Two sources have a value with the target's name |
DECANT_TYPE_MISMATCH |
A source was found, but nothing converts it |
DECANT_AMBIGUOUS_MAPPER |
Two mappers could convert a value |
DECANT_NO_CONVERSION |
convert(value), an enum or a sealed value cannot be converted |
DECANT_VALUE_TYPE_MISMATCH |
to.x = value with a value of the wrong type |
DECANT_NOT_A_PARAMETER |
to.x is not a constructor parameter or builder property |
DECANT_DUPLICATE_TARGET |
A target is assigned twice |
DECANT_NO_DEFAULT |
to.x = default() for a parameter without a default |
DECANT_NO_CONSTRUCTOR |
The target has no usable constructor |
DECANT_NO_SOURCES |
mapping is not inside a function with sources, or is inside a lambda |
DECANT_UNSUPPORTED_STATEMENT |
The block holds something other than assignments and local values |
DECANT_TARGET_READ |
to.x is read |
DECANT_INVALID_USED_MAPPERS |
@UseMappers names a class or an object without mappers |
Configuration
decant {
enabled = true // runs the compiler plugin
addApiDependency = true // adds decant-api to the dependencies
}
IDE support
Decant resolves mappings in the compiler's analysis phase, which IntelliJ IDEA also runs, so the editor shows Decant's errors while you type. The IDE must load the plugin, because to.x = value on a val is not valid Kotlin without it.
IntelliJ IDEA only loads compiler plugins it bundles, unless you allow others:
- Use K2 mode in the Kotlin plugin settings.
- Open Help | Find Action, run Registry..., and set
kotlin.k2.only.bundled.compiler.plugins.enabledtofalse. - Restart the IDE.
After updating Decant, reload the Gradle project so that the IDE picks up the new plugin.
The IDE does not know that Decant reads source properties and calls mappers, so it may mark them as unused. For mapper functions, add xyz.nillpoe.decant.Mapper under Settings | Editor | Inspections | Kotlin | Redundant constructs | Unused symbol | Annotations.
samples/ide-check is a small two-module project to try this: Valid.kt has no errors, and every mapping in Broken.kt is marked. Run ./gradlew publishAllPublicationsToTestingRepository in this repository first, then open the sample folder as a project.
Compatibility
Kotlin compiler plugins use unstable compiler APIs, so each Decant release works with one Kotlin version. The version name ends with that Kotlin version: 1.0.0-2.4.10 is Decant 1.0.0 for Kotlin 2.4.10.
| Kotlin | Decant |
|---|---|
| 2.4.10 | 1.0.0-2.4.10 |
Kotlin 2.4.10 turns off context-sensitive resolution for expected types by default, so to.type = HLS needs -Xcontext-sensitive-resolution. Without it, write to.type = MediaType.HLS.
Coming from Mappie
| Mappie | Decant |
|---|---|
object AToB : ObjectMappie<A, B>() |
@Mapper then fun A.toB(): B = mapping() |
override fun map(from: A) = mapping { } |
fun A.toB(): B = mapping { } |
ObjectMappie2<A, B, C> |
fun A.toC(b: B): C = mapping() |
EnumMappie<A, B> |
mapping(), with when for exceptions |
to::x fromProperty from::y |
to.x = y |
to::x fromValue v, fromExpression { } |
to.x = v |
to::x fromPropertyNotNull from::y |
to.x = y!! |
to::x fromProperty from::y transform { M.mapList(it) } |
nothing if the names match, else to.x = convert(y) |
to::x fromProperty from::y via M |
nothing if the names match, else to.x = convert(y) |
AToB.map(a), AToB.mapList(list) |
a.toB(), list.map { it.toB() } |
@UseDefaultArguments |
defaults are used unless a source exists; to.x = default() forces one |
| Hand-written sealed dispatch | mapping() |
Building
./gradlew build
| Module | Contents |
|---|---|
decant-api |
mapping, @Mapper, @UseMappers, for every Kotlin target |
decant-compiler-plugin |
FIR: assignment rewriting, resolution and errors. IR: code generation from the FIR plan |
decant-gradle-plugin |
The xyz.nillpoe.decant Gradle plugin |
FIR decides everything and stores a plan per mapping call; IR only turns the plan into code. That way the IDE, which only runs FIR, reports every error the build can hit.
The compiler tests use the Kotlin compiler's own test framework. Each file in decant-compiler-plugin/testData becomes a test:
box/: compiles and runsfun box(), which returns"OK"on success. Files can hold several modules and Java sources.diagnostic/: compares the reported errors with the inline markers and the.fir.diag.txtfile.
After changing a message or adding a test, run ./gradlew :decant-compiler-plugin:test -Poverwrite to rewrite the expected files, then review the diff.
The Gradle plugin tests publish all modules to build/testing-repository and build sample projects with Gradle TestKit.
Publishing
The build publishes to the Forgejo package registry with basic authentication. It reads the credentials from two Gradle properties, forgejoUsername and forgejoPassword. For the password, create an access token in Forgejo under Settings | Applications with the write:package scope.
Keep the credentials out of this repository. Put them in your user-level Gradle properties file: ~/.gradle/gradle.properties on macOS and Linux, %USERPROFILE%\.gradle\gradle.properties on Windows.
forgejoUsername=<your Forgejo user name>
forgejoPassword=<access token>
Then publish:
./gradlew publishAllPublicationsToForgejoRepository
License
Apache License 2.0. See LICENSE.