- Kotlin 100%
|
All checks were successful
Publish Package (cask) TeamCity build finished
Build against Kotlin 2.4.10 instead of 2.4.20. The IR API differs between the two releases: - Value class members use GENERATED_SINGLE_FIELD_VALUE_CLASS_MEMBER and GENERATED_MULTI_FIELD_VALUE_CLASS_MEMBER origins. - Annotation classes are resolved through the constructor symbol, and the Redact replacement argument is read by parameter. The 2.4.10 test framework names diagnostic expectations *.fir.diag.txt. The version name now carries the Kotlin version it targets. The README documents the compatibility and where to put publishing credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| cask-annotations | ||
| cask-compiler-plugin | ||
| cask-gradle-plugin | ||
| gradle | ||
| kotlin-js-store | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
Cask
Cask is a Kotlin compiler plugin. Mark a class with @Cask and the compiler writes its equals, hashCode and toString from the primary constructor properties. You can also hide sensitive values in toString while keeping them in equals and hashCode.
@Cask
class PushSubscription(
val accountId: Long,
val endpoint: String,
@Cask.Redact val authKey: String,
)
PushSubscription(1, "https://push.example", "k3y").toString()
// PushSubscription(accountId=1, endpoint=https://push.example, authKey=██)
A @Cask class works like a data class without copy and componentN. You can add, remove or reorder properties without breaking callers that destructure or copy instances, because no caller can do either.
Cask combines the features of Poko and redacted-compiler-plugin in one plugin, with detailed compiler errors and IDE support.
Contents
- Setup
- Generating members
- Redaction
- Compiler errors and warnings
- Configuration
- IDE support
- Compatibility
- Coming from Poko or redacted-compiler-plugin
- Building
- Publishing
Setup
Cask 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.cask") version "1.0.0-2.4.10"
}
The plugin adds the xyz.nillpoe.cask:cask-annotations library to implementation (or commonMainImplementation in a multiplatform project). The annotations support 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.
Generating members
@Cask
class User(val id: Long, val name: String, val nickname: String?)
User(1, "Bob", null) == User(1, "Bob", null) // true
User(1, "Bob", null).toString() // User(id=1, name=Bob, nickname=null)
Cask reads the properties declared in the primary constructor, in declaration order. It ignores constructor parameters without val or var and properties declared in the class body. The generated hashCode uses the same algorithm as a data class, so a @Cask class and a data class with the same properties produce the same hash.
A @Cask class must be a regular class. data classes, value classes, inner classes, interfaces, objects and enums get a compiler error that explains the alternative. Abstract, open and sealed classes are fine.
If you write equals, hashCode or toString yourself, Cask keeps your version and generates the others. If a superclass declares one of them final, Cask keeps the inherited version and warns you.
Equality across subclasses
For a final class, equals checks other is User. For an open, abstract or sealed class, equals compares the runtime classes of both objects instead, so a == b and b == a always agree:
@Cask open class Animal(val name: String)
@Cask class Dog(name: String, val breed: String) : Animal(name)
Animal("Rex") == Dog("Rex", "Collie") // false
Dog("Rex", "Collie") == Animal("Rex") // false
Each class compares only its own constructor properties. Dog above ignores name, because name is a plain parameter in Dog's constructor.
Generating only some members
| Annotation | Generates |
|---|---|
@Cask |
equals, hashCode, toString |
@Cask.EqualsAndHashCode |
equals, hashCode |
@Cask.ToString |
toString |
Leaving a property out
@Cask.Skip removes a property from all three functions:
@Cask
class Listener(val id: String, @Cask.Skip val callback: () -> Unit)
Listener("a") { println(1) } == Listener("a") { println(2) } // true
Listener("a") {}.toString() // Listener(id=a)
Use @Cask.Redact instead when the value should still count for equality.
Arrays
Like a data class, Cask compares array properties by identity and prints their content. Cask warns about each array property in a class that generates equals, because two instances holding equal arrays compare as different.
@Cask.ReadArrayContent compares, hashes and prints the content instead:
@Cask
class Packet(val id: Int, @Cask.ReadArrayContent val payload: ByteArray)
Packet(1, byteArrayOf(1, 2)) == Packet(1, byteArrayOf(1, 2)) // true
It supports Array (with nested arrays), the primitive arrays such as IntArray, and properties of type Any or of a type parameter. For the last two, the generated code checks the array type at runtime. A typed array never equals a primitive array with the same values: arrayOf(1) differs from intArrayOf(1).
Redaction
@Cask.Redact hides values in toString. equals and hashCode still use them.
On a property
@Cask
class Account(val id: Long, @Cask.Redact val password: String)
Account(1, "hunter2").toString() // Account(id=1, password=██)
On a class
@Cask
@Cask.Redact
class SessionKeys(val signing: String, val encryption: String)
SessionKeys("a", "b").toString() // SessionKeys(██)
Mark some properties @Cask.Reveal and Cask lists every property, masking all except the revealed ones:
@Cask
@Cask.Redact
class Credentials(@Cask.Reveal val username: String, val password: String)
Credentials("bob", "hunter2").toString() // Credentials(username=bob, password=██)
On a supertype
Put @Cask.Redact on an interface or on an open, abstract or sealed class. Every subclass then masks all of its properties except the ones marked @Cask.Reveal, including subclasses in other modules:
@Cask.Redact
interface Credential
@Cask
class Login(@Cask.Reveal val username: String, val password: String) : Credential
Login("bob", "hunter2").toString() // Login(username=bob, password=██)
The compiler rejects a concrete subclass whose toString Cask cannot rewrite, such as a plain class or a class with a hand-written toString. That check lets you use a redacted interface as a guarantee: an API that accepts Credential never prints a password by accident.
A subclass can opt out with @Cask.Reveal on the class. It then prints its values, except the properties marked @Cask.Redact:
@Cask
@Cask.Reveal
class PublicKey(val fingerprint: String, @Cask.Redact val comment: String) : Credential
// PublicKey(fingerprint=SHA256:..., comment=██)
On data classes and value classes
Redaction works without @Cask on data classes and value classes. Cask rewrites the toString that the Kotlin compiler generates and leaves copy, componentN, equals and hashCode alone:
data class DatabaseConfig(val url: String, val user: String, @Cask.Redact val password: String)
// DatabaseConfig(url=jdbc:postgresql://db, user=app, password=██)
@Cask.Redact
@JvmInline
value class RecoveryToken(val value: String)
// RecoveryToken(██)
Replacement text
The default replacement is ██. Change it for the whole module in the Gradle configuration, or for one annotation:
@Cask
class Account(val id: Long, @Cask.Redact("***") val password: String)
// Account(id=1, password=***)
A property's own replacement wins over the class's, and the class's wins over a supertype's.
Rules
For each property, the first matching rule decides what toString prints:
@Cask.Skip: the property does not appear.@Cask.Revealon the property: the value.@Cask.Redacton the property: the replacement.@Cask.Revealon the class: the value.@Cask.Redacton the class: the replacement. With no revealed property, the whole body becomesName(██).@Cask.Redacton a supertype: the replacement.- Otherwise: the value.
Compiler errors and warnings
Cask checks your annotations while Kotlin analyzes the code, so mistakes show up in the IDE as you type and fail the build. Each message says what went wrong and how to fix it:
e: User.kt:3:1 @Cask cannot be applied to a data class, which already generates equals, hashCode
and toString (plus copy and componentN). Remove the `data` modifier to let Cask generate them,
or remove @Cask. To only hide values in toString, a data class can use @Cask.Redact without @Cask.
e: Login.kt:5:1 `PlainLeak` inherits redaction from `Credential`, which is marked @Cask.Redact, but
its toString is not generated, so its values would be printed by whatever toString it inherits.
Mark `PlainLeak` with @Cask or @Cask.ToString, make it a data class, or mark it @Cask.Reveal to
opt out of the inherited redaction.
Errors stop the build:
| Name | Reported when |
|---|---|
CASK_UNSUPPORTED_CLASS_KIND |
@Cask is on an interface, object, enum or annotation class |
CASK_ON_DATA_CLASS |
@Cask is on a data class |
CASK_ON_VALUE_CLASS |
@Cask is on a value class |
CASK_ON_INNER_CLASS |
@Cask is on an inner class |
CASK_NO_PRIMARY_CONSTRUCTOR |
the class has only secondary constructors |
CASK_NO_PROPERTIES |
no constructor property is left to compare; the message names skipped properties and parameters missing val |
READ_ARRAY_CONTENT_ON_NON_ARRAY |
@Cask.ReadArrayContent is on a property that cannot hold an array |
CASK_ANNOTATION_OUTSIDE_CASK_CLASS |
@Cask.Skip or @Cask.ReadArrayContent is in a class without @Cask |
REDACT_AND_REVEAL_CONFLICT |
@Cask.Redact and @Cask.Reveal are on the same class or property |
REDACT_ON_UNSUPPORTED_CLASS_KIND |
@Cask.Redact is on an enum class or object |
REDACT_WITHOUT_GENERATED_TO_STRING |
redaction is requested in a class whose toString Cask does not generate |
REDACT_WITH_CUSTOM_TO_STRING |
a hand-written toString would bypass the redaction |
REDACTED_SUPERTYPE_WITHOUT_GENERATED_TO_STRING |
a concrete class inherits redaction but has no generated toString |
Warnings point at annotations that change nothing and at likely bugs:
| Name | Reported when |
|---|---|
CASK_REDUNDANT_MARKER |
two markers overlap, such as @Cask with @Cask.ToString |
SUPERCLASS_MEMBER_IS_FINAL |
a superclass declares equals, hashCode or toString final, so Cask cannot generate it |
ARRAY_PROPERTY_COMPARED_BY_IDENTITY |
an array property is compared by identity |
CASK_ANNOTATION_HAS_NO_EFFECT |
an annotation is on a body property, is covered by @Cask.Skip or by a class-level @Cask.Redact, or reveals a value that nothing hides |
Silence a warning with @Suppress and its name:
@Cask
@Suppress("ARRAY_PROPERTY_COMPARED_BY_IDENTITY")
class Frame(val pixels: IntArray)
Configuration
Every setting is optional. The values below are the defaults:
cask {
enabled = true
markers.set(listOf("xyz/nillpoe/cask/Cask"))
addAnnotationsDependency = true
firIdeMode = CaskFirIdeMode.ALL
redaction {
replacement = "██"
annotations.set(emptyList())
revealAnnotations.set(emptyList())
}
}
| Setting | Effect |
|---|---|
enabled |
Set to false to turn the plugin off for the module. |
markers |
Marker annotations. markers.add(...) keeps the default marker; markers.set(...) replaces it. |
addAnnotationsDependency |
Adds cask-annotations while the default marker is in markers. Turn it off to declare the dependency yourself, for example as compileOnly in a JVM library. |
firIdeMode |
Which parts of the plugin run in the IDE. See IDE support. |
redaction.replacement |
Text printed in place of hidden values. |
redaction.annotations |
Extra annotations that act like @Cask.Redact. |
redaction.revealAnnotations |
Extra annotations that act like @Cask.Reveal. |
Class ids separate packages with / and nested classes with ., as in com/example/Outer.Nested. The plugin fails the build for a malformed id and suggests the corrected form, so a typo never disables Cask without a message.
Your own marker annotation
You can use your own annotation in place of @Cask. Cask finds the nested annotations by name, so declare the ones you need inside your marker:
package com.example
annotation class Model {
annotation class Skip
annotation class ReadArrayContent
annotation class EqualsAndHashCode
annotation class ToString
@Retention(AnnotationRetention.BINARY) annotation class Redact(val replacement: String = "")
@Retention(AnnotationRetention.BINARY) annotation class Reveal
}
cask {
markers.set(listOf("com/example/Model"))
}
Give Redact and Reveal BINARY or RUNTIME retention if you put them on supertypes that other modules extend. The compiler drops SOURCE annotations from compiled classes, so a subclass in another module would not see the redaction.
Reusing existing redaction annotations
To keep an annotation you already use, such as @Redacted from redacted-compiler-plugin, register it:
cask {
redaction {
annotations.add("dev/zacsweers/redacted/annotations/Redacted")
revealAnnotations.add("dev/zacsweers/redacted/annotations/Unredacted")
}
}
IDE support
Cask declares the generated members in the compiler's analysis phase, the same phase IntelliJ IDEA runs to understand your code. With the setting below, the IDE treats equals, hashCode and toString as members of your class and shows Cask's errors and warnings in the editor.
IntelliJ IDEA only loads compiler plugins it bundles, unless you allow others. To allow Cask:
- 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.
The IDE runs Cask inside its own copy of the Kotlin compiler. If that copy differs enough from the version Cask targets, lower firIdeMode:
| Value | In the IDE |
|---|---|
ALL |
Generated members and diagnostics |
CHECKERS_ONLY |
Diagnostics only |
NONE |
Nothing |
Command line builds always run the full plugin.
Compatibility
Kotlin compiler plugins use unstable compiler APIs, so each Cask release works with one Kotlin version. The version name ends with that Kotlin version: 1.0.0-2.4.10 is Cask 1.0.0 for Kotlin 2.4.10.
| Kotlin | Cask |
|---|---|
| 2.4.10 | 1.0.0-2.4.10 |
Kotlin 2.4.20 renamed IR APIs that Cask uses, and Cask 1.0.0-2.4.10 fails on it. To check another compiler release, run the compiler tests with -PtestKotlinVersion=<version>.
Coming from Poko or redacted-compiler-plugin
From Poko:
- Replace
dev.drewhamilton.poko.Pokowithxyz.nillpoe.cask.Cask.Skip,ReadArrayContent,EqualsAndHashCodeandToStringkeep their names. @Poko.Skipremoves a property fromequals. If you used it to keep secrets out of logs, switch to@Cask.Redact, which keeps the property inequals.- Cask does not need
@OptInforEqualsAndHashCodeorToString. - Open, abstract and sealed classes compare runtime classes in
equals, so equality stays symmetric.
From redacted-compiler-plugin:
@Redactedbecomes@Cask.Redactand@Unredactedbecomes@Cask.Reveal, with the same meaning on properties, classes and supertypes. You can also register the old annotations, as shown above.- Redaction also works in
@Caskclasses, which are not data classes. @Cask.Redacttakes an optionalreplacementper use.
Building
./gradlew build
| Module | Contents |
|---|---|
cask-annotations |
The annotations, for every Kotlin target |
cask-compiler-plugin |
FIR checkers and member declarations, IR body generation |
cask-gradle-plugin |
The xyz.nillpoe.cask Gradle plugin |
The compiler tests use the Kotlin compiler's own test framework. Each file in cask-compiler-plugin/testData becomes a test:
box/: compiles and runsfun box(), which returns"OK"on success.diagnostic/: compares the reported errors and warnings with the inline markers and the.fir.diag.txtfile.firDump/: compares the members Cask declares for the IDE with the.fir.txtfile.
After changing a message or adding a test, run ./gradlew :cask-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, 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.
Cask's design follows Poko by Drew Hamilton and redacted-compiler-plugin by Zac Sweers, both under the Apache License 2.0.