diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java new file mode 100644 index 00000000000..fc3d524bdff --- /dev/null +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -0,0 +1,238 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * 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 org.openrewrite.maven; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.maven.internal.MavenPomDownloader; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.xml.ChangeTagValueVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.util.*; +import java.util.stream.Stream; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; +import static java.util.stream.Collectors.*; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ExtractVersionsAsProperties extends Recipe { + + String displayName = "Extract Maven dependency versions as properties"; + + String description = "Extracts inlined dependency versions into the `` section and replaces them with `${property}` references."; + + @Override + public TreeVisitor getVisitor() { + return new MavenIsoVisitor() { + private PropertyResolver propertyResolver; + + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + Map existingProps = loadExistingProperties(document.getRoot()); + Map groupSharedVersion = GroupVersionAnalyzer.analyze(document.getRoot(), existingProps); + propertyResolver = new PropertyResolver(groupSharedVersion, existingProps); + Set inheritedProps = inheritedPropertyKeys(ctx); + PropertyRenamer.findRenames(document.getRoot(), groupSharedVersion, existingProps, inheritedProps) + .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); + return super.visitDocument(document, ctx); + } + + // Property names declared by an ancestor POM (e.g. Spring Boot's `spring-boot-dependencies`). + // These must not be renamed: the parent's dependency management still references them by name, + // so a local rename would silently break that wiring outside the scope of this pom.xml. + private Set inheritedPropertyKeys(ExecutionContext ctx) { + MavenResolutionResult mrr = getResolutionResult(); + if (mrr.getParent() != null) { + return mrr.getParent().getPom().getProperties().keySet(); + } + if (mrr.getPom().getRequested().getParent() == null) { + return emptySet(); + } + MavenPomDownloader downloader = new MavenPomDownloader( + mrr.getProjectPoms(), ctx, mrr.getMavenSettings(), mrr.getActiveProfiles()); + try { + return mrr.getPom().getRequested() + .withProperties(emptyMap()) + .withDependencies(emptyList()) + .withDependencyManagement(emptyList()) + .withPlugins(emptyList()) + .withPluginManagement(emptyList()) + .resolve(mrr.getActiveProfiles(), downloader, ctx) + .getProperties().keySet(); + } catch (MavenDownloadingException e) { + return emptySet(); + } + } + + private Map loadExistingProperties(Xml.Tag root) { + return root.getChild("properties") + .map(this::collectPropertiesFrom) + .orElseGet(LinkedHashMap::new); + } + + private Map collectPropertiesFrom(Xml.Tag propsTag) { + return propsTag.getChildren().stream() + .filter(child -> child.getValue().isPresent()) + .collect(toMap( + Xml.Tag::getName, + child -> child.getValue().get(), + (a, b) -> a, + LinkedHashMap::new)); + } + + private void applyRename(String oldKey, String newKey, Map existingProps) { + doAfterVisit(new RenamePropertyKey(oldKey, newKey).getVisitor()); + propertyResolver.registerKey(newKey, existingProps.get(oldKey)); + } + + @Override + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + if (isDependencyTag() || isManagedDependencyTag() || isPluginTag() || isPluginDependencyTag()) { + Optional versionTag = tag.getChild("version"); + if (versionTag.isPresent()) { + String version = versionTag.get().getValue().orElse(null); + if (version != null && !PropertyResolver.isPropertyRef(version)) { + String groupId = tag.getChildValue("groupId").orElse(null); + String artifactId = tag.getChildValue("artifactId").orElse(null); + if (artifactId != null) { + String propertyKey = propertyResolver.resolvePropertyKey(groupId, artifactId, version); + doAfterVisit(new AddPropertyVisitor(propertyKey, version, true)); + doAfterVisit(new ChangeTagValueVisitor<>(versionTag.get(), "${" + propertyKey + "}")); + } + } + } + } + return super.visitTag(tag, ctx); + } + }; + } + + private static Stream allDescendants(Xml.Tag tag) { + return Stream.concat(Stream.of(tag), tag.getChildren().stream().flatMap(ExtractVersionsAsProperties::allDescendants)); + } + + private static class PropertyResolver { + private final Map propertyKeyToVersion = new LinkedHashMap<>(); + private final Map groupSharedVersion; + + PropertyResolver(Map groupSharedVersion, Map existingProps) { + this.groupSharedVersion = groupSharedVersion; + this.propertyKeyToVersion.putAll(existingProps); + } + + static boolean isPropertyRef(String version) { + String trimmedVersion = version.trim(); + return trimmedVersion.startsWith("${") && trimmedVersion.endsWith("}"); + } + + static String resolveToLiteral(String version, Map existingProps) { + if (isPropertyRef(version)) { + String trimmedVersion = version.trim(); + return existingProps.get(trimmedVersion.substring(2, trimmedVersion.length() - 1)); + } + return version; + } + + void registerKey(String key, String version) { + propertyKeyToVersion.put(key, version); + } + + String resolvePropertyKey(String groupId, String artifactId, String version) { + String baseKey = groupId != null && groupSharedVersion.containsKey(groupId) + ? groupId + ".version" + : artifactId + ".version"; + String key = baseKey; + int suffix = 1; + while (propertyKeyToVersion.containsKey(key) && !propertyKeyToVersion.get(key).equals(version)) { + key = baseKey + "." + suffix++; + } + propertyKeyToVersion.put(key, version); + return key; + } + } + + private static class GroupVersionAnalyzer { + // Returns groupId → version for groups where every dep with a resolvable version shares the same version. + static Map analyze(Xml.Tag root, Map existingProps) { + return allDescendants(root) + .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) + .filter(tag -> tag.getChildValue("groupId").isPresent()) + .collect(groupingBy( + tag -> tag.getChildValue("groupId").get(), + toList())) + .entrySet().stream() + .flatMap(groupEntry -> toSharedVersionEntry(groupEntry, existingProps)) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static Stream> toSharedVersionEntry( + Map.Entry> groupEntry, Map existingProps) { + List resolvedVersions = groupEntry.getValue().stream() + .map(tag -> tag.getChild("version").flatMap(Xml.Tag::getValue).orElse(null)) + .filter(Objects::nonNull) + .map(v -> PropertyResolver.resolveToLiteral(v, existingProps)) + .filter(Objects::nonNull) + .collect(toList()); + if (resolvedVersions.size() > 1 && new HashSet<>(resolvedVersions).size() == 1) { + return Stream.of(new AbstractMap.SimpleEntry<>(groupEntry.getKey(), resolvedVersions.get(0))); + } + return Stream.empty(); + } + } + + private static class PropertyRenamer { + // For deps in a shared-version group that already reference a non-standard ${propName}, + // returns oldKey→newKey pairs so the visitor can schedule RenamePropertyKey for each. + static Map findRenames(Xml.Tag root, Map groupSharedVersion, + Map existingProps, Set inheritedProps) { + return allDescendants(root) + .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) + .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion, inheritedProps)) + .collect(toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> a, + LinkedHashMap::new)); + } + + private static Stream> toNonStandardRenameEntry( + Xml.Tag tag, Map existingProps, Map groupSharedVersion, + Set inheritedProps) { + String groupId = tag.getChildValue("groupId").orElse(null); + if (groupId == null || !groupSharedVersion.containsKey(groupId)) { + return Stream.empty(); + } + String standardKey = groupId + ".version"; + String version = tag.getChild("version").flatMap(Xml.Tag::getValue).orElse(null); + if (version == null || !PropertyResolver.isPropertyRef(version)) { + return Stream.empty(); + } + String trimmedVersion = version.trim(); + String propRef = trimmedVersion.substring(2, trimmedVersion.length() - 1); + if (propRef.equals(standardKey) || !existingProps.containsKey(propRef) || inheritedProps.contains(propRef)) { + return Stream.empty(); + } + return Stream.of(new AbstractMap.SimpleEntry<>(propRef, standardKey)); + } + } +} diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java index 7521fd82e7b..342e69f1178 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java @@ -163,6 +163,11 @@ public boolean isDependencyTag(String groupId, String artifactId) { return false; } + public boolean isPluginDependencyTag() { + return isTag("dependency") && + (PLUGIN_DEPENDENCY_MATCHER.matches(getCursor()) || PROFILE_PLUGIN_DEPENDENCY_MATCHER.matches(getCursor())); + } + public boolean isPluginDependencyTag(String groupId, String artifactId) { if (!isTag("dependency") || !PLUGIN_DEPENDENCY_MATCHER.matches(getCursor()) && diff --git a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv index 416220af27f..2ae91fda1fa 100644 --- a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv @@ -100,3 +100,4 @@ maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.ParentPomInsigh maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.EffectiveManagedDependencies,Effective managed dependencies,Emit the data of binary dependency relationships.,1,Search,Maven,,"[{""name"":""org.openrewrite.maven.table.ManagedDependencyGraph"",""displayName"":""Managed dependency graph"",""instanceName"":""Managed dependency graph"",""description"":""Relationships between POMs and their ancestors that define managed dependencies."",""columns"":[{""name"":""from"",""type"":""String"",""displayName"":""From dependency"",""description"":""What depends on the 'to' dependency.""},{""name"":""to"",""type"":""String"",""displayName"":""From dependency"",""description"":""A dependency.""}]}]" maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.security.UseHttpsForRepositories,Use HTTPS for repositories,Use HTTPS for repository URLs.,1,Security,Maven,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.utilities.PrintMavenAsDot,Print Maven dependency hierarchy in DOT format,The DOT language format is specified [here](https://graphviz.org/doc/info/lang.html).,1,Utilities,Maven,, +maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract Maven dependency versions as properties,Extracts inlined dependency versions into the `` section and replaces them with `${property}` references.,1,"",Maven,, diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java new file mode 100644 index 00000000000..062a5c28edd --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java @@ -0,0 +1,863 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * 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 org.openrewrite.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.maven.Assertions.pomXml; + +class ExtractVersionsAsPropertiesTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ExtractVersionsAsProperties()); + } + + @DocumentExample + @Test + void extractsSingleDependencyVersionIntoNewPropertiesBlock() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + junit + junit + 4.13.2 + test + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + ${junit.version} + test + + + + """ + ) + ); + } + + @Test + void appendsToExistingPropertiesBlock() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + value + + + + junit + junit + 4.13.2 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + value + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void extractsMultipleDependencyVersions() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + junit + junit + 4.13.2 + test + + + org.slf4j + slf4j-api + 1.7.36 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + 1.7.36 + + + + junit + junit + ${junit.version} + test + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + """ + ) + ); + } + + @Test + void renamesNonStandardPropertyToStandardGroupNameWhenOtherDepHasLiteralVersion() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 5.3.0 + + + + org.springframework + spring-core + ${spring.version} + + + org.springframework + spring-context + 5.3.0 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 5.3.0 + + + + org.springframework + spring-core + ${org.springframework.version} + + + org.springframework + spring-context + ${org.springframework.version} + + + + """ + ) + ); + } + + @Test + void dependenciesWithSameGroupIdShareOneVersionProperty() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + org.springframework + spring-core + 5.3.0 + + + org.springframework + spring-context + 5.3.0 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 5.3.0 + + + + org.springframework + spring-core + ${org.springframework.version} + + + org.springframework + spring-context + ${org.springframework.version} + + + + """ + ) + ); + } + + @Test + void skipsVersionsAlreadyUsingPropertyPlaceholders() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void reusesExistingPropertyWhenNameAndValueMatch() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + 4.13.2 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void generatesUniquePropertyNameWhenExistingPropertyHasDifferentValue() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.12 + + + + junit + junit + 4.13.2 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.12 + 4.13.2 + + + + junit + junit + ${junit.version.1} + + + + """ + ) + ); + } + + @Test + void extractsVersionsFromDependencyManagement() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + junit + junit + 4.13.2 + test + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + + junit + junit + ${junit.version} + test + + + + + """ + ) + ); + } + + @Test + void extractsPluginVersions() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 3.11.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + + """ + ) + ); + } + + @Test + void extractsPluginDependencyVersions() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0 + + + org.junit.jupiter + junit-jupiter-engine + 5.9.1 + + + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 5.9.1 + 3.0.0 + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter-engine.version} + + + + + + + """ + ) + ); + } + + @Test + void isNoOpForMinimalPomWithNoDependencies() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + """ + ) + ); + } + + @Test + void skipsDepWithNoVersionTagWhenManagedByDependencyManagement() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + junit + junit + 4.13.2 + + + + + + junit + junit + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + + junit + junit + ${junit.version} + + + + + + junit + junit + + + + """ + ) + ); + } + + @Test + void isNoOpForDepAlreadyUsingPropertyWithIndirectValue() { + // lib.version's value is itself a property ref; the recipe's resolveVersion() only goes one + // level deep, returning the intermediate ref rather than the final literal. The dep already + // uses a property placeholder so the recipe must leave it alone regardless. + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + ${junit.base.version} + 4.13.2 + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void extractsPluginVersionInPluginManagement() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 3.11.0 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + + + """ + ) + ); + } + + @Test + void extractsInlinedVersionUnderSpringBootStarterParentReusingInheritedPropertyName() { + // spring-boot-starter-parent's own parent (spring-boot-dependencies) declares a `h2.version` + // property and manages com.h2database:h2 via `${h2.version}`. Those inherited properties live in + // the parent hierarchy, not in this document, so the recipe never sees them. It derives a local + // property name purely from the artifactId ("h2" -> "h2.version"), which here happens to match + // the inherited property name. Because Spring Boot's override convention is to redeclare that same + // property locally, the extracted `` not only de-inlines the version but also becomes + // the idiomatic override of the managed version. + rewriteRun( + pomXml( + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + + com.h2database + h2 + 2.1.214 + + + + """, + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + 2.1.214 + + + + com.h2database + h2 + ${h2.version} + + + + """ + ) + ); + } + + @Test + void extractsInlinedVersionUnderSpringBootStarterParentWithoutReusingInheritedPropertyName() { + // Counterpart to the h2 case: spring-boot-dependencies manages org.mockito:mockito-core via a + // `mockito.version` property, but the recipe names its local property after the artifactId + // ("mockito-core" -> "mockito-core.version") rather than after the inherited `mockito.version`. + // The dependency still resolves to the intended version through the explicit `${mockito-core.version}` + // reference, but the extracted property does not line up with Spring Boot's override convention. + rewriteRun( + pomXml( + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + + org.mockito + mockito-core + 4.5.1 + + + + """, + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + 4.5.1 + + + + org.mockito + mockito-core + ${mockito-core.version} + + + + """ + ) + ); + } + + @Test + void doesNotRenameInheritedSpringBootPropertyToGroupStandardName() { + // `mockito.version` is declared by spring-boot-dependencies and the parent's dependency management + // references it by that exact name; redeclaring it locally is Spring Boot's override mechanism. + // Both mockito dependencies share that property, so the group-standardizing rename would normally + // rewrite it to `org.mockito.version` - but that would break the parent's `${mockito.version}` + // wiring, which lives outside this pom.xml. The recipe must leave the inherited property untouched. + rewriteRun( + pomXml( + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + 4.5.1 + + + + org.mockito + mockito-core + ${mockito.version} + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + + + + """ + ) + ); + } + + @Test + void isNoOpWhenAllVersionsAlreadyExtracted() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + 1.7.36 + + + + junit + junit + ${junit.version} + test + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + """ + ) + ); + } +}