Skip to content

Conversation

mlopatkin
Copy link
Member

@mlopatkin mlopatkin commented Mar 17, 2025

This builds on top of the #32287 (which cannot be reopened), changes to it are in the last two commits.

Summary of changes:

  • extract base support classes into a separate module groovy-support
  • support Groovy static compilation with type check
  • move Groovy support into separate files where possible

For the static types to work we can no longer get away with just DefaultXxx implementing some interface, all types has to be publicly available.

Fixes #23637

@mlopatkin mlopatkin added the p:lazy-migration Issues covered by migration to an all-lazy API label Mar 17, 2025
@mlopatkin mlopatkin added this to the 9.0 RC1 milestone Mar 17, 2025
@mlopatkin mlopatkin requested review from alllex and asodja March 17, 2025 19:41
@mlopatkin mlopatkin self-assigned this Mar 17, 2025
@mlopatkin

This comment has been minimized.

@bot-gradle

This comment has been minimized.

@bot-gradle

This comment has been minimized.

@mlopatkin

This comment has been minimized.

@bot-gradle

This comment has been minimized.

@asodja asodja force-pushed the provider-api-migration/public-api-changes branch from 5172b4b to 4541adb Compare March 18, 2025 10:47
@asodja asodja force-pushed the ml/23637/plus-assign-groovy branch from 8b398c2 to 87c3ca1 Compare March 18, 2025 11:20
@bot-gradle

This comment has been minimized.

@asodja asodja force-pushed the provider-api-migration/public-api-changes branch 2 times, most recently from 7c9a808 to 569f7b6 Compare March 18, 2025 15:19
@asodja asodja force-pushed the ml/23637/plus-assign-groovy branch from 87c3ca1 to 1471ff0 Compare March 18, 2025 15:21
@asodja

This comment has been minimized.

@bot-gradle

This comment has been minimized.

@bot-gradle

This comment has been minimized.

Copy link
Member

@asodja asodja left a comment

Choose a reason for hiding this comment

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

LGTM

@Target({ElementType.METHOD, ElementType.TYPE})
@GroovyASTTransformationClass("org.gradle.api.internal.provider.TestCompoundAssignmentTransform")
@GroovyASTTransformationClass("org.gradle.api.internal.groovy.support.CompoundAssignmentTransformInTest")
public @interface TransformCompoundAssignments {}
Copy link
Member

Choose a reason for hiding this comment

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

💅 Could we also att Test prefix to this name, so it's easier to know it comes from a test fixture? E.g.:
TransformCompoundAssignments -> TestTransformCompoundAssignments

* that accepts a <b>public</b> type of your class (e.g. {@code MapProperty} for {@code DefaultMapProperty} type) and returns a stand-in.
* </li>
* </ol>
* Note that the extension method and the stand-in type and its methods become <b>public APIs</b> and must follow binary compatibility policy,
Copy link
Member

Choose a reason for hiding this comment

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

❓ Are changes to these types and method automatically tracked by our binary compatibility checks?

Copy link
Member Author

Choose a reason for hiding this comment

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

Are changes to these types and method automatically tracked by our binary compatibility checks?

Not really, but I've added some reflection-based unit tests to cover that. A cross-version test will be another option after we release a stable with this feature, but so far it is as much we can do. Kotlin DSL does something similar.

Copy link
Member

Choose a reason for hiding this comment

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

I haven't look deeper into the Kotlin thing, but if there is a similarity between this "hidden" API and the Kotlin one, it might be useful in the long-term to introduce a concept for this. As it stands, without prior knowledge it's very hard or impossible to discover this hidden API technically being part of the public one.

In effect, this is part of the public API surface, which is not intended to be used by users directly, but which must observe the same binary compatibility guarantees as the normal API. Also, it should not be included in the generated docs, etc.

I'd consider this tech debt, so it'd be nice to have something to track this, even if we'd only be able to get to the clean up much later.

/**
* This class provides catch-all support for {@code +=} overloading.
* <p>
* <b>This is a hidden public API</b>. Compiling Groovy code that depends on Gradle API may end up emitting references to methods of this class.
Copy link
Member

Choose a reason for hiding this comment

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

❓Should we add it to the Public API definition then?
build-logic-commons/basics/src/main/kotlin/gradlebuild/basics/PublicApi.kt

Copy link
Member Author

Choose a reason for hiding this comment

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

Should we add it to the Public API definition then?

Unfortunately this is tied to exposing it in documentation, and we'll have to move it out of internal package.

@@ -0,0 +1,19 @@
#
# Copyright 2018 the original author or authors.
Copy link
Member

Choose a reason for hiding this comment

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

💅2025

*
* @param lhs the original argument
* @param <T> the type of the argument, used to please the static type checker
* @return the give argument
Copy link
Member

Choose a reason for hiding this comment

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

💅given

import org.jspecify.annotations.Nullable;

/**
* This class provides catch-all support for {@code +=} overloading.
Copy link
Member

Choose a reason for hiding this comment

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

🤔 Should it be OP= here as well, same as for the Result type? This class seems general, not bound to +=

}

/**
* Rewrites {@code foo <OP>= bar} into {@code (foo = foo.forCompoundAssignment() <OP> (bar)).unwrap()}.
Copy link
Member

Choose a reason for hiding this comment

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

✍️ We replaced "unwrap" with "toAssignementResult"

}

private Expression applyForCompoundAssignment(Expression expr) {
return callSupportMethodOn("forCompoundAssignment", expr);
Copy link
Member

Choose a reason for hiding this comment

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

💭 Since it's just a string literal, I'd add a mention of CompoundAssignmentExtensions in a comment or javadoc

return callSupportMethodOn("toAssignmentResult", expr);
}

private Expression callSupportMethodOn(String methodName, Expression argument) {
Copy link
Member

Choose a reason for hiding this comment

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

💅 Call-site reads better, if expr comes as the first argument

new StatementLabelsScriptTransformer().register(compilationUnit);
new ModelBlockTransformer(scriptSource.getDisplayName(), scriptSource.getResource().getLocation().getURI()).register(compilationUnit);
imperativeStatementDetectingTransformer.register(compilationUnit);
new CompoundAssignmentTransformer().register(compilationUnit);
Copy link
Member

Choose a reason for hiding this comment

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

❔ This means that if a user writes a Gradle plugin in Groovy, the += on properties and collections would differently. What would be the difference in behavior? Should we forbid += in non-build script source code for these types to avoid silent misconfiguration?

Copy link
Member Author

Choose a reason for hiding this comment

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

This means that if a user writes a Gradle plugin in Groovy, the += on properties and collections would differently. What would be the difference in behavior? Should we forbid += in non-build script source code for these types to avoid silent misconfiguration?

With the current implementation it wouldn't work outside of build scripts at all, with the usual missing plus() method. It is solvable, but there are objections to have build script sugar in the "normal" code context.

* A helper class to implement an intermediate result of a compound assignment operation, like "+=".
* It is then assigned to the left-hand side operand. When the LHS is a ConfigurableFileCollection-typed property of some Gradle-enhanced object, then the assignment action is invoked.
*/
class CompoundAssignmentResult extends UnionFileCollection implements org.gradle.api.internal.groovy.support.CompoundAssignmentResult {
Copy link
Member

Choose a reason for hiding this comment

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

💭 I generally don't like to see same-named classes in gradle/gradle codebase. It trips up quick searches, when only the class name appears in search results, file names and such.

Is there anything preventing us to give this a more explicit name like FileCollectionCompoundAssignmentResult?

public void assignmentComplete() {
// When the expression involves a variable on the left side as opposed to a field, then this provider becomes its value.
// It must lose all "magical" properties towards its owner, because it may be used to set its value outside the original expression.
assignToOwnerAction = null;
Copy link
Member

Choose a reason for hiding this comment

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

❓Why don't we dispose of the owner here?
We do it in the org.gradle.api.internal.file.collections.CompoundAssignmentResult.assignmentComplete

@mlopatkin mlopatkin force-pushed the ml/23637/plus-assign-groovy branch from 1471ff0 to 76df1c0 Compare March 28, 2025 18:26
Copy link
Member Author

@mlopatkin mlopatkin left a comment

Choose a reason for hiding this comment

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

@alllex PTAL

/**
* This class provides catch-all support for {@code +=} overloading.
* <p>
* <b>This is a hidden public API</b>. Compiling Groovy code that depends on Gradle API may end up emitting references to methods of this class.
Copy link
Member Author

Choose a reason for hiding this comment

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

Should we add it to the Public API definition then?

Unfortunately this is tied to exposing it in documentation, and we'll have to move it out of internal package.

* that accepts a <b>public</b> type of your class (e.g. {@code MapProperty} for {@code DefaultMapProperty} type) and returns a stand-in.
* </li>
* </ol>
* Note that the extension method and the stand-in type and its methods become <b>public APIs</b> and must follow binary compatibility policy,
Copy link
Member Author

Choose a reason for hiding this comment

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

Are changes to these types and method automatically tracked by our binary compatibility checks?

Not really, but I've added some reflection-based unit tests to cover that. A cross-version test will be another option after we release a stable with this feature, but so far it is as much we can do. Kotlin DSL does something similar.

@mlopatkin mlopatkin requested a review from alllex March 28, 2025 18:32
Comment on lines +25 to +26
// This class verifies binary compatibility of non-public API used by Groovy static compilation.
// String are used intentionally to catch unexpected change to class name that breaks binary compatibility.
Copy link
Member

Choose a reason for hiding this comment

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

👍

Copy link
Member

Choose a reason for hiding this comment

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

🎉 Incredible level of documentation!

* that accepts a <b>public</b> type of your class (e.g. {@code MapProperty} for {@code DefaultMapProperty} type) and returns a stand-in.
* </li>
* </ol>
* Note that the extension method and the stand-in type and its methods become <b>public APIs</b> and must follow binary compatibility policy,
Copy link
Member

Choose a reason for hiding this comment

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

I haven't look deeper into the Kotlin thing, but if there is a similarity between this "hidden" API and the Kotlin one, it might be useful in the long-term to introduce a concept for this. As it stands, without prior knowledge it's very hard or impossible to discover this hidden API technically being part of the public one.

In effect, this is part of the public API surface, which is not intended to be used by users directly, but which must observe the same binary compatibility guarantees as the normal API. Also, it should not be included in the generated docs, etc.

I'd consider this tech debt, so it'd be nice to have something to track this, even if we'd only be able to get to the clean up much later.

Copy link
Member

@alllex alllex left a comment

Choose a reason for hiding this comment

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

LGTM!

Amazing job, Mike! It's one of those rare exemplary pull requests 🌟

@mlopatkin mlopatkin force-pushed the ml/23637/plus-assign-groovy branch from 76df1c0 to 664b9e8 Compare March 28, 2025 20:35
@mlopatkin

This comment has been minimized.

@bot-gradle

This comment has been minimized.

@bot-gradle
Copy link
Collaborator

The following builds have failed:

This only supports `+=` and not `+`. Unlike Kotlin, this is much more
involved because of the Groovy overloading restrictions. Groovy only
supports overloading of +, and uses it to implement +=, by basically
rewriting `a += b` into `a = a + b`. This doesn't play well with
Properties, because a sensible implementation of plus operator (e.g.
`a.zip(b) { l1, l2 -> l1 + l2 }`) will likely cause a self-referencing
chain to happen. While this can also be solved upon assignment, we don't
really want users to have "action-at-distance" when you can set
`c = a + b`, and, a thousand lines after, happily assign `a = c` and
get some strange results. The latter should cause circular evaluation
failure.

In order to support `+=`, and not arbitrary self-referencing `+`
expressions, we employ an AST transformation. The transformation allows
properties to provide a stand-in, which will be concatenated with the
right-hand operand.

This works for fields of Property types, where setting the property
value to the stand-in simply invokes appropriate mutation operation,
like `add` or `addAll`. This also works for variable, where the stand-in
becomes the new value of the property. The stand-in acts as a `zip`-ped
provider.

Again, unlike Kotlin, the += is an expression in Groovy. To make both
DSLs consistent, the value of += expression involving Properties is
`null`. While it is still possible to write `foo = (a += b)` in Groovy,
it is now rather pointless thing to do. All my attempts to give this
expression some sensible value (e.g. return lhs operand or a shallow
copy) faced different complicated corner cases.

Implementation for ConfigurableFileCollection mostly follows the
implementation in the collection properties. One difference is that the
value of `+=` expression is already set in Groovy, at least when
variables are involved. Therefore, we cannot simply replace it to null
without breaking compatibility.
Sharing the same node in two branches of binary expression (
`lhs = lhs + rhs`) leads to unwanted failures when other AST
transformations add node metadata. In particular, the static type
checker overwrites the setter with getter's metadata, producing invalid
bytecode.

This was uncovered with Groovy 4, Groovy 3 probably emits the same
metadata for both node instances.

Add hand-rolled binary compatibility tests

Our binary compatibility tests are tied to public _documented_ APIs
living outside "internal" packages. We don't want to document += support
methods at this point, but decoupling existing checks look involved.
For now, we stick with simple reflection-based tests that will act as
a safety net.

We might be able to utilize cross-version tests in the future, but we
need at least one release with this feature to be able to run them.
@asodja asodja force-pushed the ml/23637/plus-assign-groovy branch from 664b9e8 to cde96f9 Compare March 31, 2025 15:18
@asodja
Copy link
Member

asodja commented Mar 31, 2025

@bot-gradle test RFN

@bot-gradle

This comment has been minimized.

@bot-gradle
Copy link
Collaborator

The following builds have passed:

@asodja asodja merged commit ec69bf6 into provider-api-migration/public-api-changes Apr 1, 2025
20 checks passed
@asodja asodja deleted the ml/23637/plus-assign-groovy branch April 1, 2025 09:12
@mlopatkin mlopatkin modified the milestones: 9.0 RC1, 10.0 RC1 Apr 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

p:lazy-migration Issues covered by migration to an all-lazy API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support += for multi-value lazy properties and ConfigurableFileCollection in Groovy and Kotlin DSLs

4 participants