- Kotlin 100%
|
All checks were successful
Publish Package (graft) TeamCity build finished
Graft is a Kotlin compiler plugin that adds a copy block with nested
updates to regular final classes, with the features of Kopy.
- graft-runtime: multiplatform @Graft and @Graft.Fixed annotations, the
RequiresGraftPlugin opt-in marker and markers for generated functions.
- graft-compiler-plugin: FIR declarations of copy, invoke, set, update
and updateEach, an assignment alterer for vals in copy blocks,
checkers with detailed diagnostics, and IR rewriting of copy blocks
into primary constructor calls.
- graft-gradle-plugin: `graft { }` DSL for markers, function names and
visibility, with validation, the runtime as an api dependency and the
opt-in added to every compilation.
- Compiler tests (box, diagnostic, FIR dump, CLI options) on the Kotlin
test framework and Gradle TestKit functional tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|---|---|---|
| gradle | ||
| graft-compiler-plugin | ||
| graft-gradle-plugin | ||
| graft-runtime | ||
| kotlin-js-store | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
Graft
Graft is a Kotlin compiler plugin that adds a copy block to regular classes. You mark a class with @Graft, then assign the values you want to change, including values inside nested objects:
@Graft
class User(val name: String, val address: Address)
@Graft
class Address(val city: String, val street: String)
val moved = user.copy {
name = "Bob"
address.city = "Busan"
}
The compiler turns the block into constructor calls: User(name = "Bob", address = Address(city = "Busan", street = user.address.street)). user stays as it was. Graft uses no reflection and adds no mutable state to your classes.
Graft follows Kopy, which offers the same block for data classes. Graft works on any final class with a primary constructor, so you can keep a class out of data (and its copy(...) and componentN functions) and still copy it with a block.
Contents
- Setup
- Copy blocks
- Which classes work
- Compiler errors and warnings
- Configuration
- IDE support
- How it works
- Compatibility
- Coming from Kopy
- Building
- Publishing
Setup
Graft 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.graft") version "1.0.0-2.4.10"
}
The Gradle plugin adds xyz.nillpoe.graft:graft-runtime to api (commonMainApi in a multiplatform project). The library holds @Graft and a few marker annotations, and supports every Kotlin target: JVM, Android, JS, Wasm and Kotlin/Native.
Apply the plugin to every module that declares @Graft classes and to every module that calls copy. The second part matters: Graft compiles each copy call where you write it. A module that calls copy without the plugin gets a compile error that tells you to apply it.
To try a local build, run ./gradlew publishToMavenLocal in this repository and add mavenLocal() to both repository blocks.
Copy blocks
Assigning values
Inside the block, you assign primary constructor properties as if they were vars:
@Graft
class Order(val id: Long, val note: String, val total: Int)
val paid = order.copy {
note = "paid"
total += 500
}
+=, -= and the other compound assignments work. ++ and -- do not work on vals; the compiler reports that and asks you to write total += 1.
A var property works the same way. The assignment changes the copy and leaves the original alone:
@Graft
class Counter(var count: Int)
val original = Counter(1)
val next = original.copy { count = 5 }
// original.count == 1, next.count == 5
Nested objects
Assign through a chain of properties to change a value deep inside the object. Graft rebuilds each object on the way down and shares the parts you did not touch:
@Graft
class House(val size: Int, val kitchen: Kitchen)
@Graft
class Kitchen(val cat: Cat, val size: Int)
data class Cat(val name: String, val age: Int)
val changed = house.copy {
size = 200
kitchen.cat.name = "Felix"
kitchen.size = 20
}
// changed.kitchen.cat.age is still house.kitchen.cat.age
Every class along the chain must be one Graft can rebuild: a class marked @Graft, or a data class. Cat above needs no annotation.
For a nullable object, check it first. The smart cast lets Graft follow the chain:
val moved = person.copy {
if (address != null) address.city = "Busan"
}
Reading values in the block
this in the block is the copy you are building. Reading a property gives the value you assigned so far:
val doubled = house.copy {
size = size * 2
size = size * 2 // four times the original
kitchen.size = size / 10
}
The block is a normal lambda, so if, loops, local variables and return@copy all work. return@copy finishes the block with what you built so far. A block that assigns nothing still returns a new instance, as a data class's copy() does.
set, update and updateEach
Three functions cover what a plain assignment cannot express:
val updated = profile.copy {
name.set("Ann") // same as name = "Ann"
visits.update { it + 1 } // new value from the current one
tags.updateEach { it.lowercase() } // new value for each element
}
All three are infix, so visits update { it + 1 } works too. update sees earlier changes in the same block. updateEach works on properties of type List, Set, Collection and Iterable, and a Set property stays a Set.
invoke
Graft also generates an invoke operator, so you can drop the function name:
val renamed = user { name = "Bob" }
Which classes work
A class marked @Graft must:
- be a final class.
dataandvalueclasses work too. - have a primary constructor in which each parameter is a
valorvar. The property can be private. - not be
inner,open,abstractorsealed, and not be an interface, object or enum.
Only primary constructor properties can change in a copy block. Graft builds each copy with the primary constructor, so properties declared in the class body start over from their initializers. Graft warns you about each var in the class body for that reason.
@Graft.Fixed keeps a property out of copy blocks. Assigning it is a compile error, and each copy keeps the original value:
@Graft
class Order(@Graft.Fixed val id: Long, val note: String)
order.copy { id = 2 } // error: `id` is marked @Graft.Fixed
Visibility
Graft builds the copy where you call copy, so the call site has to reach the primary constructor and every constructor property. Graft makes the generated functions no more visible than the least visible of those:
@Graft
class Account(val id: Long, private val passwordHash: String) {
fun withPassword(hash: String) = copy { passwordHash = hash } // works: `copy` is private
}
account.copy { id = 2 } // error: `copy` of `Account` is private because property `passwordHash` is private
An internal constructor makes copy internal. You can lower the limit for the whole module with the visibility setting.
Compiler errors and warnings
Graft checks your code while Kotlin analyzes it, so IntelliJ IDEA shows the problems as you type and the build fails on errors. Each message names the property or class at fault and says how to fix it:
e: Main.kt:12:24 Graft cannot change `customer.name` in a copy, because it would have to rebuild
`Customer`, and `Customer` is neither marked @Graft nor a data class. Mark it @Graft or assign
a whole new value: `customer = Customer(...)`.
e: Main.kt:14:9 Graft cannot follow a safe call (`?.`) in a copy block. Values must be reached
through properties of the block's receiver, such as `address.city = ...`. To change a nullable
object, check it first: `if (address != null) address.city = ...`.
Errors on marked classes:
| Name | Reported when |
|---|---|
GRAFT_UNSUPPORTED_CLASS_KIND |
@Graft is on an interface, object, enum or annotation class |
GRAFT_ON_NON_FINAL_CLASS |
@Graft is on an open, abstract or sealed class |
GRAFT_ON_INNER_CLASS |
@Graft is on an inner class |
GRAFT_NO_PRIMARY_CONSTRUCTOR |
the class has only secondary constructors |
GRAFT_PARAMETER_NOT_PROPERTY |
a primary constructor parameter has no val or var |
GRAFT_FUNCTION_NAME_TAKEN |
the class declares a function that would clash with a generated one |
GRAFT_RUNTIME_MISSING |
graft-runtime is not on the compile classpath |
Errors in copy blocks:
| Name | Reported when |
|---|---|
GRAFT_BLOCK_NOT_LAMBDA |
copy gets a variable or a reference instead of a lambda |
GRAFT_FUNCTION_REFERENCE |
code refers to a generated function, as in user::copy |
GRAFT_FUNCTION_NOT_ACCESSIBLE |
copy is not visible here; the message names the private property or constructor that limits it |
GRAFT_OUTSIDE_COPY_BLOCK |
an assignment to a val, or set, update or updateEach, runs outside a copy block, for example inside with(user) { } |
GRAFT_BROKEN_CHAIN |
a chain goes through ?., !!, a function call, an index or a local variable |
GRAFT_WHOLE_RECEIVER |
this.set(...) would replace the whole object |
GRAFT_NOT_CONSTRUCTOR_PROPERTY |
the property is declared in the class body or in a superclass |
GRAFT_NOT_REBUILDABLE |
a class along the chain is neither marked @Graft nor a data class, or is not final |
GRAFT_NOT_ACCESSIBLE |
a class along the chain has a constructor or property you cannot reach from here |
GRAFT_FIXED_PROPERTY |
the block changes a property marked @Graft.Fixed |
GRAFT_TYPE_MISMATCH |
set gets a value of the wrong type |
GRAFT_UPDATE_EACH_UNSUPPORTED_TYPE |
updateEach is called on a type other than List, Set, Collection or Iterable |
GRAFT_INCREMENT_ON_VAL |
++ or -- is applied to a val |
Warnings:
| Name | Reported when |
|---|---|
GRAFT_BODY_STATE_NOT_COPIED |
a var in the class body resets to its initial value in each copy |
GRAFT_CHANGES_SHARED_OBJECT |
a copy block assigns a var inside an object that Graft cannot rebuild, so the original sees the change too |
GRAFT_FIXED_HAS_NO_EFFECT |
@Graft.Fixed is on a body property or in a class Graft never copies |
Silence a warning with @Suppress and its name:
@Graft
@Suppress("GRAFT_BODY_STATE_NOT_COPIED")
class Session(val id: String) {
var hits = 0 // each copy starts at 0, on purpose
}
Configuration
Every setting is optional. The values below are the defaults:
import xyz.nillpoe.graft.gradle.GraftVisibility
graft {
enabled = true
markers.set(listOf("xyz/nillpoe/graft/Graft"))
addRuntimeDependency = true
visibility = GraftVisibility.PUBLIC
functions {
copy = "copy"
invoke = true
set = "set"
update = "update"
updateEach = "updateEach"
}
}
| Setting | Effect |
|---|---|
enabled |
false turns the plugin off for the module. |
markers |
Marker annotations. markers.add(...) keeps @Graft; markers.set(...) replaces it. |
addRuntimeDependency |
Adds graft-runtime to api. Turn it off to declare the dependency yourself. Graft needs the library on the compile classpath either way. |
visibility |
The most visible the generated functions can be: PUBLIC, INTERNAL or PRIVATE. The class can lower it further, as described in Visibility. |
functions.copy |
Name of the function that starts a copy block. An empty string turns it off. |
functions.invoke |
false turns off the invoke operator. |
functions.set |
Name of set. Assignments compile to this function, so it cannot be turned off. |
functions.update |
Name of update. An empty string turns it off. |
functions.updateEach |
Name of updateEach. An empty string turns it off. |
The plugin checks these values when the build starts and fails with the setting's name if something is wrong, such as two functions with the same name or both copy and invoke turned off.
Renaming functions
Rename a function when your class already has one with the same name and shape. Graft does not generate a function that would clash with yours, and it reports GRAFT_FUNCTION_NAME_TAKEN on your function so you know why copy is missing:
graft {
functions {
copy = "with"
update = "modify"
}
}
val changed = config.with {
host = "example.com"
port.modify { it + 1 }
}
A function with a different shape does not clash. fun update(event: Event) in your class lives next to Graft's update, because Graft's version takes a receiver.
Your own marker annotation
You can use your own annotation in place of @Graft. Graft looks up a nested Fixed annotation by name:
package com.example
annotation class Copyable {
@Target(AnnotationTarget.PROPERTY)
annotation class Fixed
}
graft {
markers.set(listOf("com/example/Copyable"))
}
Class ids separate packages with / and nested classes with ., as in com/example/Outer.Nested. The plugin rejects a dotted name such as com.example.Copyable and suggests the corrected form. Give the marker BINARY or RUNTIME retention if other modules copy the classes or use them in chains; the compiler drops SOURCE annotations from compiled classes.
IDE support
Graft declares copy, invoke, set, update and updateEach during the compiler's analysis phase, the same phase IntelliJ IDEA runs to understand your code. The IDE completes the generated functions, accepts assignments to vals inside copy blocks, and shows Graft's errors and warnings as you type. Removing @Graft removes the functions right away, without a Gradle build.
IntelliJ IDEA only loads compiler plugins it bundles, unless you allow others. To allow Graft:
- 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 and reload the Gradle project.
The Gradle plugin adds the -opt-in=xyz.nillpoe.graft.RequiresGraftPlugin compiler argument to each compilation and source set. The IDE imports it with the Gradle project, so calls to copy show no opt-in error.
The IDE runs Graft inside its own copy of the Kotlin compiler. If the IDE shows errors that the command line build does not, try an IDE version whose bundled Kotlin is closer to the one Graft targets.
How it works
Graft works in two compiler phases.
In the analysis phase (FIR), Graft declares the five functions on each marked class. Their signatures look like this:
fun copy(block: User.() -> Unit): User
operator fun invoke(block: User.() -> Unit): User
infix fun <S> S.set(value: S)
infix fun <S> S.update(transform: (S) -> S)
infix fun <E> Iterable<E>.updateEach(transform: (E) -> E)
An assignment such as address.city = "Busan" to a val would not compile, so Graft turns it into address.city.set("Busan") before Kotlin checks it. Graft's checkers then validate every chain in every copy block.
In the code generation phase (IR), Graft rewrites each copy call together with its lambda. The lambda starts from the original, and each assignment replaces the copy with a rebuilt instance:
// user.copy { address.city = "Busan" } compiles to code like this:
{ it: User ->
var copy = it
copy = User(copy.name, Address("Busan", copy.address.street))
copy
}.invoke(user)
The generated functions have bodies that throw. They only run if something calls them without Graft compiling the call, for example Java code. Kotlin modules without the plugin get a compile error instead: the functions carry the @RequiresGraftPlugin opt-in marker, and only the Gradle plugin opts in.
Compatibility
Kotlin compiler plugins use unstable compiler APIs, so each Graft release works with one Kotlin version. The version name ends with that Kotlin version: 1.0.0-2.4.10 is Graft 1.0.0 for Kotlin 2.4.10.
| Kotlin | Graft |
|---|---|
| 2.4.10 | 1.0.0-2.4.10 |
To check another compiler release, run the compiler tests with -PtestKotlinVersion=<version>.
Coming from Kopy
- Replace
com.javiersc.kotlin.kopy.Kopywithxyz.nillpoe.graft.Graft.copy,invoke,set,updateandupdateEachkeep their names and meaning. - The class does not need to be a data class. A data class still works as a
@Graftclass and, without the annotation, in the middle of a chain. - Reading a property in the block gives the value assigned so far.
count = count + 1twice adds 2. varproperties change in the copy, not in the original.- Graft keeps no
_atomicproperty on your class and needs no atomicfu. KopyCopyFunctions,KopyTransformFunctionsandKopyVisibilitybecome thegraft { functions { } }block andgraft.visibility. Graft also lets you rename each function.- Graft has no
debugorreportPathsetting.
Building
./gradlew build
| Module | Contents |
|---|---|
graft-runtime |
@Graft, @Graft.Fixed and the marker annotations, for every Kotlin target |
graft-compiler-plugin |
FIR declarations, the assignment rewrite and checkers, IR rewriting of copy blocks |
graft-gradle-plugin |
The xyz.nillpoe.graft Gradle plugin |
The compiler tests use the Kotlin compiler's own test framework. Each file in graft-compiler-plugin/testData becomes a test:
box/: compiles and runsfun box(), which returns"OK"on success.MultiModule.ktcompiles two modules to check copies across module boundaries.diagnostic/: compares the reported errors and warnings with the inline markers and the.fir.diag.txtfile.firDump/: compares the functions Graft declares for the IDE with the.fir.txtfile.
Test files configure Graft with directives such as // COPY_FUNCTION: with or // MARKER: com/example/Copyable; GraftTestConfiguration.kt lists them. After changing a message or adding a test, run ./gradlew :graft-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, including a two-module project in which the second module lacks the plugin.
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, which Gradle reads for every build on your machine: ~/.gradle/gradle.properties on macOS and Linux, %USERPROFILE%\.gradle\gradle.properties on Windows.
forgejoUsername=<your Forgejo user name>
forgejoPassword=<access token>
On a CI server, set the environment variables ORG_GRADLE_PROJECT_forgejoUsername and ORG_GRADLE_PROJECT_forgejoPassword instead. Gradle turns them into the same properties.
Then publish:
./gradlew publishAllPublicationsToForgejoRepository
License
Apache License 2.0. See LICENSE.
Graft's features follow Kopy by Javier Segovia Córdoba, under the Apache License 2.0. Graft shares no code with it.