Skip to content

Introduce Gradle version catalog traits - #8376

Closed
KamilPatora wants to merge 13 commits into
openrewrite:mainfrom
KamilPatora:main
Closed

Introduce Gradle version catalog traits#8376
KamilPatora wants to merge 13 commits into
openrewrite:mainfrom
KamilPatora:main

Conversation

@KamilPatora

@KamilPatora KamilPatora commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What's changed?

  • Added document, library, and plugin traits for TOML catalogs.
  • Supported string and inline-table dependency/plugin entries.
  • Coordinated shared version.ref updates.
  • Centralized TOML table lookup and string accessors.
  • Preserved TOML formatting during updates.
  • Added validation for malformed module notation and versionless entries.

What's your motivation?

Provide a reusable, formatting-preserving semantic model for Gradle version catalogs and enable safe dependency, plugin, and shared-version updates.

Anything in particular you'd like reviewers to focus on?

Probably whole pr

Anyone you would like to review specifically?

Have you considered any alternatives or workarounds?

We do have our open-source 'version' of openrewrite where we handle TOML values: https://github.com/allegro/allwrite

Any additional context

Added positive and negative coverage for custom catalog paths, missing version references, versionless notation, malformed coordinates, visitor composition, and TOML quote preservation.
Created from this PR #8274

Checklist

  • I've added unit tests to cover both positive and negative cases
  • I've read and applied the recipe conventions and best practices
  • I've used the IntelliJ IDEA auto-formatter on affected files

- Add document, library, and plugin traits for TOML catalogs
- Support string and inline-table dependency/plugin entries
- Coordinate shared version.ref updates
- Centralize TOML table lookup and string accessors
- Preserve TOML formatting during updates
Comment on lines +152 to +170
/**
* Finds a top-level table with the supplied name.
*
* @param name the table name to find
* @return the matching table, or {@code null} when it is absent
*/
public @Nullable Table findTable(String name) {
for (TomlValue value : values) {
if (!(value instanceof Table)) {
continue;
}
Table table = (Table) value;
Identifier tableName = table.getName();
if (tableName != null && name.equals(tableName.getName())) {
return table;
}
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't normally put finders like this off of the LST model itself. When interacting with the LST model normally, you'd override the visitTable and interact with the named table that you're interested in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha, Im more or less rich domain guy. Doing 'table.find("version")' looks better for me than 'TomlTableValue.find(table, "version")' etc...

changed it

Comment on lines +354 to +376
public @Nullable KeyValue find(String key) {
for (Toml value : getValues()) {
if (!(value instanceof KeyValue)) {
continue;
}
KeyValue keyValue = (KeyValue) value;
if (!(keyValue.getKey() instanceof Identifier) ||
!key.equals(((Identifier) keyValue.getKey()).getName())) {
continue;
}
return keyValue;
}
return null;
}

public @Nullable String getString(String key) {
KeyValue keyValue = find(key);
if (keyValue == null || !(keyValue.getValue() instanceof Literal)) {
return null;
}
Object value = ((Literal) keyValue.getValue()).getValue();
return value instanceof String ? (String) value : null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

* has neither a {@code version} nor a {@code version.ref} key, the {@code version} key is added.</li>
* </ul>
*/
public Toml.KeyValue withCoordinatesAndVersion(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may be better modeled as something like this:

GradleVersionCatalogDependency withGroup(String);
GradleVersionCatalogDependency withName(String);
GradleVersionCatalogDependency withModule(String);
GradleVersionCatalogDependency withVersion(String);

This allows modifying the pieces in a chain.

Usage:

GradleVersionCatalogDependency dependency;
Toml.KeyValue entry = dependency.withGroup("com.example")
        .withName("example-lib")
        .withVersion("1.0")
        .getValue()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed

* {@code with*} methods; the updated {@link Toml.KeyValue} should be used as the replacement
* value in the enclosing map operation.
*/
public static @Nullable GradleVersionCatalogDependency extract(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already supported by the matcher and doesn't lose the ancestry.

Comment on lines +58 to +65
public boolean hasUnsupportedVersionDeclaration() {
if (!(getTree().getValue() instanceof Toml.Table)) {
return false;
}
Toml.Table table = (Toml.Table) getTree().getValue();
return table.find("version") != null && version == null ||
table.find("version.ref") != null && versionRef == null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like something a recipe should be knowledgeable about reconciling when it manifests to achieve a correct end state that is logically consistent. As in I'm not sure that it belongs here.

return extractWithCursor(syntheticCursor, kv, groupPattern, artifactPattern);
}

private static @Nullable GradleVersionCatalogDependency extractWithCursor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we remove extract, then this can be inlined into test.

}

private static boolean matches(String pluginId, @Nullable String pattern) {
return StringUtils.isBlank(pattern) || matchesGlob(pluginId, pattern);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return StringUtils.isBlank(pattern) || matchesGlob(pluginId, pattern);
return pattern == null || matchesGlob(pluginId, pattern);

This matches consistently with what we do elsewhere.

Comment on lines +302 to +309
private static boolean matchesPatterns(
@Nullable String groupId, @Nullable String artifactId,
@Nullable String groupPattern, @Nullable String artifactPattern) {
if (groupPattern != null && !matchesGlob(groupId, groupPattern)) {
return false;
}
return artifactPattern == null || matchesGlob(artifactId, artifactPattern);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be replaced with DependencyMatcher.

* Entries using {@code version.ref} are intentionally unchanged; their shared version
* entry is updated by the recipe after selecting a version.
*/
public Toml.KeyValue withVersion(String newVersion) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This benefits with a similar API as discussed in the dependency trait.

GradleVersionCatalogPlugin withVersion(String)

Comment on lines +73 to +97
public Toml.KeyValue withVersion(String newVersion) {
if (newVersion.equals(version) || versionRef != null) {
return getTree();
}
Toml.KeyValue kv = getTree();
if (kv.getValue() instanceof Toml.Literal) {
Toml.Literal literal = (Toml.Literal) kv.getValue();
if (!(literal.getValue() instanceof String)) {
return kv;
}
Dependency dependency = DependencyNotation.parse((String) literal.getValue());
if (dependency == null) {
return kv;
}
String notation = DependencyNotation.toStringNotation(dependency.withGav(dependency.getGav().withVersion(newVersion)));
return kv.withValue(literal.withSource(TomlTableValue.quoted(literal, notation)).withValue(notation));
}
if (kv.getValue() instanceof Toml.Table) {
Toml.Table inline = (Toml.Table) kv.getValue();
return kv.withValue(inline.find("version") == null ?
TomlTableValue.withStringOrAdd(inline, "version", newVersion) :
TomlTableValue.withString(inline, "version", newVersion));
}
return kv;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would imagine that these become simpler by reusing the TOML visitors, such as ChangeValue. There also exists DeleteKey and it's synonym AddKey/AddKeyVisitor (if we don't want a recipe right now) would likely be particularly useful.

Usage:

Toml.KeyValue keyValue = getValue();
Toml.KeyValue updated = new ChangeValue("version", newVersion).getVisitor().visitNonNull(entry, new InMemoryExecutionContext()); // this may need to come in as a method argument, but for now this is fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I got it, changed

@shanman190

shanman190 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Thanks @KamilPatora for the start on this! I've left some comments about various improvements. Let us know if you have any questions.

@KamilPatora
KamilPatora requested a review from shanman190 August 10, 2026 12:44
@KamilPatora

Copy link
Copy Markdown
Contributor Author

Bump @shanman190 @timtebeek

@KamilPatora

Copy link
Copy Markdown
Contributor Author

bump bump

@timtebeek
timtebeek removed their request for review August 25, 2026 21:47
@timtebeek

Copy link
Copy Markdown
Member

I'm not getting to a review here @KamilPatora , and will be out for a prolonged time, so I've unassigned myself from review. Hoping my colleagues can step in.

@KamilPatora

KamilPatora commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Sure @timtebeek , no worries. I've moved this PR to #8699
as here I did the PR from the main branch of the fork...

@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants