diff --git a/build-logic/build-parameters/build.gradle.kts b/build-logic/build-parameters/build.gradle.kts index a7cb17d8bfc..9b450361865 100644 --- a/build-logic/build-parameters/build.gradle.kts +++ b/build-logic/build-parameters/build.gradle.kts @@ -82,6 +82,10 @@ buildParameters { defaultValue.set(true) description.set("Skip forbidden-apis verifications") } + bool("skipOpenrewrite") { + defaultValue.set(true) + description.set("Skip OpenRewrite code cleanup (rewriteRun/rewriteDryRun). Enabled off by default until the recipe artifacts are added to gradle/verification-metadata.xml") + } bool("suppressPomMetadataWarnings") { defaultValue.set(true) description.set("Skip suppressPomMetadataWarningsFor warnings triggered by inability to map test fixtures dependences to Maven pom.xml") diff --git a/build-logic/jvm/build.gradle.kts b/build-logic/jvm/build.gradle.kts index ec1429c55b0..5c22b45f753 100644 --- a/build-logic/jvm/build.gradle.kts +++ b/build-logic/jvm/build.gradle.kts @@ -28,4 +28,5 @@ dependencies { api("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.2.21") api("org.jetbrains.kotlin.kapt:org.jetbrains.kotlin.kapt.gradle.plugin:2.2.21") api("org.jetbrains.dokka-javadoc:org.jetbrains.dokka-javadoc.gradle.plugin:2.1.0") + api(projects.openrewrite) } diff --git a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts new file mode 100644 index 00000000000..81150a18ed4 --- /dev/null +++ b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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. + */ + +plugins { + id("build-logic.openrewrite-base") +} + +dependencies { + // rewrite-core, rewrite-java, rewrite-kotlin and the other parsers arrive transitively + // with the recipe modules below; the isolated worker resolves everything from here. + "openrewrite"(platform("org.openrewrite.recipe:rewrite-recipe-bom:3.34.0")) + "openrewrite"("org.openrewrite.recipe:rewrite-static-analysis") + "openrewrite"("org.openrewrite.recipe:rewrite-testing-frameworks") + // JavaParser.fromJavaVersion() picks the parser backend matching the worker JVM, so the + // version-specific parsers must be on the recipe classpath. + "openrewrite"("org.openrewrite:rewrite-java-17") + "openrewrite"("org.openrewrite:rewrite-java-21") + "openrewrite"("org.openrewrite:rewrite-java-25") +} + +openrewrite { + configFile.set(rootProject.file("config/openrewrite/rewrite.yml")) + // Report violations instead of failing the dry run so the aggregate styleCheck task can + // collect the reports from every module. + failOnDryRunResults.set(false) + + activeStyles.add("org.apache.jmeter.style.Style") + + // See config/openrewrite/rewrite.yml. These static-analysis recipes run on Java and Kotlin. + activeRecipes.add("org.apache.jmeter.staticanalysis.CodeCleanup") + activeRecipes.add("org.apache.jmeter.staticanalysis.CommonStaticAnalysis") + + plugins.withId("build-logic.test-junit5") { + activeRecipes.add("org.openrewrite.java.testing.junit5.JUnit5BestPractices") + activeRecipes.add("org.openrewrite.java.testing.junit5.CleanupAssertions") + } + + // Recipes that crash or corrupt output are disabled by name here instead of dropping the + // whole composite. AssertThrowsOnLastStatement throws on Kotlin sources; see the fix in + // https://github.com/openrewrite/rewrite-testing-frameworks/pull/1048 + disabledRecipes.add("org.openrewrite.java.testing.junit5.AssertThrowsOnLastStatement") + + // Kotlin processing is off by default (see build-logic.openrewrite-base). When it is enabled + // via -PopenrewriteKotlin=true, these Java recipes still corrupt Kotlin and must also be + // disabled — verified by applying them and compiling the result: + // - ShortenFullyQualifiedTypeReferences drops a qualifier without adding the import + // (`API.Status.EXPERIMENTAL` -> `Status.EXPERIMENTAL`); + // - TypecastParenPad mangles `x as Foo` into `x asFoo` (root cause fixed in + // https://github.com/openrewrite/rewrite/pull/8236); + // - OperatorWrap moves a binary `+` to the start of the next line, which Kotlin parses as a + // unary plus and breaks the expression. + // They are left active by default because they are safe and useful on the Java sources. +} diff --git a/build-logic/openrewrite/build.gradle.kts b/build-logic/openrewrite/build.gradle.kts new file mode 100644 index 00000000000..8361eabbc95 --- /dev/null +++ b/build-logic/openrewrite/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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. + */ + +plugins { + id("build-logic.kotlin-dsl-gradle-plugin") +} + +dependencies { + // The OpenRewrite libraries are needed only to COMPILE the worker: at runtime the worker + // runs in an isolated process and gets rewrite-core/java/kotlin plus the recipe modules + // from the project's `openrewrite` configuration. Keeping them compileOnly avoids pulling + // OpenRewrite (and ClassGraph) onto the Gradle build-logic classpath. + compileOnly(platform("org.openrewrite.recipe:rewrite-recipe-bom:3.34.0")) + compileOnly("org.openrewrite:rewrite-core") + compileOnly("org.openrewrite:rewrite-java") + compileOnly("org.openrewrite:rewrite-kotlin") + compileOnly("org.openrewrite:rewrite-xml") +} diff --git a/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts new file mode 100644 index 00000000000..35207903b0a --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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. + */ + +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteBaseTask +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteDiagnoseTask +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteDryRunTask +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteExtension +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteRunTask +import org.gradle.api.attributes.Bundling +import org.gradle.api.attributes.java.TargetJvmEnvironment +import org.gradle.api.file.SourceDirectorySet +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.language.base.plugins.LifecycleBasePlugin + +val openrewrite by configurations.creating { + description = "OpenRewrite recipe dependencies" + isCanBeConsumed = false + isCanBeResolved = false +} + +val openrewriteClasspath by configurations.creating { + description = "Resolvable OpenRewrite recipe classpath" + isCanBeConsumed = false + isCanBeResolved = true + extendsFrom(openrewrite) + attributes { + attribute( + Bundling.BUNDLING_ATTRIBUTE, + objects.named(Bundling::class.java, Bundling.EXTERNAL) + ) + attribute( + TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, + objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM) + ) + } +} + +val openrewriteExtension = extensions.create(OpenRewriteExtension.NAME) + +fun configureCommon(task: OpenRewriteBaseTask) { + task.group = "openrewrite" + task.activeRecipes.set(openrewriteExtension.activeRecipes) + task.disabledRecipes.set(openrewriteExtension.disabledRecipes) + // Additively disable more recipes from the command line, e.g. + // -PopenrewriteDisable=org.openrewrite.java.ShortenFullyQualifiedTypeReferences,org.openrewrite.staticanalysis.TypecastParenPad + task.disabledRecipes.addAll( + providers.gradleProperty("openrewriteDisable") + .map { prop -> prop.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + .orElse(emptyList()) + ) + task.activeStyles.set(openrewriteExtension.activeStyles) + task.rewriteClasspath.from(openrewriteClasspath) + task.configFile.set(openrewriteExtension.configFile) + task.projectRootDir.set(layout.projectDirectory) + task.logCompilationWarningsAndErrors.convention(false) + // Allow flipping Kotlin processing from the command line, e.g. -PopenrewriteKotlin=true + task.processKotlin.set( + providers.gradleProperty("openrewriteKotlin") + .map { it.toBoolean() } + .orElse(openrewriteExtension.processKotlin) + ) +} + +tasks.register("rewriteRun") { + description = "Applies the active OpenRewrite recipes in place" + configureCommon(this) +} + +tasks.register("rewriteDryRun") { + description = "Reports the changes the active OpenRewrite recipes would make" + configureCommon(this) + reportDir.set(layout.buildDirectory.dir("reports/openrewrite")) + failOnDryRunResults.set(openrewriteExtension.failOnDryRunResults) +} + +tasks.register("rewriteDiagnose") { + description = "Runs each active leaf recipe on its own and reports failures and no-ops" + configureCommon(this) + reportDir.set(layout.buildDirectory.dir("reports/openrewrite")) +} + +plugins.withId("java") { + the().sourceSets.all { + val sourceSet: SourceSet = this + val javaRelease = tasks.named(sourceSet.compileJavaTaskName) + .flatMap { it.options.release } + tasks.withType().configureEach { + sourceSets.maybeCreate(sourceSet.name).apply { + this.javaRelease.set(javaRelease) + // Include the compiled classes (Java AND Kotlin output) so the Java parser can + // resolve types declared in Kotlin sources of the same module. + compileClasspath.from(sourceSet.compileClasspath) + compileClasspath.from(sourceSet.output.classesDirs) + srcDirSets.maybeCreate("java").files.from(sourceSet.java) + } + } + } +} + +plugins.withId("org.jetbrains.kotlin.jvm") { + the().sourceSets.all { + val sourceSet: SourceSet = this + val kotlin = (sourceSet as ExtensionAware).extensions.getByName("kotlin") as SourceDirectorySet + tasks.withType().configureEach { + sourceSets.maybeCreate(sourceSet.name) + .srcDirSets.maybeCreate("kotlin").files.from(kotlin) + } + } +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt new file mode 100644 index 00000000000..c7ef90a803f --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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 org.apache.jmeter.buildtools.openrewrite + +import org.gradle.api.model.ObjectFactory +import org.gradle.kotlin.dsl.property +import org.gradle.kotlin.dsl.setProperty +import javax.inject.Inject + +open class OpenRewriteExtension @Inject constructor(objects: ObjectFactory) { + companion object { + const val NAME = "openrewrite" + } + + val configFile = objects.fileProperty() + + val activeStyles = objects.setProperty() + + val activeRecipes = objects.setProperty() + + /** Fully qualified recipe names to prune from the active recipe tree, e.g. ones that crash. */ + val disabledRecipes = objects.setProperty() + + val failOnDryRunResults = objects.property().convention(true) + + /** + * Whether to parse and rewrite Kotlin sources. + * + * Off by default: rewrite-kotlin does not round-trip Kotlin faithfully yet (for example it + * prints `x as Foo` back as `x asFoo`), so applying recipes corrupts otherwise untouched code. + * The Java parser still resolves types declared in Kotlin because the module's compiled Kotlin + * classes are on its compile classpath, so Java cleanup stays correct with this off. + */ + val processKotlin = objects.property().convention(false) +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt new file mode 100644 index 00000000000..bc6d101a83b --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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 org.apache.jmeter.buildtools.openrewrite + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.submit +import org.gradle.workers.WorkerExecutor +import javax.inject.Inject + +abstract class OpenRewriteBaseTask @Inject constructor( + objects: ObjectFactory, + private val executor: WorkerExecutor, +) : DefaultTask() { + @get:Input + abstract val activeRecipes: SetProperty + + @get:Input + abstract val disabledRecipes: SetProperty + + @get:Input + abstract val activeStyles: SetProperty + + @get:Classpath + abstract val rewriteClasspath: ConfigurableFileCollection + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val projectRootDir: DirectoryProperty + + @get:Input + abstract val logCompilationWarningsAndErrors: Property + + @get:Input + abstract val processKotlin: Property + + @get:Nested + val sourceSets: NamedDomainObjectContainer = + objects.domainObjectContainer(SourceSetConfig::class.java) + + protected fun runRewrite(applyChanges: Boolean, patch: Provider?, diagnose: Boolean = false) { + val snapshots = sourceSets.map { it.toSnapshot() } + val rewriteCp = rewriteClasspath.files + val queue = executor.processIsolation { + classpath.from(rewriteClasspath) + } + queue.submit(OpenRewriteWork::class) { + this.applyChanges.set(applyChanges) + this.diagnose.set(diagnose) + this.projectRoot.set(projectRootDir) + patch?.let { this.patchFile.set(it) } + this.configFile.set(this@OpenRewriteBaseTask.configFile) + this.activeRecipes.set(this@OpenRewriteBaseTask.activeRecipes) + this.disabledRecipes.set(this@OpenRewriteBaseTask.disabledRecipes) + this.activeStyles.set(this@OpenRewriteBaseTask.activeStyles) + this.sourceSets.set(snapshots) + this.rewriteClasspath.set(rewriteCp) + this.logCompilationWarningsAndErrors.set(this@OpenRewriteBaseTask.logCompilationWarningsAndErrors) + this.processKotlin.set(this@OpenRewriteBaseTask.processKotlin) + } + queue.await() + } +} + +/** Applies the active recipes in place, creating, deleting, moving and updating source files. */ +abstract class OpenRewriteRunTask @Inject constructor( + objects: ObjectFactory, + executor: WorkerExecutor, +) : OpenRewriteBaseTask(objects, executor) { + @TaskAction + fun run() { + runRewrite(applyChanges = true, patch = null) + } +} + +/** Reports the changes the active recipes would make as a unified diff, without touching sources. */ +@CacheableTask +abstract class OpenRewriteDryRunTask @Inject constructor( + objects: ObjectFactory, + executor: WorkerExecutor, +) : OpenRewriteBaseTask(objects, executor) { + @get:OutputDirectory + abstract val reportDir: DirectoryProperty + + @get:Input + abstract val failOnDryRunResults: Property + + @TaskAction + fun run() { + val patch = reportDir.file(PATCH_NAME) + runRewrite(applyChanges = false, patch = patch) + val patchFile = patch.get().asFile + if (failOnDryRunResults.get() && patchFile.length() > 0) { + throw GradleException( + "OpenRewrite found code that violates the active recipes. " + + "Run ./gradlew ${path.replace("DryRun", "Run")} to apply the changes:\n" + + patchFile.readText() + ) + } + } + + companion object { + const val PATCH_NAME = "rewrite.patch" + } +} + +/** Runs each active leaf recipe on its own and reports which fail, change, or do nothing. */ +abstract class OpenRewriteDiagnoseTask @Inject constructor( + objects: ObjectFactory, + executor: WorkerExecutor, +) : OpenRewriteBaseTask(objects, executor) { + @get:OutputDirectory + abstract val reportDir: DirectoryProperty + + @TaskAction + fun run() { + runRewrite(applyChanges = false, patch = reportDir.file("diagnose.txt"), diagnose = true) + logger.lifecycle("OpenRewrite diagnose report: {}", reportDir.file("diagnose.txt").get().asFile) + } +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt new file mode 100644 index 00000000000..865794f6448 --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt @@ -0,0 +1,430 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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 org.apache.jmeter.buildtools.openrewrite + +import org.gradle.api.Named +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.workers.WorkAction +import org.gradle.workers.WorkParameters +import org.openrewrite.ExecutionContext +import org.openrewrite.InMemoryExecutionContext +import org.openrewrite.Parser +import org.openrewrite.Result +import org.openrewrite.SourceFile +import org.openrewrite.Tree +import org.openrewrite.config.Environment +import org.openrewrite.config.OptionDescriptor +import org.openrewrite.config.RecipeDescriptor +import org.openrewrite.config.YamlResourceLoader +import org.openrewrite.internal.InMemoryLargeSourceSet +import org.openrewrite.java.JavaParser +import org.openrewrite.java.internal.JavaTypeCache +import org.openrewrite.java.marker.JavaVersion +import org.openrewrite.kotlin.KotlinParser +import org.slf4j.LoggerFactory +import java.io.File +import java.io.Serializable +import java.net.URL +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.zip.ZipFile + +abstract class SourceSetConfig( + private val name: String, +) : Named, Serializable { + @Internal + override fun getName(): String = name + + @get:Input + abstract val javaRelease: Property + + @get:Nested + abstract val srcDirSets: NamedDomainObjectContainer + + @get:Classpath + abstract val compileClasspath: ConfigurableFileCollection +} + +abstract class SourceDirectorySetConfig( + private val name: String, +) : Named, Serializable { + @Internal + override fun getName(): String = name + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val files: ConfigurableFileCollection +} + +fun SourceSetConfig.toSnapshot() = + SourceSetSnapshot( + name = name, + javaRelease = javaRelease.get(), + srcDirSets = srcDirSets.associateBy({ it.name }) { it.files.files }, + compileClasspath = compileClasspath.files.filterTo(mutableSetOf()) { it.exists() }, + ) + +data class SourceSetSnapshot( + val name: String, + val javaRelease: Int, + val srcDirSets: Map>, + val compileClasspath: Set, +) : Serializable + +interface OpenRewriteParameters : WorkParameters { + /** When true, apply the changes in place; when false, only write a patch to [patchFile]. */ + val applyChanges: Property + + /** Directory the results' source paths are resolved against (the project directory). */ + val projectRoot: DirectoryProperty + + /** Unified diff of the proposed changes, written in dry-run mode. */ + val patchFile: RegularFileProperty + + /** When true, run each leaf recipe on its own and report which ones fail or make changes. */ + val diagnose: Property + + val configFile: RegularFileProperty + val activeRecipes: SetProperty + + /** Fully qualified recipe names to prune from the active recipe tree before running. */ + val disabledRecipes: SetProperty + + val activeStyles: SetProperty + val sourceSets: SetProperty + val rewriteClasspath: SetProperty + val logCompilationWarningsAndErrors: Property + val processKotlin: Property +} + +abstract class OpenRewriteWork : WorkAction { + companion object { + val logger = LoggerFactory.getLogger(OpenRewriteWork::class.java) as org.gradle.api.logging.Logger + } + + override fun execute() { + val env = createEnvironment() + val activated = env.activateRecipes(parameters.activeRecipes.get()) + val disabled = parameters.disabledRecipes.get() + val diagnose = parameters.diagnose.get() + + if (activated.recipeList.isEmpty()) { + logger.warn("No recipes were activated. Add a recipe with openrewrite.activeRecipes in the build file.") + return + } + val styles = env.activateStyles(parameters.activeStyles.get()) + + val ctx: ExecutionContext = InMemoryExecutionContext { t -> logger.warn("Error while rewriting", t) } + val sourceFiles = parse(ctx) + .map { source -> + if (styles.isEmpty()) { + source + } else { + source.withMarkers(styles.fold(source.markers) { markers, style -> markers.add(style) }) + } + } + .toList() + + // In diagnose we test everything (disabled recipes are still reported, annotated) so the + // report is a complete picture. In normal runs we prune the disabled recipes from the tree. + if (diagnose) { + diagnose(activated, sourceFiles, disabled) + return + } + + val recipe = if (disabled.isEmpty()) { + activated + } else { + val present = collectNames(activated).intersect(disabled) + logger.lifecycle("OpenRewrite disabled {} recipe(s): {}", present.size, present.sorted()) + FilteringRecipe(activated, disabled) + } + + val results = recipe.run(InMemoryLargeSourceSet(sourceFiles), ctx).changeset.allResults + if (parameters.applyChanges.get()) { + applyResults(results) + } else { + writePatch(results) + } + } + + private fun collectNames(recipe: org.openrewrite.Recipe): Set { + val names = mutableSetOf() + fun visit(r: org.openrewrite.Recipe) { + names.add(r.name) + r.recipeList.forEach { visit(it) } + } + visit(recipe) + return names + } + + /** Runs each leaf recipe individually so a single failure does not hide the others. */ + private fun diagnose(recipe: org.openrewrite.Recipe, sourceFiles: List, disabled: Set) { + fun leaves(r: org.openrewrite.Recipe): List = + if (r.recipeList.isEmpty()) listOf(r) else r.recipeList.flatMap { leaves(it) } + + val leaves = leaves(recipe).distinctBy { it.name } + val report = StringBuilder() + var failed = 0 + var changed = 0 + var clean = 0 + for (leaf in leaves) { + val tag = if (leaf.name in disabled) " [disabled]" else "" + val errors = mutableListOf() + val ctx: ExecutionContext = InMemoryExecutionContext { errors.add(it) } + val line = try { + val results = leaf.run(InMemoryLargeSourceSet(sourceFiles), ctx).changeset.allResults + when { + errors.isNotEmpty() -> { + failed++ + "FAILED ${leaf.name}$tag -> ${errors.first().firstMessageLine()}" + } + results.isNotEmpty() -> { + changed++ + "changes:${results.size.toString().padStart(3)} ${leaf.name}$tag" + } + else -> { + clean++ + "ok ${leaf.name}$tag" + } + } + } catch (t: Throwable) { + failed++ + "FAILED ${leaf.name}$tag -> ${t.firstMessageLine()}" + } + report.appendLine(line) + logger.lifecycle(line) + } + val summary = "OpenRewrite diagnose: ${leaves.size} recipes -> " + + "$failed failed, $changed would change, $clean no-op" + report.appendLine().appendLine(summary) + logger.lifecycle(summary) + val out = parameters.patchFile.get().asFile + out.parentFile.mkdirs() + out.writeText(report.toString()) + } + + private fun Throwable.firstMessageLine(): String = + (message ?: this::class.java.simpleName).lineSequence().first().take(200) + + private fun applyResults(results: List) { + val root = parameters.projectRoot.get().asFile.toPath() + var changes = 0 + for (result in results) { + val before = result.before + val after = result.after + when { + before == null && after != null -> { + logger.lifecycle("Generated new file {} by:", after.sourcePath) + logRecipesThatMadeChanges(result) + writeAfter(root, after) + changes++ + } + + before != null && after == null -> { + logger.lifecycle("Deleted file {} by:", before.sourcePath) + logRecipesThatMadeChanges(result) + Files.deleteIfExists(root.resolve(before.sourcePath)) + changes++ + } + + before != null && after != null && before.sourcePath != after.sourcePath -> { + logger.lifecycle("Moved file {} to {} by:", before.sourcePath, after.sourcePath) + logRecipesThatMadeChanges(result) + Files.deleteIfExists(root.resolve(before.sourcePath)) + writeAfter(root, after) + changes++ + } + + before != null && after != null -> { + logger.lifecycle("Changed file {} by:", after.sourcePath) + logRecipesThatMadeChanges(result) + writeAfter(root, after) + changes++ + } + } + } + logger.lifecycle("OpenRewrite applied changes to {} file(s)", changes) + } + + private fun writeAfter(root: Path, after: SourceFile) { + val target = root.resolve(after.sourcePath) + Files.createDirectories(target.parent) + val charset = after.charset ?: StandardCharsets.UTF_8 + Files.newBufferedWriter(target, charset).use { writer -> + writer.write(after.printAll()) + } + } + + private fun writePatch(results: List) { + val patch = parameters.patchFile.get().asFile + patch.parentFile.mkdirs() + val diffs = results.mapNotNull { result -> + result.diff().takeIf { it.isNotBlank() }?.also { + val path = (result.after ?: result.before)?.sourcePath + logger.lifecycle("These recipes would make changes to {}:", path) + logRecipesThatMadeChanges(result) + } + } + patch.writeText(if (diffs.isEmpty()) "" else diffs.joinToString("\n") + "\n") + } + + private fun logRecipesThatMadeChanges(result: Result) { + var prefix = " " + for (recipeDescriptor in result.recipeDescriptorsThatMadeChanges) { + logRecipe(recipeDescriptor, prefix) + prefix += " " + } + } + + private fun logRecipe(rd: RecipeDescriptor, prefix: String) { + val recipeString = StringBuilder(prefix + rd.name) + val opts = rd.options + .mapNotNull { option: OptionDescriptor -> option.value?.let { "${option.name}=$it" } } + .joinToString(", ") + if (opts.isNotEmpty()) { + recipeString.append(": {").append(opts).append("}") + } + logger.lifecycle("{}", recipeString) + for (child in rd.recipeList) { + logRecipe(child, "$prefix ") + } + } + + private fun createEnvironment(): Environment = + Environment.builder() + .apply { + // Load declarative recipes from META-INF/rewrite/*.yml on the recipe classpath. + // This avoids Environment.scanClassLoader (ClassGraph), which OOMs on large + // classpaths (see https://github.com/openrewrite/rewrite/issues/3899); compiled + // recipes referenced by the YAML are still instantiated by name at activation. + parameters.rewriteClasspath.get().forEach { cpFile -> + if (cpFile.isFile && cpFile.path.endsWith(".jar", ignoreCase = true)) { + val baseUrl = URL(cpFile.toURI().toURL(), "!/") + ZipFile(cpFile).use { zip -> + zip.entries().asSequence().forEach { ze -> + if (ze.name.startsWith("META-INF/rewrite/") && + (ze.name.endsWith(".yml", true) || ze.name.endsWith(".yaml", true)) + ) { + zip.getInputStream(ze).use { stream -> + val uri = URL(baseUrl, ze.name).toURI() + load(YamlResourceLoader(stream, uri, Properties(), javaClass.classLoader)) + } + } + } + } + } + } + val configFile = parameters.configFile.asFile.get() + configFile.inputStream().use { stream -> + load(YamlResourceLoader(stream, configFile.toURI(), Properties(), javaClass.classLoader)) + } + } + .build() + + private fun parse(ctx: ExecutionContext): Sequence { + val root = parameters.projectRoot.get().asFile.toPath() + var res = sequenceOf() + for (sourceSet in parameters.sourceSets.get()) { + val javaTypeCache = JavaTypeCache() + val classpath = sourceSet.compileClasspath.map { it.toPath() } + + val javaVersion = JavaVersion( + Tree.randomId(), + System.getProperty("java.runtime.version"), + System.getProperty("java.vm.vendor"), + sourceSet.javaRelease.toString(), + sourceSet.javaRelease.toString() + ) + + fun collect(name: String, extension: String): List = + sourceSet.srcDirSets[name].orEmpty() + .filter { it.path.endsWith(extension, ignoreCase = true) } + .map { it.toPath() } + + fun Parser.parseInto(files: List) { + if (files.isEmpty()) { + return + } + logger.info("OpenRewrite parsing {} files under {}", files.size, root) + res += parse(files, root, ctx).iterator().asSequence() + .map { it.withMarkers(it.markers.add(javaVersion)) } + } + + val javaFiles = collect("java", ".java") + if (javaFiles.isNotEmpty()) { + JavaParser.fromJavaVersion() + .classpath(classpath) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(parameters.logCompilationWarningsAndErrors.get()) + .build() + .parseInto(javaFiles) + } + + val kotlinFiles = if (parameters.processKotlin.get()) collect("kotlin", ".kt") else emptyList() + if (kotlinFiles.isNotEmpty()) { + KotlinParser.builder() + .classpath(classpath) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(parameters.logCompilationWarningsAndErrors.get()) + .build() + .parseInto(kotlinFiles) + } + } + return res + } +} + +/** + * Wraps a recipe tree and hides recipes whose name is in [disabled], so a crashing or unwanted + * recipe can be switched off by name without editing the third-party composite that declares it. + * The compiled composites expose an immutable recipeList, so filtering by wrapping is the reliable + * way to remove a recipe from the run. + */ +private class FilteringRecipe( + private val delegate: org.openrewrite.Recipe, + private val disabled: Set, +) : org.openrewrite.Recipe() { + private val filtered: List by lazy { + delegate.recipeList + .filter { it.name !in disabled } + .map { FilteringRecipe(it, disabled) } + } + + override fun getName(): String = delegate.name + override fun getDisplayName(): String = delegate.displayName + override fun getDescription(): String = + delegate.description?.takeIf { it.isNotBlank() } ?: delegate.displayName + override fun getVisitor(): org.openrewrite.TreeVisitor<*, org.openrewrite.ExecutionContext> = + delegate.visitor + override fun getRecipeList(): List = filtered +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 6693f40e417..d9023c084da 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -36,6 +36,7 @@ include("basics") include("batchtest") include("build-parameters") include("jvm") +include("openrewrite") include("publishing") include("root-build") include("verification") diff --git a/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts b/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts index 4ecf9ca094d..65541d7033b 100644 --- a/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts +++ b/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts @@ -15,6 +15,7 @@ * limitations under the License. */ +import com.github.autostyle.gradle.AutostyleTask import org.gradle.kotlin.dsl.apply import org.gradle.language.base.plugins.LifecycleBasePlugin @@ -26,6 +27,14 @@ if (!buildParameters.skipAutostyle) { apply(plugin = "build-logic.autostyle") } +// The OpenRewrite Gradle plugin applies its recipes to allprojects when it is +// applied to the root project, so the workaround is to avoid applying it to the root. +val skipOpenrewrite = project == rootProject || buildParameters.skipOpenrewrite + +if (!skipOpenrewrite) { + apply(plugin = "build-logic.openrewrite") +} + val skipCheckstyle = buildParameters.skipCheckstyle || run { logger.info("Checkstyle requires Java 11+") buildParameters.buildJdkVersion < 11 @@ -55,10 +64,27 @@ if (!skipCheckstyle && !buildParameters.skipAutostyle) { } } -if (!buildParameters.skipAutostyle || !skipCheckstyle || !buildParameters.skipForbiddenApis) { +// OpenRewrite fixes many warnings, so it should run before the other verifications +if (!skipOpenrewrite) { + if (!buildParameters.skipAutostyle) { + tasks.withType().configureEach { + mustRunAfter("rewriteRun", "rewriteDryRun") + } + } + if (!skipCheckstyle) { + tasks.withType().configureEach { + mustRunAfter("rewriteRun", "rewriteDryRun") + } + } +} + +if (!buildParameters.skipAutostyle || !skipCheckstyle || !buildParameters.skipForbiddenApis || !skipOpenrewrite) { tasks.register("style") { group = LifecycleBasePlugin.VERIFICATION_GROUP description = "Formats code (license header, import order, whitespace at end of line, ...) and executes Checkstyle verifications" + if (!skipOpenrewrite) { + dependsOn("rewriteRun") + } if (!buildParameters.skipAutostyle) { dependsOn("autostyleApply") } @@ -69,6 +95,22 @@ if (!buildParameters.skipAutostyle || !skipCheckstyle || !buildParameters.skipFo dependsOn("forbiddenApis") } } + tasks.register("styleCheck") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Reports code style violations (license header, import order, whitespace at end of line, ...)" + if (!skipOpenrewrite) { + dependsOn("rewriteDryRun") + } + if (!buildParameters.skipAutostyle) { + dependsOn("autostyleCheck") + } + if (!skipCheckstyle) { + dependsOn("checkstyleAll") + } + if (!buildParameters.skipForbiddenApis) { + dependsOn("forbiddenApis") + } + } } tasks.register("checkstyle") { diff --git a/config/openrewrite/rewrite.yml b/config/openrewrite/rewrite.yml new file mode 100644 index 00000000000..1a388670669 --- /dev/null +++ b/config/openrewrite/rewrite.yml @@ -0,0 +1,163 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you 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 +# +# http://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. +--- +# See reference at https://docs.openrewrite.org/concepts-explanations/styles#declarative-styles +type: specs.openrewrite.org/v1beta/style +name: org.apache.jmeter.style.Style +styleConfigs: + - org.openrewrite.java.style.TabsAndIndentsStyle: + # See https://github.com/openrewrite/rewrite/issues/3666 + # For now, it should be the same as in .editorconfig + useTabCharacter: false + tabSize: 2 + indentSize: 2 + continuationIndent: 4 + - org.openrewrite.java.style.SpacesStyle: + # See https://github.com/openrewrite/rewrite/blob/1d0aeec11e2d7f239f2f656bfd964b17ca8e4d17/rewrite-java/src/main/java/org/openrewrite/java/style/SpacesStyle.java#L26C14-L26C25 + other: + afterForSemicolon: true + - org.openrewrite.java.style.NoWhitespaceAfterStyle: + # This is to workaround https://github.com/openrewrite/rewrite/issues/3869 + arrayDeclarator: false + - org.openrewrite.java.style.ImportLayoutStyle: + classCountToUseStarImport: 999 + nameCountToUseStarImport: 999 + # The order of the imports should match ij_java_imports_layout in .editorconfig + layout: + - import static all other imports + - + - import java.* + - + - import javax.* + - + - import org.* + - + - import net.* + - + - import com.* + - + - import all other imports +--- +type: specs.openrewrite.org/v1beta/recipe +name: org.apache.jmeter.staticanalysis.CodeCleanup +displayName: Code cleanup +description: Automatically cleanup code, e.g. remove unnecessary parentheses, simplify expressions. +recipeList: + # The list is taken from https://github.com/openrewrite/rewrite-static-analysis/blob/8c803a9c50b480841a4af031f60bac5ee443eb4e/src/main/resources/META-INF/rewrite/static-analysis.yml#L16C1-L42 + - org.openrewrite.staticanalysis.DefaultComesLast + - org.openrewrite.staticanalysis.EmptyBlock + - org.openrewrite.java.format.EmptyNewlineAtEndOfFile + - org.openrewrite.staticanalysis.ForLoopControlVariablePostfixOperators + - org.openrewrite.staticanalysis.FinalizePrivateFields + - org.openrewrite.java.format.MethodParamPad + - org.openrewrite.java.format.NoWhitespaceAfter + - org.openrewrite.java.format.NoWhitespaceBefore + - org.openrewrite.java.format.PadEmptyForLoopComponents + - org.openrewrite.staticanalysis.TypecastParenPad + - org.openrewrite.staticanalysis.EqualsAvoidsNull + - org.openrewrite.staticanalysis.ExplicitInitialization + - org.openrewrite.staticanalysis.FallThrough + # It breaks backward compatibility, so + # - org.openrewrite.staticanalysis.HideUtilityClassConstructor + - org.openrewrite.staticanalysis.NeedBraces + - org.openrewrite.staticanalysis.OperatorWrap + - org.openrewrite.staticanalysis.UnnecessaryParentheses + - org.openrewrite.staticanalysis.ReplaceThreadRunWithThreadStart + - org.openrewrite.staticanalysis.ChainStringBuilderAppendCalls + - org.openrewrite.staticanalysis.ReplaceStringBuilderWithString + - org.openrewrite.java.ShortenFullyQualifiedTypeReferences + - org.openrewrite.staticanalysis.MissingOverrideAnnotation + - org.openrewrite.java.OrderImports +--- +# Copied from https://github.com/openrewrite/rewrite-static-analysis/blob/8c803a9c50b480841a4af031f60bac5ee443eb4e/src/main/resources/META-INF/rewrite/common-static-analysis.yml#L17-L93 +type: specs.openrewrite.org/v1beta/recipe +name: org.apache.jmeter.staticanalysis.CommonStaticAnalysis +displayName: Common static analysis issues +description: Resolve common static analysis issues discovered through 3rd party tools. +recipeList: + # - org.openrewrite.staticanalysis.AddSerialVersionUidToSerializable + - org.openrewrite.staticanalysis.AtomicPrimitiveEqualsUsesGet + - org.openrewrite.staticanalysis.BigDecimalRoundingConstantsToEnums + - org.openrewrite.staticanalysis.BooleanChecksNotInverted + - org.openrewrite.staticanalysis.CaseInsensitiveComparisonsDoNotChangeCase + - org.openrewrite.staticanalysis.CatchClauseOnlyRethrows + - org.openrewrite.staticanalysis.ChainStringBuilderAppendCalls + - org.openrewrite.staticanalysis.CovariantEquals + - org.openrewrite.staticanalysis.DefaultComesLast + - org.openrewrite.staticanalysis.EmptyBlock + - org.openrewrite.staticanalysis.EqualsAvoidsNull + - org.openrewrite.staticanalysis.ExplicitInitialization + - org.openrewrite.staticanalysis.ExternalizableHasNoArgsConstructor + - org.openrewrite.staticanalysis.FinalizePrivateFields + # pgjdbc: see https://github.com/openrewrite/rewrite/issues/3668 + # - org.openrewrite.staticanalysis.FallThrough + # pgjdbc: it might break backward compatibility + # - org.openrewrite.staticanalysis.FinalClass + - org.openrewrite.staticanalysis.FixStringFormatExpressions + - org.openrewrite.staticanalysis.ForLoopIncrementInUpdate + # - org.openrewrite.staticanalysis.HideUtilityClassConstructor + - org.openrewrite.staticanalysis.IndexOfChecksShouldUseAStartPosition + - org.openrewrite.staticanalysis.IndexOfReplaceableByContains + - org.openrewrite.staticanalysis.IndexOfShouldNotCompareGreaterThanZero + - org.openrewrite.staticanalysis.InlineVariable + - org.openrewrite.staticanalysis.IsEmptyCallOnCollections + - org.openrewrite.staticanalysis.LambdaBlockToExpression + # - org.openrewrite.staticanalysis.LowercasePackage + - org.openrewrite.staticanalysis.MethodNameCasing + - org.openrewrite.staticanalysis.MinimumSwitchCases + - org.openrewrite.staticanalysis.ModifierOrder + - org.openrewrite.staticanalysis.MultipleVariableDeclarations + - org.openrewrite.staticanalysis.NeedBraces + - org.openrewrite.staticanalysis.NestedEnumsAreNotStatic + - org.openrewrite.staticanalysis.NewStringBuilderBufferWithCharArgument + - org.openrewrite.staticanalysis.NoDoubleBraceInitialization + - org.openrewrite.staticanalysis.NoEmptyCollectionWithRawType + - org.openrewrite.staticanalysis.NoEqualityInForCondition + - org.openrewrite.staticanalysis.NoFinalizer + - org.openrewrite.staticanalysis.NoPrimitiveWrappersForToStringOrCompareTo + - org.openrewrite.staticanalysis.NoRedundantJumpStatements + - org.openrewrite.staticanalysis.NoToStringOnStringType + - org.openrewrite.staticanalysis.NoValueOfOnStringType + - org.openrewrite.staticanalysis.ObjectFinalizeCallsSuper + - org.openrewrite.staticanalysis.PrimitiveWrapperClassConstructorToValueOf + - org.openrewrite.staticanalysis.RedundantFileCreation + - org.openrewrite.staticanalysis.RemoveExtraSemicolons + # - org.openrewrite.staticanalysis.RemoveRedundantTypeCast + - org.openrewrite.java.RemoveUnusedImports + # - org.openrewrite.staticanalysis.RemoveUnusedLocalVariables + # - org.openrewrite.staticanalysis.RemoveUnusedPrivateMethods + - org.openrewrite.staticanalysis.RenameLocalVariablesToCamelCase + - org.openrewrite.staticanalysis.RenameMethodsNamedHashcodeEqualOrTostring + - org.openrewrite.staticanalysis.RenamePrivateFieldsToCamelCase + - org.openrewrite.staticanalysis.ReplaceLambdaWithMethodReference + - org.openrewrite.staticanalysis.ReplaceStringBuilderWithString + - org.openrewrite.staticanalysis.SimplifyBooleanExpression + - org.openrewrite.staticanalysis.SimplifyBooleanReturn + - org.openrewrite.staticanalysis.StaticMethodNotFinal + - org.openrewrite.staticanalysis.StringLiteralEquality + - org.openrewrite.staticanalysis.UnnecessaryCloseInTryWithResources + - org.openrewrite.staticanalysis.UnnecessaryExplicitTypeArguments + - org.openrewrite.staticanalysis.UnnecessaryParentheses + - org.openrewrite.staticanalysis.UnnecessaryPrimitiveAnnotations + - org.openrewrite.staticanalysis.UpperCaseLiteralSuffixes + # - org.openrewrite.staticanalysis.UnnecessaryThrows + # - org.openrewrite.staticanalysis.UseCollectionInterfaces + - org.openrewrite.staticanalysis.UseDiamondOperator + - org.openrewrite.staticanalysis.UseJavaStyleArrayDeclarations + # https://github.com/openrewrite/rewrite-static-analysis/issues/10 + # - org.openrewrite.staticanalysis.UseLambdaForFunctionalInterface + # - org.openrewrite.staticanalysis.UseStringReplace + - org.openrewrite.staticanalysis.WhileInsteadOfFor + - org.openrewrite.staticanalysis.WriteOctalValuesAsDecimal