diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/AddDependency.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/AddDependency.java index 675259b88c5..29f2c2f9e46 100644 --- a/rewrite-gradle/src/main/java/org/openrewrite/gradle/AddDependency.java +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/AddDependency.java @@ -30,9 +30,15 @@ import org.openrewrite.java.search.UsesType; import org.openrewrite.java.tree.J; import org.openrewrite.java.tree.JavaSourceFile; +import org.openrewrite.marker.Markup; +import org.openrewrite.maven.MavenDownloadingException; +import org.openrewrite.maven.MavenExecutionContextView; +import org.openrewrite.maven.internal.MavenPomDownloader; import org.openrewrite.maven.table.MavenMetadataFailures; -import org.openrewrite.maven.tree.GroupArtifact; +import org.openrewrite.maven.tree.*; +import org.openrewrite.maven.utilities.JavaSourceSetUpdater; import org.openrewrite.semver.Semver; +import org.openrewrite.semver.VersionComparator; import java.util.*; @@ -138,6 +144,11 @@ public Validated validate() { public static class Scanned { Map usingType = new HashMap<>(); Map> configurationsByProject = new HashMap<>(); + @Nullable + String resolvedVersion; + List repositories = new ArrayList<>(); + @Nullable + Exception versionResolutionFailure; } @Override @@ -176,7 +187,9 @@ private boolean usesType(SourceFile sourceFile, ExecutionContext ctx) { sourceFile == hasTestSourceSet.visit(sourceFile, ctx)) { return tree; } - sourceFile.getMarkers().findFirst(JavaProject.class).ifPresent(javaProject -> { + Optional maybeJavaProject = sourceFile.getMarkers().findFirst(JavaProject.class); + if (maybeJavaProject.isPresent()) { + JavaProject javaProject = maybeJavaProject.get(); boolean uses = usesType(sourceFile, ctx); acc.usingType.compute(javaProject, (jp, usingType) -> Boolean.TRUE.equals(usingType) || uses); @@ -185,7 +198,25 @@ private boolean usesType(SourceFile sourceFile, ExecutionContext ctx) { sourceFile.getMarkers().findFirst(JavaSourceSet.class).ifPresent(sourceSet -> configurations.add("main".equals(sourceSet.getName()) ? "implementation" : sourceSet.getName() + "Implementation")); } - }); + + // Resolve version once for JavaSourceSet updates + if (acc.resolvedVersion == null && version != null) { + Optional maybeGp = sourceFile.getMarkers().findFirst(GradleProject.class); + if (maybeGp.isPresent()) { + try { + DependencyVersionSelector selector = new DependencyVersionSelector(metadataFailures, maybeGp.get(), null); + acc.resolvedVersion = selector.select( + new GroupArtifact(groupId, artifactId), "implementation", + version, versionPattern, ctx); + if (acc.resolvedVersion != null) { + acc.repositories = maybeGp.get().getMavenRepositories(); + } + } catch (MavenDownloadingException e) { + acc.versionResolutionFailure = e; + } + } + } + } return tree; } }; @@ -196,7 +227,7 @@ public TreeVisitor getVisitor(Scanned acc) { // Allow when configuration is explicitly provided, when onlyIfUsing is not set (default to "implementation"), // or when source files were scanned boolean hasExplicitConfiguration = !StringUtils.isBlank(configuration); - return Preconditions.check(hasExplicitConfiguration || onlyIfUsing == null || !acc.configurationsByProject.isEmpty(), + TreeVisitor gradleVisitor = Preconditions.check(hasExplicitConfiguration || onlyIfUsing == null || !acc.configurationsByProject.isEmpty(), Preconditions.check(new IsBuildGradle<>(true), new JavaIsoVisitor() { @Override @@ -280,5 +311,65 @@ private boolean isTopLevel(Cursor cursor) { } }) ); + + if (acc.configurationsByProject.isEmpty() || acc.resolvedVersion == null) { + if (acc.versionResolutionFailure != null) { + Exception failure = acc.versionResolutionFailure; + return new TreeVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return gradleVisitor.isAcceptable(sourceFile, ctx); + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + Tree result = gradleVisitor.visit(tree, ctx); + if (result != tree) { + result = Markup.warn(result, failure); + } + return result; + } + }; + } + return gradleVisitor; + } + + return new TreeVisitor() { + @Nullable + private JavaSourceSetUpdater updater; + + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return gradleVisitor.isAcceptable(sourceFile, ctx) || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (gradleVisitor.isAcceptable(sf, ctx)) { + return gradleVisitor.visit(tree, ctx); + } + if (sf instanceof JavaSourceFile) { + return updateJavaSourceSet(sf, ctx); + } + return tree; + } + + private SourceFile updateJavaSourceSet(SourceFile sf, ExecutionContext ctx) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + if (!maybeJp.isPresent() || !acc.configurationsByProject.containsKey(maybeJp.get())) { + return sf; + } + if (updater == null) { + updater = new JavaSourceSetUpdater(ctx); + } + return JavaSourceSet.updateOnSourceFile(sf, sourceSet -> + sourceSet.getGavToTypes().isEmpty() ? sourceSet : + updater.addDependency(sourceSet, groupId, artifactId, acc.resolvedVersion, acc.repositories)); + } + }; } } diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/ChangeDependency.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/ChangeDependency.java index 9f5c752d643..1471225e1e7 100644 --- a/rewrite-gradle/src/main/java/org/openrewrite/gradle/ChangeDependency.java +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/ChangeDependency.java @@ -30,6 +30,8 @@ import org.openrewrite.internal.StringUtils; import org.openrewrite.java.JavaIsoVisitor; import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.marker.JavaProject; +import org.openrewrite.java.marker.JavaSourceSet; import org.openrewrite.java.tree.J; import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.kotlin.tree.K; @@ -37,6 +39,7 @@ import org.openrewrite.maven.MavenDownloadingException; import org.openrewrite.maven.tree.*; import org.openrewrite.maven.table.MavenMetadataFailures; +import org.openrewrite.maven.utilities.JavaSourceSetUpdater; import org.openrewrite.properties.PropertiesVisitor; import org.openrewrite.properties.tree.Properties; import org.openrewrite.semver.DependencyMatcher; @@ -171,6 +174,8 @@ public static class Accumulator { Map versionVariableUpdates = new HashMap<>(); Map> versionVariableUsages = new HashMap<>(); Set failedResolutions = new HashSet<>(); + Map modulesWithOldDependency = new HashMap<>(); + Map> moduleRepositories = new HashMap<>(); } @Override @@ -197,6 +202,21 @@ public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { if (gradleProject == null) { return (J) tree; } + // Record this module if it has the old dependency + Optional maybeJp = tree.getMarkers().findFirst(JavaProject.class); + if (maybeJp.isPresent() && !acc.modulesWithOldDependency.containsKey(maybeJp.get())) { + outer: + for (GradleDependencyConfiguration config : gradleProject.getConfigurations()) { + for (ResolvedDependency resolved : config.getDirectResolved()) { + if (StringUtils.matchesGlob(resolved.getGroupId(), oldGroupId) && + StringUtils.matchesGlob(resolved.getArtifactId(), oldArtifactId)) { + acc.modulesWithOldDependency.put(maybeJp.get(), resolved); + acc.moduleRepositories.put(maybeJp.get(), gradleProject.getMavenRepositories()); + break outer; + } + } + } + } } return super.visit(tree, ctx); } @@ -480,14 +500,23 @@ private GradleProject updateGradleModel(GradleProject gp, ExecutionContext ctx) }); DependencyMatcher propsMatcher = requireNonNull(DependencyMatcher.build(oldGroupId + ":" + oldArtifactId).getValue()); + boolean hasModulesWithOldDep = !acc.modulesWithOldDependency.isEmpty(); return new TreeVisitor() { + @Nullable + private JavaSourceSetUpdater updater; + private final Map updatedSourceSets = new HashMap<>(); + @Override public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { if (sourceFile instanceof Properties.File) { return sourceFile.getSourcePath().endsWith(GRADLE_PROPERTIES_FILE_NAME); } - return (sourceFile instanceof G.CompilationUnit || sourceFile instanceof K.CompilationUnit) - && sourceFile.getMarkers().findFirst(GradleProject.class).isPresent(); + if ((sourceFile instanceof G.CompilationUnit || sourceFile instanceof K.CompilationUnit) + && sourceFile.getMarkers().findFirst(GradleProject.class).isPresent()) { + return true; + } + // Accept Java source files for JavaSourceSet updates + return hasModulesWithOldDep && sourceFile instanceof JavaSourceFile; } @Override @@ -517,8 +546,51 @@ public Properties visitEntry(Properties.Entry entry, ExecutionContext ctx) { } return tree; } + // For Java source files, update JavaSourceSet marker + if (hasModulesWithOldDep && tree instanceof JavaSourceFile) { + return updateJavaSourceSet((SourceFile) tree, ctx); + } return gradleVisitor.visit(tree, ctx); } + + private SourceFile updateJavaSourceSet(SourceFile sf, ExecutionContext ctx) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + if (!maybeJp.isPresent()) { + return sf; + } + ResolvedDependency oldDep = acc.modulesWithOldDependency.get(maybeJp.get()); + if (oldDep == null) { + return sf; + } + if (updater == null) { + updater = new JavaSourceSetUpdater(ctx); + } + JavaProject jp = maybeJp.get(); + return JavaSourceSet.updateOnSourceFile(sf, updatedSourceSets, sourceSet -> { + String effectiveNewGroupId = newGroupId != null ? newGroupId : oldDep.getGroupId(); + String effectiveNewArtifactId = newArtifactId != null ? newArtifactId : oldDep.getArtifactId(); + ResolvedGroupArtifactVersion newGav = new ResolvedGroupArtifactVersion( + oldDep.getGav().getRepository(), + effectiveNewGroupId, effectiveNewArtifactId, oldDep.getVersion(), null); + ResolvedDependency newDep = oldDep + .withGav(newGav) + .withRepository(findRemoteRepository(jp)); + return updater.changeDependency(sourceSet, oldDep, newDep); + }); + } + + private MavenRepository findRemoteRepository(JavaProject jp) { + List repos = acc.moduleRepositories.get(jp); + if (repos != null) { + for (MavenRepository repo : repos) { + String uri = repo.getUri(); + if (uri != null && (uri.startsWith("http://") || uri.startsWith("https://"))) { + return repo; + } + } + } + return MavenRepository.MAVEN_CENTRAL; + } }; } diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/RemoveDependency.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/RemoveDependency.java index 44117bbf50d..2e8634d514a 100644 --- a/rewrite-gradle/src/main/java/org/openrewrite/gradle/RemoveDependency.java +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/RemoveDependency.java @@ -26,6 +26,7 @@ import org.openrewrite.internal.ListUtils; import org.openrewrite.internal.StringUtils; import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.marker.JavaSourceSet; import org.openrewrite.java.tree.J; import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.kotlin.tree.K; @@ -69,7 +70,7 @@ public String getInstanceNameSuffix() { @Override public TreeVisitor getVisitor() { - return Preconditions.check(new IsBuildGradle<>(), new JavaIsoVisitor() { + TreeVisitor gradleVisitor = Preconditions.check(new IsBuildGradle<>(), new JavaIsoVisitor() { final GradleDependency.Matcher gradleDependencyMatcher = new GradleDependency.Matcher() .configuration(configuration) .groupId(groupId) @@ -157,5 +158,28 @@ private GradleProject updateGradleModel(GradleProject gp) { return gp; } }); + + return new TreeVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return gradleVisitor.isAcceptable(sourceFile, ctx) || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (gradleVisitor.isAcceptable(sf, ctx)) { + return gradleVisitor.visit(tree, ctx); + } + if (sf instanceof JavaSourceFile) { + return JavaSourceSet.updateOnSourceFile(sf, + sourceSet -> sourceSet.removeTypesMatching(groupId, artifactId)); + } + return tree; + } + }; } } diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/UpgradeDependencyVersion.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/UpgradeDependencyVersion.java index 237089d9c0c..b8855540153 100644 --- a/rewrite-gradle/src/main/java/org/openrewrite/gradle/UpgradeDependencyVersion.java +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/UpgradeDependencyVersion.java @@ -33,18 +33,18 @@ import org.openrewrite.java.JavaIsoVisitor; import org.openrewrite.java.JavaVisitor; import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.marker.JavaProject; +import org.openrewrite.java.marker.JavaSourceSet; import org.openrewrite.java.tree.Expression; import org.openrewrite.java.tree.J; import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.kotlin.tree.K; +import org.openrewrite.maven.utilities.JavaSourceSetUpdater; import org.openrewrite.marker.Markup; import org.openrewrite.maven.MavenDownloadingException; import org.openrewrite.maven.internal.MavenPomDownloader; import org.openrewrite.maven.table.MavenMetadataFailures; -import org.openrewrite.maven.tree.Dependency; -import org.openrewrite.maven.tree.GroupArtifact; -import org.openrewrite.maven.tree.GroupArtifactVersion; -import org.openrewrite.maven.tree.ResolvedDependency; +import org.openrewrite.maven.tree.*; import org.openrewrite.properties.PropertiesVisitor; import org.openrewrite.properties.tree.Properties; import org.openrewrite.semver.DependencyMatcher; @@ -134,6 +134,8 @@ public static class DependencyVersionState { Map gaToNewVersion = new HashMap<>(); Map>> configurationPerGAPerModule = new HashMap<>(); + + Map> modulesWithDependency = new HashMap<>(); } @Override @@ -159,6 +161,24 @@ public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { public @Nullable J visit(@Nullable Tree tree, ExecutionContext ctx) { if (tree instanceof JavaSourceFile) { gradleProject = tree.getMarkers().findFirst(GradleProject.class).orElse(null); + // Record modules with matching resolved dependencies for JavaSourceSet updates + if (gradleProject != null) { + Optional maybeJp = tree.getMarkers().findFirst(JavaProject.class); + if (maybeJp.isPresent() && !acc.modulesWithDependency.containsKey(maybeJp.get())) { + DependencyMatcher depMatcher = new DependencyMatcher(groupId, artifactId, null); + Set matched = new HashSet<>(); + for (GradleDependencyConfiguration config : gradleProject.getConfigurations()) { + for (ResolvedDependency resolved : config.getDirectResolved()) { + if (depMatcher.matches(resolved.getGroupId(), resolved.getArtifactId())) { + matched.add(resolved); + } + } + } + if (!matched.isEmpty()) { + acc.modulesWithDependency.put(maybeJp.get(), matched); + } + } + } } return super.visit(tree, ctx); } @@ -291,13 +311,20 @@ private void gatherVariables(GradleDependency gradleDependency) { @Override public TreeVisitor getVisitor(DependencyVersionState acc) { + boolean hasModulesWithDep = !acc.modulesWithDependency.isEmpty(); return new TreeVisitor() { private final UpdateGradle updateGradle = new UpdateGradle(acc); private final UpdateProperties updateProperties = new UpdateProperties(acc); + @Nullable + private JavaSourceSetUpdater jssUpdater; @Override public boolean isAcceptable(SourceFile sf, ExecutionContext ctx) { - return updateProperties.isAcceptable(sf, ctx) || updateGradle.isAcceptable(sf, ctx); + if (updateProperties.isAcceptable(sf, ctx) || updateGradle.isAcceptable(sf, ctx)) { + return true; + } + return hasModulesWithDep && sf instanceof JavaSourceFile + && !(sf instanceof G.CompilationUnit) && !(sf instanceof K.CompilationUnit); } @Override @@ -306,6 +333,11 @@ public boolean isAcceptable(SourceFile sf, ExecutionContext ctx) { Tree t = tree; if (t instanceof SourceFile) { SourceFile sf = (SourceFile) t; + // Handle regular Java source files for JavaSourceSet updates + if (hasModulesWithDep && sf instanceof JavaSourceFile + && !(sf instanceof G.CompilationUnit) && !(sf instanceof K.CompilationUnit)) { + return updateJavaSourceSet(sf, ctx); + } if (updateProperties.isAcceptable(sf, ctx)) { t = updateProperties.visitNonNull(t, ctx); } else if (updateGradle.isAcceptable(sf, ctx)) { @@ -361,6 +393,44 @@ public boolean isAcceptable(SourceFile sf, ExecutionContext ctx) { } return t; } + + private SourceFile updateJavaSourceSet(SourceFile sf, ExecutionContext ctx) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + if (!maybeJp.isPresent()) { + return sf; + } + Set oldDeps = acc.modulesWithDependency.get(maybeJp.get()); + if (oldDeps == null || oldDeps.isEmpty()) { + return sf; + } + Optional maybeSourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + if (!maybeSourceSet.isPresent() || maybeSourceSet.get().getGavToTypes().isEmpty()) { + return sf; + } + if (jssUpdater == null) { + jssUpdater = new JavaSourceSetUpdater(ctx); + } + JavaSourceSet sourceSet = maybeSourceSet.get(); + for (ResolvedDependency oldDep : oldDeps) { + GroupArtifact ga = new GroupArtifact(oldDep.getGroupId(), oldDep.getArtifactId()); + Object newVersionObj = acc.gaToNewVersion.get(ga); + if (!(newVersionObj instanceof String)) { + continue; + } + String resolvedNewVersion = (String) newVersionObj; + ResolvedGroupArtifactVersion newGav = new ResolvedGroupArtifactVersion( + oldDep.getGav().getRepository(), + oldDep.getGroupId(), oldDep.getArtifactId(), resolvedNewVersion, null); + ResolvedDependency newDep = oldDep + .withGav(newGav) + .withRepository(MavenRepository.MAVEN_CENTRAL); + sourceSet = jssUpdater.changeDependency(sourceSet, oldDep, newDep); + } + if (sourceSet != maybeSourceSet.get()) { + return sf.withMarkers(sf.getMarkers().setByType(sourceSet)); + } + return sf; + } }; } diff --git a/rewrite-java-test/build.gradle.kts b/rewrite-java-test/build.gradle.kts index 427a9f6c582..54aa4ba0892 100644 --- a/rewrite-java-test/build.gradle.kts +++ b/rewrite-java-test/build.gradle.kts @@ -4,6 +4,9 @@ plugins { recipeDependencies { parserClasspath("jakarta.persistence:jakarta.persistence-api:3.1.0") + testParserClasspath("jakarta.validation:jakarta.validation-api:3.0.2") + testParserClasspath("javax.validation:validation-api:1.1.0.Final") + testParserClasspath("org.hibernate:hibernate-validator:5.4.3.Final") } dependencies { diff --git a/rewrite-java-test/src/main/resources/META-INF/rewrite/classpath.tsv.gz b/rewrite-java-test/src/main/resources/META-INF/rewrite/classpath.tsv.gz index b15940eecca..45dd8332c3b 100644 Binary files a/rewrite-java-test/src/main/resources/META-INF/rewrite/classpath.tsv.gz and b/rewrite-java-test/src/main/resources/META-INF/rewrite/classpath.tsv.gz differ diff --git a/rewrite-java-test/src/test/java/org/openrewrite/java/ChangePackageTest.java b/rewrite-java-test/src/test/java/org/openrewrite/java/ChangePackageTest.java index ebf408fcb13..439a492bd6a 100644 --- a/rewrite-java-test/src/test/java/org/openrewrite/java/ChangePackageTest.java +++ b/rewrite-java-test/src/test/java/org/openrewrite/java/ChangePackageTest.java @@ -18,6 +18,7 @@ import org.intellij.lang.annotations.Language; import org.junit.jupiter.api.Test; import org.openrewrite.DocumentExample; +import org.openrewrite.InMemoryExecutionContext; import org.openrewrite.Issue; import org.openrewrite.java.search.FindTypes; import org.openrewrite.java.tree.J; @@ -28,11 +29,17 @@ import org.openrewrite.test.RewriteTest; import org.openrewrite.test.SourceSpec; +import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; +import static org.openrewrite.java.Assertions.addTypesToSourceSet; import static org.openrewrite.java.Assertions.java; +import static org.openrewrite.java.Assertions.srcMainJava; +import static org.openrewrite.java.Assertions.withSourceTypesOnClasspath; import static org.openrewrite.properties.Assertions.properties; import static org.openrewrite.test.SourceSpecs.text; import static org.openrewrite.xml.Assertions.xml; @@ -666,6 +673,92 @@ T method(T t) { ); } + @Test + void changePackageExpandsStarImportWhenItWouldCreateAmbiguity() { + InMemoryExecutionContext ctx = new InMemoryExecutionContext(); + List classpath = JavaParser.dependenciesFromResources(ctx, + "validation-api", "jakarta.validation-api", "hibernate-validator"); + rewriteRun( + spec -> spec.recipe(new ChangePackage("javax.validation.constraints", "jakarta.validation.constraints", true)) + .parser(JavaParser.fromJavaVersion().classpathFromResources(ctx, + "validation-api", "hibernate-validator")) + .beforeRecipe(addTypesToSourceSet("main", + emptyList(), classpath)), + srcMainJava( + java( + """ + package xyz; + + import javax.validation.constraints.*; + import org.hibernate.validator.constraints.*; + + class A { + @NotNull + private String someField; + @NotEmpty + private String otherField; + } + """, + """ + package xyz; + + import jakarta.validation.constraints.NotNull; + import org.hibernate.validator.constraints.*; + + class A { + @NotNull + private String someField; + @NotEmpty + private String otherField; + } + """ + ) + ) + ); + } + + @Test + void changePackagePreservesStarImportWhenNoAmbiguity() { + InMemoryExecutionContext ctx = new InMemoryExecutionContext(); + List classpath = JavaParser.dependenciesFromResources(ctx, + "validation-api", "jakarta.validation-api"); + rewriteRun( + spec -> spec.recipe(new ChangePackage("javax.validation.constraints", "jakarta.validation.constraints", true)) + .parser(JavaParser.fromJavaVersion().classpathFromResources(ctx, + "validation-api")) + .beforeRecipe(addTypesToSourceSet("main", + emptyList(), classpath)), + srcMainJava( + java( + """ + package xyz; + + import javax.validation.constraints.*; + + class A { + @NotNull + private String someField; + @Size(max = 100) + private String otherField; + } + """, + """ + package xyz; + + import jakarta.validation.constraints.*; + + class A { + @NotNull + private String someField; + @Size(max = 100) + private String otherField; + } + """ + ) + ) + ); + } + @Test void annotation() { rewriteRun( @@ -2059,4 +2152,5 @@ void changePackageInServiceProviderFileContentOnly() { ) ); } + } diff --git a/rewrite-java-test/src/test/java/org/openrewrite/java/ChangeTypeTest.java b/rewrite-java-test/src/test/java/org/openrewrite/java/ChangeTypeTest.java index 1a4467c2876..41d4c580f9e 100644 --- a/rewrite-java-test/src/test/java/org/openrewrite/java/ChangeTypeTest.java +++ b/rewrite-java-test/src/test/java/org/openrewrite/java/ChangeTypeTest.java @@ -31,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.openrewrite.java.Assertions.java; +import static org.openrewrite.java.Assertions.withSourceTypesOnClasspath; import static org.openrewrite.properties.Assertions.properties; import static org.openrewrite.test.SourceSpecs.text; import static org.openrewrite.xml.Assertions.xml; @@ -2596,4 +2597,59 @@ void changeTypeInServiceProviderFileName() { ) ); } + + @Test + void changeTypeAddsExplicitImportWhenStarImportsWouldBeAmbiguous() { + rewriteRun( + spec -> spec.recipe(new ChangeType("a.Ambiguous", "b.Ambiguous", true)) + .beforeRecipe(withSourceTypesOnClasspath()), + java( + """ + package a; + public class Ambiguous {} + """ + ), + java( + """ + package b; + public class Ambiguous {} + """ + ), + java( + """ + package b; + public class Other {} + """ + ), + java( + """ + package c; + public class Ambiguous {} + """ + ), + java( + """ + import a.Ambiguous; + import b.*; + import c.*; + + class Test { + Ambiguous a; + Other o; + } + """, + """ + import b.Ambiguous; + import b.*; + import c.*; + + class Test { + Ambiguous a; + Other o; + } + """ + ) + ); + } + } diff --git a/rewrite-java-test/src/test/resources/META-INF/rewrite/classpath.tsv.gz b/rewrite-java-test/src/test/resources/META-INF/rewrite/classpath.tsv.gz new file mode 100644 index 00000000000..78735adfd16 Binary files /dev/null and b/rewrite-java-test/src/test/resources/META-INF/rewrite/classpath.tsv.gz differ diff --git a/rewrite-java/src/main/java/org/openrewrite/java/Assertions.java b/rewrite-java/src/main/java/org/openrewrite/java/Assertions.java index 1e4ba58c09a..13845282b72 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/Assertions.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/Assertions.java @@ -28,6 +28,7 @@ import org.openrewrite.java.search.FindMissingTypes; import org.openrewrite.java.tree.J; import org.openrewrite.java.tree.JavaSourceFile; +import org.openrewrite.java.tree.JavaType; import org.openrewrite.test.SourceSpec; import org.openrewrite.test.SourceSpecs; import org.openrewrite.test.TypeValidation; @@ -265,6 +266,42 @@ public static UncheckedConsumer> addTypesToSourceSet(String sou return addTypesToSourceSet(sourceSetName, emptyList(), emptyList()); } + /** + * Enrich each source file's JavaSourceSet marker with types declared in other source files, + * so that classpath-based ambiguity detection works in tests where types come from source + * files rather than JARs. + */ + public static UncheckedConsumer> withSourceTypesOnClasspath() { + return sourceFiles -> { + List sourceTypes = new ArrayList<>(); + for (SourceFile sf : sourceFiles) { + if (sf instanceof JavaSourceFile) { + for (J.ClassDeclaration classDecl : ((JavaSourceFile) sf).getClasses()) { + JavaType.FullyQualified type = classDecl.getType(); + if (type != null) { + sourceTypes.add(type); + } + } + } + } + for (int i = 0; i < sourceFiles.size(); i++) { + SourceFile sf = sourceFiles.get(i); + Optional maybeSourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + JavaSourceSet ss; + if (maybeSourceSet.isPresent()) { + ss = maybeSourceSet.get(); + List enriched = new ArrayList<>(ss.getClasspath()); + enriched.addAll(sourceTypes); + ss = ss.withClasspath(enriched); + } else { + ss = new JavaSourceSet(Tree.randomId(), "main", sourceTypes, emptyMap()); + } + sourceFiles.set(i, sf.withMarkers( + sf.getMarkers().computeByType(ss, (orig, upd) -> upd))); + } + }; + } + public static JavaVersion javaVersion(int version) { return javaVersions.computeIfAbsent(version, v -> new JavaVersion(Tree.randomId(), "openjdk", "adoptopenjdk", diff --git a/rewrite-java/src/main/java/org/openrewrite/java/ChangePackage.java b/rewrite-java/src/main/java/org/openrewrite/java/ChangePackage.java index 662fed140c4..501a92a97c8 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/ChangePackage.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/ChangePackage.java @@ -21,26 +21,25 @@ import org.jspecify.annotations.Nullable; import org.openrewrite.*; import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.marker.JavaSourceSet; import org.openrewrite.java.tree.*; import org.openrewrite.marker.SearchResult; import org.openrewrite.trait.Reference; import java.nio.file.Paths; -import java.util.HashMap; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import static java.util.Objects.requireNonNull; +import static org.openrewrite.Tree.randomId; /** * A recipe that will rename a package name in package statements, imports, and fully-qualified types (see: NOTE). *

* NOTE: Does not currently transform all possible type references, and accomplishing this would be non-trivial. - * For example, a method invocation select might refer to field `A a` whose type has now changed to `A2`, and so the type - * on the select should change as well. But how do we identify the set of all method selects which refer to `a`? Suppose - * it were prefixed like `this.a`, or `MyClass.this.a`, or indirectly via a separate method call like `getA()` where `getA()` + * For example, a method invocation select might refer to field {@code A a} whose type has now changed to {@code A2}, and so the type + * on the select should change as well. But how do we identify the set of all method selects which refer to {@code a}? Suppose + * it were prefixed like {@code this.a}, or {@code MyClass.this.a}, or indirectly via a separate method call like {@code getA()} where {@code getA()} * is defined on the super class. */ @Value @@ -278,12 +277,147 @@ public J postVisit(J tree, ExecutionContext ctx) { } } + // Expand changed star imports that would create ambiguity with other star imports + sf = maybeExpandStarImport(sf, newPackageName, oldPackageName); + if (changingTo != null && !changingTo.equals(newPackageName)) { + String oldSubPkg = oldPackageName + changingTo.substring(newPackageName.length()); + sf = maybeExpandStarImport(sf, changingTo, oldSubPkg); + } + if (Boolean.TRUE.equals(recursive)) { + for (J.Import anImport : sf.getImports()) { + if (!anImport.isStatic() && "*".equals(anImport.getQualid().getSimpleName())) { + String pkg = anImport.getPackageName(); + if (pkg.startsWith(newPackageName + ".")) { + String oldPkg = oldPackageName + pkg.substring(newPackageName.length()); + sf = maybeExpandStarImport(sf, pkg, oldPkg); + } + } + } + } + j = sf; } //noinspection DataFlowIssue return j; } + /** + * If a star import for {@code changedPackage} exists alongside other star imports, + * and types from {@code changedPackage} share simple names with types from those + * other packages, expand the star import into explicit imports to avoid ambiguity. + * + * @param changedPackage the new package name (after rename) + * @param originalPackage the old package name (before rename), used to find types on classpath + */ + private JavaSourceFile maybeExpandStarImport(JavaSourceFile sf, String changedPackage, String originalPackage) { + J.Import changedStarImport = null; + Set otherStarPackages = new LinkedHashSet<>(); + for (J.Import anImport : sf.getImports()) { + if (anImport.isStatic() || !"*".equals(anImport.getQualid().getSimpleName())) { + continue; + } + if (anImport.getPackageName().equals(changedPackage)) { + changedStarImport = anImport; + } else { + otherStarPackages.add(anImport.getPackageName()); + } + } + + if (changedStarImport == null || otherStarPackages.isEmpty()) { + return sf; + } + + if (!hasAmbiguity(sf, changedPackage, originalPackage, otherStarPackages)) { + return sf; + } + + // Collect simple names of types used from the changed package + Set usedFromChangedPackage = new TreeSet<>(); + for (JavaType type : sf.getTypesInUse().getTypesInUse()) { + if (type instanceof JavaType.FullyQualified) { + JavaType.FullyQualified fq = (JavaType.FullyQualified) type; + if (fq.getPackageName().equals(changedPackage)) { + usedFromChangedPackage.add(fq.getClassName()); + } + } + } + + if (usedFromChangedPackage.isEmpty()) { + return sf; + } + + // Expand the changed star import into explicit imports for used types + J.Import starImport = changedStarImport; + return sf.withImports(ListUtils.flatMap(sf.getImports(), anImport -> { + if (anImport == starImport) { + List expanded = new ArrayList<>(usedFromChangedPackage.size()); + int i = 0; + for (String simpleName : usedFromChangedPackage) { + J.FieldAccess newQualid = starImport.getQualid() + .withName(starImport.getQualid().getName().withSimpleName(simpleName)); + String fqn = changedPackage + "." + simpleName; + newQualid = newQualid.withType(findType(fqn, sf)); + J.Import explicit = starImport.withQualid(newQualid).withId(randomId()); + expanded.add(i++ == 0 ? explicit : explicit.withPrefix(Space.format("\n"))); + } + return expanded; + } + return anImport; + })); + } + + /** + * Checks whether types in the changed package share simple names with types in + * any of the other star-imported packages, using the JavaSourceSet classpath. + * Checks both the new package name and the original package name, since the + * classpath may still have types under the old package name. + */ + private boolean hasAmbiguity(JavaSourceFile sf, String changedPackage, String originalPackage, Set otherStarPackages) { + Optional sourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + if (!sourceSet.isPresent()) { + return false; + } + + Set typesInChangedPackage = new HashSet<>(); + Set typesInOtherPackages = new HashSet<>(); + for (JavaType.FullyQualified fq : sourceSet.get().getClasspath()) { + String pkg = fq.getPackageName(); + String className = fq.getClassName(); + if (pkg.equals(changedPackage) || pkg.equals(originalPackage)) { + typesInChangedPackage.add(className); + } else if (otherStarPackages.contains(pkg)) { + typesInOtherPackages.add(className); + } + } + + for (String typeName : typesInChangedPackage) { + if (typesInOtherPackages.contains(typeName)) { + return true; + } + } + return false; + } + + private JavaType.FullyQualified findType(String fqn, JavaSourceFile cu) { + for (JavaType type : cu.getTypesInUse().getTypesInUse()) { + if (type instanceof JavaType.FullyQualified) { + JavaType.FullyQualified fq = (JavaType.FullyQualified) type; + if (TypeUtils.fullyQualifiedNamesAreEqual(fq.getFullyQualifiedName(), fqn)) { + return fq; + } + } + } + Optional sourceSet = cu.getMarkers().findFirst(JavaSourceSet.class); + if (sourceSet.isPresent()) { + for (JavaType.FullyQualified fq : sourceSet.get().getClasspath()) { + if (TypeUtils.fullyQualifiedNamesAreEqual(fq.getFullyQualifiedName(), fqn)) { + return fq; + } + } + } + return JavaType.ShallowClass.build(fqn); + } + private @Nullable JavaType updateType(@Nullable JavaType oldType) { if (oldType == null || oldType instanceof JavaType.Unknown) { return oldType; diff --git a/rewrite-java/src/main/java/org/openrewrite/java/ChangeType.java b/rewrite-java/src/main/java/org/openrewrite/java/ChangeType.java index 8c7491971e4..38afd091df6 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/ChangeType.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/ChangeType.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; import org.openrewrite.*; import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.marker.JavaSourceSet; import org.openrewrite.java.search.UsesType; import org.openrewrite.java.tree.*; import org.openrewrite.marker.Markers; @@ -276,6 +277,12 @@ private void addImport(JavaType.FullyQualified owningClass) { setCursor(cursor); } })); + + // If the new type is covered by a star import and another star import + // provides a type with the same simple name, add an explicit import + if (fullyQualifiedTarget != null) { + j = maybeAddExplicitImportForAmbiguity((JavaSourceFile) j, fullyQualifiedTarget); + } } return j; @@ -596,6 +603,77 @@ private JavaType.FullyQualified updateNestedType(JavaType.FullyQualified nestedT return JavaType.ShallowClass.build(newNestedFqn); } + /** + * When the new type is provided by a star import and another star import provides + * a type with the same simple name, add an explicit import to disambiguate. + */ + private JavaSourceFile maybeAddExplicitImportForAmbiguity(JavaSourceFile sf, JavaType.FullyQualified newType) { + String newPkg = newType.getPackageName(); + String simpleName = newType.getClassName(); + + // Check if the new type is covered by a star import + boolean coveredByStar = false; + Set otherStarPackages = new LinkedHashSet<>(); + for (J.Import anImport : sf.getImports()) { + if (anImport.isStatic() || !"*".equals(anImport.getQualid().getSimpleName())) { + // Check if there's already an explicit import for this type + if (!anImport.isStatic() && anImport.getTypeName().replace('$', '.').equals(newType.getFullyQualifiedName())) { + return sf; // Already has explicit import, no ambiguity possible + } + continue; + } + if (anImport.getPackageName().equals(newPkg)) { + coveredByStar = true; + } else { + otherStarPackages.add(anImport.getPackageName()); + } + } + + if (!coveredByStar || otherStarPackages.isEmpty()) { + return sf; + } + + // Check if any other star-imported package has a type with the same simple name + Optional sourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + if (!sourceSet.isPresent()) { + return sf; + } + boolean ambiguous = false; + for (JavaType.FullyQualified fq : sourceSet.get().getClasspath()) { + if (fq.getClassName().equals(simpleName) && otherStarPackages.contains(fq.getPackageName())) { + ambiguous = true; + break; + } + } + + if (!ambiguous) { + return sf; + } + + // Add an explicit import to resolve the ambiguity + J.Import explicitImport = new J.Import(Tree.randomId(), + Space.EMPTY, + Markers.EMPTY, + new JLeftPadded<>(Space.EMPTY, false, Markers.EMPTY), + TypeTree.build(newType.getFullyQualifiedName()).withPrefix(Space.SINGLE_SPACE), + null); + + List> imports = new ArrayList<>(sf.getPadding().getImports()); + // Insert the explicit import right before the star import for its package + for (int i = 0; i < imports.size(); i++) { + J.Import imp = imports.get(i).getElement(); + if (!imp.isStatic() && "*".equals(imp.getQualid().getSimpleName()) && imp.getPackageName().equals(newPkg)) { + // Give the explicit import the star import's prefix (which has the leading newline) + JRightPadded padded = JRightPadded.build(explicitImport.withPrefix(imp.getPrefix())); + // Give the star import a fresh newline prefix + imports.set(i, imports.get(i).map(starImp -> starImp.withPrefix(Space.format("\n")))); + imports.add(i, padded); + break; + } + } + return sf.getPadding().withImports(imports); + } + private boolean hasNoConflictingImport(@Nullable JavaSourceFile sf) { JavaType.FullyQualified oldType = TypeUtils.asFullyQualified(originalType); JavaType.FullyQualified newType = TypeUtils.asFullyQualified(targetType); diff --git a/rewrite-java/src/main/java/org/openrewrite/java/marker/JavaSourceSet.java b/rewrite-java/src/main/java/org/openrewrite/java/marker/JavaSourceSet.java index 97c82154eac..f464c88c567 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/marker/JavaSourceSet.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/marker/JavaSourceSet.java @@ -24,6 +24,7 @@ import lombok.With; import org.jspecify.annotations.Nullable; import org.openrewrite.PathUtils; +import org.openrewrite.SourceFile; import org.openrewrite.java.internal.JavaTypeCache; import org.openrewrite.java.tree.JavaType; import org.openrewrite.marker.SourceSet; @@ -32,11 +33,13 @@ import java.net.URI; import java.nio.file.*; import java.util.*; +import java.util.function.Function; import java.util.jar.JarEntry; import java.util.jar.JarFile; import static java.util.Collections.emptyMap; import static org.openrewrite.Tree.randomId; +import static org.openrewrite.internal.StringUtils.matchesGlob; @Value @EqualsAndHashCode(onlyExplicitlyIncluded = true) @@ -55,6 +58,136 @@ public class JavaSourceSet implements SourceSet { */ Map> gavToTypes; + /** + * Add types for the given GAV key to this source set's classpath and gavToTypes mapping. + * + * @param gavKey a "group:artifact:version" string + * @param types the types provided by the artifact + * @return a new JavaSourceSet with the types added + */ + public JavaSourceSet addTypesForGav(String gavKey, List types) { + List newClasspath = new ArrayList<>(classpath); + newClasspath.addAll(types); + + Map> newGavToTypes = new LinkedHashMap<>(gavToTypes); + newGavToTypes.put(gavKey, types); + + return withClasspath(newClasspath).withGavToTypes(newGavToTypes); + } + + /** + * Remove all types associated with the given GAV key from this source set's classpath and gavToTypes mapping. + * + * @param gavKey a "group:artifact:version" string + * @return a new JavaSourceSet with the types removed, or this instance if the key is not present + */ + public JavaSourceSet removeTypesForGav(String gavKey) { + if (gavToTypes.isEmpty() || !gavToTypes.containsKey(gavKey)) { + return this; + } + Set oldTypesSet = new HashSet<>(gavToTypes.get(gavKey)); + + List newClasspath = new ArrayList<>(classpath.size()); + for (JavaType.FullyQualified type : classpath) { + if (!oldTypesSet.contains(type)) { + newClasspath.add(type); + } + } + + Map> newGavToTypes = new LinkedHashMap<>(gavToTypes); + newGavToTypes.remove(gavKey); + + return withClasspath(newClasspath).withGavToTypes(newGavToTypes); + } + + /** + * Remove types from this source set whose GAV keys match the given groupId and artifactId glob patterns. + * + * @param groupIdPattern glob pattern for groupId matching + * @param artifactIdPattern glob pattern for artifactId matching + * @return a new JavaSourceSet with matching types removed, or this instance if no keys match + */ + public JavaSourceSet removeTypesMatching(String groupIdPattern, String artifactIdPattern) { + if (gavToTypes.isEmpty()) { + return this; + } + List keysToRemove = new ArrayList<>(); + for (String key : gavToTypes.keySet()) { + String[] parts = key.split(":"); + if (parts.length >= 2 && + matchesGlob(parts[0], groupIdPattern) && + matchesGlob(parts[1], artifactIdPattern)) { + keysToRemove.add(key); + } + } + if (keysToRemove.isEmpty()) { + return this; + } + Set typesToRemove = new HashSet<>(); + for (String key : keysToRemove) { + typesToRemove.addAll(gavToTypes.get(key)); + } + List newClasspath = new ArrayList<>(classpath.size()); + for (JavaType.FullyQualified type : classpath) { + if (!typesToRemove.contains(type)) { + newClasspath.add(type); + } + } + Map> newGavToTypes = new LinkedHashMap<>(gavToTypes); + for (String key : keysToRemove) { + newGavToTypes.remove(key); + } + return withClasspath(newClasspath).withGavToTypes(newGavToTypes); + } + + /** + * Apply a transformation to the {@link JavaSourceSet} marker on a source file and replace it if changed. + * + * @param sf the source file to update + * @param transform a function that takes the current JavaSourceSet and returns an updated one + * @return the source file with the updated marker, or unchanged if no JavaSourceSet is present or the transform is a no-op + */ + public static SourceFile updateOnSourceFile(SourceFile sf, Function transform) { + Optional maybeSourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + if (!maybeSourceSet.isPresent()) { + return sf; + } + JavaSourceSet updated = transform.apply(maybeSourceSet.get()); + if (updated != maybeSourceSet.get()) { + return sf.withMarkers(sf.getMarkers().setByType(updated)); + } + return sf; + } + + /** + * Apply a transformation to the {@link JavaSourceSet} marker on a source file, using a cache keyed by + * {@link JavaProject} ID and source set name to avoid redundant recomputation across files in the same source set. + * + * @param sf the source file to update + * @param cache a mutable map used to cache updated JavaSourceSets across calls + * @param transform a function that takes the current JavaSourceSet and returns an updated one + * @return the source file with the updated marker, or unchanged if no JavaSourceSet/JavaProject is present + */ + public static SourceFile updateOnSourceFile(SourceFile sf, Map cache, + Function transform) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + Optional maybeSourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + if (!maybeJp.isPresent() || !maybeSourceSet.isPresent()) { + return sf; + } + String cacheKey = maybeJp.get().getId().toString() + ":" + maybeSourceSet.get().getName(); + JavaSourceSet cached = cache.get(cacheKey); + if (cached != null) { + return sf.withMarkers(sf.getMarkers().setByType(cached)); + } + JavaSourceSet updated = transform.apply(maybeSourceSet.get()); + if (updated != maybeSourceSet.get()) { + cache.put(cacheKey, updated); + return sf.withMarkers(sf.getMarkers().setByType(updated)); + } + return sf; + } + /** * Extract type information from the provided classpath. * Uses ClassGraph to compute the classpath. @@ -269,7 +402,7 @@ public static JavaSourceSet build(String sourceSetName, Collection classpa // Worth caching as there is typically substantial overlap in dependencies in use within the same repository // Even a single module project will typically have at least two source sets, main and test - private static List typesFromPath(Path path, @Nullable String acceptPackage) { + public static List typesFromPath(Path path, @Nullable String acceptPackage) { List types = new ArrayList<>(); try { // Paths will be to either directories of class files or jar files diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/AddDependency.java b/rewrite-maven/src/main/java/org/openrewrite/maven/AddDependency.java index da61d6ec3a6..cc77f7ed0e6 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/AddDependency.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/AddDependency.java @@ -20,16 +20,15 @@ import lombok.With; import org.jspecify.annotations.Nullable; import org.openrewrite.*; +import org.openrewrite.marker.Markup; import org.openrewrite.java.marker.JavaProject; import org.openrewrite.java.marker.JavaSourceSet; import org.openrewrite.java.search.HasSourceSet; import org.openrewrite.java.search.UsesType; import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.maven.table.MavenMetadataFailures; -import org.openrewrite.maven.tree.MavenResolutionResult; -import org.openrewrite.maven.tree.ResolvedDependency; -import org.openrewrite.maven.tree.ResolvedGroupArtifactVersion; -import org.openrewrite.maven.tree.Scope; +import org.openrewrite.maven.tree.*; +import org.openrewrite.maven.utilities.JavaSourceSetUpdater; import org.openrewrite.semver.Semver; import org.openrewrite.semver.VersionComparator; import org.openrewrite.xml.tree.Xml; @@ -162,6 +161,11 @@ public static class Scanned { boolean usingType; Map scopeByProject = new HashMap<>(); Set pomsDefinedInCurrentRepository = new HashSet<>(); + @Nullable + String resolvedVersion; + List repositories = new ArrayList<>(); + @Nullable + Exception versionResolutionFailure; } @Override @@ -199,6 +203,27 @@ public Tree visit(@Nullable Tree tree, ExecutionContext ctx) { return sourceFile; } acc.pomsDefinedInCurrentRepository.add(mrr.getPom().getGav()); + // Resolve the version once for JavaSourceSet updates + if (acc.resolvedVersion == null && version != null) { + try { + List repos = mrr.getPom().getRepositories(); + VersionComparator vc = requireNonNull(Semver.validate(version, versionPattern).getValue()); + MavenExecutionContextView mctx = MavenExecutionContextView.view(ctx); + org.openrewrite.maven.internal.MavenPomDownloader downloader = new org.openrewrite.maven.internal.MavenPomDownloader( + Collections.emptyMap(), ctx, mctx.getSettings(), null); + MavenMetadata metadata = downloader.downloadMetadata( + new GroupArtifact(groupId, artifactId), null, repos); + acc.resolvedVersion = metadata.getVersioning().getVersions().stream() + .filter(v -> vc.isValid(null, v)) + .max((v1, v2) -> vc.compare(null, v1, v2)) + .orElse(null); + if (acc.resolvedVersion != null) { + acc.repositories = repos; + } + } catch (Exception e) { + acc.versionResolutionFailure = e; + } + } } return sourceFile; } @@ -207,7 +232,7 @@ public Tree visit(@Nullable Tree tree, ExecutionContext ctx) { @Override public TreeVisitor getVisitor(Scanned acc) { - return Preconditions.check(onlyIfUsing == null || acc.usingType && !acc.scopeByProject.isEmpty(), new MavenVisitor() { + TreeVisitor mavenVisitor = Preconditions.check(onlyIfUsing == null || acc.usingType && !acc.scopeByProject.isEmpty(), new MavenVisitor() { @Nullable final Pattern familyPatternCompiled = familyPattern == null ? null : Pattern.compile(familyPattern.replace("*", ".*")); @@ -256,9 +281,13 @@ public Xml visitDocument(Xml.Document document, ExecutionContext ctx) { return maven; } - return new AddDependencyVisitor( + Xml result = new AddDependencyVisitor( groupId, artifactId, version, versionPattern, resolvedScope, releasesOnly, type, classifier, optional, familyPatternCompiled, metadataFailures).visitNonNull(document, ctx); + if (result != document && acc.versionResolutionFailure != null) { + result = Markup.warn(result, acc.versionResolutionFailure); + } + return result; } private boolean isSubprojectOfParentInRepository(Scanned acc) { @@ -284,6 +313,48 @@ private boolean isAggregatorNotUsedAsParent() { } }); + + if (acc.scopeByProject.isEmpty() || acc.resolvedVersion == null) { + return mavenVisitor; + } + + return new TreeVisitor() { + @Nullable + private JavaSourceSetUpdater updater; + + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return mavenVisitor.isAcceptable(sourceFile, ctx) || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (sf instanceof Xml.Document) { + return mavenVisitor.visit(tree, ctx); + } + if (sf instanceof JavaSourceFile) { + return updateJavaSourceSet(sf, ctx); + } + return tree; + } + + private SourceFile updateJavaSourceSet(SourceFile sf, ExecutionContext ctx) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + if (!maybeJp.isPresent() || !acc.scopeByProject.containsKey(maybeJp.get())) { + return sf; + } + if (updater == null) { + updater = new JavaSourceSetUpdater(ctx); + } + return JavaSourceSet.updateOnSourceFile(sf, sourceSet -> + sourceSet.getGavToTypes().isEmpty() ? sourceSet : + updater.addDependency(sourceSet, groupId, artifactId, acc.resolvedVersion, acc.repositories)); + } + }; } private boolean hasAcceptableTransitivity(ResolvedDependency d, Scanned acc) { diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactId.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactId.java index 0d036878a7e..7fbb1cb3e1d 100755 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactId.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactId.java @@ -20,8 +20,13 @@ import lombok.Value; import org.jspecify.annotations.Nullable; import org.openrewrite.*; +import org.openrewrite.java.marker.JavaProject; +import org.openrewrite.java.marker.JavaSourceSet; +import org.openrewrite.java.tree.JavaSourceFile; +import org.openrewrite.maven.internal.MavenPomDownloader; import org.openrewrite.maven.table.MavenMetadataFailures; import org.openrewrite.maven.tree.*; +import org.openrewrite.maven.utilities.JavaSourceSetUpdater; import org.openrewrite.semver.Semver; import org.openrewrite.semver.VersionComparator; import org.openrewrite.xml.AddToTagVisitor; @@ -156,6 +161,68 @@ public Accumulator getInitialValue(ExecutionContext ctx) { @Override public TreeVisitor getScanner(Accumulator acc) { + TreeVisitor mavenScanner = getMavenScanner(acc); + @Nullable VersionComparator scannerVersionComparator = newVersion != null ? + Semver.validate(newVersion, versionPattern).getValue() : null; + + // Wrap to also scan pom.xml files for modules that have the old dependency + return new TreeVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return sourceFile instanceof Xml.Document || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (sf instanceof Xml.Document && mavenScanner.isAcceptable(sf, ctx)) { + mavenScanner.visit(tree, ctx); + // Also check if this pom has the old dependency + sf.getMarkers().findFirst(MavenResolutionResult.class).ifPresent(mrr -> { + List deps = mrr.findDependencies(oldGroupId, oldArtifactId, null); + if (!deps.isEmpty()) { + sf.getMarkers().findFirst(JavaProject.class).ifPresent(jp -> { + acc.getModulesWithOldDependency().put(jp, deps.get(0)); + acc.getModuleRepositories().put(jp, mrr.getPom().getRepositories()); + // Resolve the new version for JavaSourceSet updates + if (newVersion != null) { + String effectiveGroupId = newGroupId != null ? newGroupId : deps.get(0).getGroupId(); + String effectiveArtifactId = newArtifactId != null ? newArtifactId : deps.get(0).getArtifactId(); + try { + MavenMetadata metadata = metadataFailures.insertRows(ctx, () -> + new MavenPomDownloader(mrr.getProjectPoms(), ctx, mrr.getMavenSettings(), mrr.getActiveProfiles()) + .downloadMetadata(new GroupArtifactVersion(effectiveGroupId, effectiveArtifactId, null), null, mrr.getPom().getRepositories())); + String resolved = newVersion; + if (scannerVersionComparator != null) { + List available = new ArrayList<>(); + for (String v : metadata.getVersioning().getVersions()) { + if (scannerVersionComparator.isValid(deps.get(0).getVersion(), v)) { + available.add(v); + } + } + if (!available.isEmpty()) { + resolved = max(available, scannerVersionComparator); + } + } + acc.getResolvedNewVersions().put(jp, resolved); + } catch (Exception e) { + // Version resolution failure is not fatal for JavaSourceSet updates + } + } + }); + } + }); + } + // Java files are no-op in scanner + return tree; + } + }; + } + + private TreeVisitor getMavenScanner(Accumulator acc) { if (newVersion == null) { return TreeVisitor.noop(); } @@ -254,6 +321,82 @@ private void storeParentPomProperty(@Nullable MavenResolutionResult parent, Stri @Override public TreeVisitor getVisitor(Accumulator acc) { + MavenVisitor mavenVisitor = getMavenVisitor(acc); + + if (acc.getModulesWithOldDependency().isEmpty()) { + // No modules have the old dependency; skip Java file processing + return mavenVisitor; + } + + return new TreeVisitor() { + @Nullable + private JavaSourceSetUpdater updater; + private final Map updatedSourceSets = new HashMap<>(); + + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return mavenVisitor.isAcceptable(sourceFile, ctx) || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (mavenVisitor.isAcceptable(sf, ctx)) { + return mavenVisitor.visit(tree, ctx); + } + if (sf instanceof JavaSourceFile) { + return updateJavaSourceSet(sf, ctx); + } + return tree; + } + + private SourceFile updateJavaSourceSet(SourceFile sf, ExecutionContext ctx) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + if (!maybeJp.isPresent()) { + return sf; + } + ResolvedDependency oldDep = acc.getModulesWithOldDependency().get(maybeJp.get()); + if (oldDep == null) { + return sf; + } + if (updater == null) { + updater = new JavaSourceSetUpdater(ctx); + } + JavaProject jp = maybeJp.get(); + return JavaSourceSet.updateOnSourceFile(sf, updatedSourceSets, sourceSet -> { + String effectiveNewGroupId = newGroupId != null ? newGroupId : oldDep.getGroupId(); + String effectiveNewArtifactId = newArtifactId != null ? newArtifactId : oldDep.getArtifactId(); + String resolvedVersion = acc.getResolvedNewVersions().get(jp); + String effectiveVersion = resolvedVersion != null ? resolvedVersion : oldDep.getVersion(); + ResolvedGroupArtifactVersion newGav = new ResolvedGroupArtifactVersion( + oldDep.getGav().getRepository(), + effectiveNewGroupId, effectiveNewArtifactId, effectiveVersion, null); + ResolvedDependency newDep = oldDep + .withGav(newGav) + .withRepository(findRemoteRepository(jp)); + return updater.changeDependency(sourceSet, oldDep, newDep); + }); + } + + private MavenRepository findRemoteRepository(JavaProject jp) { + List repos = acc.getModuleRepositories().get(jp); + if (repos != null) { + for (MavenRepository repo : repos) { + String uri = repo.getUri(); + if (uri != null && (uri.startsWith("http://") || uri.startsWith("https://"))) { + return repo; + } + } + } + return MavenRepository.MAVEN_CENTRAL; + } + }; + } + + private MavenVisitor getMavenVisitor(Accumulator acc) { return new MavenVisitor() { @Nullable final VersionComparator versionComparator = newVersion != null ? Semver.validate(newVersion, versionPattern).getValue() : null; @@ -497,6 +640,9 @@ private String resolveSemverVersion(ExecutionContext ctx, String groupId, String @Value public static class Accumulator { Set pomProperties = new HashSet<>(); + Map modulesWithOldDependency = new HashMap<>(); + Map> moduleRepositories = new HashMap<>(); + Map resolvedNewVersions = new HashMap<>(); } @Value diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDependency.java b/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDependency.java index 3ab6d8d9458..b4f081673f4 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDependency.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDependency.java @@ -18,10 +18,9 @@ import lombok.EqualsAndHashCode; import lombok.Value; import org.jspecify.annotations.Nullable; -import org.openrewrite.ExecutionContext; -import org.openrewrite.Option; -import org.openrewrite.Recipe; -import org.openrewrite.TreeVisitor; +import org.openrewrite.*; +import org.openrewrite.java.marker.JavaSourceSet; +import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.maven.tree.ResolvedDependency; import org.openrewrite.maven.tree.Scope; import org.openrewrite.xml.RemoveContentVisitor; @@ -62,7 +61,7 @@ public String getInstanceNameSuffix() { @Override public TreeVisitor getVisitor() { - return new MavenIsoVisitor() { + MavenIsoVisitor mavenVisitor = new MavenIsoVisitor() { @Override public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { if (isDependencyTag(groupId, artifactId)) { @@ -77,6 +76,29 @@ public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { return super.visitTag(tag, ctx); } }; + + return new TreeVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return mavenVisitor.isAcceptable(sourceFile, ctx) || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (mavenVisitor.isAcceptable(sf, ctx)) { + return mavenVisitor.visit(tree, ctx); + } + if (sf instanceof JavaSourceFile) { + return JavaSourceSet.updateOnSourceFile(sf, + sourceSet -> sourceSet.removeTypesMatching(groupId, artifactId)); + } + return tree; + } + }; } } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/UpgradeDependencyVersion.java b/rewrite-maven/src/main/java/org/openrewrite/maven/UpgradeDependencyVersion.java index 88e1532dbd5..4239a1dbaad 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/UpgradeDependencyVersion.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/UpgradeDependencyVersion.java @@ -19,10 +19,14 @@ import lombok.Value; import org.jspecify.annotations.Nullable; import org.openrewrite.*; +import org.openrewrite.java.marker.JavaProject; +import org.openrewrite.java.marker.JavaSourceSet; +import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.maven.internal.MavenPomDownloader; import org.openrewrite.maven.table.MavenMetadataFailures; import org.openrewrite.maven.trait.MavenDependency; import org.openrewrite.maven.tree.*; +import org.openrewrite.maven.utilities.JavaSourceSetUpdater; import org.openrewrite.maven.utilities.RetainVersions; import org.openrewrite.semver.LatestRelease; import org.openrewrite.semver.Semver; @@ -127,7 +131,7 @@ public Accumulator getInitialValue(ExecutionContext ctx) { @Override public TreeVisitor getScanner(Accumulator accumulator) { - return new MavenIsoVisitor() { + MavenIsoVisitor mavenScanner = new MavenIsoVisitor() { private final VersionComparator versionComparator = requireNonNull(Semver.validate(newVersion, versionPattern).getValue()); @@ -135,6 +139,28 @@ public TreeVisitor getScanner(Accumulator accumulator) { public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { ResolvedPom pom = getResolutionResult().getPom(); accumulator.projectArtifacts.add(new GroupArtifact(pom.getGroupId(), pom.getArtifactId())); + + // Record modules that have the matching dependency for JavaSourceSet updates + Optional maybeJp = document.getMarkers().findFirst(JavaProject.class); + if (maybeJp.isPresent() && !accumulator.modulesWithDependency.containsKey(maybeJp.get())) { + for (ResolvedDependency dep : getResolutionResult().findDependencies(groupId, artifactId, null)) { + if (dep.getRepository() != null) { + accumulator.modulesWithDependency.put(maybeJp.get(), dep); + accumulator.moduleRepositories.put(maybeJp.get(), pom.getRepositories()); + try { + String newerVersion = MavenDependency.findNewerVersion(dep.getGroupId(), dep.getArtifactId(), dep.getVersion(), getResolutionResult(), metadataFailures, + versionComparator, ctx); + if (newerVersion != null) { + accumulator.resolvedNewVersions.put(maybeJp.get(), newerVersion); + } + } catch (MavenDownloadingException e) { + // Version resolution failed; don't record new version + } + break; + } + } + } + return super.visitDocument(document, ctx); } @@ -190,11 +216,23 @@ private void storeParentPomProperty(@Nullable MavenResolutionResult currentMaven storeParentPomProperty(currentMavenResolutionResult.getParent(), propertyName, newerVersion); } }; + + return new TreeVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return mavenScanner.isAcceptable(sourceFile, ctx); + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + return mavenScanner.visit(tree, ctx); + } + }; } @Override public TreeVisitor getVisitor(Accumulator accumulator) { - return new MavenIsoVisitor() { + MavenIsoVisitor mavenVisitor = new MavenIsoVisitor() { private final VersionComparator versionComparator = requireNonNull(Semver.validate(newVersion, versionPattern).getValue()); @Override @@ -570,12 +608,90 @@ null, null, getResolutionResult().getPom().getRepositories() return null; } }; + + if (accumulator.modulesWithDependency.isEmpty()) { + return mavenVisitor; + } + + return new TreeVisitor() { + @Nullable + private JavaSourceSetUpdater updater; + + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return mavenVisitor.isAcceptable(sourceFile, ctx) || sourceFile instanceof JavaSourceFile; + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sf = (SourceFile) tree; + if (mavenVisitor.isAcceptable(sf, ctx)) { + return mavenVisitor.visit(tree, ctx); + } + if (sf instanceof JavaSourceFile) { + return updateJavaSourceSet(sf, ctx); + } + return tree; + } + + private SourceFile updateJavaSourceSet(SourceFile sf, ExecutionContext ctx) { + Optional maybeJp = sf.getMarkers().findFirst(JavaProject.class); + if (!maybeJp.isPresent()) { + return sf; + } + ResolvedDependency oldDep = accumulator.modulesWithDependency.get(maybeJp.get()); + if (oldDep == null) { + return sf; + } + String newVersion = accumulator.resolvedNewVersions.get(maybeJp.get()); + if (newVersion == null) { + return sf; + } + Optional maybeSourceSet = sf.getMarkers().findFirst(JavaSourceSet.class); + if (!maybeSourceSet.isPresent() || maybeSourceSet.get().getGavToTypes().isEmpty()) { + return sf; + } + if (updater == null) { + updater = new JavaSourceSetUpdater(ctx); + } + ResolvedGroupArtifactVersion newGav = new ResolvedGroupArtifactVersion( + oldDep.getGav().getRepository(), + oldDep.getGroupId(), oldDep.getArtifactId(), newVersion, null); + ResolvedDependency newDep = oldDep + .withGav(newGav) + .withRepository(findRemoteRepository(maybeJp.get())); + JavaSourceSet updated = updater.changeDependency(maybeSourceSet.get(), oldDep, newDep); + if (updated != maybeSourceSet.get()) { + return sf.withMarkers(sf.getMarkers().setByType(updated)); + } + return sf; + } + + private MavenRepository findRemoteRepository(JavaProject jp) { + List repos = accumulator.moduleRepositories.get(jp); + if (repos != null) { + for (MavenRepository repo : repos) { + String uri = repo.getUri(); + if (uri != null && (uri.startsWith("http://") || uri.startsWith("https://"))) { + return repo; + } + } + } + return MavenRepository.MAVEN_CENTRAL; + } + }; } @Value public static class Accumulator { Set projectArtifacts = new HashSet<>(); Set pomProperties = new HashSet<>(); + Map modulesWithDependency = new HashMap<>(); + Map resolvedNewVersions = new HashMap<>(); + Map> moduleRepositories = new HashMap<>(); } @Value diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/JavaSourceSetUpdater.java b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/JavaSourceSetUpdater.java new file mode 100644 index 00000000000..e6c076b0a7a --- /dev/null +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/JavaSourceSetUpdater.java @@ -0,0 +1,134 @@ +/* + * 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.utilities; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.HttpSenderExecutionContextView; +import org.openrewrite.ipc.http.HttpSender; +import org.openrewrite.java.marker.JavaSourceSet; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.maven.MavenExecutionContextView; +import org.openrewrite.maven.cache.LocalMavenArtifactCache; +import org.openrewrite.maven.cache.MavenArtifactCache; +import org.openrewrite.maven.tree.Dependency; +import org.openrewrite.maven.tree.GroupArtifactVersion; +import org.openrewrite.maven.tree.MavenRepository; +import org.openrewrite.maven.tree.ResolvedDependency; +import org.openrewrite.maven.tree.ResolvedGroupArtifactVersion; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Consumer; + +/** + * Updates {@link JavaSourceSet} markers to reflect dependency changes made by recipes. + *

+ * When dependency-modifying recipes (ChangeDependency, AddDependency) change a project's + * dependencies, the JavaSourceSet marker on Java source files becomes stale — it still + * reflects the pre-change classpath. This utility downloads the new dependency's JAR, + * scans it for type names, and updates the JavaSourceSet accordingly. + */ +public class JavaSourceSetUpdater { + private final MavenArtifactDownloader downloader; + + public JavaSourceSetUpdater(ExecutionContext ctx) { + MavenExecutionContextView mctx = MavenExecutionContextView.view(ctx); + HttpSender httpSender = HttpSenderExecutionContextView.view(ctx).getHttpSender(); + // Use a lenient error handler: download failures are not fatal for JavaSourceSet updates + Consumer onError = t -> {}; + Path tempDir; + try { + tempDir = Files.createTempDirectory("rewrite-artifact-cache"); + tempDir.toFile().deleteOnExit(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + MavenArtifactCache cache = new LocalMavenArtifactCache(tempDir); + this.downloader = new MavenArtifactDownloader(cache, mctx.getSettings(), httpSender, onError); + } + + /** + * Update a JavaSourceSet to reflect a dependency coordinate change. + * Removes types from the old dependency and adds types from the new dependency. + */ + public JavaSourceSet changeDependency(JavaSourceSet sourceSet, + ResolvedDependency oldDep, + ResolvedDependency newDep) { + String oldGavKey = gavKey(oldDep); + String newGavKey = gavKey(newDep); + // Idempotent: if already changed (old absent, new present), skip + if (!sourceSet.getGavToTypes().containsKey(oldGavKey) && + sourceSet.getGavToTypes().containsKey(newGavKey)) { + return sourceSet; + } + sourceSet = sourceSet.removeTypesForGav(oldGavKey); + List newTypes = downloadAndScanTypes(newDep); + if (!newTypes.isEmpty()) { + sourceSet = sourceSet.addTypesForGav(newGavKey, newTypes); + } + return sourceSet; + } + + /** + * Update a JavaSourceSet to reflect a newly added dependency. + * Tries each repository in order until the JAR is successfully downloaded. + */ + public JavaSourceSet addDependency(JavaSourceSet sourceSet, + String groupId, String artifactId, String version, + List repositories) { + String key = groupId + ":" + artifactId + ":" + version; + if (sourceSet.getGavToTypes().containsKey(key)) { + return sourceSet; + } + ResolvedGroupArtifactVersion gav = new ResolvedGroupArtifactVersion( + null, groupId, artifactId, version, null); + Dependency requested = Dependency.builder() + .gav(new GroupArtifactVersion(groupId, artifactId, version)) + .build(); + for (MavenRepository repo : repositories) { + ResolvedDependency dep = ResolvedDependency.builder() + .gav(gav) + .repository(repo) + .requested(requested) + .build(); + List newTypes = downloadAndScanTypes(dep); + if (!newTypes.isEmpty()) { + return sourceSet.addTypesForGav(key, newTypes); + } + } + return sourceSet; + } + + private List downloadAndScanTypes(ResolvedDependency dep) { + try { + Path jarPath = downloader.downloadArtifact(dep); + if (jarPath == null) { + return Collections.emptyList(); + } + return JavaSourceSet.typesFromPath(jarPath, null); + } catch (Exception e) { + // Graceful degradation: if download fails, return empty list + return Collections.emptyList(); + } + } + + private static String gavKey(ResolvedDependency dep) { + return dep.getGroupId() + ":" + dep.getArtifactId() + ":" + dep.getVersion(); + } +} diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/AddDependencyTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/AddDependencyTest.java index ef9278af337..8caca485a4d 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/AddDependencyTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/AddDependencyTest.java @@ -96,7 +96,8 @@ void dontAddDuplicateIfUpdateModelOnPriorRecipeCycleFailed() { """, """ - + com.mycompany.app my-app 1 diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactIdTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactIdTest.java index 5abd19c4516..88821c9cb92 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactIdTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ChangeDependencyGroupIdAndArtifactIdTest.java @@ -20,11 +20,17 @@ import org.openrewrite.DocumentExample; import org.openrewrite.Issue; import org.openrewrite.Validated; +import org.openrewrite.java.ChangePackage; +import org.openrewrite.java.marker.JavaSourceSet; +import org.openrewrite.java.tree.JavaType; import org.openrewrite.test.RewriteTest; import org.openrewrite.test.SourceSpec; +import java.util.List; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; -import static org.openrewrite.java.Assertions.mavenProject; +import static org.openrewrite.java.Assertions.*; import static org.openrewrite.maven.Assertions.pomXml; class ChangeDependencyGroupIdAndArtifactIdTest implements RewriteTest { @@ -3539,4 +3545,263 @@ void shouldNotChangeDependencyWithImplicitlyDefinedVersionProperty() { ); } + @Test + void updatesJavaSourceSetMarkerOnJavaFiles() { + rewriteRun( + spec -> spec.recipe(new ChangeDependencyGroupIdAndArtifactId( + "javax.activation", + "javax.activation-api", + "jakarta.activation", + "jakarta.activation-api", + "2.0.1", + null, + false, + false + )), + mavenProject("project", + srcMainJava( + java( + "class A {}", + "class A {}", + s -> s.afterRecipe(cu -> { + JavaSourceSet jss = cu.getMarkers().findFirst(JavaSourceSet.class).orElseThrow(); + Map> gavToTypes = jss.getGavToTypes(); + // Old dependency types should be removed + assertThat(gavToTypes.keySet()) + .noneMatch(k -> k.startsWith("javax.activation:javax.activation-api:")); + // New dependency types should be present + assertThat(gavToTypes.keySet()) + .anyMatch(k -> k.startsWith("jakarta.activation:jakarta.activation-api:")); + // Classpath should contain types from the new dependency + assertThat(jss.getClasspath()) + .extracting(JavaType.FullyQualified::getFullyQualifiedName) + .anyMatch(fqn -> fqn.startsWith("jakarta.activation.")); + }) + ) + ), + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + javax.activation + javax.activation-api + 1.2.0 + + + + """, + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + jakarta.activation + jakarta.activation-api + 2.0.1 + + + + """ + ) + ) + ); + } + + @Test + void javaSourceSetUnchangedWhenModuleDoesNotHaveDependency() { + rewriteRun( + spec -> spec.recipe(new ChangeDependencyGroupIdAndArtifactId( + "javax.activation", + "javax.activation-api", + "jakarta.activation", + "jakarta.activation-api", + "2.0.1", + null, + false, + false + )), + mavenProject("project", + srcMainJava( + java( + "class A {}", + s -> s.afterRecipe(cu -> { + JavaSourceSet jss = cu.getMarkers().findFirst(JavaSourceSet.class).orElseThrow(); + // Module doesn't have the dependency, so no jakarta types should appear + assertThat(jss.getGavToTypes().keySet()) + .noneMatch(k -> k.contains("jakarta.activation")); + }) + ) + ), + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.google.guava + guava + 29.0-jre + + + + """ + ) + ) + ); + } + + @Test + void javaSourceSetGracefulWhenDownloadFails() { + rewriteRun( + spec -> spec.recipe(new ChangeDependencyGroupIdAndArtifactId( + "javax.activation", + "javax.activation-api", + "com.doesnotexist", + "doesnotexist", + null, + null, + false, + false + )), + mavenProject("project", + srcMainJava( + java( + "class A {}", + s -> s.afterRecipe(cu -> { + // Should not throw even though the new dependency JAR can't be downloaded + JavaSourceSet jss = cu.getMarkers().findFirst(JavaSourceSet.class).orElseThrow(); + assertThat(jss).isNotNull(); + }) + ) + ), + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + javax.activation + javax.activation-api + 1.2.0 + + + + """, + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.doesnotexist + doesnotexist + 1.2.0 + + + + """ + ) + ) + ); + } + + @Test + void composedWithChangePackageUpdatesImports() { + rewriteRun( + spec -> spec.recipes( + new ChangeDependencyGroupIdAndArtifactId( + "javax.activation", + "javax.activation-api", + "jakarta.activation", + "jakarta.activation-api", + "2.0.1", + null, + false, + false + ), + new ChangePackage("javax.activation", "jakarta.activation", true) + ), + mavenProject("project", + srcMainJava( + java( + """ + import javax.activation.DataHandler; + import javax.activation.MimeType; + + class A { + DataHandler handler; + MimeType type; + } + """, + """ + import jakarta.activation.DataHandler; + import jakarta.activation.MimeType; + + class A { + DataHandler handler; + MimeType type; + } + """, + s -> s.afterRecipe(cu -> { + JavaSourceSet jss = cu.getMarkers().findFirst(JavaSourceSet.class).orElseThrow(); + // New dependency types should be present on classpath + assertThat(jss.getClasspath()) + .extracting(JavaType.FullyQualified::getFullyQualifiedName) + .anyMatch(fqn -> fqn.startsWith("jakarta.activation.")); + }) + ) + ), + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + javax.activation + javax.activation-api + 1.2.0 + + + + """, + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + jakarta.activation + jakarta.activation-api + 2.0.1 + + + + """ + ) + ) + ); + } + }