Skip to content

Add Wire Binary Compatibility plugin #3306

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ jmh-generator = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.r
jsr305 = { module = "com.google.code.findbugs:jsr305", version.ref = "jsr305" }
junit = { module = "junit:junit", version.ref = "junit" }
kaml = { module = "com.charleskorn.kaml:kaml", version = "0.72.0" }
kotlin-compile-testing = { module = "dev.zacsweers.kctfork:core", version = "0.7.0" }
kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlin-gradleApi = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api", version.ref = "kotlin" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-jsr223 = { module = "org.jetbrains.kotlin:kotlin-scripting-jsr223", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version = "1.8.0" }
Expand Down
3 changes: 3 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,6 @@ include(":samples:wire-codegen-sample")
include(":samples:wire-grpc-sample:client")
include(":samples:wire-grpc-sample:protos")
include(":samples:wire-grpc-sample:server")
include(":wire-binary-compatibility-gradle-plugin")
include(":wire-binary-compatibility-kotlin-plugin")
include(":wire-binary-compatibility-kotlin-plugin-tests")
43 changes: 43 additions & 0 deletions wire-binary-compatibility-gradle-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Wire Binary Compatibility Plugin

The Wire Binary Compatibility Kotlin compiler plugin adapts Wire-generated callsites to be more resilient
to schema changes.

## Current Support
Generated constructor callsites are rewritten at compile-time to instead use the Builder.
Generated copy() callsites are not yet supported.

## Example
Given a generated class, Dinosaur, from a proto defintion:
```protobuf
package com.squareup.dinosaurs;

message Dinosaur {
optional string name = 1;
optional double avg_length_meters = 2;
}
```

With existing usage:
```kotlin
val newDinosaur = Dinosaur(
name = "triceratops",
avg_length_meters = 9.0,
)
```

When a new field is added to the Dinosaur schema, if there are competing versions of the compiled class and the new
version is resolved at runtime, the usage above may encounter an error:
```
java.lang.NoSuchMethodError: 'void com.squareup.dinosaurs.Dinosaur<init>(java.lang.String, java.lang.Double,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YASS!

{new_field})'
```

Using the Wire Binary Compatibility Plugin will adapt the compiled binary to use the equivalent Builder:
```kotlin
val newDinosaur = Dinosaur.Builder()
.name("triceratops")
.avg_length_meters(9.0)
.build()
```
The rewritten, compiled code is backwards compatible with the new field.
36 changes: 36 additions & 0 deletions wire-binary-compatibility-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import com.vanniktech.maven.publish.GradlePlugin
import com.vanniktech.maven.publish.JavadocJar
import com.vanniktech.maven.publish.MavenPublishBaseExtension

plugins {
id("java-gradle-plugin")
`kotlin-dsl`
kotlin("jvm")
id("org.jetbrains.dokka")
id("com.vanniktech.maven.publish.base")
}

dependencies {
compileOnly(kotlin("gradle-plugin-api"))
compileOnly(project(":wire-binary-compatibility-kotlin-plugin"))
implementation(libs.kotlin.gradlePlugin)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this dependency on the kotlin gradle plugin should be compileOnly to avoid polluting downstream consumers classpath. @autonomousapps do you know? Perhaps the gradle-plugin-api dependency as well?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, thank you!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you got the change slightly backwards - the libs.kotlin.gradlePlugin should be compileOnly and the project("...") one should be `implementation

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with that change made I have no other concerns, thanks for doing this!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree with what Kats has said. While this plugin only makes sense in the context of a Kotlin project, we don't want to force a Kotlin version update on anyone if we can avoid it, so we should use compileOnly(libs.kotlin.gradlePlugin).

}

gradlePlugin {
plugins {
create("wireBinaryCompatibility") {
id = "com.squareup.wire.binarycompatibility"
displayName = "Wire Binary Compatibility"
description = "Rewrites Wire callsites to be resilient to API changes"
implementationClass = "com.squareup.wire.binarycompatibility.gradle.WireBinaryCompatibility"
}
}
}

configure<MavenPublishBaseExtension> {
configure(
GradlePlugin(
javadocJar = JavadocJar.Empty()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, why publish an empty javadoc jar?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn’t a library for external use, but Maven Central requires Javadoc

)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2025 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.wire.binarycompatibility.gradle

object BuildConfig {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually this file can be generated from gradle properties.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was looking at some prior art! I figured that is a good follow up unless someone felt strongly to have it generated here now

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It must be generated, at least the version component, or it will not work properly in the destination project. It's only a few lines in the Gradle build and you can copy it from Burst or Zipline probably without any changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe this PR also demonstrates this: https://github.com/square/gradle-dependencies-sorter/pull/123/files

const val KOTLIN_PLUGIN_GROUP: String = "com.squareup.wire.binarycompatibility"

const val KOTLIN_PLUGIN_NAME: String = "wire-binary-compatibility-kotlin-plugin"

const val KOTLIN_PLUGIN_VERSION: String = "1.0.0-SNAPSHOT"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2025 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.wire.binarycompatibility.gradle

import org.gradle.api.provider.Provider
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin
import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact
import org.jetbrains.kotlin.gradle.plugin.SubpluginOption

@Suppress("unused") // Created reflectively by Gradle.
class WireBinaryCompatibilityPlugin : KotlinCompilerPluginSupportPlugin {
override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean = true

override fun getCompilerPluginId(): String = "com.squareup.wire.binarycompatibility.kotlin"

override fun getPluginArtifact(): SubpluginArtifact = SubpluginArtifact(
groupId = BuildConfig.KOTLIN_PLUGIN_GROUP,
artifactId = BuildConfig.KOTLIN_PLUGIN_NAME,
version = BuildConfig.KOTLIN_PLUGIN_VERSION,
)

override fun applyToCompilation(
kotlinCompilation: KotlinCompilation<*>,
): Provider<List<SubpluginOption>> {
return kotlinCompilation.target.project.provider {
listOf() // No options.
}
}
}
12 changes: 12 additions & 0 deletions wire-binary-compatibility-kotlin-plugin-tests/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
plugins {
kotlin("jvm")
id("org.jetbrains.dokka")
}

dependencies {
testImplementation(project(":wire-binary-compatibility-kotlin-plugin"))
testImplementation(kotlin("compiler-embeddable"))
testImplementation(kotlin("test-junit"))
testImplementation(libs.assertk)
testImplementation(libs.kotlin.compile.testing)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Copyright (C) 2025 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.wire.binarycompatibility.kotlin

import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactly
import com.tschuchort.compiletesting.JvmCompilationResult
import com.tschuchort.compiletesting.KotlinCompilation
import com.tschuchort.compiletesting.SourceFile
import kotlin.test.Test
import kotlin.test.assertEquals
import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi

@OptIn(ExperimentalCompilerApi::class)
class WireBinaryCompatibilityKotlinPluginTest {
@Test
fun rewriteConstructorCallToBuilderCall() {
val result = compile(
sourceFile = SourceFile.kotlin(
"Sample.kt",
"""
package com.squareup.wire

val log = mutableListOf<String>()

fun callConstructor() {
log += "${'$'}{Money(5, "USD")}"
}

fun callConstructorWithDefaultParameters() {
log += "${'$'}{Money(amount = 5)}"
log += "${'$'}{Money(currencyCode = "USD")}"
}

data class Money(
val amount: Long? = null,
val currencyCode: String? = null,
) : Message {

class Builder : Message.Builder {
var amount: Long? = null
var currencyCode: String? = null

fun amount(amount: Long) : Builder {
log += "calling amount()!"
this.amount = amount
return this
}

fun currencyCode(currencyCode: String) : Builder {
log += "calling currencyCode()!"
this.currencyCode = currencyCode
return this
}
fun build() : Money = Money(amount, currencyCode)
}
}

interface Message {
interface Builder
}
""",
),
)
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages)

val testClass = result.classLoader.loadClass("com.squareup.wire.SampleKt")
val log = testClass.getMethod("getLog")
.invoke(null) as MutableList<String>

testClass.getMethod("callConstructor").invoke(null)
assertThat(log).containsExactly(
"calling amount()!",
"calling currencyCode()!",
"Money(amount=5, currencyCode=USD)",
)
log.clear()

testClass.getMethod("callConstructorWithDefaultParameters").invoke(null)
assertThat(log).containsExactly(
"calling amount()!",
"Money(amount=5, currencyCode=null)",
"calling currencyCode()!",
"Money(amount=null, currencyCode=USD)",
)
log.clear()
}
}

@ExperimentalCompilerApi
fun compile(
sourceFiles: List<SourceFile>,
plugin: CompilerPluginRegistrar = WireBinaryCompatibilityCompilerPluginRegistrar(),
): JvmCompilationResult {
return KotlinCompilation().apply {
sources = sourceFiles
compilerPluginRegistrars = listOf(plugin)
inheritClassPath = true
kotlincArguments += "-Xverify-ir=error"
kotlincArguments += "-Xverify-ir-visibility"
}.compile()
}

@ExperimentalCompilerApi
fun compile(
sourceFile: SourceFile,
plugin: CompilerPluginRegistrar = WireBinaryCompatibilityCompilerPluginRegistrar(),
): JvmCompilationResult {
return compile(listOf(sourceFile), plugin)
}
21 changes: 21 additions & 0 deletions wire-binary-compatibility-kotlin-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import com.vanniktech.maven.publish.JavadocJar
import com.vanniktech.maven.publish.KotlinJvm
import com.vanniktech.maven.publish.MavenPublishBaseExtension

plugins {
kotlin("jvm")
id("com.vanniktech.maven.publish.base")
}

dependencies {
compileOnly(kotlin("compiler-embeddable"))
compileOnly(kotlin("stdlib"))
}

configure<MavenPublishBaseExtension> {
configure(
KotlinJvm(
javadocJar = JavadocJar.Empty()
)
)
}
2 changes: 2 additions & 0 deletions wire-binary-compatibility-kotlin-plugin/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# We want the stdlib as a compileOnly dependency.
kotlin.stdlib.default.dependency=false
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2025 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.wire.binarycompatibility.kotlin

import org.jetbrains.kotlin.compiler.plugin.CliOption
import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi

@OptIn(ExperimentalCompilerApi::class)
class WireBinaryCompatibilityCommandLineProcessor : CommandLineProcessor {
override val pluginId = "com.squareup.wire.binarycompatibility.kotlin"

override val pluginOptions: List<CliOption> = listOf()
}
Loading