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 5d2b203bb29..1c8161efd6d 100644 --- a/rewrite-gradle/src/main/java/org/openrewrite/gradle/UpgradeDependencyVersion.java +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/UpgradeDependencyVersion.java @@ -23,9 +23,11 @@ import org.openrewrite.gradle.internal.AddDependencyVisitor; import org.openrewrite.gradle.marker.GradleDependencyConfiguration; import org.openrewrite.gradle.marker.GradleProject; +import org.openrewrite.gradle.marker.GradleSettings; import org.openrewrite.gradle.trait.ExtraProperty; import org.openrewrite.gradle.trait.GradleDependency; import org.openrewrite.gradle.trait.GradleMultiDependency; +import org.openrewrite.gradle.trait.GradleVersionCatalog; import org.openrewrite.gradle.trait.SpringDependencyManagementPluginEntry; import org.openrewrite.groovy.tree.G; import org.openrewrite.internal.ListUtils; @@ -543,6 +545,9 @@ private class UpdateGradle extends JavaVisitor { @Nullable GradleProject gradleProject; + @Nullable + GradleSettings gradleSettings; + @Nullable List newlyManaged; @@ -565,6 +570,8 @@ public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { newlyManaged = null; gradleProject = original.getMarkers().findFirst(GradleProject.class) .orElse(null); + gradleSettings = original.getMarkers().findFirst(GradleSettings.class) + .orElse(null); JavaSourceFile sourceFile = applyPluginProvidedDependencies(original, ctx); JavaSourceFile result = (JavaSourceFile) super.visit(sourceFile, ctx); if (result != original && gradleProject != null) { @@ -749,6 +756,30 @@ public J visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) } } + GradleVersionCatalog catalog = new GradleVersionCatalog.Matcher() + .get(getCursor()) + .orElse(null); + if (catalog != null) { + DependencyVersionSelector versionSelector = new DependencyVersionSelector(metadataFailures, gradleProject, gradleSettings); + for (GroupArtifact ga : catalog.getLibraries().keySet()) { + if (dependencyMatcher.matches(ga.getGroupId(), ga.getArtifactId())) { + String currentVersion = catalog.getVersion(ga); + if (currentVersion != null) { + try { + GroupArtifactVersion gav = new GroupArtifactVersion(ga.getGroupId(), ga.getArtifactId(), currentVersion); + String selectedVersion = versionSelector.select(gav, null, newVersion, versionPattern, ctx); + if (selectedVersion != null && !selectedVersion.equals(currentVersion)) { + catalog = catalog.withVersion(ga, selectedVersion); + } + } catch (MavenDownloadingException ignored) { + // leave this library's version unchanged + } + } + } + } + m = catalog.getTree(); + } + if ("ext".equals(method.getSimpleName()) && getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath().endsWith("settings.gradle")) { // rare case that gradle versions are set via settings.gradle ext block (only possible for Groovy DSL) m = (J.MethodInvocation) new JavaIsoVisitor() { diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/marker/GradleVersionCatalogVersionReferences.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/marker/GradleVersionCatalogVersionReferences.java new file mode 100644 index 00000000000..4924ba5c0cc --- /dev/null +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/marker/GradleVersionCatalogVersionReferences.java @@ -0,0 +1,79 @@ +/* + * 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.gradle.marker; + +import lombok.Value; +import lombok.With; +import org.openrewrite.Cursor; +import org.openrewrite.marker.Marker; +import org.openrewrite.maven.tree.GroupArtifact; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +/** + * A snapshot of which libraries declared in a + * {@code org.openrewrite.gradle.trait.GradleVersionCatalog} originally shared each + * {@code versionRef(...)} declaration, taken before any recipe mutates the catalog. + *

+ * Attached to the version catalog's own root AST node, so downstream recipes can tell whether + * two separately-requested version bumps actually target the same underlying + * {@code version(...)} declaration. + */ +@Value +@With +public class GradleVersionCatalogVersionReferences implements Marker { + UUID id; + + /** + * Keyed by a shared {@code version(...)} declaration's own alias. Only references actually + * resolved through by at least one library are recorded. + */ + Map sharedReferencesByAlias; + + @Override + public String print(Cursor cursor, UnaryOperator commentWrapper, boolean verbose) { + return verbose ? commentWrapper.apply("(" + this + ")") : ""; + } + + @Override + public String toString() { + return sharedReferencesByAlias.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> e.getKey() + "->" + e.getValue()) + .collect(Collectors.joining(", ")); + } + + /** + * The version value a shared reference held when the snapshot was taken, together with the + * group:artifact of every library that originally resolved its version through it. + */ + @Value + public static class SharedReference { + String version; + List groupArtifacts; + + @Override + public String toString() { + return version + "@" + groupArtifacts.stream() + .map(ga -> ga.getGroupId() + ":" + ga.getArtifactId()) + .collect(Collectors.joining(", ", "[", "]")); + } + } +} diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleTraitMatcher.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleTraitMatcher.java index 6c91eadd690..2b005f23ce8 100644 --- a/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleTraitMatcher.java +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleTraitMatcher.java @@ -19,6 +19,7 @@ import org.openrewrite.Cursor; import org.openrewrite.SourceFile; import org.openrewrite.gradle.marker.GradleProject; +import org.openrewrite.java.tree.Expression; import org.openrewrite.java.tree.J; import org.openrewrite.trait.SimpleTraitMatcher; import org.openrewrite.trait.Trait; @@ -50,4 +51,35 @@ protected boolean withinBlock(Cursor cursor, String name) { return false; } + + /** + * @return {@code true} if the cursor's tree is itself a statement in an enclosing block, + * rather than a nested expression such as the receiver of a chained method call. + */ + protected boolean isTopLevelStatement(Cursor cursor) { + Cursor parent = cursor.getParentTreeCursor(); + if (parent.getValue() instanceof J.Return) { + // Groovy closures implicitly return their last expression through a synthetic Return + parent = parent.getParentTreeCursor(); + } + return !parent.isRoot() && parent.getValue() instanceof J.Block; + } + + protected static J.@Nullable MethodInvocation asChainedInvocation(J.MethodInvocation m) { + return m.getSelect() instanceof J.MethodInvocation ? (J.MethodInvocation) m.getSelect() : null; + } + + /** + * @return the string value of {@code m}'s argument at {@code index}, or {@code null} if + * there's no such argument or it isn't a string literal. + */ + protected static @Nullable String literalArgument(J.MethodInvocation m, int index) { + if (index < m.getArguments().size()) { + Expression argument = m.getArguments().get(index); + if (argument instanceof J.Literal && ((J.Literal) argument).getValue() instanceof String) { + return (String) ((J.Literal) argument).getValue(); + } + } + return null; + } } diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleVersionCatalog.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleVersionCatalog.java new file mode 100644 index 00000000000..81daca5f15e --- /dev/null +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/GradleVersionCatalog.java @@ -0,0 +1,340 @@ +/* + * 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.gradle.trait; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.gradle.marker.GradleVersionCatalogVersionReferences; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.tree.J; +import org.openrewrite.maven.tree.GroupArtifact; +import org.openrewrite.trait.Trait; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.openrewrite.Tree.randomId; +import static org.openrewrite.internal.StringUtils.matchesGlob; + +/** + * Represents a single named catalog declared inside a Gradle + * {@code dependencyResolutionManagement { versionCatalogs { ... } } } block, e.g. the + * Groovy {@code libs { ... } } closure or the Kotlin {@code create("libs") { ... } } call. + *

+ * Works against the raw {@code version(...)}/{@code library(...)} DSL calls rather than + * type-attributed method signatures, since Gradle's Groovy/Kotlin DSL closures for + * user-defined catalogs are not reliably type-attributed to + * {@code org.gradle.api.initialization.dsl.VersionCatalogBuilder} during parsing. + */ +@EqualsAndHashCode(of = {"cursor", "catalogName"}) +@ToString(of = {"cursor", "catalogName"}) +public class GradleVersionCatalog implements Trait { + @Getter + Cursor cursor; + @Getter + String catalogName; + + private @Nullable Map cachedLibrariesByGroupArtifact; + private @Nullable Map cachedVersionValuesByAlias; + + public GradleVersionCatalog(Cursor cursor, String catalogName) { + this.cursor = cursor; + this.catalogName = catalogName; + } + + /** + * @return every {@code library(...)} declaration with a resolvable group:artifact, keyed by + * it, in declaration order. Where two aliases declare the same group:artifact, the first one + * encountered wins. + */ + public Map getLibraries() { + collectLibrariesAndVersions(); + return cachedLibrariesByGroupArtifact; + } + + private Map getVersionDeclarations() { + collectLibrariesAndVersions(); + return cachedVersionValuesByAlias; + } + + private void collectLibrariesAndVersions() { + if (cachedLibrariesByGroupArtifact == null) { + Map librariesByGroupArtifact = new LinkedHashMap<>(); + Map versionValuesByAlias = new LinkedHashMap<>(); + VersionCatalogLibrary.Matcher libraryMatcher = new VersionCatalogLibrary.Matcher(); + VersionCatalogVersion.Matcher versionMatcher = new VersionCatalogVersion.Matcher(); + new JavaIsoVisitor() { + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + J.MethodInvocation m = super.visitMethodInvocation(method, ctx); + libraryMatcher.get(getCursor()).ifPresent(library -> { + GroupArtifact ga = library.getGroupArtifact(); + if (ga != null) { + librariesByGroupArtifact.putIfAbsent(ga, library); + } + }); + versionMatcher.get(getCursor()).ifPresent(version -> versionValuesByAlias.put(version.getAlias(), version.getVersion())); + return m; + } + }.visit(getTree(), new InMemoryExecutionContext(), cursor.getParent()); + + cachedLibrariesByGroupArtifact = librariesByGroupArtifact; + cachedVersionValuesByAlias = versionValuesByAlias; + } + } + + /** + * Snapshots which libraries share each {@code versionRef(...)} declaration onto this + * catalog's root AST node as a {@link GradleVersionCatalogVersionReferences} marker. + */ + GradleVersionCatalog withOriginalVersionReferencesMarker() { + if (getTree().getMarkers().findFirst(GradleVersionCatalogVersionReferences.class).isPresent()) { + // Never recompute: a later call would only see the post-detachment structure, losing + // the "these used to share a ref" fact the detach/re-attach algorithm depends on. + return this; + } + Map versionValuesByAlias = getVersionDeclarations(); + + Map> groupArtifactsByRefAlias = new LinkedHashMap<>(); + for (VersionCatalogLibrary library : getLibraries().values()) { + String versionRefAlias = library.getVersionRefAlias(); + if (versionRefAlias != null) { + groupArtifactsByRefAlias.computeIfAbsent(versionRefAlias, k -> new ArrayList<>()).add(library.getGroupArtifact()); + } + } + + Map sharedReferencesByAlias = new LinkedHashMap<>(); + for (Map.Entry> entry : groupArtifactsByRefAlias.entrySet()) { + String version = versionValuesByAlias.get(entry.getKey()); + if (version != null) { + sharedReferencesByAlias.put(entry.getKey(), + new GradleVersionCatalogVersionReferences.SharedReference(version, entry.getValue())); + } + } + + GradleVersionCatalogVersionReferences marker = new GradleVersionCatalogVersionReferences(randomId(), sharedReferencesByAlias); + J.MethodInvocation newTree = getTree().withMarkers(getTree().getMarkers().add(marker)); + Cursor newCursor = new Cursor(cursor.getParent(), newTree); + return new GradleVersionCatalog(newCursor, catalogName); + } + + /** + * @return the version of the library matching {@code ga}, following {@code versionRef(...)} + * indirection, or {@code null} if there is no such library or it has no resolvable version. + */ + public @Nullable String getVersion(GroupArtifact ga) { + VersionCatalogLibrary library = getLibraries().get(ga); + if (library != null) { + String inlineVersion = library.getInlineVersion(); + if (inlineVersion != null) { + return inlineVersion; + } + String versionRefAlias = library.getVersionRefAlias(); + if (versionRefAlias != null) { + return getVersionDeclarations().get(versionRefAlias); + } + } + return null; + } + + /** + * Rewrites the version of the library matching {@code ga} to {@code newVersion}. An inline + * version has its literal rewritten directly; a {@code versionRef(...)} is tentatively + * detached to an inline literal, then checked for convergence with the rest of its original + * sharing group -- see {@link #reconciledAfterDetaching(String)}. + *

+ * The library is re-located by {@code ga} against the current tree on every call, so calls + * can be chained by threading the returned catalog from one to the next. + */ + public GradleVersionCatalog withVersion(GroupArtifact ga, String newVersion) { + VersionCatalogLibrary library = getLibraries().get(ga); + if (library != null) { + String inlineVersion = library.getInlineVersion(); + if (inlineVersion != null) { + if (!inlineVersion.equals(newVersion)) { + return withLibraryVersion(ga, newVersion); + } + } else { + String versionRefAlias = library.getVersionRefAlias(); + if (versionRefAlias != null) { + String currentValue = getVersionDeclarations().get(versionRefAlias); + if (currentValue == null || !currentValue.equals(newVersion)) { + return withOriginalVersionReferencesMarker() + .withDetachedLibraryVersion(ga, newVersion) + .reconciledAfterDetaching(versionRefAlias); + } + } + } + } + return this; + } + + private GradleVersionCatalog withLibraryVersion(GroupArtifact ga, String newVersion) { + VersionCatalogLibrary.Matcher libraryMatcher = new VersionCatalogLibrary.Matcher(); + J newTree = new JavaIsoVisitor() { + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + J.MethodInvocation m = super.visitMethodInvocation(method, ctx); + return libraryMatcher.get(getCursor()) + .filter(library -> ga.equals(library.getGroupArtifact())) + .map(library -> library.withVersion(newVersion).getTree()) + .orElse(m); + } + }.visit(getTree(), new InMemoryExecutionContext(), cursor.getParent()); + return withTree(newTree); + } + + private GradleVersionCatalog withDetachedLibraryVersion(GroupArtifact ga, String newVersion) { + VersionCatalogLibrary.Matcher libraryMatcher = new VersionCatalogLibrary.Matcher(); + J newTree = new JavaIsoVisitor() { + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + J.MethodInvocation m = super.visitMethodInvocation(method, ctx); + return libraryMatcher.get(getCursor()) + .filter(library -> ga.equals(library.getGroupArtifact())) + .map(library -> library.detachToVersion(newVersion).getTree()) + .orElse(m); + } + }.visit(getTree(), new InMemoryExecutionContext(), cursor.getParent()); + return withTree(newTree); + } + + /** + * Collapses a sharing group back onto {@code refAlias} when every library that originally + * shared it (per the {@link GradleVersionCatalogVersionReferences} marker) has since + * converged on one version. Otherwise the tentative detach stands, leaving {@code refAlias} + * and any members still pointing at it alone. + */ + private GradleVersionCatalog reconciledAfterDetaching(String refAlias) { + GradleVersionCatalogVersionReferences marker = getTree().getMarkers() + .findFirst(GradleVersionCatalogVersionReferences.class) + .orElse(null); + GradleVersionCatalogVersionReferences.SharedReference sharedReference = + marker == null ? null : marker.getSharedReferencesByAlias().get(refAlias); + if (sharedReference != null) { + List groupMembers = sharedReference.getGroupArtifacts(); + Map librariesByGroupArtifact = getLibraries(); + Map versionValuesByAlias = getVersionDeclarations(); + + Set<@Nullable String> resolvedVersions = new LinkedHashSet<>(); + for (GroupArtifact groupMember : groupMembers) { + VersionCatalogLibrary library = librariesByGroupArtifact.get(groupMember); + String resolvedVersion = library == null ? null : library.getInlineVersion(); + if (resolvedVersion == null && library != null) { + String currentRefAlias = library.getVersionRefAlias(); + resolvedVersion = currentRefAlias == null ? null : versionValuesByAlias.get(currentRefAlias); + } + resolvedVersions.add(resolvedVersion); + } + + if (resolvedVersions.size() == 1) { + String commonVersion = resolvedVersions.iterator().next(); + if (commonVersion != null) { + GradleVersionCatalog collapsed = withVersionDeclarationValue(refAlias, commonVersion); + for (GroupArtifact groupMember : groupMembers) { + collapsed = collapsed.withLibraryReattachedToVersionRef(groupMember, refAlias); + } + return collapsed; + } + } + } + return this; + } + + private GradleVersionCatalog withVersionDeclarationValue(String alias, String newVersion) { + VersionCatalogVersion.Matcher versionMatcher = new VersionCatalogVersion.Matcher(); + J newTree = new JavaIsoVisitor() { + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + J.MethodInvocation m = super.visitMethodInvocation(method, ctx); + return versionMatcher.get(getCursor()) + .filter(version -> alias.equals(version.getAlias())) + .map(version -> version.withVersion(newVersion).getTree()) + .orElse(m); + } + }.visit(getTree(), new InMemoryExecutionContext(), cursor.getParent()); + return withTree(newTree); + } + + private GradleVersionCatalog withLibraryReattachedToVersionRef(GroupArtifact ga, String refAlias) { + VersionCatalogLibrary.Matcher libraryMatcher = new VersionCatalogLibrary.Matcher(); + J newTree = new JavaIsoVisitor() { + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + J.MethodInvocation m = super.visitMethodInvocation(method, ctx); + return libraryMatcher.get(getCursor()) + .filter(library -> ga.equals(library.getGroupArtifact())) + .map(library -> library.reattachToVersionRef(refAlias).getTree()) + .orElse(m); + } + }.visit(getTree(), new InMemoryExecutionContext(), cursor.getParent()); + return withTree(newTree); + } + + private GradleVersionCatalog withTree(J newTree) { + if (newTree != getTree()) { + return new GradleVersionCatalog(new Cursor(cursor.getParent(), newTree), catalogName); + } + return this; + } + + public static class Matcher extends GradleTraitMatcher { + @Nullable + private String catalogNamePattern; + + public Matcher catalogName(@Nullable String catalogNamePattern) { + this.catalogNamePattern = catalogNamePattern; + return this; + } + + @Override + protected @Nullable GradleVersionCatalog test(Cursor cursor) { + Object value = cursor.getValue(); + if (value instanceof J.MethodInvocation) { + J.MethodInvocation m = (J.MethodInvocation) value; + if (isDirectChildOfBlock(cursor, "versionCatalogs") && withinBlock(cursor, "dependencyResolutionManagement")) { + String catalogName; + if ("create".equals(m.getSimpleName())) { + // Kotlin DSL: versionCatalogs { create("libs") { ... } } + catalogName = literalArgument(m, 0); + } else { + // Groovy DSL sugar: versionCatalogs { libs { ... } } -- the method name IS the catalog name + catalogName = m.getSimpleName(); + } + + if (catalogName != null && (catalogNamePattern == null || matchesGlob(catalogName, catalogNamePattern))) { + return new GradleVersionCatalog(cursor, catalogName); + } + } + } + return null; + } + + private boolean isDirectChildOfBlock(Cursor cursor, String name) { + Cursor parent = cursor.dropParentUntil(v -> v instanceof J.MethodInvocation || v == Cursor.ROOT_VALUE); + return !parent.isRoot() && name.equals(((J.MethodInvocation) parent.getValue()).getSimpleName()); + } + } +} diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/VersionCatalogLibrary.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/VersionCatalogLibrary.java new file mode 100644 index 00000000000..d8518dc249e --- /dev/null +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/VersionCatalogLibrary.java @@ -0,0 +1,208 @@ +/* + * 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.gradle.trait; + +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.maven.tree.GroupArtifact; +import org.openrewrite.trait.Trait; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.openrewrite.gradle.trait.GradleTraitMatcher.asChainedInvocation; +import static org.openrewrite.gradle.trait.GradleTraitMatcher.literalArgument; + +/** + * Represents a single {@code library(...)} declaration inside a Gradle version catalog, in any + * of its forms: the three-argument {@code library(alias, group, artifact)} form, optionally + * terminated by {@code .version(...)}, {@code .versionRef(...)}, or {@code .withoutVersion()}, + * or the single coordinate-string {@code library(alias, "group:artifact:version")} form (which + * is always terminal -- Gradle's API returns {@code void} for it, so it can't be chained). + */ +@Value +public class VersionCatalogLibrary implements Trait { + Cursor cursor; + + public @Nullable String getAlias() { + return literalArgument(libraryCall(), 0); + } + + public @Nullable GroupArtifact getGroupArtifact() { + J.MethodInvocation library = libraryCall(); + if (library.getArguments().size() == 3) { + String groupId = literalArgument(library, 1); + String artifactId = literalArgument(library, 2); + return groupId == null || artifactId == null ? null : new GroupArtifact(groupId, artifactId); + } + if (library.getArguments().size() == 2) { + String groupArtifactVersion = literalArgument(library, 1); + String[] parts = groupArtifactVersion == null ? null : groupArtifactVersion.split(":"); + return parts != null && parts.length == 3 ? new GroupArtifact(parts[0], parts[1]) : null; + } + return null; + } + + /** + * @return the alias of the shared {@code version(...)} declaration this library resolves its + * version through, or {@code null} if it's declared inline or not at all. + */ + public @Nullable String getVersionRefAlias() { + J.MethodInvocation outer = getTree(); + return "versionRef".equals(outer.getSimpleName()) && outer.getArguments().size() == 1 ? + literalArgument(outer, 0) : null; + } + + /** + * @return the inline version literal, whether chained as {@code .version(...)} or embedded in + * a single coordinate string, or {@code null} if the version comes via + * {@code versionRef(...)} or isn't declared at all. + */ + public @Nullable String getInlineVersion() { + J.MethodInvocation outer = getTree(); + if ("version".equals(outer.getSimpleName()) && outer.getArguments().size() == 1 && outer.getSelect() instanceof J.MethodInvocation) { + return literalArgument(outer, 0); + } + if ("library".equals(outer.getSimpleName()) && outer.getArguments().size() == 2) { + String groupArtifactVersion = literalArgument(outer, 1); + String[] parts = groupArtifactVersion == null ? null : groupArtifactVersion.split(":"); + return parts != null && parts.length == 3 ? parts[2] : null; + } + return null; + } + + /** + * @return a copy with its inline version literal rewritten to {@code newVersion}, or this + * library unchanged if it has no inline version to rewrite. + */ + public VersionCatalogLibrary withVersion(String newVersion) { + J.MethodInvocation outer = getTree(); + if ("version".equals(outer.getSimpleName()) && outer.getArguments().size() == 1 && outer.getSelect() instanceof J.MethodInvocation) { + return withArgumentLiteral(outer, 0, newVersion); + } + if ("library".equals(outer.getSimpleName()) && outer.getArguments().size() == 2) { + String groupArtifactVersion = literalArgument(outer, 1); + String[] parts = groupArtifactVersion == null ? null : groupArtifactVersion.split(":"); + if (parts != null && parts.length == 3) { + return withArgumentLiteral(outer, 1, parts[0] + ":" + parts[1] + ":" + newVersion); + } + } + return this; + } + + private VersionCatalogLibrary withArgumentLiteral(J.MethodInvocation outer, int argIndex, String newValue) { + Expression argument = outer.getArguments().get(argIndex); + if (argument instanceof J.Literal) { + J.Literal oldLiteral = (J.Literal) argument; + String quote = oldLiteral.getValueSource() == null ? "'" : oldLiteral.getValueSource().substring(0, 1); + J.Literal newLiteral = oldLiteral.withValue(newValue).withValueSource(quote + newValue + quote); + List newArguments = new ArrayList<>(outer.getArguments()); + newArguments.set(argIndex, newLiteral); + return new VersionCatalogLibrary(new Cursor(cursor.getParent(), outer.withArguments(newArguments))); + } + return this; + } + + /** + * @return a copy with its chained {@code .versionRef(...)} call replaced by + * {@code .version(newVersion)}, or this library unchanged if it isn't on a + * {@code versionRef(...)} chain. + */ + public VersionCatalogLibrary detachToVersion(String newVersion) { + J.MethodInvocation outer = getTree(); + if ("versionRef".equals(outer.getSimpleName()) && outer.getArguments().size() == 1) { + return withRenamedChainedCall(outer, "version", newVersion); + } + return this; + } + + /** + * @return a copy with its chained {@code .version(...)} call replaced by + * {@code .versionRef(alias)}, or this library unchanged if it has no chained + * {@code .version(...)} call to rewrite. + */ + public VersionCatalogLibrary reattachToVersionRef(String alias) { + J.MethodInvocation outer = getTree(); + if ("version".equals(outer.getSimpleName()) && outer.getArguments().size() == 1 && outer.getSelect() instanceof J.MethodInvocation) { + return withRenamedChainedCall(outer, "versionRef", alias); + } + return this; + } + + private VersionCatalogLibrary withRenamedChainedCall(J.MethodInvocation outer, String methodName, String newArgumentValue) { + Expression argument = outer.getArguments().get(0); + if (!(argument instanceof J.Literal)) { + return this; + } + J.Literal oldLiteral = (J.Literal) argument; + String quote = oldLiteral.getValueSource() == null ? "'" : oldLiteral.getValueSource().substring(0, 1); + J.Literal newLiteral = oldLiteral.withValue(newArgumentValue).withValueSource(quote + newArgumentValue + quote); + J.MethodInvocation newOuter = outer.withName(outer.getName().withSimpleName(methodName)) + .withArguments(Collections.singletonList(newLiteral)); + return new VersionCatalogLibrary(new Cursor(cursor.getParent(), newOuter)); + } + + private J.MethodInvocation libraryCall() { + J.MethodInvocation outer = getTree(); + J.MethodInvocation chained = asChainedInvocation(outer); + return chained != null && "library".equals(chained.getSimpleName()) ? chained : outer; + } + + public static class Matcher extends GradleTraitMatcher { + @Override + protected @Nullable VersionCatalogLibrary test(Cursor cursor) { + Object value = cursor.getValue(); + if (value instanceof J.MethodInvocation && isTopLevelStatement(cursor) && withinBlock(cursor, "versionCatalogs")) { + + J.MethodInvocation outer = (J.MethodInvocation) value; + String versionRefAlias = null; + String inlineVersion = null; + boolean withoutVersion = false; + + if ("versionRef".equals(outer.getSimpleName()) && outer.getArguments().size() == 1) { + versionRefAlias = literalArgument(outer, 0); + outer = asChainedInvocation(outer); + } else if ("version".equals(outer.getSimpleName()) && outer.getArguments().size() == 1) { + inlineVersion = literalArgument(outer, 0); + outer = asChainedInvocation(outer); + } else if ("withoutVersion".equals(outer.getSimpleName()) && outer.getArguments().isEmpty()) { + withoutVersion = true; + outer = asChainedInvocation(outer); + } + + if (outer != null && "library".equals(outer.getSimpleName()) && literalArgument(outer, 0) != null) { + if (outer.getArguments().size() == 3) { + if (literalArgument(outer, 1) != null && literalArgument(outer, 2) != null) { + return new VersionCatalogLibrary(cursor); + } + } else if (outer.getArguments().size() == 2 && versionRefAlias == null && inlineVersion == null && !withoutVersion) { + String groupArtifactVersion = literalArgument(outer, 1); + String[] parts = groupArtifactVersion == null ? null : groupArtifactVersion.split(":"); + if (parts != null && parts.length == 3) { + return new VersionCatalogLibrary(cursor); + } + } + } + + } + return null; + } + } +} diff --git a/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/VersionCatalogVersion.java b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/VersionCatalogVersion.java new file mode 100644 index 00000000000..9641c97257e --- /dev/null +++ b/rewrite-gradle/src/main/java/org/openrewrite/gradle/trait/VersionCatalogVersion.java @@ -0,0 +1,87 @@ +/* + * 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.gradle.trait; + +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.trait.Trait; + +import java.util.ArrayList; +import java.util.List; + +import static org.openrewrite.gradle.trait.GradleTraitMatcher.literalArgument; +import static org.openrewrite.internal.StringUtils.matchesGlob; + +/** + * Represents a single {@code version(alias, value)} declaration inside a Gradle version + * catalog. Several {@code library(...)} entries commonly share one of these via + * {@code versionRef(...)}, so bumping it satisfies every one of them at once. + */ +@Value +public class VersionCatalogVersion implements Trait { + Cursor cursor; + + public @Nullable String getAlias() { + return literalArgument(getTree(), 0); + } + + public @Nullable String getVersion() { + return literalArgument(getTree(), 1); + } + + public VersionCatalogVersion withVersion(String newVersion) { + J.MethodInvocation outer = getTree(); + Expression argument = outer.getArguments().get(1); + if (!(argument instanceof J.Literal)) { + return this; + } + J.Literal oldLiteral = (J.Literal) argument; + String quote = oldLiteral.getValueSource() == null ? "'" : oldLiteral.getValueSource().substring(0, 1); + J.Literal newLiteral = oldLiteral.withValue(newVersion).withValueSource(quote + newVersion + quote); + List newArguments = new ArrayList<>(outer.getArguments()); + newArguments.set(1, newLiteral); + return new VersionCatalogVersion(new Cursor(cursor.getParent(), outer.withArguments(newArguments))); + } + + public static class Matcher extends GradleTraitMatcher { + @Nullable + private String aliasPattern; + + public Matcher alias(@Nullable String aliasPattern) { + this.aliasPattern = aliasPattern; + return this; + } + + @Override + protected @Nullable VersionCatalogVersion test(Cursor cursor) { + Object value = cursor.getValue(); + if (value instanceof J.MethodInvocation) { + J.MethodInvocation m = (J.MethodInvocation) value; + if ("version".equals(m.getSimpleName()) && m.getArguments().size() == 2 && m.getSelect() == null + && isTopLevelStatement(cursor) && withinBlock(cursor, "versionCatalogs")) { + String alias = literalArgument(m, 0); + if (alias != null && matchesGlob(alias, aliasPattern)) { + return new VersionCatalogVersion(cursor); + } + } + } + return null; + } + } +} diff --git a/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionVersionCatalogTest.java b/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionVersionCatalogTest.java new file mode 100644 index 00000000000..afb120b93b1 --- /dev/null +++ b/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionVersionCatalogTest.java @@ -0,0 +1,746 @@ +/* + * 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.gradle; + +import org.junit.jupiter.api.Test; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.gradle.Assertions.buildGradle; +import static org.openrewrite.gradle.Assertions.buildGradleKts; +import static org.openrewrite.gradle.Assertions.settingsGradle; +import static org.openrewrite.gradle.Assertions.settingsGradleKts; +import static org.openrewrite.gradle.toolingapi.Assertions.withToolingApi; + +class UpgradeDependencyVersionVersionCatalogTest implements RewriteTest { + + @Test + void sequentialRecipesTargetingBothSharersOnlyUpdateTheSharedVersionReference() { + rewriteRun( + spec -> spec.recipes( + new UpgradeDependencyVersion("com.acme", "widget-a", "2.0", null), + new UpgradeDependencyVersion("com.acme", "widget-b", "2.0", null) + ), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '2.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + } + } + } + """ + ) + ); + } + + @Test + void kotlinSequentialRecipesTargetingBothSharersOnlyUpdateTheSharedVersionReference() { + rewriteRun( + spec -> spec.recipes( + new UpgradeDependencyVersion("com.acme", "widget-a", "2.0", null), + new UpgradeDependencyVersion("com.acme", "widget-b", "2.0", null) + ), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "1.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetVersion") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "2.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetVersion") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + } + } + } + """ + ) + ); + } + + @Test + void singleRecipeTargetingOneSharerDetachesInsteadOfUpdatingTheSharedReference() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-a", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').version('2.0') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + } + } + } + """ + ) + ); + } + + @Test + void kotlinSingleRecipeTargetingOneSharerDetachesInsteadOfUpdatingTheSharedReference() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-a", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "1.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetVersion") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "1.0") + library("widgetA", "com.acme", "widget-a").version("2.0") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + } + } + } + """ + ) + ); + } + + @Test + void inlineVersionLibraryIsUpgradedDirectly() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "acme-core", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeCoreLib', 'com.acme', 'acme-core').version('2.0') + } + } + } + """ + ) + ); + } + + @Test + void kotlinInlineVersionLibraryIsUpgradedDirectly() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "acme-core", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("acmeCoreLib", "com.acme", "acme-core").version("1.0") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("acmeCoreLib", "com.acme", "acme-core").version("2.0") + } + } + } + """ + ) + ); + } + + @Test + void singleStringCoordinateLibraryIsUpgradedDirectly() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeWidgetLib', 'com.acme:widget:1.0') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeWidgetLib', 'com.acme:widget:2.0') + } + } + } + """ + ) + ); + } + + @Test + void kotlinSingleStringCoordinateLibraryIsUpgradedDirectly() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("acmeWidgetLib", "com.acme:widget:1.0") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("acmeWidgetLib", "com.acme:widget:2.0") + } + } + } + """ + ) + ); + } + + @Test + void libraryWithoutVersionIsLeftUnmanaged() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "acme-tool", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeToolLib', 'com.acme', 'acme-tool').withoutVersion() + } + } + } + """ + ) + ); + } + + @Test + void kotlinLibraryWithoutVersionIsLeftUnmanaged() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "acme-tool", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("acmeToolLib", "com.acme", "acme-tool").withoutVersion() + } + } + } + """ + ) + ); + } + + @Test + void nonSharedVersionRefIsUpgradedDirectly() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "acme-gadget", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('acmeGadgetVersion', '1.0') + library('acmeGadgetLib', 'com.acme', 'acme-gadget').versionRef('acmeGadgetVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('acmeGadgetVersion', '2.0') + library('acmeGadgetLib', 'com.acme', 'acme-gadget').versionRef('acmeGadgetVersion') + } + } + } + """ + ) + ); + } + + @Test + void kotlinNonSharedVersionRefIsUpgradedDirectly() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "acme-gadget", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("acmeGadgetVersion", "1.0") + library("acmeGadgetLib", "com.acme", "acme-gadget").versionRef("acmeGadgetVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("acmeGadgetVersion", "2.0") + library("acmeGadgetLib", "com.acme", "acme-gadget").versionRef("acmeGadgetVersion") + } + } + } + """ + ) + ); + } + + @Test + void wildcardGroupAndArtifactUpgradesAllMatchingSharersInOnePass() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '2.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + } + } + } + """ + ) + ); + } + + @Test + void kotlinWildcardGroupAndArtifactUpgradesAllMatchingSharersInOnePass() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "1.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetVersion") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "2.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetVersion") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + } + } + } + """ + ) + ); + } + + @Test + void wildcardGroupAndArtifactUpgradesMultipleNonSharedLibrariesInOnePass() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetAVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetAVersion') + version('widgetBVersion', '1.0') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetBVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetAVersion', '2.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetAVersion') + version('widgetBVersion', '2.0') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetBVersion') + } + } + } + """ + ) + ); + } + + @Test + void kotlinWildcardGroupAndArtifactUpgradesMultipleNonSharedLibrariesInOnePass() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetAVersion", "1.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetAVersion") + version("widgetBVersion", "1.0") + library("widgetB", "com.acme", "widget-b").versionRef("widgetBVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetAVersion", "2.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetAVersion") + version("widgetBVersion", "2.0") + library("widgetB", "com.acme", "widget-b").versionRef("widgetBVersion") + } + } + } + """ + ) + ); + } + + @Test + void wildcardGroupAndArtifactDetachesWhenAnUnmatchedSharerRemains() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null)), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + library('gadgetC', 'com.acme', 'gadget-c').versionRef('widgetVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').version('2.0') + library('widgetB', 'com.acme', 'widget-b').version('2.0') + library('gadgetC', 'com.acme', 'gadget-c').versionRef('widgetVersion') + } + } + } + """ + ) + ); + } + + @Test + void kotlinWildcardGroupAndArtifactDetachesWhenAnUnmatchedSharerRemains() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null)), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "1.0") + library("widgetA", "com.acme", "widget-a").versionRef("widgetVersion") + library("widgetB", "com.acme", "widget-b").versionRef("widgetVersion") + library("gadgetC", "com.acme", "gadget-c").versionRef("widgetVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("widgetVersion", "1.0") + library("widgetA", "com.acme", "widget-a").version("2.0") + library("widgetB", "com.acme", "widget-b").version("2.0") + library("gadgetC", "com.acme", "gadget-c").versionRef("widgetVersion") + } + } + } + """ + ) + ); + } + + @Test + void libraryWithUnresolvableVersionConstraintIsLeftUnchanged() { + rewriteRun( + spec -> spec.recipes( + new UpgradeDependencyVersion("com.acme", "widget-a", "2.0", null), + new UpgradeDependencyVersion("com.acme", "widget-b", "2.0", null), + new UpgradeDependencyVersion("com.acme", "widget-c", "2.0", null) + ), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + library('widgetC', 'com.acme', 'widget-c').version { strictly('1.0') } + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '2.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + library('widgetC', 'com.acme', 'widget-c').version { strictly('1.0') } + } + } + } + """ + ) + ); + } + + @Test + void libraryWithUnresolvableVersionConstraintIsLeftUnchangedWithWildcard() { + rewriteRun( + spec -> spec.recipes( + new UpgradeDependencyVersion("com.acme", "widget-*", "2.0", null) + ), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '1.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + library('widgetC', 'com.acme', 'widget-c').version { strictly('1.0') } + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('widgetVersion', '2.0') + library('widgetA', 'com.acme', 'widget-a').versionRef('widgetVersion') + library('widgetB', 'com.acme', 'widget-b').versionRef('widgetVersion') + library('widgetC', 'com.acme', 'widget-c').version { strictly('1.0') } + } + } + } + """ + ) + ); + } + + @Test + void libraryWithInterpolatedVersionIsLeftUnchanged() { + rewriteRun( + spec -> spec.recipe(new UpgradeDependencyVersion("com.acme", "widget-d", "2.0", null)), + settingsGradle( + """ + def widgetDVersion = '1.0' + + dependencyResolutionManagement { + versionCatalogs { + libs { + library('widgetD', 'com.acme', 'widget-d').version("${widgetDVersion}") + } + } + } + """ + ) + ); + } + + @Test + void symbolicVersionResolvedWhenCatalogAppliedFromSeparateFileWithRepoInBuildGradle() { + rewriteRun( + spec -> spec.beforeRecipe(withToolingApi()) + .recipe(new UpgradeDependencyVersion("com.google.guava", "guava", "30.x", "-jre")), + settingsGradle( + """ + rootProject.name = 'catalog-applied-file' + apply from: './gradle/versions.gradle' + """ + ), + buildGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('guava', 'com.google.guava', 'guava').version('29.0-jre') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('guava', 'com.google.guava', 'guava').version('30.1.1-jre') + } + } + } + """, + spec1 -> spec1.path("gradle/versions.gradle") + ), + buildGradle( + """ + plugins { + id 'java-library' + } + + repositories { + mavenCentral() + } + + dependencies { + implementation libs.guava + } + """ + ) + ); + } + + @Test + void kotlinSymbolicVersionResolvedWhenCatalogAppliedFromSeparateFileWithRepoInBuildGradle() { + rewriteRun( + spec -> spec.beforeRecipe(withToolingApi()) + .recipe(new UpgradeDependencyVersion("com.google.guava", "guava", "30.x", "-jre")), + settingsGradleKts( + """ + rootProject.name = "catalog-applied-file" + apply(from = "./gradle/versions.gradle.kts") + """ + ), + buildGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("guava", "com.google.guava", "guava").version("29.0-jre") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + library("guava", "com.google.guava", "guava").version("30.1.1-jre") + } + } + } + """, + spec1 -> spec1.path("gradle/versions.gradle.kts") + ), + buildGradleKts( + """ + plugins { + `java-library` + } + + repositories { + mavenCentral() + } + + dependencies { + implementation(libs.guava) + } + """ + ) + ); + } +} diff --git a/rewrite-gradle/src/test/java/org/openrewrite/gradle/trait/GradleVersionCatalogTest.java b/rewrite-gradle/src/test/java/org/openrewrite/gradle/trait/GradleVersionCatalogTest.java new file mode 100644 index 00000000000..45f2829c011 --- /dev/null +++ b/rewrite-gradle/src/test/java/org/openrewrite/gradle/trait/GradleVersionCatalogTest.java @@ -0,0 +1,307 @@ +/* + * 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.gradle.trait; + +import org.junit.jupiter.api.Test; +import org.openrewrite.PrintOutputCapture; +import org.openrewrite.maven.tree.GroupArtifact; +import org.openrewrite.marker.SearchResult; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.gradle.Assertions.*; + +class GradleVersionCatalogTest implements RewriteTest { + + @Test + void capturesOriginalVersionReferencesOnCatalogRoot() { + // The marker only prints under verbose printing, as it must stay invisible in normal recipe output + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> catalog.withOriginalVersionReferencesMarker().getTree()))) + .markerPrinter(PrintOutputCapture.MarkerPrinter.VERBOSE), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('springBootVersion', '3.5.15') + library('springBootStarterWeb', 'org.springframework.boot', 'spring-boot-starter-web').versionRef('springBootVersion') + library('springBootStarterWebflux', 'org.springframework.boot', 'spring-boot-starter-webflux').versionRef('springBootVersion') + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0.0') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~(springBootVersion->3.5.15@[org.springframework.boot:spring-boot-starter-web, org.springframework.boot:spring-boot-starter-webflux])~~>*/libs { + version('springBootVersion', '3.5.15') + library('springBootStarterWeb', 'org.springframework.boot', 'spring-boot-starter-web').versionRef('springBootVersion') + library('springBootStarterWebflux', 'org.springframework.boot', 'spring-boot-starter-webflux').versionRef('springBootVersion') + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0.0') + } + } + } + """ + ) + ); + } + + @Test + void matchesInlineInSettingsGradle() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree())))), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('lombokVersion', '1.18.30') + library('projectLombok', 'org.projectlombok', 'lombok').versionRef('lombokVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~>*/libs { + version('lombokVersion', '1.18.30') + library('projectLombok', 'org.projectlombok', 'lombok').versionRef('lombokVersion') + } + } + } + """ + ) + ); + } + + @Test + void matchesWhenSplitIntoASeparateGradleFile() { + // Mirrors the `apply from: './gradle/versions.gradle'` pattern + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree())))), + buildGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('lombokVersion', '1.18.30') + library('projectLombok', 'org.projectlombok', 'lombok').versionRef('lombokVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~>*/libs { + version('lombokVersion', '1.18.30') + library('projectLombok', 'org.projectlombok', 'lombok').versionRef('lombokVersion') + } + } + } + """, + spec1 -> spec1.path("gradle/versions.gradle") + ) + ); + } + + @Test + void matchesOnlyRequestedCatalogName() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().catalogName("testLibs").asVisitor(catalog -> SearchResult.found(catalog.getTree())))), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('lombokVersion', '1.18.30') + } + testLibs { + version('junitVersion', '5.10.0') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('lombokVersion', '1.18.30') + } + /*~~>*/testLibs { + version('junitVersion', '5.10.0') + } + } + } + """ + ) + ); + } + + @Test + void getVersionResolvesInlineVersion() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree(), + catalog.getVersion(new GroupArtifact("com.acme", "acme-core")))))), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0.0') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~(1.0.0)~~>*/libs { + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0.0') + } + } + } + """ + ) + ); + } + + @Test + void getVersionResolvesThroughVersionRef() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree(), + catalog.getVersion(new GroupArtifact("com.acme", "acme-gadget")))))), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + version('acmeGadgetVersion', '1.0') + library('acmeGadgetLib', 'com.acme', 'acme-gadget').versionRef('acmeGadgetVersion') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~(1.0)~~>*/libs { + version('acmeGadgetVersion', '1.0') + library('acmeGadgetLib', 'com.acme', 'acme-gadget').versionRef('acmeGadgetVersion') + } + } + } + """ + ) + ); + } + + @Test + void getVersionReturnsNullForLibraryWithoutVersion() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree(), + "version=" + catalog.getVersion(new GroupArtifact("com.acme", "acme-tool")))))), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeToolLib', 'com.acme', 'acme-tool').withoutVersion() + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~(version=null)~~>*/libs { + library('acmeToolLib', 'com.acme', 'acme-tool').withoutVersion() + } + } + } + """ + ) + ); + } + + @Test + void getVersionReturnsNullWhenLibraryNotFound() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree(), + "version=" + catalog.getVersion(new GroupArtifact("com.acme", "does-not-exist")))))), + settingsGradle( + """ + dependencyResolutionManagement { + versionCatalogs { + libs { + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0.0') + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~(version=null)~~>*/libs { + library('acmeCoreLib', 'com.acme', 'acme-core').version('1.0.0') + } + } + } + """ + ) + ); + } + + @Test + void kotlinMatchesInlineInSettingsGradle() { + rewriteRun( + spec -> spec.recipe(RewriteTest.toRecipe(() -> + new GradleVersionCatalog.Matcher().asVisitor(catalog -> SearchResult.found(catalog.getTree())))), + settingsGradleKts( + """ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + version("lombokVersion", "1.18.30") + library("projectLombok", "org.projectlombok", "lombok").versionRef("lombokVersion") + } + } + } + """, + """ + dependencyResolutionManagement { + versionCatalogs { + /*~~>*/create("libs") { + version("lombokVersion", "1.18.30") + library("projectLombok", "org.projectlombok", "lombok").versionRef("lombokVersion") + } + } + } + """ + ) + ); + } +}