-
Notifications
You must be signed in to change notification settings - Fork 5k
Support += in Groovy build scripts #32745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support += in Groovy build scripts #32745
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
5172b4b
to
4541adb
Compare
8b398c2
to
87c3ca1
Compare
This comment has been minimized.
This comment has been minimized.
7c9a808
to
569f7b6
Compare
87c3ca1
to
1471ff0
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this 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 {} |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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()}. |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
1471ff0
to
76df1c0
Compare
There was a problem hiding this 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. |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
// 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this 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 🌟
76df1c0
to
664b9e8
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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.
664b9e8
to
cde96f9
Compare
@bot-gradle test RFN |
This comment has been minimized.
This comment has been minimized.
The following builds have passed: |
ec69bf6
into
provider-api-migration/public-api-changes
This builds on top of the #32287 (which cannot be reopened), changes to it are in the last two commits.
Summary of changes:
groovy-support
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