From 25b30307d3fe821b86090884ae58735d3148b89b Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Mon, 11 May 2026 12:46:03 +0200 Subject: [PATCH 1/4] add ExtractVersionsAsProperties recipe --- .../maven/ExtractVersionsAsProperties.java | 216 ++++++ .../org/openrewrite/maven/MavenVisitor.java | 5 + .../ExtractVersionsAsPropertiesTest.java | 702 ++++++++++++++++++ 3 files changed, 923 insertions(+) create mode 100644 rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java create mode 100644 rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java 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..75ede39510e --- /dev/null +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -0,0 +1,216 @@ +/* + * 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.xml.ChangeTagValueVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ExtractVersionsAsProperties extends Recipe { + + @Override + public String getDisplayName() { + return "Extract Maven dependency versions as properties"; + } + + @Override + public String getDescription() { + return "Extracts inlined dependency versions into the `` section and replaces them with `${property}` references."; + } + + @Override + public TreeVisitor getVisitor() { + return new VersionExtractionVisitor(); + } + + private static Stream allDescendants(Xml.Tag tag) { + return Stream.concat(Stream.of(tag), tag.getChildren().stream().flatMap(ExtractVersionsAsProperties::allDescendants)); + } + + private static class VersionExtractionVisitor extends 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); + schedulePropertyRenames(document.getRoot(), existingProps, groupSharedVersion); + return super.visitDocument(document, ctx); + } + + private static Map loadExistingProperties(Xml.Tag root) { + return root.getChild("properties") + .map(VersionExtractionVisitor::collectPropertiesFrom) + .orElseGet(LinkedHashMap::new); + } + + private static Map collectPropertiesFrom(Xml.Tag propsTag) { + return propsTag.getChildren().stream() + .filter(child -> child.getValue().isPresent()) + .collect(Collectors.toMap( + Xml.Tag::getName, + child -> child.getValue().get(), + (a, b) -> a, + LinkedHashMap::new)); + } + + private void schedulePropertyRenames(Xml.Tag root, Map existingProps, + Map groupSharedVersion) { + PropertyRenamer.findRenames(root, existingProps, groupSharedVersion) + .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); + } + + 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 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(Collectors.groupingBy( + tag -> tag.getChildValue("groupId").get(), + Collectors.toList())) + .entrySet().stream() + .flatMap(groupEntry -> toSharedVersionEntry(groupEntry, existingProps)) + .collect(Collectors.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(Collectors.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 existingProps, + Map groupSharedVersion) { + return allDescendants(root) + .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) + .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion)) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> a, + LinkedHashMap::new)); + } + + private static Stream> toNonStandardRenameEntry( + Xml.Tag tag, Map existingProps, Map groupSharedVersion) { + 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)) { + 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/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java new file mode 100644 index 00000000000..08e2052fa53 --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java @@ -0,0 +1,702 @@ +/* + * 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()); + } + + @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 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} + + + + """ + ) + ); + } +} From 08af5b8e59bc710fd52d5053830fc1aa331ca25d Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Tue, 12 May 2026 21:13:50 +0200 Subject: [PATCH 2/4] add the new recipe to recipes.csv --- rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv | 1 + 1 file changed, 1 insertion(+) 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..556992ddb73 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 dependency versions to Maven properties,Extract dependency versions to Maven properties,1,"",Maven,, From c0decf6b60e6602f4aed814b3f3dc7256b26f707 Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Tue, 12 May 2026 21:17:49 +0200 Subject: [PATCH 3/4] fix: append . to description for ExtractVersionsAsProperties recipe in recipes.csv --- rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 556992ddb73..f6bd1b2680c 100644 --- a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv @@ -100,4 +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 dependency versions to Maven properties,Extract dependency versions to Maven properties,1,"",Maven,, +maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract dependency versions to Maven properties,Extract dependency versions to Maven properties.,1,"",Maven,, From 46d5426b76b9d6e1ba0ef20d54ed61419b18f869 Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Thu, 14 May 2026 10:02:20 +0200 Subject: [PATCH 4/4] show more details about failing tests --- build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 371ce2df11e..a64c5bed33a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,16 @@ allprojects { } subprojects { + tasks.withType().configureEach { + testLogging { + events("failed") + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + } + tasks.withType().configureEach { if (name == "generateAntlrSources") { doLast {