Kotlin compiler plugin for equals/hashCode/toString with redaction
Find a file
NillPoe 05540b7b4e
All checks were successful
Publish Package (cask) TeamCity build finished
Target Kotlin 2.4.10 and version 1.0.0-2.4.10
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>
2026-09-29 11:55:35 +09:00
cask-annotations Initial Cask implementation 2026-09-29 11:47:51 +09:00
cask-compiler-plugin Target Kotlin 2.4.10 and version 1.0.0-2.4.10 2026-09-29 11:55:35 +09:00
cask-gradle-plugin Initial Cask implementation 2026-09-29 11:47:51 +09:00
gradle Target Kotlin 2.4.10 and version 1.0.0-2.4.10 2026-09-29 11:55:35 +09:00
kotlin-js-store Target Kotlin 2.4.10 and version 1.0.0-2.4.10 2026-09-29 11:55:35 +09:00
.gitattributes Initial Cask implementation 2026-09-29 11:47:51 +09:00
.gitignore Initial Cask implementation 2026-09-29 11:47:51 +09:00
build.gradle.kts Initial Cask implementation 2026-09-29 11:47:51 +09:00
gradle.properties Target Kotlin 2.4.10 and version 1.0.0-2.4.10 2026-09-29 11:55:35 +09:00
gradlew Initial Cask implementation 2026-09-29 11:47:51 +09:00
gradlew.bat Initial Cask implementation 2026-09-29 11:47:51 +09:00
LICENSE Initial Cask implementation 2026-09-29 11:47:51 +09:00
README.md Target Kotlin 2.4.10 and version 1.0.0-2.4.10 2026-09-29 11:55:35 +09:00
settings.gradle.kts Initial Cask implementation 2026-09-29 11:47:51 +09:00

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

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:

  1. @Cask.Skip: the property does not appear.
  2. @Cask.Reveal on the property: the value.
  3. @Cask.Redact on the property: the replacement.
  4. @Cask.Reveal on the class: the value.
  5. @Cask.Redact on the class: the replacement. With no revealed property, the whole body becomes Name(██).
  6. @Cask.Redact on a supertype: the replacement.
  7. 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:

  1. Use K2 mode in the Kotlin plugin settings.
  2. Open Help | Find Action, run Registry..., and set kotlin.k2.only.bundled.compiler.plugins.enabled to false.
  3. 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.Poko with xyz.nillpoe.cask.Cask. Skip, ReadArrayContent, EqualsAndHashCode and ToString keep their names.
  • @Poko.Skip removes a property from equals. If you used it to keep secrets out of logs, switch to @Cask.Redact, which keeps the property in equals.
  • Cask does not need @OptIn for EqualsAndHashCode or ToString.
  • Open, abstract and sealed classes compare runtime classes in equals, so equality stays symmetric.

From redacted-compiler-plugin:

  • @Redacted becomes @Cask.Redact and @Unredacted becomes @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 @Cask classes, which are not data classes.
  • @Cask.Redact takes an optional replacement per 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 runs fun box(), which returns "OK" on success.
  • diagnostic/: compares the reported errors and warnings with the inline markers and the .fir.diag.txt file.
  • firDump/: compares the members Cask declares for the IDE with the .fir.txt file.

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.