-
Notifications
You must be signed in to change notification settings - Fork 581
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
base: master
Are you sure you want to change the base?
Changes from all commits
fe5b6f0
8bf29b0
30528fc
cfd8a4b
cabbb63
5ec88c2
af75cfc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
{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. |
swankjesse marked this conversation as resolved.
Show resolved
Hide resolved
|
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suspect this dependency on the kotlin gradle plugin should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated, thank you! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you got the change slightly backwards - the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
|
||
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Out of curiosity, why publish an empty javadoc jar? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Usually this file can be generated from gradle properties. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
} | ||
} | ||
} |
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) | ||
} |
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() | ||
) | ||
) | ||
} |
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() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
YASS!