diff --git a/src/main/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePom.java b/src/main/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePom.java new file mode 100644 index 0000000000..c2bedfe32e --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePom.java @@ -0,0 +1,287 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.openrewrite.java.migrate.maven; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.maven.AddPluginVisitor; +import org.openrewrite.maven.ChangePropertyValue; +import org.openrewrite.maven.MavenIsoVisitor; +import org.openrewrite.maven.trait.MavenPlugin; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.maven.tree.Plugin; +import org.openrewrite.maven.tree.Pom; +import org.openrewrite.maven.tree.ResolvedPom; +import org.openrewrite.semver.Semver; +import org.openrewrite.semver.VersionComparator; +import org.openrewrite.xml.XmlIsoVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Adds an annotation processor to the maven-compiler-plugin configuration, but only if + * the processor is not already present in the effective POM (including configurations + * inherited from parent POMs outside the reactor). + *

+ * The behavior differs based on project structure: + *

+ */ +@Value +@EqualsAndHashCode(callSuper = false) +public class AddAnnotationProcessorToEffectivePom extends ScanningRecipe { + private static final String MAVEN_COMPILER_PLUGIN_GROUP_ID = "org.apache.maven.plugins"; + private static final String MAVEN_COMPILER_PLUGIN_ARTIFACT_ID = "maven-compiler-plugin"; + + @Option(displayName = "Group", + description = "The first part of the coordinate 'org.projectlombok:lombok-mapstruct-binding:0.2.0' of the processor to add.", + example = "org.projectlombok") + String groupId; + + @Option(displayName = "Artifact", + description = "The second part of a coordinate 'org.projectlombok:lombok-mapstruct-binding:0.2.0' of the processor to add.", + example = "lombok-mapstruct-binding") + String artifactId; + + @Option(displayName = "Version", + description = "The third part of a coordinate 'org.projectlombok:lombok-mapstruct-binding:0.2.0' of the processor to add. " + + "Note that an exact version is expected", + example = "0.2.0") + String version; + + String displayName = "Add an annotation processor to `maven-compiler-plugin` if not in effective POM"; + + String description = "Add an annotation processor path to the `maven-compiler-plugin` configuration, " + + "skipping POMs where the annotation processor is already present in the effective POM " + + "(including configurations inherited from parent POMs outside the reactor). " + + "For modules with an in-reactor parent, adds to the parent's `build/pluginManagement/plugins` section. " + + "For modules without a parent or with a parent outside the reactor, adds directly to `build/plugins`."; + + public static class Scanned { + Set parentPomPaths = new HashSet<>(); + Set candidateOrphanPaths = new HashSet<>(); + Set aggregatorPaths = new HashSet<>(); + + /** + * Paths of POMs where the annotation processor is already present in the effective POM + * (via a parent POM outside the reactor), but not in the POM's own XML. + * These POMs should be skipped to avoid redundant configuration. + */ + Set alreadyConfiguredInEffectivePomPaths = new HashSet<>(); + + Set getOrphanPomPaths() { + Set result = new HashSet<>(candidateOrphanPaths); + for (Path aggregatorPath : aggregatorPaths) { + if (!parentPomPaths.contains(aggregatorPath)) { + result.remove(aggregatorPath); + } + } + return result; + } + } + + @Override + public Scanned getInitialValue(ExecutionContext ctx) { + return new Scanned(); + } + + @Override + public TreeVisitor getScanner(Scanned acc) { + return new MavenIsoVisitor() { + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + MavenResolutionResult mrr = getResolutionResult(); + ResolvedPom resolvedPom = mrr.getPom(); + Path sourcePath = resolvedPom.getRequested().getSourcePath(); + + // Check if the annotation processor is already in the effective POM (merged from parent POMs) + // but NOT in the current POM's own XML. In that case, skip this POM to avoid adding + // redundant configuration that duplicates what the parent already provides. + boolean inEffectivePom = hasAnnotationProcessor(resolvedPom.getPlugins()) || + hasAnnotationProcessor(resolvedPom.getPluginManagement()); + Pom requestedPom = resolvedPom.getRequested(); + boolean inCurrentPomXml = hasAnnotationProcessor(requestedPom.getPlugins()) || + hasAnnotationProcessor(requestedPom.getPluginManagement()); + if (sourcePath != null && inEffectivePom && !inCurrentPomXml) { + acc.alreadyConfiguredInEffectivePomPaths.add(sourcePath); + } + + if (mrr.parentPomIsProjectPom()) { + MavenResolutionResult parent = mrr.getParent(); + if (parent != null) { + Path parentPath = parent.getPom().getRequested().getSourcePath(); + if (parentPath != null) { + acc.parentPomPaths.add(parentPath); + } + } + } else { + if (sourcePath != null) { + acc.candidateOrphanPaths.add(sourcePath); + } + } + + List subprojects = resolvedPom.getSubprojects(); + if (sourcePath != null && subprojects != null && !subprojects.isEmpty()) { + acc.aggregatorPaths.add(sourcePath); + } + + return document; + } + }; + } + + @Override + public TreeVisitor getVisitor(Scanned acc) { + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (tree == null) { + return null; + } + + MavenResolutionResult mrr = tree.getMarkers().findFirst(MavenResolutionResult.class).orElse(null); + if (mrr == null) { + return tree; + } + + Path sourcePath = mrr.getPom().getRequested().getSourcePath(); + if (sourcePath == null) { + return tree; + } + + boolean isParent = acc.parentPomPaths.contains(sourcePath); + if (!isParent && !acc.getOrphanPomPaths().contains(sourcePath)) { + return tree; + } + + // Skip POMs where the annotation processor is already configured via a parent POM + if (acc.alreadyConfiguredInEffectivePomPaths.contains(sourcePath)) { + return tree; + } + + tree = new AddPluginVisitor(isParent, + MAVEN_COMPILER_PLUGIN_GROUP_ID, MAVEN_COMPILER_PLUGIN_ARTIFACT_ID, null, + "", null, null, null + ).visit(tree, ctx); + + return new MavenIsoVisitor() { + @Override + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + Xml.Tag plugins = super.visitTag(tag, ctx); + plugins = (Xml.Tag) new MavenPlugin.Matcher(isParent, MAVEN_COMPILER_PLUGIN_GROUP_ID, MAVEN_COMPILER_PLUGIN_ARTIFACT_ID).asVisitor(plugin -> { + MavenResolutionResult currentMrr = getResolutionResult(); + AtomicReference> maybePropertyUpdate = new AtomicReference<>(); + + Xml.Tag modifiedPlugin = new XmlIsoVisitor() { + @Override + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + Xml.Tag tg = super.visitTag(tag, ctx); + + if (!"annotationProcessorPaths".equals(tg.getName())) { + return tg; + } + + for (int i = 0; i < tg.getChildren().size(); i++) { + Xml.Tag child = tg.getChildren().get(i); + if (!groupId.equals(child.getChildValue("groupId").orElse(null)) || + !artifactId.equals(child.getChildValue("artifactId").orElse(null))) { + continue; + } + + if (!version.equals(child.getChildValue("version").orElse(null))) { + String oldVersion = child.getChildValue("version").orElse(""); + boolean oldVersionUsesProperty = oldVersion.startsWith("${"); + String lookupVersion = oldVersionUsesProperty ? + currentMrr.getPom().getValue(oldVersion.trim()) : oldVersion; + VersionComparator comparator = Semver.validate(lookupVersion, null).getValue(); + if (comparator.compare(version, lookupVersion) > 0) { + if (oldVersionUsesProperty) { + maybePropertyUpdate.set(new ChangePropertyValue(oldVersion, version, null, null).getVisitor()); + } else { + List tags = tg.getChildren(); + tags.set(i, child.withChildValue("version", version)); + return tg.withContent(tags); + } + } + } + + return tg; + } + + return tg.withContent(ListUtils.concat(tg.getChildren(), Xml.Tag.build(String.format( + "\n%s\n%s\n%s\n", + groupId, artifactId, version)))); + } + }.visitTag(plugin.getTree(), ctx); + + if (maybePropertyUpdate.get() != null) { + doAfterVisit(maybePropertyUpdate.get()); + } + + return modifiedPlugin; + }).visitNonNull(plugins, 0); + + if (plugins != tag) { + plugins = autoFormat(plugins, ctx); + } + return plugins; + } + }.visit(tree, ctx); + } + }; + } + + private boolean hasAnnotationProcessor(List plugins) { + for (Plugin plugin : plugins) { + if (!MAVEN_COMPILER_PLUGIN_GROUP_ID.equals(plugin.getGroupId()) || + !MAVEN_COMPILER_PLUGIN_ARTIFACT_ID.equals(plugin.getArtifactId())) { + continue; + } + JsonNode config = plugin.getConfiguration(); + if (config == null || config.isMissingNode()) { + continue; + } + JsonNode paths = config.path("annotationProcessorPaths").path("path"); + if (paths.isArray()) { + for (JsonNode path : paths) { + if (isMatchingProcessor(path)) { + return true; + } + } + } else if (!paths.isMissingNode() && isMatchingProcessor(paths)) { + return true; + } + } + return false; + } + + private boolean isMatchingProcessor(JsonNode path) { + return groupId.equals(path.path("groupId").asText("")) && + artifactId.equals(path.path("artifactId").asText("")); + } +} diff --git a/src/main/resources/META-INF/rewrite/java-version-25.yml b/src/main/resources/META-INF/rewrite/java-version-25.yml index c0cb0d3925..6d3c8bdca1 100644 --- a/src/main/resources/META-INF/rewrite/java-version-25.yml +++ b/src/main/resources/META-INF/rewrite/java-version-25.yml @@ -177,7 +177,7 @@ preconditions: artifactIdPattern: lombok - org.openrewrite.Singleton recipeList: - - org.openrewrite.maven.AddAnnotationProcessor: + - org.openrewrite.java.migrate.maven.AddAnnotationProcessorToEffectivePom: groupId: org.projectlombok artifactId: lombok version: 1.18.40 diff --git a/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java b/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java index b5a009e56b..099be36d8d 100644 --- a/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java +++ b/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java @@ -139,4 +139,78 @@ void addsLombokAnnotationProcessor() { ) ); } + + @Test + void doesNotDuplicateLombokAnnotationProcessorWhenAlreadyInParentPluginManagement() { + rewriteRun( + spec -> spec.cycles(1).expectedCyclesThatMakeChanges(1), + //language=xml + pomXml( + """ + + com.mycompany.app + parent + 1 + pom + + child + + + 17 + + + + org.projectlombok + lombok + 1.18.40 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.40 + + + + + + + + + """, + spec -> spec.after(actual -> + assertThat(actual) + .contains("25") + // annotation processor path for lombok should appear exactly once, not duplicated + .satisfies(content -> assertThat(content.split("", -1).length - 1) + .as("annotationProcessorPaths should have exactly one entry") + .isEqualTo(1)) + .actual() + ) + ), + mavenProject("child", + //language=xml + pomXml( + """ + + + com.mycompany.app + parent + 1 + + child + + """ + ) + ) + ); + } } diff --git a/src/test/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePomTest.java b/src/test/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePomTest.java new file mode 100644 index 0000000000..422db4e540 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePomTest.java @@ -0,0 +1,275 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.openrewrite.java.migrate.maven; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.mavenProject; +import static org.openrewrite.maven.Assertions.pomXml; + +class AddAnnotationProcessorToEffectivePomTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AddAnnotationProcessorToEffectivePom( + "org.projectlombok", + "lombok", + "1.18.40" + )); + } + + @DocumentExample + @Test + void addToSingleModule() { + rewriteRun( + pomXml( + //language=xml + """ + + com.example + my-app + 1 + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + + + + """, + //language=xml + """ + + com.example + my-app + 1 + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.40 + + + + + + + + """ + ) + ); + } + + @Test + void doesNotDuplicateWhenAlreadyInOwnXml() { + rewriteRun( + pomXml( + //language=xml + """ + + com.example + my-app + 1 + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.40 + + + + + + + + """ + ) + ); + } + + @Test + void addToParentPluginManagementInMultiModule() { + rewriteRun( + //language=xml + pomXml( + """ + + com.example + parent + 1 + pom + + child + + + """, + //language=xml + """ + + com.example + parent + 1 + pom + + child + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.40 + + + + + + + + + """ + ), + mavenProject("child", + //language=xml + pomXml( + """ + + + com.example + parent + 1 + + child + + """ + ) + ) + ); + } + + /** + * Tests that the recipe does not add an annotation processor that is already present + * in the effective POM via an ancestor, even when the intermediate parent POM does not + * have it in its own XML. In this 3-level hierarchy: + *

    + *
  • Grandparent defines lombok in {@code pluginManagement}
  • + *
  • Intermediate parent inherits it via effective POM but has no own configuration
  • + *
  • Child depends on the intermediate parent
  • + *
+ * The intermediate parent should NOT have lombok added again. + */ + @Test + void doesNotDuplicateWhenAlreadyInEffectivePomViaAncestor() { + rewriteRun( + //language=xml + pomXml( + // Grandparent already has lombok configured in pluginManagement + """ + + com.example + grandparent + 1 + pom + + parent-module + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.40 + + + + + + + + + """ + ), + mavenProject("parent-module", + //language=xml + pomXml( + // Intermediate parent: inherits lombok from grandparent via effective POM, + // but does NOT have it in its own XML + """ + + + com.example + grandparent + 1 + + parent-module + pom + + child-module + + + """ + ) + ), + mavenProject("child-module", + //language=xml + pomXml( + """ + + + com.example + parent-module + 1 + + child-module + + """ + ) + ) + ); + } +}