diff --git a/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java b/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java index 155e3107f6f..d4d6388702a 100644 --- a/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java +++ b/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java @@ -34,6 +34,14 @@ public static boolean isVersion(@Nullable String version) { return LatestRelease.RELEASE_PATTERN.matcher(version).matches(); } + /** + * Validates the given version against an optional pattern + * + * @param toVersion the version to validate. Node-style [version selectors](https://docs.openrewrite.org/reference/dependency-version-selectors) may be used. + * @param metadataPattern optional metadata appended to the version. Allows version selection to be extended beyond the original Node Semver semantics. So for example, + * Setting 'version' to "25-29" can be paired with a metadata pattern of "-jre" to select Guava 29.0-jre + * @return the validation result + */ public static Validated validate(String toVersion, @Nullable String metadataPattern) { return Validated.testNone( "metadataPattern", diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java b/rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java new file mode 100644 index 00000000000..11e21fd8189 --- /dev/null +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java @@ -0,0 +1,119 @@ +/* + * Copyright 2024 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.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.internal.StringUtils; +import org.openrewrite.maven.internal.MavenPomDownloader; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.maven.tree.Parent; +import org.openrewrite.maven.tree.ResolvedPom; +import org.openrewrite.semver.Semver; +import org.openrewrite.semver.VersionComparator; +import org.openrewrite.xml.AddCommentToXmlTag; +import org.openrewrite.xml.tree.Xml; + +import static java.lang.String.format; +import static java.util.Collections.emptyList; +import static org.openrewrite.Validated.required; + +@Value +@EqualsAndHashCode(callSuper = false) +public class HasMavenAncestry extends Recipe { + @Override + public String getDisplayName() { + return "Has Maven ancestry"; + } + + @Override + public String getDescription() { + return "Checks if a pom file has a given Maven ancestry among its parent poms. " + + "This is useful especially as a precondition for other recipes."; + } + + @Override + public String getInstanceNameSuffix() { + return version == null ? format("%s:%s", groupId, artifactId) : format("%s:%s:%s", groupId, artifactId, version); + } + + @Option(displayName = "Group", + description = "The groupId to find. The groupId is the first part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob expressions.", + example = "org.springframework.*") + String groupId; + + @Option(displayName = "Artifact ID", + description = "The artifactId to find. The artifactId is the second part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob expressions.", + example = "spring-boot-starter-*") + String artifactId; + + @Option(displayName = "Version", + description = "Match only an ancestor with the specified version. " + + "Node-style [version selectors](https://docs.openrewrite.org/reference/dependency-version-selectors) may be used. " + + "All versions are searched by default.", + example = "1.x", + required = false) + @Nullable + String version; + + @Override + public Validated validate() { + Validated validated = super.validate().and( + required("groupId", groupId).or(required("artifactId", artifactId))); + if (version != null) { + return validated.and(Semver.validate(version, null)); + } + return validated; + } + + @Override + public TreeVisitor getVisitor() { + return new MavenIsoVisitor() { + @Nullable + final VersionComparator versionComparator = version != null ? Semver.validate(version, null).getValue() : null; + + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + checkParents(ctx); + return document; + } + + private void checkParents(ExecutionContext ctx) { + try { + MavenResolutionResult mrr = getResolutionResult(); + MavenPomDownloader mpd = new MavenPomDownloader(mrr.getProjectPoms(), ctx, mrr.getMavenSettings(), mrr.getActiveProfiles()); + + Parent ancestor = mrr.getPom().getRequested().getParent(); + while (ancestor != null) { + if (StringUtils.matchesGlob(ancestor.getGroupId(), groupId) && + StringUtils.matchesGlob(ancestor.getArtifactId(), artifactId) && + (versionComparator == null || versionComparator.isValid(null, ancestor.getVersion()))) { + doAfterVisit(new AddCommentToXmlTag("/project/parent", "HasMavenAncestry: " + getInstanceNameSuffix()).getVisitor()); + break; + } + ResolvedPom ancestorPom = mpd.download(ancestor.getGav(), null, null, mrr.getPom().getRepositories()) + .resolve(emptyList(), mpd, ctx); + ancestor = ancestorPom.getRequested().getParent(); + } + } catch (MavenDownloadingException e) { + throw new RuntimeException(e); + } + } + }; + } +} diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java new file mode 100644 index 00000000000..3057e19d918 --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java @@ -0,0 +1,557 @@ +/* + * Copyright 2024 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.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.test.SourceSpec; + +import static org.openrewrite.java.Assertions.mavenProject; +import static org.openrewrite.maven.Assertions.pomXml; + +class HasMavenAncestryTest implements RewriteTest { + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new HasMavenAncestry( + "org.springframework.boot", + "spring-boot-starter-parent", + null + )); + } + + @Test + void noParent() { + rewriteRun( + pomXml( + //language=xml + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void directParentMatches() { + rewriteRun( + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void directParentMatchesFullGAV() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.3")), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void directParentMatchesGAVMinorVersion() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.x")), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void directParentMatchesGroupIdGlob() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.*", "spring-boot-starter-parent", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void directParentMatchesArtifactIdGlob() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-*-parent", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void indirectParentMatches() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-dependencies", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void indirectParentMatchesGAVPattern() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("*.springframework.*", "spring-*-dependencies", "3.x")), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """, + //language=xml + """ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void multiModuleParentMatches() { + rewriteRun( + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + child + + + com.mycompany.app + my-app + 1 + + """, + SourceSpec::skip + ), + mavenProject("child", + pomXml( + //language=xml + """ + + 4.0.0 + + + com.mycompany.app + my-app + 1 + + + child + + """, + //language=xml + """ + + 4.0.0 + + + + com.mycompany.app + my-app + 1 + + + child + + """ + ) + ) + ); + } + + @Test + void groupIdDoesNotMatch() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.invalid", "spring-boot-starter-parent", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void artifactIdDoesNotMatch() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-web", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void versionDoesNotMatch() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.4")), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void minorVersionDoesNotMatch() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.x")), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.0.5 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void doesNotMatchGroupIdGlob() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.invalid.*", "spring-boot-starter-parent", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } + + @Test + void doesNotMatchArtifactIdGlob() { + rewriteRun( + spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-*-web", null)), + pomXml( + //language=xml + """ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.mycompany.app + my-app + 1 + + """ + ) + ); + } +}